require 'pdk'

module PDK
  module CLI
    module Exec
      class Command
        attr_reader :argv, :context
        attr_accessor :timeout, :environment
        attr_writer :exec_group

        # The spinner for this command.
        # This should only be used for testing
        #
        # @return [TTY::Spinner, nil]
        #
        # @api private
        attr_reader :spinner

        TEMPFILE_MODE = File::RDWR | File::BINARY | File::CREAT | File::TRUNC

        def initialize(*argv)
          require 'childprocess'
          require 'tempfile'

          @argv = argv

          @process = ChildProcess.build(*@argv)
          # https://github.com/puppetlabs/pdk/issues/1083:
          # When @process.leader is set, childprocess will set the CREATE_BREAKAWAY_FROM_JOB
          # and JOB_OBJECT_LIMIT_BREAKAWAY_OK flags in the Win32 API calls. This will cause
          # issues on systems > Windows 7 / Server 2008, if the JOB_OBJECT_LIMIT_BREAKAWAY_OK
          # flag is set and the Task Scheduler is trying to kick off a job, it can sometimes
          # result in ACCESS_DENIED being returned by the Win32 API, depending on the permission
          # levels / user account this user.
          # The resolution for pdk/issues/1083 is to ensure @process.leader is not set.
          # This will potentially cause issues on older Windows systems, in which case we may
          # need to revisit and conditionally set this param depending on what OS we're on
          # @process.leader = true

          @stdout = Tempfile.new('stdout', mode: TEMPFILE_MODE).tap { |io| io.sync = true }
          @stderr = Tempfile.new('stderr', mode: TEMPFILE_MODE).tap { |io| io.sync = true }

          @process.io.stdout = @stdout
          @process.io.stderr = @stderr

          # Default to running things in the system context.
          @context = :system

          # Extra environment vars to add to base set.
          @environment = {}

          # Register the ExecGroup when running in parallel
          @exec_group = nil
        end

        def context=(new_context)
          raise ArgumentError, format("Expected execution context to be :system or :module but got '%{context}'.", context: new_context) unless [:system, :module, :pwd].include?(new_context)

          @context = new_context
        end

        def register_spinner(spinner, opts = {})
          require 'pdk/cli/util'

          return unless PDK::CLI::Util.interactive?

          @success_message = opts.delete(:success)
          @failure_message = opts.delete(:failure)

          @spinner = spinner
        end

        def add_spinner(message, opts = {})
          require 'pdk/cli/util'

          return unless PDK::CLI::Util.interactive?

          @success_message = opts.delete(:success)
          @failure_message = opts.delete(:failure)

          require 'pdk/cli/util/spinner'

          @spinner = TTY::Spinner.new("[:spinner] #{message}")
        end

        def update_environment(additional_env)
          @environment.merge!(additional_env)
        end

        # @return [Hash[Symbol => Object]] The result from executing the command
        #   :stdout => String : The result of STDOUT
        #   :stderr => String : The result of STDERR
        #   :exit_code => Integer : The exit code from the command
        #   :duration => Float : Number seconds it took to execute
        def execute!
          # Start spinning if configured.
          @spinner&.auto_spin

          # Set env for child process
          resolved_env_for_command.each { |k, v| @process.environment[k] = v }

          if [:module, :pwd].include?(context)
            require 'pdk/util'
            mod_root = PDK::Util.module_root

            unless mod_root
              @spinner&.error

              raise PDK::CLI::FatalError, 'Current working directory is not part of a module. (No metadata.json was found.)'
            end

            if Dir.pwd == mod_root || context == :pwd
              run_process_in_clean_env!
            else
              Dir.chdir(mod_root) do
                run_process_in_clean_env!
              end
            end
          else
            run_process!
          end

          # Stop spinning when done (if configured).
          stop_spinner

          @stdout.rewind
          @stderr.rewind

          process_data = {
            stdout: @stdout.read,
            stderr: @stderr.read,
            exit_code: @process.exit_code,
            duration: @duration
          }

          PDK.logger.debug format('STDOUT: %{output}', output: process_data[:stdout].empty? ? 'N/A' : "\n#{process_data[:stdout]}")
          PDK.logger.debug format('STDERR: %{output}', output: process_data[:stderr].empty? ? 'N/A' : "\n#{process_data[:stderr]}")

          process_data
        ensure
          @stdout.close
          @stderr.close
        end

        protected

        def warn_on_legacy_env_vars!
          if PDK::Util::Env['PUPPET_GEM_VERSION']
            PDK.logger.warn_once 'PUPPET_GEM_VERSION is not supported by PDK. ' \
                                 'Use the --puppet-version option on your PDK command ' \
                                 'or set the PDK_PUPPET_VERSION environment variable instead'
          end

          ['FACTER', 'HIERA'].each do |gem|
            PDK.logger.warn_once format('%{varname} is not supported by PDK.', varname: "#{gem}_GEM_VERSION") if PDK::Util::Env["#{gem}_GEM_VERSION"]
          end
        end

        def resolved_env_for_command
          warn_on_legacy_env_vars!

          resolved_env = {}

          resolved_env['TERM'] = PDK::Util::Env['TERM']
          resolved_env['PUPPET_GEM_VERSION'] = nil
          resolved_env['FACTER_GEM_VERSION'] = nil
          resolved_env['HIERA_GEM_VERSION'] = nil

          resolved_env.merge!(@environment.dup)

          resolved_env['BUNDLE_IGNORE_CONFIG'] = '1'

          if [:module, :pwd].include?(context)
            require 'pdk/util'
            require 'pdk/util/git'
            require 'pdk/util/ruby_version'

            resolved_env['GEM_HOME'] = PDK::Util::RubyVersion.gem_home
            gem_path = PDK::Util::RubyVersion.gem_path
            resolved_env['GEM_PATH'] = gem_path.empty? ? resolved_env['GEM_HOME'] : gem_path

            # Make sure invocation of Ruby prefers our private installation.
            package_binpath = PDK::Util.package_install? ? File.join(PDK::Util.pdk_package_basedir, 'bin') : nil

            resolved_env['PATH'] = [
              PDK::Util::RubyVersion.bin_path,
              File.join(resolved_env['GEM_HOME'], 'bin'),
              PDK::Util::RubyVersion.gem_paths_raw.map { |gem_path_raw| File.join(gem_path_raw, 'bin') },
              package_binpath,
              PDK::Util.package_install? ? PDK::Util::Git.git_paths : nil,
              PDK::Util::Env['PATH']
            ].compact.flatten.join(File::PATH_SEPARATOR)
          end

          resolved_env
        end

        def stop_spinner
          return unless @spinner

          # If it is a single spinner, we need to send it a success/error message
          if @process.exit_code.zero?
            @spinner.success(@success_message || '')
          else
            @spinner.error(@failure_message || '')
          end
        end

        def run_process_in_clean_env!
          require 'bundler'

          # Bundler 2.1.0 has deprecated the use of `Bundler.with_clean_env` in favour of
          # `Bundler.with_unbundled_env`. So prefer to use the newer method if it exists
          # otherwise revert back to the old method.
          if ::Bundler.respond_to?(:with_unbundled_env)
            run_process_with_unbundled_env!
          else
            run_process_with_clean_env!
          end
        end

        def run_process!
          command_string = argv.join(' ')

          PDK.logger.debug(format("Executing '%{command}'", command: command_string))

          if context == :module
            PDK.logger.debug('Command environment:')
            @process.environment.each do |var, val|
              PDK.logger.debug("  #{var}: #{val}")
            end
          end

          start_time = Time.now

          begin
            @process.start
          rescue ChildProcess::LaunchError => e
            raise PDK::CLI::FatalError, format("Failed to execute '%{command}': %{message}", command: command_string, message: e.message)
          end

          if timeout
            begin
              @process.poll_for_exit(timeout)
            rescue ChildProcess::TimeoutError
              @process.stop # tries increasingly harsher methods to kill the process.
            end
          else
            # Wait indfinitely if no timeout set.
            @process.wait
          end

          @duration = Time.now - start_time

          PDK.logger.debug(format("Execution of '%{command}' complete (duration: %{duration_in_seconds}s; exit code: %{exit_code})", command: command_string, duration_in_seconds: @duration,
                                                                                                                                     exit_code: @process.exit_code))
        end

        private

        # :nocov:
        # These are just bundler helper methods and are tested via the public run_process_in_clean_env! method
        def run_process_with_unbundled_env!
          # Bundler 2.1.0 or greater
          ::Bundler.with_unbundled_env do
            run_process!
          end
        end
        # :nocov:

        # :nocov:
        # These are just bundler helper methods and are tested via the public run_process_in_clean_env! method
        def run_process_with_clean_env!
          # Bundler 2.0.2 or less
          ::Bundler.with_clean_env do
            run_process!
          end
        end
        # :nocov:
      end
    end
  end
end
