# encoding: ascii-8bit

# Copyright 2022 Ball Aerospace & Technologies Corp.
# All Rights Reserved.
#
# This program is free software; you can modify and/or redistribute it
# under the terms of the GNU Affero General Public License
# as published by the Free Software Foundation; version 3 with
# attribution addendums as found in the LICENSE.txt
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.

# Modified by OpenC3, Inc.
# All changes Copyright 2022, OpenC3, Inc.
# All Rights Reserved
#
# This file may also be used under the terms of a commercial license
# if purchased from OpenC3, Inc.

require 'openc3/script/exceptions'
require 'openc3/core_ext/stringio'
require 'openc3/io/stderr'
require 'openc3/io/stdout'

module OpenC3
  # Base class for Script Runner suites. OpenC3 Suites inherit from Suite
  # and can implement setup and teardown methods. Script groups are added via add_group(Group)
  # and individual scripts added via add_script(Group, script_method).
  class Suite
    def scripts
      @scripts ||= {}
    end

    def plans
      @plans ||= []
    end

    ###########################################################################
    # START PUBLIC API
    ###########################################################################

    # Explicitly avoid creating an initialize method which forces end users to call super()

    # Add a group to the suite
    def add_group(group_class)
      @scripts ||= {}
      @plans ||= []
      group_class = Object.const_get(group_class.to_s.intern) unless group_class.class == Class
      @scripts[group_class] = group_class.new unless @scripts[group_class]
      @plans << [:GROUP, group_class, nil]
    end

    # Add a script to the suite
    def add_script(group_class, script)
      @scripts ||= {}
      @plans ||= []
      group_class = Object.const_get(group_class.to_s.intern) unless group_class.class == Class
      @scripts[group_class] = group_class.new unless @scripts[group_class]
      @plans << [:SCRIPT, group_class, script]
    end

    # Add a group setup to the suite
    def add_group_setup(group_class)
      @scripts ||= {}
      @plans ||= []
      group_class = Object.const_get(group_class.to_s.intern) unless group_class.class == Class
      @scripts[group_class] = group_class.new unless @scripts[group_class]
      @plans << [:GROUP_SETUP, group_class, nil]
    end

    # Add a group teardown to the suite
    def add_group_teardown(group_class)
      @scripts ||= {}
      @plans ||= []
      group_class = Object.const_get(group_class.to_s.intern) unless group_class.class == Class
      @scripts[group_class] = group_class.new unless @scripts[group_class]
      @plans << [:GROUP_TEARDOWN, group_class, nil]
    end

    ###########################################################################
    # END PUBLIC API
    ###########################################################################

    def <=>(other)
      self.name <=> other.name
    end

    # Name of the suite
    def name
      if self.class != Suite
        self.class.to_s.split('::')[-1]
      else
        'UnassignedSuite'
      end
    end

    # Returns the number of scripts in the suite including setup and teardown methods
    def get_num_scripts
      num_scripts = 0
      @plans.each do |type, group_class, _script|
        case type
        when :GROUP
          num_scripts += group_class.get_num_scripts
        when :SCRIPT, :GROUP_SETUP, :GROUP_TEARDOWN
          num_scripts += 1
        end
      end
      num_scripts += 1 if self.class.method_defined?(:setup)
      num_scripts += 1 if self.class.method_defined?(:teardown)
      num_scripts
    end

    # Run all the scripts
    def run(&)
      ScriptResult.suite = name()
      ScriptStatus.instance.total = get_num_scripts()
      results = []

      # Setup the suite
      result = run_setup(true)
      if result
        results << result
        yield result if block_given?
        raise StopScript if result.stopped
      end

      # Run each script
      @plans.each do |type, group_class, script|
        case type
        when :GROUP
          results.concat(run_group(group_class, true, &))
        when :SCRIPT
          result = run_script(group_class, script, true)
          results << result
          yield result if block_given?
          raise StopScript if (result.exceptions and group_class.abort_on_exception) or result.stopped
        when :GROUP_SETUP
          result = run_group_setup(group_class, true)
          if result
            results << result
            yield result if block_given?
            raise StopScript if (result.exceptions and group_class.abort_on_exception) or result.stopped
          end
        when :GROUP_TEARDOWN
          result = run_group_teardown(group_class, true)
          if result
            results << result
            yield result if block_given?
            raise StopScript if (result.exceptions and group_class.abort_on_exception) or result.stopped
          end
        end
      end

      # Teardown the suite
      result = run_teardown(true)
      if result
        results << result
        yield result if block_given?
        raise StopScript if result.stopped
      end

      ScriptResult.suite = nil
      results
    end

    # Run a specific group
    def run_group(group_class, internal = false, &)
      ScriptResult.suite = name() unless internal

      # Determine if this group_class is in the plan and the number of scripts associated with this group_class
      in_plan = false
      num_scripts = 0
      @plans.each do |plan_type, plan_group_class, plan_script|
        if plan_type == :GROUP and group_class == plan_group_class
          in_plan = true
        end
        if (plan_type == :GROUP_SETUP and group_class == plan_group_class) or
           (plan_type == :GROUP_TEARDOWN and group_class == plan_group_class) or
           (plan_script and group_class == plan_group_class)
          num_scripts += 1
        end
      end

      if in_plan
        ScriptStatus.instance.total = group_class.get_num_scripts() unless internal
        results = @scripts[group_class].run(&)
      else
        results = []
        ScriptStatus.instance.total = num_scripts unless internal

        # Run each setup, teardown, or script associated with this group_class in the order
        # defined in the plan
        @plans.each do |plan_type, plan_group_class, plan_script|
          if plan_group_class == group_class
            case plan_type
            when :SCRIPT
              result = run_script(plan_group_class, plan_script, true)
              results << result
              yield result if block_given?
            when :GROUP_SETUP
              result = run_group_setup(plan_group_class, true)
              if result
                results << result
                yield result if block_given?
              end
            when :GROUP_TEARDOWN
              result = run_group_teardown(plan_group_class, true)
              if result
                results << result
                yield result if block_given?
              end
            end
          end
        end
      end

      ScriptResult.suite = nil unless internal
      return results
    end

    # Run a specific script
    def run_script(group_class, script, internal = false)
      ScriptResult.suite = name() unless internal
      ScriptStatus.instance.total = 1 unless internal
      result = @scripts[group_class].run_script(script)
      ScriptResult.suite = nil unless internal
      result
    end

    def run_setup(internal = false)
      ScriptResult.suite = name() unless internal
      result = nil
      if self.class.method_defined?(:setup) and @scripts.length > 0
        ScriptStatus.instance.total = 1 unless internal
        ScriptStatus.instance.status = "#{self.class} : setup"
        result = @scripts[@scripts.keys[0]].run_method(self, :setup)
      end
      ScriptResult.suite = nil unless internal
      result
    end

    def run_teardown(internal = false)
      ScriptResult.suite = name() unless internal
      result = nil
      if self.class.method_defined?(:teardown) and @scripts.length > 0
        ScriptStatus.instance.total = 1 unless internal
        ScriptStatus.instance.status = "#{self.class} : teardown"
        result = @scripts[@scripts.keys[0]].run_method(self, :teardown)
      end
      ScriptResult.suite = nil unless internal
      result
    end

    def run_group_setup(group_class, internal = false)
      ScriptResult.suite = name() unless internal
      ScriptStatus.instance.total = 1 unless internal
      result = @scripts[group_class].run_setup
      ScriptResult.suite = nil unless internal
      result
    end

    def run_group_teardown(group_class, internal = false)
      ScriptResult.suite = name() unless internal
      ScriptStatus.instance.total = 1 unless internal
      result = @scripts[group_class].run_teardown
      ScriptResult.suite = nil unless internal
      result
    end
  end

  # Base class for a group. All OpenC3 Script Runner scripts should inherit Group
  # and then implement scripts methods starting with 'script_', 'test_', or 'op_'
  # e.g. script_mech_open, test_mech_open, op_mech_open.
  class Group
    @@abort_on_exception = false
    @@current_result = nil

    # Explicitly avoid creating an initialize method which forces end users to call super()

    def self.abort_on_exception
      @@abort_on_exception
    end

    def self.abort_on_exception=(value)
      @@abort_on_exception = value
    end

    def self.scripts
      # Find all the script methods
      methods = []
      self.instance_methods.each do |method_name|
        if /^test|^script|op_/.match?(method_name.to_s)
          methods << method_name.to_s
        end
      end
      # Sort by name for all found methods
      methods.sort!
      methods
    end

    # Name of the script group
    def name
      if self.class != Group
        self.class.to_s.split('::')[-1]
      else
        'UnnamedGroup'
      end
    end

    # Run all the scripts
    def run
      results = []

      # Setup the script group
      result = run_setup()
      if result
        results << result
        yield result if block_given?
        raise StopScript if (results[-1].exceptions and @@abort_on_exception) or results[-1].stopped
      end

      # Run all the scripts
      self.class.scripts.each do |method_name|
        results << run_script(method_name)
        yield results[-1] if block_given?
        raise StopScript if (results[-1].exceptions and @@abort_on_exception) or results[-1].stopped
      end

      # Teardown the script group
      result = run_teardown()
      if result
        results << result
        yield result if block_given?
        raise StopScript if (results[-1].exceptions and @@abort_on_exception) or results[-1].stopped
      end

      results
    end

    # Run a specific script method
    def run_script(method_name)
      ScriptStatus.instance.status = "#{self.class} : #{method_name}"
      run_method(self, method_name)
    end

    def run_method(object, method_name)
      # Convert to a symbol to use as a method_name
      method_name = method_name.to_s.intern unless method_name.class == Symbol

      result = ScriptResult.new
      @@current_result = result

      # Verify script method exists
      if object.class.method_defined?(method_name)
        @output_io ||= StringIO.new('', 'r+')
        # Capture STDOUT and STDERR
        # $stdout & $stderr must be set to change output
        $stdout = Stdout.instance
        $stderr = Stderr.instance
        $stdout.add_stream(@output_io)
        $stderr.add_stream(@output_io)

        result.group = object.class.to_s.split('::')[-1]
        result.script = method_name.to_s
        begin
          object.public_send(method_name)
          result.result = :PASS

          if RunningScript.instance and RunningScript.instance.exceptions
            result.exceptions = RunningScript.instance.exceptions
            result.result     = :FAIL
            RunningScript.instance.exceptions = nil
          end
        rescue StandardError, SyntaxError => e
          # Check that the error belongs to the StopScript inheritance chain
          if e.class <= StopScript
            result.stopped = true
            result.result  = :STOP
          end
          # Check that the error belongs to the SkipScript inheritance chain
          if e.class <= SkipScript
            result.result  = :SKIP
            result.message ||= ''
            result.message << (e.message + "\n")
          else
            if e.class != StopScript and
               (not RunningScript.instance or
                 not RunningScript.instance.exceptions or
                 not RunningScript.instance.exceptions.include? e)
              result.exceptions ||= []
              result.exceptions << e
              puts "*** Exception in Control Statement:"
              e.formatted.each_line do |line|
                puts '  ' + line
              end
            end
            if RunningScript.instance and RunningScript.instance.exceptions
              result.exceptions ||= []
              result.exceptions.concat(RunningScript.instance.exceptions)
              RunningScript.instance.exceptions = nil
            end
          end

          result.result = :FAIL if result.exceptions
        ensure
          result.output     = @output_io.string
          @output_io.string = ''
          $stdout.remove_stream(@output_io)
          $stderr.remove_stream(@output_io)

          case result.result
          when :FAIL
            ScriptStatus.instance.fail_count += 1
          when :SKIP
            ScriptStatus.instance.skip_count += 1
          when :PASS
            ScriptStatus.instance.pass_count += 1
          end
        end

      else
        @@current_result = nil
        raise "Unknown method #{method_name} for #{object.class}"
      end

      @@current_result = nil
      result
    end

    def run_setup
      result = nil
      if self.class.method_defined?(:setup)
        ScriptStatus.instance.status = "#{self.class} : setup"
        result = run_script(:setup)
      end
      result
    end

    def run_teardown
      result = nil
      if self.class.method_defined?(:teardown)
        ScriptStatus.instance.status = "#{self.class} : teardown"
        result = run_script(:teardown)
      end
      result
    end

    def self.get_num_scripts
      num_scripts = 0
      num_scripts += 1 if self.method_defined?(:setup)
      num_scripts += 1 if self.method_defined?(:teardown)
      num_scripts += self.scripts.length
      num_scripts
    end

    def self.puts(string)
      $stdout.puts string
      if @@current_result
        @@current_result.message ||= ''
        @@current_result.message << string.chomp
        @@current_result.message << "\n"
      end
    end

    def self.current_suite
      if @@current_result
        @@current_result.suite
      else
        nil
      end
    end

    def self.current_group
      if @@current_result
        @@current_result.group
      else
        nil
      end
    end

    def self.current_script
      if @@current_result
        @@current_result.script
      else
        nil
      end
    end
  end

  # Helper class to collect information about the running scripts like pass / fail counts
  class ScriptStatus
    attr_accessor :status
    attr_accessor :pass_count
    attr_accessor :skip_count
    attr_accessor :fail_count
    attr_reader :total

    @@instance = nil

    def initialize
      @status = ''
      @pass_count = 0
      @skip_count = 0
      @fail_count = 0
      @total = 1
    end

    def total=(new_total)
      if new_total <= 0
        @total = 1
      else
        @total = new_total
      end
    end

    def self.instance
      @@instance = self.new unless @@instance
      @@instance
    end
  end

  # Helper class to collect script result information
  class ScriptResult
    attr_accessor :suite
    attr_accessor :group
    attr_accessor :script
    attr_accessor :output
    attr_accessor :exceptions
    attr_accessor :stopped
    attr_accessor :result
    attr_accessor :message

    @@suite = nil

    def initialize
      @suite = nil
      @suite = @@suite.clone if @@suite
      @group = nil
      @script = nil
      @output = nil
      @exceptions = nil
      @stopped = false
      @result = :SKIP
      @message = nil
    end

    def self.suite=(suite)
      @@suite = suite
    end
  end
end
