require 'digest/md5'
module OrigenTesters
  # Provides a common API to add tests to a flow that is supported by all testers.
  #
  # This builds up a flow model using the Abstract Test Program (ATP) gem, which
  # now deals with implementing the flow control API.
  #
  # Individual tester drivers in this plugin are then responsible at the end to
  # render the abstract flow to their specific format and conventions.
  module Flow
    include OrigenTesters::Generator

    PROGRAM_MODELS_DIR = "#{Origen.root}/tmp/program_models"

    def self.callstack
      @callstack ||= []
    end

    def self.comment_stack
      @comment_stack ||= []
    end

    def self.flow_comments
      @flow_comments
    end

    def self.flow_comments=(val)
      @flow_comments = val
    end

    def self.unique_ids
      @unique_ids
    end

    def self.unique_ids=(val)
      @unique_ids = val
    end

    def lines
      @lines
    end

    # Returns the abstract test program model, this is shared by all
    # flow created together in a generation run
    def program
      @@program ||= ATP::Program.new
    end

    def save_program
      FileUtils.mkdir_p(PROGRAM_MODELS_DIR) unless File.exist?(PROGRAM_MODELS_DIR)
      program.save("#{PROGRAM_MODELS_DIR}/#{Origen.target.name}")
    end

    def model
      if Origen.interface.resources_mode?
        @throwaway ||= ATP::Flow.new(self)
      else
        @model ||= begin
          f = program.flow(id, description: OrigenTesters::Flow.flow_comments)
          @sig = flow_sig(id)
          f.id = @sig if OrigenTesters::Flow.unique_ids
          f
        end
      end
    end

    def enable(var, options = {})
      add_meta!(options)
      model.enable(var, clean_options(options))
    end

    def disable(var, options = {})
      add_meta!(options)
      model.disable(var, clean_options(options))
    end

    def bin(number, options = {})
      options[:bin_description] ||= options.delete(:description)
      if number.is_a?(Hash)
        fail 'The bin number must be passed as the first argument'
      end
      add_meta!(options)
      options[:type] ||= :fail
      model.bin(number, clean_options(options))
    end

    def pass(number, options = {})
      if number.is_a?(Hash)
        fail 'The bin number must be passed as the first argument'
      end
      options[:type] = :pass
      bin(number, clean_options(options))
    end

    def test(instance, options = {})
      add_meta_and_description!(options)
      model.test(instance, clean_options(options))
    end

    def render(file, options = {})
      add_meta!(options)
      begin
        text = super
      rescue
        text = file
      end
      model.render(text, clean_options(options))
    end

    def cz(instance, cz_setup, options = {})
      add_meta_and_description!(options)
      model.cz(instance, cz_setup, clean_options(options))
    end
    alias_method :characterize, :cz

    def log(message, options = {})
      add_meta!(options)
      model.log(message, clean_options(options))
    end
    alias_method :logprint, :log

    def group(name, options = {})
      add_meta!(options)
      model.group(name, clean_options(options)) do
        yield
      end
    end

    def nop(options = {})
    end

    # @api private
    # This fires between target loads
    def at_run_start
      @@program = nil
    end

    # @api private
    # This fires between flows
    def at_flow_start
      @labels = {}
    end

    def if_job(*jobs)
      options = jobs.last.is_a?(Hash) ? jobs.pop : {}
      options[:if_job] = jobs.flatten
      add_meta!(options)
      model.with_conditions(options) do
        yield
      end
    end
    alias_method :if_jobs, :if_job

    def unless_job(*jobs)
      options = jobs.last.is_a?(Hash) ? jobs.pop : {}
      options[:unless_job] = jobs.flatten
      add_meta!(options)
      model.with_conditions(options) do
        yield
      end
    end
    alias_method :unless_jobs, :unless_job

    def if_enable(word, options = {})
      if word.nil? || word == ''
        yield   # then no enable word will be used
        return
      end
      if options[:or]
        yield
      else
        options = { enable: word }
        add_meta!(options)
        model.with_conditions(options) do
          yield
        end
      end
    end
    alias_method :if_enabled, :if_enable

    def unless_enable(word, options = {})
      if word.nil? || word == ''
        yield   # then no enable word will be used
        return
      end
      if options[:or]
        yield
      else
        options = { unless_enable: word }
        add_meta!(options)
        model.with_conditions(options) do
          yield
        end
      end
    end
    alias_method :unless_enabled, :unless_enable

    def if_passed(test_id, options = {})
      if test_id.is_a?(Array)
        fail 'if_passed only accepts one ID, use if_any_passed or if_all_passed for multiple IDs'
      end
      options = { if_passed: test_id }
      add_meta!(options)
      model.with_conditions(options) do
        yield
      end
    end
    alias_method :unless_failed, :if_passed

    def if_failed(test_id, options = {})
      if test_id.is_a?(Array)
        fail 'if_failed only accepts one ID, use if_any_failed or if_all_failed for multiple IDs'
      end
      options = { if_failed: test_id }
      add_meta!(options)
      model.with_conditions(options) do
        yield
      end
    end
    alias_method :unless_passed, :if_failed

    def if_ran(test_id, options = {})
      options = { if_ran: test_id }
      add_meta!(options)
      model.with_conditions(options) do
        yield
      end
    end

    def unless_ran(test_id, options = {})
      options = { unless_ran: test_id }
      add_meta!(options)
      model.with_conditions(options) do
        yield
      end
    end

    def if_any_failed(*ids)
      options = ids.last.is_a?(Hash) ? ids.pop : {}
      options[:if_any_failed] = ids.flatten
      add_meta!(options)
      model.with_conditions(options) do
        yield
      end
    end

    def if_all_failed(*ids)
      options = ids.last.is_a?(Hash) ? ids.pop : {}
      options[:if_all_failed] = ids.flatten
      add_meta!(options)
      model.with_conditions(options) do
        yield
      end
    end

    def if_any_passed(*ids)
      options = ids.last.is_a?(Hash) ? ids.pop : {}
      options[:if_any_passed] = ids.flatten
      add_meta!(options)
      model.with_conditions(options) do
        yield
      end
    end

    def if_all_passed(*ids)
      options = ids.last.is_a?(Hash) ? ids.pop : {}
      options[:if_all_passed] = ids.flatten
      add_meta!(options)
      model.with_conditions(options) do
        yield
      end
    end

    def if_flag(flag, options = {})
      options = { if_flag: flag }
      add_meta!(options)
      model.with_conditions(options) do
        yield
      end
    end

    def unless_flag(flag, options = {})
      options = { unless_flag: flag }
      add_meta!(options)
      model.with_conditions(options) do
        yield
      end
    end

    # @api private
    def is_the_flow?
      true
    end

    # Returns true if the test context generated from the supplied options + existing condition
    # wrappers is different from that which was applied to the previous test.
    def context_changed?(options)
      options = clean_options(options)
      model.context_changed?(options)
    end

    def generate_unique_label(name = nil)
      name = 'label' if !name || name == ''
      name.gsub!(' ', '_')
      name.upcase!
      @labels ||= {}
      @labels[name] ||= 0
      @labels[name] += 1
      "#{name}_#{@labels[name]}_#{sig}"
    end

    # Returns a unique signature that has been generated for the current flow, this can be appended
    # to named references to avoid naming collisions with any other flow
    def sig
      @sig
    end
    alias_method :signature, :sig

    private

    # Make a unique signature for the flow based on the flow name and the name of
    # the plugin/app that owns it
    def flow_sig(id)
      s = Digest::MD5.new
      # These guarantee uniqueness within a plugin/app
      s << id.to_s
      s << filename
      # This will add the required plugin uniqueness in the case of a top-level app
      # that has multiple plugins that can generate test program snippets
      if file = OrigenTesters::Flow.callstack.first
        s << get_app(file).name.to_s
      end
      s.to_s[0..6].upcase
    end

    def get_app(file)
      path = Pathname.new(file).dirname
      until File.exist?(File.join(path, 'config/application.rb')) || path.root?
        path = path.parent
      end
      if path.root?
        fail 'Something went wrong resoving the app root in OrigenTesters'
      end
      Origen.find_app_by_root(path)
    end

    def add_meta!(options)
      flow_file = OrigenTesters::Flow.callstack.last
      called_from = caller.find { |l| l =~ /^#{flow_file}:.*/ }
      if called_from
        called_from = called_from.split(':')
        options[:source_file] = called_from[0]
        options[:source_line_number] = called_from[1].to_i
      end
    end

    def add_meta_and_description!(options)
      add_meta!(options)
      # Can be useful if an app generates additional tests on the fly for a single test in the flow,
      # e.g. a POR, in that case they will not want the description to be attached to the POR, but to
      # the test that follows it
      unless options[:inhibit_description_consumption]
        comments = OrigenTesters::Flow.comment_stack.last
        if options[:source_line_number]
          while comments.first && comments.first.first < options[:source_line_number]
            options[:description] ||= []
            c = comments.shift
            if c[0] + c[1].size == options[:source_line_number]
              options[:description] += c[1]
            end
          end
        end
      end
    end

    def clean_options(options)
      ATP::AST::Builder::CONDITION_KEYS.each do |key|
        if options[key] == ''
          options.delete(key)   # remove option if any invalid values
        end
        if v = options.delete(key)
          options[:conditions] ||= {}
          if options[:conditions][key]
            fail "Multiple values assigned to flow condition #{key}"
          else
            options[:conditions][key] = v
          end
        end
      end
      options
    end
  end
end
