require 'puppet/node'
require 'puppet/indirector/ldap'

class Puppet::Node::Ldap < Puppet::Indirector::Ldap
  desc "Search in LDAP for node configuration information.  See
  the [LDAP Nodes](https://puppet.com/docs/puppet/latest/nodes_ldap.html) page for more information.  This will first
  search for whatever the certificate name is, then (if that name
  contains a `.`) for the short name, then `default`.
  Requires ruby-ldap with MRI ruby or jruby-ldap with puppetserver/jruby"

  # The attributes that Puppet class information is stored in.
  def class_attributes
    Puppet[:ldapclassattrs].split(/\s*,\s*/)
  end

  # Separate this out so it's relatively atomic.  It's tempting to call
  # process instead of name2hash() here, but it ends up being
  # difficult to test because all exceptions get caught by ldapsearch.
  # LAK:NOTE Unfortunately, the ldap support is too stupid to throw anything
  # but LDAP::ResultError, even on bad connections, so we are rough-handed
  # with our error handling.
  def name2hash(name)
    info = nil
    ldapsearch(search_filter(name)) { |entry| info = entry2hash(entry) }
    info
  end

  # Look for our node in ldap.
  def find(request)
    names = [request.key]
    names << request.key.sub(/\..+/, '') if request.key.include?(".") # we assume it's an fqdn
    names << "default"

    facts = request.options[:facts].is_a?(Puppet::Node::Facts) ? request.options[:facts] : nil

    node = nil
    names.each do |name|
      next unless info = name2hash(name)

      merge_parent(info) if info[:parent]
      info[:environment] ||= request.environment
      node = info2node(request.key, info, facts)
      break
    end

    node
  end

  # Find more than one node.  LAK:NOTE This is a bit of a clumsy API, because the 'search'
  # method currently *requires* a key.  It seems appropriate in some cases but not others,
  # and I don't really know how to get rid of it as a requirement but allow it when desired.
  def search(request)
    if classes = request.options[:class]
      classes = [classes] unless classes.is_a?(Array)
      filter = "(&(objectclass=puppetClient)(puppetclass=" + classes.join(")(puppetclass=") + "))"
    else
      filter = "(objectclass=puppetClient)"
    end

    infos = []
    ldapsearch(filter) { |entry| infos << entry2hash(entry, request.options[:fqdn]) }

    return infos.collect do |info|
      merge_parent(info) if info[:parent]
      info[:environment] ||= request.environment
      info2node(info[:name], info)
    end
  end

  # The parent attribute, if we have one.
  def parent_attribute
    if pattr = Puppet[:ldapparentattr] and ! pattr.empty?
      pattr
    else
      nil
    end
  end

  # The attributes that Puppet will stack as array over the full
  # hierarchy.
  def stacked_attributes
    Puppet[:ldapstackedattrs].split(/\s*,\s*/)
  end

  # Convert the found entry into a simple hash.
  def entry2hash(entry, fqdn = false)
    result = {}

    cn  = entry.dn[     /cn\s*=\s*([^,\s]+)/i,1]
    dcs = entry.dn.scan(/dc\s*=\s*([^,\s]+)/i)
    result[:name]    = fqdn ? ([cn]+dcs).join('.') : cn
    result[:parent] = get_parent_from_entry(entry) if parent_attribute
    result[:classes] = get_classes_from_entry(entry)
    result[:stacked] = get_stacked_values_from_entry(entry)
    result[:parameters] = get_parameters_from_entry(entry)

    # delete from parameters to prevent "Already declered variable $environment" error
    result[:environment] = result[:parameters].delete(:environment) if result[:parameters][:environment]

    result[:stacked_parameters] = {}

    if result[:stacked]
      result[:stacked].each do |value|
        param = value.split('=', 2)
        result[:stacked_parameters][param[0]] = param[1]
      end
    end

    if result[:stacked_parameters]
      result[:stacked_parameters].each do |param, value|
        result[:parameters][param] = value unless result[:parameters].include?(param)
      end
    end

    result[:parameters] = convert_parameters(result[:parameters])

    result
  end

  # Default to all attributes.
  def search_attributes
    ldapattrs = Puppet[:ldapattrs]

    # results in everything getting returned
    return nil if ldapattrs == "all"

    search_attrs = class_attributes + ldapattrs.split(/\s*,\s*/)

    if pattr = parent_attribute
      search_attrs << pattr
    end

    search_attrs
  end

  # The ldap search filter to use.
  def search_filter(name)
    filter = Puppet[:ldapstring]

    if filter.include? "%s"
      # Don't replace the string in-line, since that would hard-code our node
      # info.
      filter = filter.gsub('%s', name)
    end
    filter
  end

  private

  # Add our hash of ldap information to the node instance.
  def add_to_node(node, information)
    node.classes = information[:classes].uniq unless information[:classes].nil? or information[:classes].empty?
    node.parameters = information[:parameters] unless information[:parameters].nil? or information[:parameters].empty?
    node.environment = information[:environment] if information[:environment]
  end

  def convert_parameters(parameters)
    result = {}
    parameters.each do |param, value|
      if value.is_a?(Array)
        result[param] = value.collect { |v| convert(v) }
      else
        result[param] = convert(value)
      end
    end
    result
  end

  # Convert any values if necessary.
  def convert(value)
    case value
    when Integer; value
    when "true"; true
    when "false"; false
    else
      value
    end
  end

  # Find information for our parent and merge it into the current info.
  def find_and_merge_parent(parent, information)
    parent_info = name2hash(parent) || raise(Puppet::Error.new(_("Could not find parent node '%{parent}'") % { parent: parent }))
    information[:classes] += parent_info[:classes]
    parent_info[:parameters].each do |param, value|
      # Specifically test for whether it's set, so false values are handled correctly.
      information[:parameters][param] = value unless information[:parameters].include?(param)
    end
    information[:environment] ||= parent_info[:environment]
    parent_info[:parent]
  end

  # Take a name and a hash, and return a node instance.
  def info2node(name, info, facts = nil)
    node = Puppet::Node.new(name)

    add_to_node(node, info)

    node.fact_merge(facts)

    node
  end

  def merge_parent(info)
    parent = info[:parent]

    # Preload the parent array with the node name.
    parents = [info[:name]]
    while parent
      raise ArgumentError, _("Found loop in LDAP node parents; %{parent} appears twice") % { parent: parent } if parents.include?(parent)
      parents << parent
      parent = find_and_merge_parent(parent, info)
    end

    info
  end

  def get_classes_from_entry(entry)
    result = class_attributes.inject([]) do |array, attr|
      if values = entry.vals(attr)
        values.each do |v| array << v end
      end
      array
    end
    result.uniq
  end

  # Workaround jruby-ldap 0.0.2 missing the #to_hash method
  #
  # @see https://github.com/jruby/jruby-ldap/pull/5
  #
  # @param entry [LDAP::Entry] The LDAP::Entry object to convert to a hash
  # @return [Hash] The hash of the provided LDAP::Entry object with keys
  #   downcased (puppet variables need to start with lowercase char)
  def ldap_entry_to_hash(entry)
     h = {}
     entry.get_attributes.each { |a| h[a.downcase.to_sym] = entry[a] }
     h[:dn] = [entry.dn]
     h
  end

  def get_parameters_from_entry(entry)
    stacked_params = stacked_attributes
    ldap_entry_to_hash(entry).inject({}) do |hash, ary|
      unless stacked_params.include?(ary[0]) # don't add our stacked parameters to the main param list
        if ary[1].length == 1
          hash[ary[0]] = ary[1].shift
        else
          hash[ary[0]] = ary[1]
        end
      end
      hash
    end
  end

  def get_parent_from_entry(entry)
    pattr = parent_attribute

    return nil unless values = entry.vals(pattr)

    if values.length > 1
      raise Puppet::Error,
        _("Node entry %{entry} specifies more than one parent: %{parents}") % { entry: entry.dn, parents: values.inspect }
    end
    return(values.empty? ? nil : values.shift)
  end

  def get_stacked_values_from_entry(entry)
    stacked_attributes.inject([]) do |result, attr|
      if values = entry.vals(attr)
        result += values
      end
      result
    end
  end
end
