require "set"

module Vagrant
  module Plugin
    module V2
      # This is the base class for a configuration key defined for
      # V2. Any configuration key plugins for V2 should inherit from this
      # class.
      class Config
        # This constant represents an unset value. This is useful so it is
        # possible to know the difference between a configuration value that
        # was never set, and a value that is nil (explicitly). Best practice
        # is to initialize all variables to this value, then the {#merge}
        # method below will "just work" in many cases.

        UNSET_VALUE = :__UNSET__VALUE__

        # This is called as a last-minute hook that allows the configuration
        # object to finalize itself before it will be put into use. This is
        # a useful place to do some defaults in the case the user didn't
        # configure something or so on.
        #
        # An example of where this sort of thing is used or has been used:
        # the "vm" configuration key uses this to make sure that at least
        # one sub-VM has been defined: the default VM.
        #
        # The configuration object is expected to mutate itself.
        def finalize!
          # Default implementation is to do nothing.
        end

        # Merge another configuration object into this one. This assumes that
        # the other object is the same class as this one. This should not
        # mutate this object, but instead should return a new, merged object.
        #
        # The default implementation will simply iterate over the instance
        # variables and merge them together, with this object overriding
        # any conflicting instance variables of the older object. Instance
        # variables starting with "__" (double underscores) will be ignored.
        # This lets you set some sort of instance-specific state on your
        # configuration keys without them being merged together later.
        #
        # @param [Object] other The other configuration object to merge from,
        #   this must be the same type of object as this one.
        # @return [Object] The merged object.
        def merge(other)
          result = self.class.new

          # Set all of our instance variables on the new class
          [self, other].each do |obj|
            obj.instance_variables.each do |key|
              # Ignore keys that start with a double underscore. This allows
              # configuration classes to still hold around internal state
              # that isn't propagated.
              if !key.to_s.start_with?("@__")
                # Don't set the value if it is the unset value, either.
                value = obj.instance_variable_get(key)
                result.instance_variable_set(key, value) if value != UNSET_VALUE
              end
            end
          end

          # Persist through the set of invalid methods
          this_invalid  = @__invalid_methods || Set.new
          other_invalid = other.instance_variable_get(:"@__invalid_methods") || Set.new
          result.instance_variable_set(:"@__invalid_methods", this_invalid + other_invalid)

          result
        end

        # Capture all bad configuration calls and save them for an error
        # message later during validation.
        def method_missing(name, *args, &block)
          return super if @__finalized

          # There are a few scenarios where ruby will attempt to implicity
          # coerce a given object into a certain type. Configs can end up
          # in some of these scenarios when they're being shipped around in
          # callbacks with splats. If method_missing allows these methods to be
          # called but continues to return Config back, Ruby will raise a
          # TypeError. Doing the normal thing of raising NoMethodError allows
          # Config to behave normally as its being passed through splats.
          #
          # For a bit more detail and some keywords for further searching, see:
          # https://ruby-doc.org/core-2.7.2/doc/implicit_conversion_rdoc.html
          if [:to_hash, :to_ary].include?(name)
            return super
          end

          name = name.to_s
          name = name[0...-1] if name.end_with?("=")

          @__invalid_methods ||= Set.new
          @__invalid_methods.add(name)

          # Return the dummy object so that anything else works
          ::Vagrant::Config::V2::DummyConfig.new
        end

        # Allows setting options from a hash. By default this simply calls
        # the `#{key}=` method on the config class with the value, which is
        # the expected behavior most of the time.
        #
        # This is expected to mutate itself.
        #
        # @param [Hash] options A hash of options to set on this configuration
        #   key.
        def set_options(options)
          options.each do |key, value|
            send("#{key}=", value)
          end
        end

        # Converts this configuration object to JSON.
        def to_json(*a)
          instance_variables_hash.to_json(*a)
        end

        # A default to_s implementation.
        def to_s
          self.class.to_s
        end

        # Returns the instance variables as a hash of key-value pairs.
        def instance_variables_hash
          instance_variables.inject({}) do |acc, iv|
            acc[iv.to_s[1..-1]] = instance_variable_get(iv)
            acc
          end
        end

        # Called after the configuration is finalized and loaded to validate
        # this object.
        #
        # @param [Machine] machine Access to the machine that is being
        #   validated.
        # @return [Hash]
        def validate(machine)
          return { self.to_s => _detected_errors }
        end

        # This returns any automatically detected errors.
        #
        # @return [Array<String>]
        def _detected_errors
          return [] if !@__invalid_methods || @__invalid_methods.empty?
          return [I18n.t("vagrant.config.common.bad_field",
                         fields: @__invalid_methods.to_a.sort.join(", "))]
        end

        # An internal finalize call that no subclass should override.
        def _finalize!
          @__finalized = true
        end

        def clean_up_config_object(config)
          # Remote variables that are internal
          config.delete_if{|k,_| k.start_with?("_") }
          config
        end
      end
    end
  end
end
