# A LookupAdapter is a specialized DataAdapter that uses its hash to store module providers. It also remembers the environment
# that it is attached to and maintains a cache of _lookup options_ retrieved from its data providers.
#
class Puppet::DataProviders::LookupAdapter < Puppet::DataProviders::DataAdapter

  LOOKUP_OPTIONS = 'lookup_options'.freeze
  HASH = 'hash'.freeze
  MERGE = 'merge'.freeze

  def self.create_adapter(env)
    new(env)
  end

  def initialize(env)
    super()
    @env = env
    @lookup_options = {}
  end

  # Performs a lookup using global, environment, and module data providers. Merge the result using the given
  # _merge_ strategy. If the merge strategy is nil, then an attempt is made to find merge options in the
  # `lookup_options` hash for an entry associated with the key. If no options are found, the no merge is performed
  # and the first found entry is returned.
  #
  # @param key [String] The key to lookup
  # @param merge [Puppet::Pops::MergeStrategy,String,Hash<String,Object>,nil] Merge strategy or hash with strategy and options
  # @param lookup_invocation [Puppet::Pops::Lookup::Invocation] Invocation data containing scope, overrides, and defaults
  # @return [Object] The found value
  # @throws :no_such_key if the given key is not found
  #
  def lookup(key, lookup_invocation, merge)
    merge = lookup_merge_options(key, lookup_invocation) if merge.nil?
    merge_strategy = Puppet::Pops::MergeStrategy.strategy(merge)
    lookup_invocation.with(:merge, merge_strategy) do
      result = merge_strategy.merge_lookup([:lookup_global, :lookup_in_environment, :lookup_in_module]) { |m| send(m, key, lookup_invocation, merge_strategy) }
      lookup_invocation.report_result(result)
      result
    end
  end

  # @api private
  def lookup_global(name, lookup_invocation, merge_strategy)
    terminus = Puppet[:data_binding_terminus]
    lookup_invocation.with(:global, terminus) do
      catch(:no_such_key) do
        return lookup_invocation.report_found(name, Puppet::DataBinding.indirection.find(name,
            { :environment => @env.to_s, :variables => lookup_invocation.scope, :merge => merge_strategy }))
      end
      lookup_invocation.report_not_found(name)
      throw :no_such_key
    end
  rescue Puppet::DataBinding::LookupError => e
    raise Puppet::Error.new("Error from DataBinding '#{terminus}' while looking up '#{name}': #{e.message}", e)
  end

  # @api private
  def lookup_in_environment(name, lookup_invocation, merge_strategy)
    env_provider.lookup(name, lookup_invocation, merge_strategy)
  end

  # @api private
  def lookup_in_module(name, lookup_invocation, merge_strategy)
    module_name = extract_module_name(name)

    # Do not attempt to do a lookup in a module unless the name is qualified.
    throw :no_such_key if module_name.nil?

    lookup_invocation.with(:module, module_name) do
      if @env.module(module_name).nil?
        lookup_invocation.report_module_not_found
        throw :no_such_key
      end
      module_provider(module_name).lookup(name, lookup_invocation, merge_strategy)
    end
  end

  # Retrieve the merge options that match the given `name`.
  #
  # @param name [String] The key for which we want merge options
  # @param lookup_invocation [Puppet::Pops::Lookup::Invocation] the lookup invocation
  # @return [String,Hash,nil] The found merge options or nil
  #
  def lookup_merge_options(name, lookup_invocation)
    lookup_options = lookup_lookup_options(name, lookup_invocation)
    lookup_options.nil? ? nil : lookup_options[MERGE]
  end

  # Retrieve the lookup options that match the given `name`.
  #
  # @param name [String] The key for which we want lookup options
  # @param lookup_invocation [Puppet::Pops::Lookup::Invocation] the lookup invocation
  # @return [String,Hash,nil] The found lookup options or nil
  #
  def lookup_lookup_options(name, lookup_invocation)
    module_name = extract_module_name(name)

    # Retrieve the options for the module. We use nil as a key in case we have none
    options = @lookup_options[module_name]
    if options.nil? && !@lookup_options.include?(module_name)
      options = retrieve_lookup_options(module_name, lookup_invocation, Puppet::Pops::MergeStrategy.strategy(HASH))
      raise Puppet::DataBinding::LookupError.new("value of #{LOOKUP_OPTIONS} must be a hash") unless options.nil? || options.is_a?(Hash)
      @lookup_options[module_name] = options
    end
    options.nil? ? nil : options[name]
  end

  private

  # Retrieve lookup options that applies when using a specific module (i.e. a merge of the pre-cached
  # `env_lookup_options` and the module specific data)
  def retrieve_lookup_options(module_name, lookup_invocation, merge_strategy)
    lookup_invocation.with(:meta, module_name) do
      env_opts = env_lookup_options(lookup_invocation, merge_strategy)
      options = nil
      unless module_name.nil?
        catch(:no_such_key) do
          options = module_provider(module_name).lookup(LOOKUP_OPTIONS, lookup_invocation, merge_strategy)
          options = merge_strategy.merge(env_opts, options) unless env_opts.nil?
        end
      end
      options.nil? ? env_opts : options
    end
  end

  # Retrieve and cache lookup options specific to the environment that this adapter is attached to (i.e. a merge
  # of global and environment lookup options).
  def env_lookup_options(lookup_invocation, merge_strategy)
    unless instance_variable_defined?(:@env_lookup_options)
      @env_lookup_options = nil
      catch(:no_such_key) do
        @env_lookup_options = merge_strategy.merge_lookup([:lookup_global, :lookup_in_environment]) do |m|
          send(m, LOOKUP_OPTIONS, lookup_invocation, merge_strategy)
        end
      end
    end
    @env_lookup_options
  end

  def env_provider
    @env_provider ||= initialize_env_provider
  end

  def module_provider(module_name)
    # Test if the key is present for the given module_name. It might be there even if the
    # value is nil (which indicates that no module provider is configured for the given name)
    unless data.include?(module_name)
      data[module_name] = initialize_module_provider(module_name)
    end
    data[module_name]
  end

  def initialize_module_provider(module_name)
    injector = Puppet.lookup(:injector) { nil }

    # Support running tests without an injector being configured == using a null implementation
    return ModuleDataProvider.new() unless injector

    # Get the registry of module to provider implementation name
    module_service_type = Registry.hash_of_per_module_data_provider
    module_service_name = PER_MODULE_DATA_PROVIDER_KEY
    module_service = injector.lookup(nil, module_service_type, module_service_name)
    provider_name = module_service[module_name] || 'none'

    service_type = Registry.hash_of_module_data_providers
    service_name = MODULE_DATA_PROVIDERS_KEY

    # Get the service (registry of known implementations)
    service = injector.lookup(nil, service_type, service_name)
    provider = service[provider_name]
    unless provider
      raise Puppet::Error.new("Environment '#{@env.name}', cannot find module_data_provider '#{provider_name}'")
    end
    provider
  end

  def initialize_env_provider
    injector = Puppet.lookup(:injector) { nil }

    # Support running tests without an injector being configured == using a null implementation
    return EnvironmentDataProvider.new() unless injector

    # Get the name of the data provider from the environment's configuration and find the bound implementation
    provider_name = @env.configuration.environment_data_provider
    service_type = Registry.hash_of_environment_data_providers
    service_name = ENV_DATA_PROVIDERS_KEY

    # Get the service (registry of known implementations)
    service = injector.lookup(nil, service_type, service_name)
    provider = service[provider_name]
    unless provider
      raise Puppet::Error.new("Environment '#{@env.name}', cannot find environment_data_provider '#{provider_name}'")
    end
    provider
  end

  def extract_module_name(name)
    qual_index = name.index('::')
    qual_index.nil? ? nil : name[0..qual_index-1]
  end
end
