# frozen_string_literal: true

require_relative 'powershell/snippets'

module Bolt
  class Shell
    class Powershell < Shell
      DEFAULT_EXTENSIONS = Set.new(%w[.ps1 .rb .pp])
      PS_ARGS = %w[-NoProfile -NonInteractive -NoLogo -ExecutionPolicy Bypass].freeze

      def initialize(target, conn)
        super

        extensions = [target.options['extensions'] || []].flatten.map { |ext| ext[0] == '.' ? ext : '.' + ext }
        extensions += target.options['interpreters'].keys if target.options['interpreters']
        @extensions = DEFAULT_EXTENSIONS + extensions
        validate_ps_version
      end

      def validate_ps_version
        version = execute("$PSVersionTable.PSVersion.Major").stdout.string.chomp
        if !version.empty? && version.to_i < 3
          # This lets us know how many targets have Powershell 2, and lets the
          # user know how many targets they have with PS2
          msg = "Detected PowerShell 2 on one or more targets.\nPowerShell 2 "\
            "is unsupported. See bolt-debug.log or run with '--log-level debug' to see the full "\
            "list of targets with PowerShell 2."

          Bolt::Logger.deprecate_once("powershell_2", msg)
          @logger.debug("Detected PowerShell 2 on #{target}.")
        end
      end

      def provided_features
        ['powershell']
      end

      def default_input_method(executable)
        powershell_file?(executable) ? 'powershell' : 'both'
      end

      def powershell_file?(path)
        File.extname(path).downcase == '.ps1'
      end

      def validate_extensions(ext)
        unless @extensions.include?(ext)
          raise Bolt::Node::FileError.new("File extension #{ext} is not enabled, "\
                                          "to run it please add to 'winrm: extensions'", 'FILETYPE_ERROR')
        end
      end

      def process_from_extension(path)
        case Pathname(path).extname.downcase
        when '.rb'
          [
            'ruby.exe',
            %W[-S "#{path}"]
          ]
        when '.ps1'
          [
            'powershell.exe',
            [*PS_ARGS, '-File', path]
          ]
        when '.pp'
          [
            'puppet.bat',
            %W[apply "#{path}"]
          ]
        else
          # Run the script via cmd, letting Windows extension handling determine how
          [
            'cmd.exe',
            %W[/c "#{path}"]
          ]
        end
      end

      def escape_arguments(arguments)
        arguments.map do |arg|
          if arg =~ / /
            "\"#{arg}\""
          else
            arg
          end
        end
      end

      def env_declarations(env_vars)
        env_vars.map do |var, val|
          "[Environment]::SetEnvironmentVariable('#{var}', @'\n#{val}\n'@)"
        end
      end

      def quote_string(string)
        "'" + string.gsub("'", "''") + "'"
      end

      def write_executable(dir, file, filename = nil)
        filename ||= File.basename(file)
        validate_extensions(File.extname(filename))
        destination = "#{dir}\\#{filename}"
        conn.upload_file(file, destination)
        destination
      end

      def execute_process(path, arguments, stdin = nil)
        quoted_args = arguments.map { |arg| quote_string(arg) }.join(' ')

        quoted_path = if path =~ /^'.*'$/ || path =~ /^".*"$/
                        path
                      else
                        quote_string(path)
                      end
        exec_cmd =
          if stdin.nil?
            "& #{quoted_path} #{quoted_args}"
          else
            <<~STR
            $command_stdin = @'
            #{stdin}
            '@

            $command_stdin | & #{quoted_path} #{quoted_args}
            STR
          end
        Snippets.execute_process(exec_cmd)
      end

      def mkdirs(dirs)
        paths = dirs.uniq.sort.join('","')
        mkdir_command = "mkdir -Force -Path (\"#{paths}\")"
        result = execute(mkdir_command)
        if result.exit_code != 0
          message = "Could not create directories: #{result.stderr.string}"
          raise Bolt::Node::FileError.new(message, 'MKDIR_ERROR')
        end
      end

      def make_tmpdir
        find_parent = target.options['tmpdir'] ? "\"#{target.options['tmpdir']}\"" : '[System.IO.Path]::GetTempPath()'
        result = execute(Snippets.make_tmpdir(find_parent))
        if result.exit_code != 0
          raise Bolt::Node::FileError.new("Could not make tmpdir: #{result.stderr.string}", 'TMPDIR_ERROR')
        end
        result.stdout.string.chomp
      end

      def rmdir(dir)
        execute(Snippets.rmdir(dir))
      end

      def with_tmpdir
        unless @tmpdir
          # Only cleanup the directory afterward if we made it to begin with
          owner = true
          @tmpdir = make_tmpdir
        end
        yield @tmpdir
      ensure
        if owner && @tmpdir
          if target.options['cleanup']
            rmdir(@tmpdir)
          else
            Bolt::Logger.warn("Skipping cleanup of tmpdir '#{@tmpdir}'", "skip_cleanup")
          end
        end
      end

      def run_ps_task(task_path, arguments, input_method)
        # NOTE: cannot redirect STDIN to a .ps1 script inside of PowerShell
        # must create new powershell.exe process like other interpreters
        # fortunately, using PS with stdin input_method should never happen
        if input_method == 'powershell'
          Snippets.ps_task(task_path, arguments)
        else
          Snippets.try_catch(task_path)
        end
      end

      def upload(source, destination, _options = {})
        conn.upload_file(source, destination)
        Bolt::Result.for_upload(target, source, destination)
      end

      def download(source, destination, _options = {})
        download = File.join(destination, Bolt::Util.windows_basename(source))
        conn.download_file(source, destination, download)
        Bolt::Result.for_download(target, source, destination, download)
      end

      def run_command(command, options = {}, position = [])
        command = [*env_declarations(options[:env_vars]), command].join("\r\n") if options[:env_vars]

        wrap_command = conn.is_a?(Bolt::Transport::Local::Connection)
        output = execute(command, wrap_command)
        Bolt::Result.for_command(target,
                                 output.to_h,
                                 'command',
                                 command,
                                 position)
      end

      def run_script(script, arguments, options = {}, position = [])
        # unpack any Sensitive data
        arguments = unwrap_sensitive_args(arguments)
        with_tmpdir do |dir|
          script_path = write_executable(dir, script)
          command = if powershell_file?(script_path) && options[:pwsh_params]
                      # Scripts run with pwsh_params can be run like tasks
                      Snippets.ps_task(script_path, options[:pwsh_params])
                    elsif powershell_file?(script_path)
                      Snippets.run_script(arguments, script_path)
                    else
                      interpreter = select_interpreter(script_path, target.options['interpreters'])
                      if options[:script_interpreter] && interpreter
                        path = interpreter
                        args = escape_arguments([script_path])
                        logger.trace("Running '#{script_path}' using '#{interpreter}' interpreter")
                      else
                        path, args = *process_from_extension(script_path)
                      end
                      args += escape_arguments(arguments)
                      execute_process(path, args)
                    end
          env_assignments = options[:env_vars] ? env_declarations(options[:env_vars]) : []
          shell_init = options[:pwsh_params] ? Snippets.shell_init : ''

          output = execute([shell_init, *env_assignments, command].join("\r\n"))

          Bolt::Result.for_command(target,
                                   output.to_h,
                                   'script',
                                   script,
                                   position)
        end
      end

      def run_task(task, arguments, _options = {}, position = [])
        implementation = select_implementation(target, task)
        executable = implementation['path']
        input_method = implementation['input_method']
        extra_files = implementation['files']
        input_method ||= powershell_file?(executable) ? 'powershell' : 'both'

        # unpack any Sensitive data
        arguments = unwrap_sensitive_args(arguments)
        with_tmpdir do |dir|
          if extra_files.empty?
            task_dir = dir
          else
            # TODO: optimize upload of directories
            arguments['_installdir'] = dir
            task_dir = File.join(dir, task.tasks_dir)
            mkdirs([task_dir] + extra_files.map { |file| File.join(dir, File.dirname(file['name'])) })
            extra_files.each do |file|
              conn.upload_file(file['path'], File.join(dir, file['name']))
            end
          end

          task_path = write_executable(task_dir, executable)

          if Bolt::Task::STDIN_METHODS.include?(input_method)
            stdin = JSON.dump(arguments)
          end

          command = if powershell_file?(task_path) && stdin.nil?
                      run_ps_task(task_path, arguments, input_method)
                    else
                      if (interpreter = select_interpreter(task_path, target.options['interpreters']))
                        path = interpreter
                        args = [task_path]
                      else
                        path, args = *process_from_extension(task_path)
                      end
                      execute_process(path, args, stdin)
                    end

          env_assignments = if Bolt::Task::ENVIRONMENT_METHODS.include?(input_method)
                              env_declarations(envify_params(arguments))
                            else
                              []
                            end

          output = execute([
            Snippets.shell_init,
            Snippets.append_ps_module_path(dir),
            *env_assignments,
            command
          ].join("\n"))

          Bolt::Result.for_task(target, output.stdout.string,
                                output.stderr.string,
                                output.exit_code,
                                task.name,
                                position)
        end
      end

      def execute(command, wrap_command = false)
        if (conn.max_command_length && command.length > conn.max_command_length) ||
           wrap_command
          return with_tmpdir do |dir|
            command += "\r\nif (!$?) { if($LASTEXITCODE) { exit $LASTEXITCODE } else { exit 1 } }"
            script_file = File.join(dir, "#{SecureRandom.uuid}_wrapper.ps1")
            conn.upload_file(StringIO.new(command), script_file)
            args = escape_arguments([script_file])
            script_invocation = ['powershell.exe', *PS_ARGS, '-File', *args].join(' ')
            execute(script_invocation)
          end
        end
        inp, out, err, t = conn.execute(command)

        result = Bolt::Node::Output.new
        inp.close
        stdout = Thread.new do
          # Set to binmode to preserve \r\n line endings, but save and restore
          # the proper encoding so the string isn't later misinterpreted
          encoding = out.external_encoding
          out.binmode
          to_print = out.read.force_encoding(encoding)
          if !to_print.chomp.empty? && @stream_logger
            formatted = to_print.lines.map do |msg|
              "[#{@target.safe_name}] out: #{msg.chomp}"
            end.join("\n")
            @stream_logger.warn(formatted)
          end
          result.stdout        << to_print
          result.merged_output << to_print
        end
        stderr = Thread.new do
          encoding = err.external_encoding
          err.binmode
          to_print = err.read.force_encoding(encoding)
          if !to_print.chomp.empty? && @stream_logger
            formatted = to_print.lines.map do |msg|
              "[#{@target.safe_name}] err: #{msg.chomp}"
            end.join("\n")
            @stream_logger.warn(formatted)
          end
          result.stderr        << to_print
          result.merged_output << to_print
        end

        stdout.join
        stderr.join
        result.exit_code = t.value.respond_to?(:exitstatus) ? t.value.exitstatus : t.value

        result
      end
    end
  end
end