# frozen_string_literal: true

require 'bolt/node/errors'
require 'bolt/transport/base'
require 'json'
require 'shellwords'

module Bolt
  module Transport
    class SSH < Base
      def self.options
        %w[port user password sudo-password private-key host-key-check
           connect-timeout tmpdir run-as tty run-as-command proxyjump interpreters]
      end

      def self.default_options
        {
          'connect-timeout' => 10,
          'host-key-check' => true,
          'tty' => false
        }
      end

      def provided_features
        ['shell']
      end

      def self.validate(options)
        logger = Logging.logger[self]

        if options['sudo-password'] && options['run-as'].nil?
          logger.warn("--sudo-password will not be used without specifying a " \
                       "user to escalate to with --run-as")
        end

        host_key = options['host-key-check']
        unless !!host_key == host_key
          raise Bolt::ValidationError, 'host-key-check option must be a Boolean true or false'
        end

        if (key_opt = options['private-key'])
          unless key_opt.instance_of?(String) || (key_opt.instance_of?(Hash) && key_opt.include?('key-data'))
            raise Bolt::ValidationError,
                  "private-key option must be the path to a private key file or a hash containing the 'key-data'"
          end
        end

        timeout_value = options['connect-timeout']
        unless timeout_value.is_a?(Integer) || timeout_value.nil?
          error_msg = "connect-timeout value must be an Integer, received #{timeout_value}:#{timeout_value.class}"
          raise Bolt::ValidationError, error_msg
        end

        run_as_cmd = options['run-as-command']
        if run_as_cmd && (!run_as_cmd.is_a?(Array) || run_as_cmd.any? { |n| !n.is_a?(String) })
          raise Bolt::ValidationError, "run-as-command must be an Array of Strings, received #{run_as_cmd}"
        end
      end

      def initialize
        super

        require 'net/ssh'
        require 'net/scp'
        begin
          require 'net/ssh/krb'
        rescue LoadError
          logger.debug("Authentication method 'gssapi-with-mic' (Kerberos) is not available.")
        end

        @transport_logger = Logging.logger[Net::SSH]
        @transport_logger.level = :warn
      end

      def with_connection(target, load_config = true)
        conn = Connection.new(target, @transport_logger, load_config)
        conn.connect
        yield conn
      ensure
        begin
          conn&.disconnect
        rescue StandardError => ex
          logger.info("Failed to close connection to #{target.uri} : #{ex.message}")
        end
      end

      def upload(target, source, destination, options = {})
        with_connection(target) do |conn|
          conn.running_as(options['_run_as']) do
            conn.with_remote_tempdir do |dir|
              basename = File.basename(destination)
              tmpfile = "#{dir}/#{basename}"
              conn.write_remote_file(source, tmpfile)
              # pass over file ownership if we're using run-as to be a different user
              dir.chown(conn.run_as)
              result = conn.execute(['mv', tmpfile, destination], sudoable: true)
              if result.exit_code != 0
                message = "Could not move temporary file '#{tmpfile}' to #{destination}: #{result.stderr.string}"
                raise Bolt::Node::FileError.new(message, 'MV_ERROR')
              end
            end
            Bolt::Result.for_upload(target, source, destination)
          end
        end
      end

      def run_command(target, command, options = {})
        with_connection(target) do |conn|
          conn.running_as(options['_run_as']) do
            output = conn.execute(command, sudoable: true)
            Bolt::Result.for_command(target,
                                     output.stdout.string,
                                     output.stderr.string,
                                     output.exit_code,
                                     'command', command)
          end
        end
      end

      def run_script(target, script, arguments, options = {})
        # unpack any Sensitive data
        arguments = unwrap_sensitive_args(arguments)

        with_connection(target) do |conn|
          conn.running_as(options['_run_as']) do
            conn.with_remote_tempdir do |dir|
              remote_path = conn.write_remote_executable(dir, script)
              dir.chown(conn.run_as)
              output = conn.execute([remote_path, *arguments], sudoable: true)
              Bolt::Result.for_command(target,
                                       output.stdout.string,
                                       output.stderr.string,
                                       output.exit_code,
                                       'script', script)
            end
          end
        end
      end

      def run_task(target, task, arguments, options = {})
        implementation = select_implementation(target, task)
        executable = implementation['path']
        input_method = implementation['input_method']
        extra_files = implementation['files']

        # unpack any Sensitive data
        arguments = unwrap_sensitive_args(arguments)
        with_connection(target, options.fetch('_load_config', true)) do |conn|
          conn.running_as(options['_run_as']) do
            stdin, output = nil
            command = []
            execute_options = {}
            execute_options[:interpreter] = select_interpreter(executable, target.options['interpreters'])

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

              remote_task_path = conn.write_remote_executable(task_dir, executable)

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

              if ENVIRONMENT_METHODS.include?(input_method)
                execute_options[:environment] = envify_params(arguments)
              end

              if conn.run_as && stdin
                # Inject interpreter in to wrapper script and remove from execute options
                wrapper = make_wrapper_stringio(remote_task_path, stdin, execute_options[:interpreter])
                execute_options.delete(:interpreter)
                remote_wrapper_path = conn.write_remote_executable(dir, wrapper, 'wrapper.sh')
                command << remote_wrapper_path
              else
                command << remote_task_path
                execute_options[:stdin] = stdin
              end
              dir.chown(conn.run_as)

              execute_options[:sudoable] = true if conn.run_as
              output = conn.execute(command, execute_options)
            end
            Bolt::Result.for_task(target, output.stdout.string,
                                  output.stderr.string,
                                  output.exit_code,
                                  task.name)
          end
        end
      end

      def make_wrapper_stringio(task_path, stdin, interpreter = nil)
        if interpreter
          StringIO.new(<<-SCRIPT)
#!/bin/sh
'#{interpreter}' '#{task_path}' <<'EOF'
#{stdin}
EOF
SCRIPT
        else
          StringIO.new(<<-SCRIPT)
#!/bin/sh
'#{task_path}' <<'EOF'
#{stdin}
EOF
SCRIPT
        end
      end

      def connected?(target)
        with_connection(target) { true }
      rescue Bolt::Node::ConnectError
        false
      end
    end
  end
end

require 'bolt/transport/ssh/connection'