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

require 'log4r'

require "vagrant/util/safe_puts"

module Vagrant
  module Plugin
    module V2
      # This is the base class for a CLI command.
      class Command
        include Util::SafePuts

        # This should return a brief (60 characters or less) synopsis of what
        # this command does. It will be used in the output of the help.
        #
        # @return [String]
        def self.synopsis
          ""
        end

        def initialize(argv, env)
          @argv = argv
          @env  = env
          @logger = Log4r::Logger.new("vagrant::command::#{self.class.to_s.downcase}")
        end

        # This is what is called on the class to actually execute it. Any
        # subclasses should implement this method and do any option parsing
        # and validation here.
        def execute
        end

        protected

        # Parses the options given an OptionParser instance.
        #
        # This is a convenience method that properly handles duping the
        # originally argv array so that it is not destroyed.
        #
        # This method will also automatically detect "-h" and "--help"
        # and print help. And if any invalid options are detected, the help
        # will be printed, as well.
        #
        # If this method returns `nil`, then you should assume that help
        # was printed and parsing failed.
        def parse_options(opts=nil)
          # make sure optparse doesn't use POSIXLY_CORRECT parsing
          ENV["POSIXLY_CORRECT"] = nil

          # Creating a shallow copy of the arguments so the OptionParser
          # doesn't destroy the originals.
          argv = @argv.dup

          # Default opts to a blank optionparser if none is given
          opts ||= Vagrant::OptionParser.new

          # Add the help option, which must be on every command.
          opts.on_tail("-h", "--help", "Print this help") do
            safe_puts(opts.help)
            return nil
          end

          opts.parse!(argv)
          return argv
        rescue OptionParser::InvalidOption, OptionParser::MissingArgument, OptionParser::AmbiguousOption
          raise Errors::CLIInvalidOptions, help: opts.help.chomp
        end

        # Yields a VM for each target VM for the command.
        #
        # This is a convenience method for easily implementing methods that
        # take a target VM (in the case of multi-VM) or every VM if no
        # specific VM name is specified.
        #
        # @param [String] name The name of the VM. Nil if every VM.
        # @param [Hash] options Additional tweakable settings.
        # @option options [Symbol] :provider The provider to back the
        #   machines with. All machines will be backed with this
        #   provider. If none is given, a sensible default is chosen.
        # @option options [Boolean] :reverse If true, the resulting order
        #   of machines is reversed.
        # @option options [Boolean] :single_target If true, then an
        #   exception will be raised if more than one target is found.
        def with_target_vms(names=nil, options=nil)
          @logger.debug("Getting target VMs for command. Arguments:")
          @logger.debug(" -- names: #{names.inspect}")
          @logger.debug(" -- options: #{options.inspect}")

          # Setup the options hash
          options ||= {}

          # Require that names be an array
          names ||= []
          names = [names] if !names.is_a?(Array)

          # Determine if we require a local Vagrant environment. There are
          # two cases that we require a local environment:
          #
          #   * We're asking for ANY/EVERY VM (no names given).
          #
          #   * We're asking for specific VMs, at least once of which
          #     is NOT in the local machine index.
          #
          requires_local_env = false
          requires_local_env = true if names.empty?
          requires_local_env ||= names.any? { |n|
            !@env.machine_index.include?(n)
          }
          raise Errors::NoEnvironmentError if requires_local_env && !@env.root_path

          @logger.info("getting active machines")
          # Cache the active machines outside the loop
          active_machines = @env.active_machines

          # This is a helper that gets a single machine with the proper
          # provider. The "proper provider" in this case depends on what was
          # given:
          #
          #   * If a provider was explicitly specified, then use that provider.
          #     But if an active machine exists with a DIFFERENT provider,
          #     then throw an error (for now), since we don't yet support
          #     bringing up machines with different providers.
          #
          #   * If no provider was specified, then use the active machine's
          #     provider if it exists, otherwise use the default provider.
          #
          get_machine = lambda do |name|
            # Check for an active machine with the same name
            provider_to_use = options[:provider]
            provider_to_use = provider_to_use.to_sym if provider_to_use

            # If we have this machine in our index, load that.
            entry = @env.machine_index.get(name.to_s)
            if entry
              @env.machine_index.release(entry)

              # Create an environment for this location and yield the
              # machine in that environment. We silence warnings here because
              # Vagrantfiles often have constants, so people would otherwise
              # constantly (heh) get "already initialized constant" warnings.
              begin
                env = entry.vagrant_env(
                  @env.home_path, ui_class: @env.ui_class)
              rescue Vagrant::Errors::EnvironmentNonExistentCWD
                # This means that this environment working directory
                # no longer exists, so delete this entry.
                entry = @env.machine_index.get(name.to_s)
                @env.machine_index.delete(entry) if entry
                raise
              end

              next env.machine(entry.name.to_sym, entry.provider.to_sym)
            end

            active_machines.each do |active_name, active_provider|
              if name == active_name
                # We found an active machine with the same name

                if provider_to_use && provider_to_use != active_provider
                  # We found an active machine with a provider that doesn't
                  # match the requested provider. Show an error.
                  raise Errors::ActiveMachineWithDifferentProvider,
                    name: active_name.to_s,
                    active_provider: active_provider.to_s,
                    requested_provider: provider_to_use.to_s
                else
                  # Use this provider and exit out of the loop. One of the
                  # invariants [for now] is that there shouldn't be machines
                  # with multiple providers.
                  @logger.info("Active machine found with name #{active_name}. " +
                               "Using provider: #{active_provider}")
                  provider_to_use = active_provider
                  break
                end
              end
            end

            # Use the default provider if nothing else
            provider_to_use ||= @env.default_provider(machine: name)

            # Get the right machine with the right provider
            @env.machine(name, provider_to_use)
          end

          # First determine the proper array of VMs.
          machines = []
          if names.length > 0
            names.each do |name|
              if pattern = name[/^\/(.+?)\/$/, 1]
                @logger.debug("Finding machines that match regex: #{pattern}")

                # This is a regular expression name, so we convert to a regular
                # expression and allow that sort of matching.
                regex = Regexp.new(pattern)

                @env.machine_names.each do |machine_name|
                  if machine_name =~ regex
                    machines << get_machine.call(machine_name)
                  end
                end

                raise Errors::VMNoMatchError if machines.empty?
              else
                # String name, just look for a specific VM
                @logger.debug("Finding machine that match name: #{name}")
                machines << get_machine.call(name.to_sym)
                raise Errors::VMNotFoundError, name: name if !machines[0]
              end
            end
          else
            # No name was given, so we return every VM in the order
            # configured.
            @logger.debug("Loading all machines...")
            machines = @env.machine_names.map do |machine_name|
              get_machine.call(machine_name)
            end
          end

          @logger.debug("have machine list to process")

          # Make sure we're only working with one VM if single target
          if options[:single_target] && machines.length != 1
            @logger.debug("Using primary machine since single target")
            primary_name = @env.primary_machine_name
            raise Errors::MultiVMTargetRequired if !primary_name
            machines = [get_machine.call(primary_name)]
          end

          # If we asked for reversed ordering, then reverse it
          machines.reverse! if options[:reverse]

          # Go through each VM and yield it!
          color_order = [:default]
          color_index = 0

          machines.each do |machine|
            if (machine.state && machine.state.id != :not_created &&
                !machine.index_uuid.nil? && !@env.machine_index.include?(machine.index_uuid))
              machine.recover_machine(machine.state.id)
            end

            # Set the machine color
            machine.ui.opts[:color] = color_order[color_index % color_order.length]
            color_index += 1

            @logger.info("With machine: #{machine.name} (#{machine.provider.inspect})")
            yield machine

            # Call the state method so that we update our index state. Don't
            # worry about exceptions here, since we just care about updating
            # the cache.
            begin
              # Called for side effects
              machine.state
            rescue Errors::VagrantError
            end
          end
        end

        # This method will split the argv given into three parts: the
        # flags to this command, the subcommand, and the flags to the
        # subcommand. For example:
        #
        #     -v status -h -v
        #
        # The above would yield 3 parts:
        #
        #     ["-v"]
        #     "status"
        #     ["-h", "-v"]
        #
        # These parts are useful because the first is a list of arguments
        # given to the current command, the second is a subcommand, and the
        # third are the commands given to the subcommand.
        #
        # @return [Array] The three parts.
        def split_main_and_subcommand(argv)
          # Initialize return variables
          main_args   = nil
          sub_command = nil
          sub_args    = []

          # We split the arguments into two: One set containing any
          # flags before a word, and then the rest. The rest are what
          # get actually sent on to the subcommand.
          argv.each_index do |i|
            if !argv[i].start_with?("-")
              # We found the beginning of the sub command. Split the
              # args up.
              main_args   = argv[0, i]
              sub_command = argv[i]
              sub_args    = argv[i + 1, argv.length - i + 1]

              # Break so we don't find the next non flag and shift our
              # main args.
              break
            end
          end

          # Handle the case that argv was empty or didn't contain any subcommand
          main_args = argv.dup if main_args.nil?

          return [main_args, sub_command, sub_args]
        end
      end
    end
  end
end
