# Copyright (c) HashiCorp, Inc.
# SPDX-License-Identifier: BUSL-1.1

require File.expand_path("../../ssh/communicator", __FILE__)

require "net/sftp"

module VagrantPlugins
  module CommunicatorWinSSH
    # This class provides communication with a Windows VM running
    # the Windows native port of OpenSSH
    class Communicator < VagrantPlugins::CommunicatorSSH::Communicator
      # Command to run when checking if connection is ready and working
      READY_COMMAND="dir"

      def initialize(machine)
        super
        @logger = Log4r::Logger.new("vagrant::communication::winssh")
      end

      # Wrap the shell if required. By default we are using powershell
      # which requires no modification. If cmd is defined as shell, add
      # prefix to start within cmd.exe
      def shell_cmd(opts)
        case opts[:shell].to_s
        when "cmd"
          "cmd.exe /c '#{opts[:command]}'"
        else
          opts[:command]
        end
      end

      # Executes the command on an SSH connection within a login shell.
      def shell_execute(connection, command, **opts)
        opts[:shell] ||= machine_config_ssh.shell

        command = shell_cmd(opts.merge(command: command))

        @logger.info("Execute: #{command} - opts: #{opts}")
        exit_status = nil

        # Open the channel so we can execute or command
        channel = connection.open_channel do |ch|
          marker_found = false
          data_buffer = ''
          stderr_marker_found = false
          stderr_data_buffer = ''

          @logger.debug("Base SSH exec command: #{command}")
          command = "$ProgressPreference = 'SilentlyContinue';Write-Output #{CMD_GARBAGE_MARKER};[Console]::Error.WriteLine('#{CMD_GARBAGE_MARKER}');#{command}"

          ch.exec(command) do |ch2, _|
            # Setup the channel callbacks so we can get data and exit status
            ch2.on_data do |ch3, data|
              # Filter out the clear screen command
              data = remove_ansi_escape_codes(data)

              if !marker_found
                data_buffer << data
                marker_index = data_buffer.index(CMD_GARBAGE_MARKER)
                if marker_index
                  marker_found = true
                  data_buffer.slice!(0, marker_index + CMD_GARBAGE_MARKER.size)
                  data.replace(data_buffer)
                  data_buffer = nil
                end
              end

              if block_given? && marker_found
                yield :stdout, data
              end
            end

            ch2.on_extended_data do |ch3, type, data|
              # Filter out the clear screen command
              data = remove_ansi_escape_codes(data)
              @logger.debug("stderr: #{data}")
              if !stderr_marker_found
                stderr_data_buffer << data
                marker_index = stderr_data_buffer.index(CMD_GARBAGE_MARKER)
                if marker_index
                  stderr_marker_found = true
                  stderr_data_buffer.slice!(0, marker_index + CMD_GARBAGE_MARKER.size)
                  data.replace(stderr_data_buffer.lstrip)
                  data_buffer = nil
                end
              end

              if block_given? && stderr_marker_found && !data.empty?
                yield :stderr, data
              end
            end

            ch2.on_request("exit-status") do |ch3, data|
              exit_status = data.read_long
              @logger.debug("Exit status: #{exit_status}")

              # Close the channel, since after the exit status we're
              # probably done. This fixes up issues with hanging.
              ch.close
            end

          end
        end

        begin
          keep_alive = nil

          if @machine.config.ssh.keep_alive
            # Begin sending keep-alive packets while we wait for the script
            # to complete. This avoids connections closing on long-running
            # scripts.
            keep_alive = Thread.new do
              loop do
                sleep 5
                @logger.debug("Sending SSH keep-alive...")
                connection.send_global_request("keep-alive@openssh.com")
              end
            end
          end

          # Wait for the channel to complete
          begin
            channel.wait
          rescue Errno::ECONNRESET, IOError
            @logger.info(
              "SSH connection unexpected closed. Assuming reboot or something.")
            exit_status = 0
            pty = false
          rescue Net::SSH::ChannelOpenFailed
            raise Vagrant::Errors::SSHChannelOpenFail
          rescue Net::SSH::Disconnect
            raise Vagrant::Errors::SSHDisconnected
          end
        ensure
          # Kill the keep-alive thread
          keep_alive.kill if keep_alive
        end

        # Return the final exit status
        return exit_status
      end

      def machine_config_ssh
        @machine.config.winssh
      end

      def download(from, to=nil)
        @logger.debug("Downloading: #{from} to #{to}")

        sftp_connect do |sftp|
          sftp.download!(from, to)
        end
      end

      # Note: I could not get Net::SFTP to throw a permissions denied error,
      # even when uploading to a directory where I did not have write
      # privileges. I believe this is because Windows SSH sessions are started
      # in an elevated process.
      def upload(from, to)
        to = Vagrant::Util::Platform.unix_windows_path(to)
        @logger.debug("Uploading: #{from} to #{to}")

        if File.directory?(from)
          if from.end_with?(".")
            @logger.debug("Uploading directory contents of: #{from}")
            from = from.sub(/\.$/, "")
          else
            @logger.debug("Uploading full directory container of: #{from}")
            to = File.join(to, File.basename(File.expand_path(from)))
          end
        end

        sftp_connect do |sftp|
          uploader = lambda do |path, remote_dest=nil|
            if File.directory?(path)
              Dir.new(path).each do |entry|
                next if entry == "." || entry == ".."
                full_path = File.join(path, entry)
                dest = File.join(to, path.sub(/^#{Regexp.escape(from)}/, ""))
                sftp.mkdir(dest)
                uploader.call(full_path, dest)
              end
            else
              if remote_dest
                dest = File.join(remote_dest, File.basename(path))
              else
                dest = to
                if to.end_with?(File::SEPARATOR)
                  dest = File.join(to, File.basename(path))
                end
              end
              @logger.debug("Ensuring remote directory exists for destination upload")
              sftp.mkdir(File.dirname(dest))
              @logger.debug("Uploading file #{path} to remote #{dest}")
              upload_file = File.open(path, "rb")
              begin
                sftp.upload!(upload_file, dest)
              ensure
                upload_file.close
              end
            end
          end
          uploader.call(from)
        end
      end

      # Opens an SFTP connection and yields it so that you can download and
      # upload files. SFTP works more reliably than SCP on Windows due to
      # issues with shell quoting and escaping.
      def sftp_connect
        # Connect to SFTP and yield the SFTP object
        connect do |connection|
          return yield connection.sftp
        end
      end

      protected

      # The WinSSH communicator connection provides isolated modification
      # to the generated connection instances. This modification forces
      # all provided commands to run within powershell
      def connect(**opts)
        connection = nil
        super { |c| connection = c }

        if !connection.instance_variable_get(:@winssh_patched)
          open_chan = connection.method(:open_channel)
          connection.define_singleton_method(:open_channel) do |*args, &chan_block|
            open_chan.call(*args) do |ch|
              exec = ch.method(:exec)
              ch.define_singleton_method(:exec) do |command, &block|
                command = Base64.strict_encode64(command.encode("UTF-16LE", "UTF-8"))
                command = "powershell -NoLogo -NonInteractive -ExecutionPolicy Bypass " \
                  "-NoProfile -EncodedCommand #{command}"
                exec.call(command, &block)
              end
              chan_block.call(ch)
            end
          end
          connection.instance_variable_set(:@winssh_patched, true)
        end

        if block_given?
          yield connection
        else
          connection
        end
      end
    end
  end
end
