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

require "vagrant/util/template_renderer"
require "log4r"

module Vagrant
  # This class provides a way to load and access the contents
  # of a Vagrantfile.
  #
  # This class doesn't actually load Vagrantfiles, parse them,
  # merge them, etc. That is the job of {Config::Loader}. This
  # class, on the other hand, has higher-level operations on
  # a loaded Vagrantfile such as looking up the defined machines,
  # loading the configuration of a specific machine/provider combo,
  # etc.
  class Vagrantfile
    # This is the configuration loaded as-is given the loader and
    # keys to #initialize.
    attr_reader :config

    # Initializes by loading a Vagrantfile.
    #
    # @param [Config::Loader] loader Configuration loader that should
    #   already be configured with the proper Vagrantfile locations.
    #   This usually comes from {Vagrant::Environment}
    # @param [Array<Symbol>] keys The Vagrantfiles to load and the
    #   order to load them in (keys within the loader).
    def initialize(loader, keys)
      @keys   = keys
      @loader = loader
      @config, _ = loader.load(keys)
      @logger = Log4r::Logger.new("vagrant::vagrantfile")
    end

    # Returns a {Machine} for the given name and provider that
    # is represented by this Vagrantfile.
    #
    # @param [Symbol] name Name of the machine.
    # @param [Symbol] provider The provider the machine should
    #   be backed by (required for provider overrides).
    # @param [BoxCollection] boxes BoxCollection to look up the
    #   box Vagrantfile.
    # @param [Pathname] data_path Path where local machine data
    #   can be stored.
    # @param [Environment] env The environment running this machine
    # @return [Machine]
    def machine(name, provider, boxes, data_path, env)
      # Load the configuration for the machine
      results = machine_config(name, provider, boxes, data_path)
      box             = results[:box]
      config          = results[:config]
      config_errors   = results[:config_errors]
      config_warnings = results[:config_warnings]
      provider_cls    = results[:provider_cls]
      provider_options = results[:provider_options]

      # If there were warnings or errors we want to output them
      if !config_warnings.empty? || !config_errors.empty?
        # The color of the output depends on whether we have warnings
        # or errors...
        level  = config_errors.empty? ? :warn : :error
        output = Util::TemplateRenderer.render(
          "config/messages",
          warnings: config_warnings,
          errors: config_errors).chomp
        env.ui.send(level, I18n.t("vagrant.general.config_upgrade_messages",
                               name: name,
                               output: output))

        # If we had errors, then we bail
        raise Errors::ConfigUpgradeErrors if !config_errors.empty?
      end

      # Get the provider configuration from the final loaded configuration
      provider_config = config.vm.get_provider_config(provider)

      # Create machine data directory if it doesn't exist
      # XXX: Permissions error here.
      FileUtils.mkdir_p(data_path)

      # Create the machine and cache it for future calls. This will also
      # return the machine from this method.
      return Machine.new(name, provider, provider_cls, provider_config,
        provider_options, config, data_path, box, env, self)
    end
    # Returns the configuration for a single machine.
    #
    # When loading a box Vagrantfile, it will be prepended to the
    # key order specified when initializing this class. Sub-machine
    # and provider-specific overrides are appended at the end. The
    # actual order is:
    #
    # - box
    # - keys specified for #initialize
    # - sub-machine
    # - provider
    #
    # The return value is a hash with the following keys (symbols)
    # and values:
    #
    #   - box: the {Box} backing the machine
    #   - config: the actual configuration
    #   - config_errors: list of errors, if any
    #   - config_warnings: list of warnings, if any
    #   - provider_cls: class of the provider backing the machine
    #   - provider_options: options for the provider
    #
    # @param [Symbol] name Name of the machine.
    # @param [Symbol] provider The provider the machine should
    #   be backed by (required for provider overrides).
    # @param [BoxCollection] boxes BoxCollection to look up the
    #   box Vagrantfile.
    # @param [Pathname] data_path Machine data path
    # @return [Hash<Symbol, Object>] Various configuration parameters for a
    #   machine. See the main documentation body for more info.
    def machine_config(name, provider, boxes, data_path=nil, validate_provider=true)
      keys = @keys.dup

      sub_machine = @config.vm.defined_vms[name]
      if !sub_machine
        raise Errors::MachineNotFound,
          name: name, provider: provider
      end

      provider_plugin  = nil
      provider_cls     = nil
      provider_options = {}
      box_formats      = nil
      if provider != nil
        provider_plugin  = Vagrant.plugin("2").manager.providers[provider]
        if !provider_plugin && validate_provider
          providers  = Vagrant.plugin("2").manager.providers.to_hash.keys
          if providers
            providers_str = providers.join(', ')
          else
            providers_str = "N/A"
          end

          if providers.include? provider.downcase
            raise Errors::ProviderNotFoundSuggestion,
              machine: name, provider: provider,
              suggestion: provider.downcase, providers: providers_str
          end

          raise Errors::ProviderNotFound,
            machine: name, provider: provider, providers: providers_str
        end

        if validate_provider
          provider_cls     = provider_plugin[0]
          provider_options = provider_plugin[1]
          box_formats      = provider_options[:box_format] || provider

          # Test if the provider is usable or not
          begin
            provider_cls.usable?(true)
          rescue Errors::VagrantError => e
            raise Errors::ProviderNotUsable,
              machine: name.to_s,
              provider: provider.to_s,
              message: e.to_s
          end
        else
          box_formats = provider
        end
      end

      # Add the sub-machine configuration to the loader and keys
      vm_config_key = "#{object_id}_machine_#{name}"
      @loader.set(vm_config_key, sub_machine.config_procs)
      keys << vm_config_key

      # Load once so that we can get the proper box value
      config, config_warnings, config_errors = @loader.load(keys)

      # Track the original box so we know if we changed
      box = nil
      initial_box = original_box = config.vm.box
      initial_version = original_version = config.vm.box_version

      # Check if this machine has a local box metadata file
      # describing the existing guest. If so, load it and
      # set the box name and version to allow the actual
      # box in use to be discovered.
      if data_path
        meta_file = data_path.join("box_meta")
        if meta_file.file?
          box_meta = JSON.parse(meta_file.read)
          config.vm.box = box_meta["name"]
          config.vm.box_version = box_meta["version"]
        end
      end

      # The proc below loads the box and provider overrides. This is
      # in a proc because it may have to recurse if the provider override
      # changes the box.
      load_box_proc = lambda do
        local_keys = keys.dup

        # Load the box Vagrantfile, if there is one
        if !config.vm.box.to_s.empty? && boxes
          box = boxes.find(config.vm.box, box_formats, config.vm.box_version, config.vm.box_architecture)
          if box
            box_vagrantfile = find_vagrantfile(box.directory)
            if box_vagrantfile && !config.vm.ignore_box_vagrantfile
              box_config_key =
                "#{boxes.object_id}_#{box.name}_#{box.provider}".to_sym
              @loader.set(box_config_key, box_vagrantfile)
              local_keys.unshift(box_config_key)
              config, config_warnings, config_errors = @loader.load(local_keys)
            elsif box_vagrantfile && config.vm.ignore_box_vagrantfile
              @logger.warn("Ignoring #{box.name} provided Vagrantfile inside box")
            end
          end
        end

        # Load provider overrides
        provider_overrides = config.vm.get_provider_overrides(provider)
        if !provider_overrides.empty?
          config_key =
            "#{object_id}_vm_#{name}_#{config.vm.box}_#{provider}".to_sym
          @loader.set(config_key, provider_overrides)
          local_keys << config_key
          config, config_warnings, config_errors = @loader.load(local_keys)
        end

        # If the box changed, then we need to reload
        if original_box != config.vm.box || original_version != config.vm.box_version
          # TODO: infinite loop protection?

          original_box = config.vm.box
          original_version = config.vm.box_version
          load_box_proc.call
        end
      end

      # Load the box and provider overrides
      load_box_proc.call

      # NOTE: In cases where the box_meta file contains stale information
      #       and the reference box no longer exists, fall back to initial
      #       configuration and attempt to load that
      if box.nil?
        @logger.warn("Failed to locate #{config.vm.box} with version #{config.vm.box_version}")
        @logger.warn("Performing lookup with initial values #{initial_box} with version #{initial_version}")
        config.vm.box = original_box = initial_box
        config.vm.box_version = original_box = initial_version
        load_box_proc.call
      end

      # Ensure box attributes are set to original values in
      # case they were modified by the local box metadata
      config.vm.box = original_box
      config.vm.box_version = original_version

      return {
        box: box,
        provider_cls: provider_cls,
        provider_options: provider_options.dup,
        config: config,
        config_warnings: config_warnings,
        config_errors: config_errors,
      }
    end

    # Returns a list of the machines that are defined within this
    # Vagrantfile.
    #
    # @return [Array<Symbol>]
    def machine_names
      @config.vm.defined_vm_keys.dup
    end

    # Returns a list of the machine names as well as the options that
    # were specified for that machine.
    #
    # @return [Hash<Symbol, Hash>]
    def machine_names_and_options
      {}.tap do |r|
        @config.vm.defined_vms.each do |name, subvm|
          r[name] = subvm.options || {}
        end
      end
    end

    # Returns the name of the machine that is designated as the
    # "primary."
    #
    # In the case of a single-machine environment, this is just the
    # single machine name. In the case of a multi-machine environment,
    # then this is the machine that is marked as primary, or nil if
    # no primary machine was specified.
    #
    # @return [Symbol]
    def primary_machine_name
      # If it is a single machine environment, then return the name
      return machine_names.first if machine_names.length == 1

      # If it is a multi-machine environment, then return the primary
      @config.vm.defined_vms.each do |name, subvm|
        return name if subvm.options[:primary]
      end

      # If no primary was specified, nil it is
      nil
    end

    protected

    def find_vagrantfile(search_path)
      ["Vagrantfile", "vagrantfile"].each do |vagrantfile|
        current_path = search_path.join(vagrantfile)
        return current_path if current_path.file?
      end

      nil
    end
  end
end
