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

require "log4r"
require "tempfile"
require "timeout"

require_relative "helper"
require_relative "shell"
require_relative "command_filter"

module VagrantPlugins
  module CommunicatorWinRM
    # Provides communication channel for Vagrant commands via WinRM.
    class Communicator < Vagrant.plugin("2", :communicator)
      include Vagrant::Util

      def self.match?(machine)
        # This is useless, and will likely be removed in the future (this
        # whole method).
        true
      end

      def initialize(machine)
        @cmd_filter = CommandFilter.new()
        @logger     = Log4r::Logger.new("vagrant::communication::winrm")
        @machine    = machine
        @shell      = nil

        @logger.info("Initializing WinRMCommunicator")
      end

      def wait_for_ready(timeout)
        Timeout.timeout(timeout) do
          # Wait for winrm_info to be ready
          winrm_info = nil
          while true
            winrm_info = nil
            begin
              winrm_info = Helper.winrm_info(@machine)
            rescue Errors::WinRMNotReady
              @logger.debug("WinRM not ready yet; retrying until boot_timeout is reached.")
            end
            break if winrm_info
            sleep 0.5
          end

          # Got it! Let the user know what we're connecting to.
          @machine.ui.detail("WinRM address: #{shell.host}:#{shell.port}")
          @machine.ui.detail("WinRM username: #{shell.username}")
          @machine.ui.detail("WinRM execution_time_limit: #{shell.execution_time_limit}")
          @machine.ui.detail("WinRM transport: #{shell.config.transport}")

          last_message = nil
          last_message_repeat_at = 0
          while true
            message  = nil
            begin
              begin
                return true if ready?
              rescue Vagrant::Errors::VagrantError => e
                @logger.info("WinRM not ready: #{e.inspect}")
                raise
              end
            rescue Errors::ConnectionTimeout
              message = "Connection timeout."
            rescue Errors::AuthenticationFailed
              message = "Authentication failure."
            rescue Errors::Disconnected
              message = "Remote connection disconnect."
            rescue Errors::ConnectionRefused
              message = "Connection refused."
            rescue Errors::ConnectionReset
              message = "Connection reset."
            rescue Errors::HostDown
              message = "Host appears down."
            rescue Errors::NoRoute
              message = "Host unreachable."
            rescue Errors::TransientError => e
              # Any other retryable errors
              message = e.message
            end

            # If we have a message to show, then show it. We don't show
            # repeated messages unless they've been repeating longer than
            # 10 seconds.
            if message
              message_at   = Time.now.to_f
              show_message = true
              if last_message == message
                show_message = (message_at - last_message_repeat_at) > 10.0
              end

              if show_message
                @machine.ui.detail("Warning: #{message} Retrying...")
                last_message = message
                last_message_repeat_at = message_at
              end
            end
          end
        end
      rescue Timeout::Error
        return false
      end

      def ready?
        @logger.info("Checking whether WinRM is ready...")

        result = Timeout.timeout(@machine.config.winrm.timeout) do
          shell(true).cmd("hostname")
        end

        @logger.info("WinRM is ready!")
        return true
      rescue Errors::TransientError, VagrantPlugins::CommunicatorWinRM::Errors::WinRMNotReady => e
        # We catch a `TransientError` which would signal that something went
        # that might work if we wait and retry.
        @logger.info("WinRM not up: #{e.inspect}")

        # We reset the shell to trigger calling of winrm_finder again.
        # This resolves a problem when using vSphere where the winrm_info was not refreshing
        # thus never getting the correct hostname.
        @shell = nil
        return false
      end

      def reset!
        shell(true)
      end

      def shell(reload=false)
        @shell = nil if reload
        @shell ||= create_shell
      end

      def execute(command, opts={}, &block)
        # If this is a *nix command with no Windows equivalent, don't run it
        command = @cmd_filter.filter(command)
        return 0 if command.empty?

        opts = {
          command:     command,
          elevated:    false,
          error_check: true,
          error_class: Errors::WinRMBadExitStatus,
          error_key:   nil, # use the error_class message key
          good_exit:   0,
          shell:       :powershell,
          interactive: false,
        }.merge(opts || {})

        opts[:shell] = :elevated if opts[:elevated]
        opts[:good_exit] = Array(opts[:good_exit])
        @logger.debug("#{opts[:shell]} executing:\n#{command}")
        output = shell.send(opts[:shell], command, opts, &block)
        execution_output(output, opts)
      end
      alias_method :sudo, :execute

      def test(command, opts=nil)
        # If this is a *nix command (which we know about) with no Windows
        # equivalent, assume failure
        command = @cmd_filter.filter(command)
        return false if command.empty?

        opts = {
          command:     command,
          elevated:    false,
          error_check: false,
        }.merge(opts || {})

        # If we're passed a *nix command which PS can't parse we get exit code
        # 0, but output in stderr. We need to check both exit code and stderr.
        output = shell.send(:powershell, command)
        return output.exitcode == 0 && output.stderr.length == 0
      end

      def upload(from, to)
        @logger.info("Uploading: #{from} to #{to}")
        shell.upload(from, to)
      end

      def download(from, to)
        @logger.info("Downloading: #{from} to #{to}")
        shell.download(from, to)
      end

      protected

      # This creates a new WinRMShell based on the information we know
      # about this machine.
      def create_shell
        winrm_info = Helper.winrm_info(@machine)

        WinRMShell.new(
          winrm_info[:host],
          winrm_info[:port],
          @machine.config.winrm
        )
      end

      # Handles the raw WinRM shell result and converts it to a
      # standard Vagrant communicator result
      def execution_output(output, opts)
        if opts[:shell] == :wql
          return output
        elsif opts[:error_check] && \
          !opts[:good_exit].include?(output.exitcode)
          raise_execution_error(output, opts)
        end
        output.exitcode
      end

      def raise_execution_error(output, opts)
        # WinRM can return multiple stderr and stdout entries
        error_opts = opts.merge(
          stdout: output.stdout,
          stderr: output.stderr
        )

        # Use a different error message key if the caller gave us one,
        # otherwise use the error's default message
        error_opts[:_key] = opts[:error_key] if opts[:error_key]

        # Raise the error, use the type the caller gave us or the comm default
        raise opts[:error_class], error_opts
      end
    end #WinRM class
  end
end
