require 'pdk'

module PDK
  module Validate
    # An abstract validator that runs external commands within a Ruby Bundled environment
    # e.g. `puppet-lint`, or `puppet validate`
    #
    # At a a minimum child classes should implment the `name`, `cmd`, `pattern` and `parse_output` methods
    #
    # An example concrete implementation could look like:
    #
    # module PDK
    #   module Validate
    #     module Ruby
    #       class RubyRubocopValidator < ExternalCommandValidator
    #         def name
    #           'rubocop'
    #         end
    #
    #         def cmd
    #           'rubocop'
    #         end
    #
    #         def pattern
    #           '**/**.rb'
    #         end
    #
    #         def parse_options(targets)
    #           ['--format', 'json']
    #         end
    #
    #         def parse_output(report, result, _targets)
    #    ... ruby code ...
    #           report.add_event(
    #             line:     offense['location']['line'],
    #             column:   offense['location']['column'],
    #             message:  offense['message'],
    #             severity: offense['corrected'] ? 'corrected' : offense['severity'],
    #             test:     offense['cop_name'],
    #             state:    :failure,
    #           )
    #         end
    #       end
    #     end
    #   end
    # end
    #
    # @see PDK::Validate::InvokableValidator
    class ExternalCommandValidator < InvokableValidator
      # @return Array[PDK::CLI::Exec::Command] This is a private implementation attribute used for unit testing
      # @api private
      attr_reader :commands

      # @see PDK::Validate::Validator.spinner
      def spinner
        # The validator has sub-commands with their own spinners.
        nil
      end

      # Calculates the text of the spinner based on the target list
      # @return [String]
      # @abstract
      def spinner_text_for_targets(targets); end

      # The name of the command to be run for validation
      # @return [String]
      # @abstract
      def cmd; end

      # Alternate paths which the command (cmd) may exist in. Typically other ruby gem caches,
      # or packaged installation bin directories.
      # @return [Array[String]]
      # @api private
      def alternate_bin_paths
        [
          PDK::Util::RubyVersion.bin_path,
          File.join(PDK::Util::RubyVersion.gem_home, 'bin'),
          PDK::Util::RubyVersion.gem_paths_raw.map { |gem_path_raw| File.join(gem_path_raw, 'bin') },
          PDK::Util.package_install? ? File.join(PDK::Util.pdk_package_basedir, 'bin') : nil
        ].flatten.compact
      end

      # The full path to the command (cmd)
      # Can be overridden in child classes to a non-default path
      # @return [String]
      # @api private
      def cmd_path
        return @cmd_path unless @cmd_path.nil?

        @cmd_path = File.join(context.root_path, 'bin', cmd)
        # Return the path to the command if it exists on disk, or we have a gemfile (i.e. Bundled install)
        # The Bundle may be created after the prepare_invoke so if the file doesn't exist, it may not be an error
        return @cmd_path if PDK::Util::Filesystem.exist?(@cmd_path) || !PDK::Util::Bundler::BundleHelper.new.gemfile.nil?

        # But if there is no Gemfile AND cmd doesn't exist in the default path, we need to go searching...
        @cmd_path = alternate_bin_paths.map { |alternate_path| File.join(alternate_path, cmd) }
                                       .find { |path| PDK::Util::Filesystem.exist?(path) }
        return @cmd_path unless @cmd_path.nil?

        # If we can't find it anywhere, just let the OS find it
        @cmd_path = cmd
      end

      # An array of command line arguments to pass to the command for validation
      # @return Array[String]
      # @abstract
      def parse_options(_targets)
        []
      end

      # Parses the output from the command and appends formatted events to the report.
      # This is called for each command, which is a group of targets
      #
      # @param report [PDK::Report] The report to add events to
      # @param result [Hash[Symbol => Object]] The result of validation command process
      # @param targets [Array[String]] The targets for this command result
      # @api private
      # @see PDK::CLI::Exec::Command.execute!
      # @abstract
      def parse_output(_report, _result, _targets); end

      # Prepares for invokation by parsing targets and creating the needed commands.
      # @api private
      # @see PDK::Validate::Validator.prepare_invoke!
      def prepare_invoke!
        return if @prepared

        super

        @targets, @skipped, @invalid = parse_targets
        @targets = [] if @targets.nil?

        target_groups = if @targets.empty? && allow_empty_targets?
                          # If we have no targets and we allow empty targets, create an empty target group list
                          [[]]
                        elsif invoke_style == :per_target
                          # If invoking :per_target, split the targets array into an array of
                          # single element arrays (one per target).
                          @targets.combination(1).to_a.compact
                        else
                          # Else we're invoking :once, wrap the targets array in another array. This is so we
                          # can loop through the invokes with the same logic, regardless of which invoke style
                          # is needed.
                          @targets.each_slice(1000).to_a.compact
                        end

        # Register all of the commands for all of the targets
        @commands = []
        target_groups.each do |invokation_targets|
          next if invokation_targets.empty? && !allow_empty_targets?

          cmd_argv = parse_options(invokation_targets).unshift(cmd_path).compact
          cmd_argv.unshift(File.join(PDK::Util::RubyVersion.bin_path, 'ruby.exe'), '-W0') if Gem.win_platform?

          command = PDK::CLI::Exec::Command.new(*cmd_argv).tap do |c|
            c.context = :module
            c.environment = { 'PUPPET_GEM_VERSION' => options[:puppet] } if options[:puppet]

            if spinners_enabled?
              parent_validator = options[:parent_validator]
              if parent_validator.nil? || parent_validator.spinner.nil? || !parent_validator.spinner.is_a?(TTY::Spinner::Multi)
                c.add_spinner(spinner_text_for_targets(invokation_targets))
              else
                spinner = TTY::Spinner.new("[:spinner] #{spinner_text_for_targets(invokation_targets)}")
                parent_validator.spinner.register(spinner)
                c.register_spinner(spinner)
              end
            end
          end

          @commands << { command: command, invokation_targets: invokation_targets }
        end
        nil
      end

      # Invokes the prepared commands as an ExecGroup
      # @see PDK::Validate::Validator.invoke
      def invoke(report)
        prepare_invoke!

        process_skipped(report, @skipped)
        process_invalid(report, @invalid)

        # Nothing to execute so return success
        return 0 if @commands.empty?

        # If there's no Gemfile, then we can't ensure the binstubs are correct
        PDK::Util::Bundler.ensure_binstubs!(cmd) unless PDK::Util::Bundler::BundleHelper.new.gemfile.nil?

        exec_group = PDK::CLI::ExecGroup.create(name, { parallel: false }, options)

        # Register all of the commands for all of the targets
        @commands.each do |item|
          command = item[:command]
          invokation_targets = item[:invokation_targets]

          exec_group.register do
            result = command.execute!
            begin
              parse_output(report, result, invokation_targets.compact)
            rescue PDK::Validate::ParseOutputError => e
              $stderr.puts e.message
            end
            result[:exit_code]
          end
        end

        # Now execute and get the return code
        exec_group.exit_code
      end
    end
  end
end
