require_relative 'config_path'

module Aptible
  module CLI
    module Helpers
      module Ssh
        include Helpers::ConfigPath

        def connect_to_ssh_portal(operation, *extra_ssh_args)
          # NOTE: This is a little tricky to get rigt, so before you make any
          # changes, read this.
          #
          # - The first gotcha is that we cannot use Kernel.exec here, because
          # we need to perform cleanup when exiting from
          # operation#with_ssh_cmd.
          #
          # - The second gotcha is that we need to somehow capture the exit
          # status, so that CLI commands that call the SSH portal can proxy
          # this back to their own caller (the most important one here is
          # aptible ssh).
          #
          # To do this, we have to handle interrutps as a signal, as opposed to
          # handle an Interrupt exception. The reason for this has to do with
          # how Ruby's wait is implemented (this happens in process.c's
          # rb_waitpid). There are two main considerations here:
          #
          # - It automatically resumes when it receives EINTR, so our control
          # is pretty high-level here.
          # - It handles interrupts prior to setting $? (this appears to have
          # changed between Ruby 2.2 and 2.3, perhaps the newer implementation
          # behaves differently).
          #
          # Unfortunately, this means that if we receive SIGINT while in
          # Process::wait2, then we never get access to SSH's exitstatus: Ruby
          # throws a Interrupt so we don't have a return value, and it doesn't
          # set $?, so we can't read it back there.
          #
          # Of course, we can't just call Proces::wait2 again, because at this
          # point, we've reaped our child.
          #
          # To solve this, we add our own signal handler on SIGINT, which
          # simply proxies SIGINT to SSH if we happen to have a different
          # process group (which shouldn't be the case), just to be safe and
          # let users exit the CLI.
          with_ssh_cmd(operation) do |base_ssh_cmd|
            spawn_passthrough(base_ssh_cmd + extra_ssh_args)
          end
        end

        def exit_with_ssh_portal(*args)
          exit connect_to_ssh_portal(*args)
        end

        def with_ssh_cmd(operation)
          ensure_ssh_dir!
          ensure_config!
          ensure_key!

          operation.with_ssh_cmd(private_key_file) do |cmd, connection|
            yield cmd + common_ssh_args, connection
          end
        end

        private

        def spawn_passthrough(command)
          redirection = { in: :in, out: :out, err: :err, close_others: true }
          pid = Process.spawn(*command, redirection)

          reset = Signal.trap('SIGINT') do
            # FIXME: If we're on Windows, we don't really know whether SSH
            # received SIGINT or not, so for now, we just ignore it.
            next if Gem.win_platform?

            begin
              # SSH should be running in our process group, which means that
              # if the user sends CTRL+C, we'll both receive it. In this
              # case, just ignore the signal and let SSH handle it.
              next if Process.getpgid(Process.pid) == Process.getpgid(pid)

              # If we get here, then oddly, SSH is not running in our process
              # group and yet we got the signal. In this case, let's simply
              # ignore it.
              Process.kill(:SIGINT, pid)
            rescue Errno::ESRCH
              # This could happen if SSH exited after receiving the SIGINT,
              # Ruby waited it, then ran our signal handler. In this case, we
              # don't need to do anything, so we proceed.
            end
          end

          begin
            _, status = Process.wait2(pid)
            return status.exited? ? status.exitstatus : 128 + status.termsig
          ensure
            Signal.trap('SIGINT', reset)
          end
        end

        def ensure_ssh_dir!
          FileUtils.mkdir_p(ssh_dir, mode: 0o700)
        end

        def ensure_config!
          return if File.exist?(ssh_config_file)
          File.open(ssh_config_file, 'w', 0o600) { |f| f.write('') }
        end

        def ensure_key!
          key_files = [private_key_file, public_key_file]
          return if key_files.all? { |f| File.exist?(f) }

          # If we're missing *some* files, then we should clean them up.

          key_files.each do |key_file|
            begin
              File.delete(key_file)
            rescue Errno::ENOENT
              # We don't care, that's what we want.
            end
          end

          begin
            cmd = ['ssh-keygen', '-t', 'rsa', '-N', '', '-f', private_key_file]
            out, status = Open3.capture2e(*cmd)
            raise "Failed to generate ssh key: #{out}" unless status.success?
          rescue Errno::ENOENT
            raise 'ssh-keygen must be installed'
          end
        end

        def ssh_dir
          File.join aptible_config_path, 'ssh'
        end

        def ssh_config_file
          File.join ssh_dir, 'config'
        end

        def private_key_file
          File.join ssh_dir, 'id_rsa'
        end

        def public_key_file
          "#{private_key_file}.pub"
        end

        def common_ssh_args
          log_level = ENV['APTIBLE_SSH_DEBUG'] ? 'DEBUG3' : 'ERROR'

          [
            '-o', 'TCPKeepAlive=yes',
            '-o', 'KeepAlive=yes',
            '-o', 'ServerAliveInterval=60',
            '-o', "LogLevel=#{log_level}",
            '-o', 'ControlMaster=no',
            '-o', 'ControlPath=none',
            '-F', ssh_config_file
          ]
        end
      end
    end
  end
end