require 'puppet/indirector'

# A class for managing nodes, including their facts and environment.
class Puppet::Node
  require 'puppet/node/facts'
  require 'puppet/node/environment'

  # Set up indirection, so that nodes can be looked for in
  # the node sources.
  extend Puppet::Indirector

  # Asymmetric serialization/deserialization required in this class via to/from datahash
  include Puppet::Util::PsychSupport

  # Use the node source as the indirection terminus.
  indirects :node, :terminus_setting => :node_terminus, :doc => "Where to find node information.
    A node is composed of its name, its facts, and its environment."

  attr_accessor :name, :classes, :source, :ipaddress, :parameters, :trusted_data, :environment_name
  attr_reader :time, :facts

  attr_reader :server_facts

  ENVIRONMENT = 'environment'.freeze

  def initialize_from_hash(data)
    @name       = data['name']       || (raise ArgumentError, _("No name provided in serialized data"))
    @classes    = data['classes']    || []
    @parameters = data['parameters'] || {}
    env_name = data['environment'] || @parameters[ENVIRONMENT]
    unless env_name.nil?
      @parameters[ENVIRONMENT] = env_name
      @environment_name = env_name.intern
    end
  end

  def self.from_data_hash(data)
    node = new(name)
    node.initialize_from_hash(data)
    node
  end

  def to_data_hash
    result = {
      'name' => name,
      'environment' => environment.name.to_s,
    }
    result['classes'] = classes unless classes.empty?
    serialized_params = self.serializable_parameters
    result['parameters'] = serialized_params unless serialized_params.empty?
    result
  end

  def serializable_parameters
    new_params = parameters.dup
    new_params.delete(ENVIRONMENT)
    new_params
  end

  def environment
    if @environment
      @environment
    else
      env = parameters[ENVIRONMENT]
      if env
        self.environment = env
      elsif environment_name
        self.environment = environment_name
      else
        # This should not be :current_environment, this is the default
        # for a node when it has not specified its environment
        # it will be used to establish what the current environment is.
        #
        self.environment = Puppet.lookup(:environments).get!(Puppet[:environment])
      end

      @environment
    end
  end

  def environment=(env)
    if env.is_a?(String) or env.is_a?(Symbol)
      @environment = Puppet.lookup(:environments).get!(env)
    else
      @environment = env
    end

    # Keep environment_name attribute and parameter in sync if they have been set
    unless @environment.nil?
      # always set the environment parameter. It becomes top scope $environment for a manifest during catalog compilation.
      @parameters[ENVIRONMENT] = @environment.name.to_s
      self.environment_name = @environment.name if instance_variable_defined?(:@environment_name)
    end
    @environment
  end

  def has_environment_instance?
    !@environment.nil?
  end

  def initialize(name, options = {})
    raise ArgumentError, _("Node names cannot be nil") unless name
    @name = name

    classes = options[:classes]
    if classes
      if classes.is_a?(String)
        @classes = [classes]
      else
        @classes = classes
      end
    else
      @classes = []
    end

    @parameters = options[:parameters] || {}

    @facts = options[:facts]

    @server_facts = {}

    env = options[:environment]
    if env
      self.environment = env
    end

    @time = Time.now
  end

  # Merge the node facts with parameters from the node source.
  # @api public
  # @param facts [optional, Puppet::Node::Facts] facts to merge into node parameters.
  #   Will query Facts indirection if not supplied.
  # @raise [Puppet::Error] Raise on failure to retrieve facts if not supplied
  # @return [nil]
  def fact_merge(facts = nil)
    begin
      @facts = facts.nil? ? Puppet::Node::Facts.indirection.find(name, :environment => environment) : facts
    rescue => detail
      error = Puppet::Error.new(_("Could not retrieve facts for %{name}: %{detail}") % { name: name, detail: detail }, detail)
      error.set_backtrace(detail.backtrace)
      raise error
    end

    if !@facts.nil?
      @facts.sanitize
      # facts should never modify the environment parameter
      orig_param_env = @parameters[ENVIRONMENT]
      merge(@facts.values)
      @parameters[ENVIRONMENT] = orig_param_env
    end
  end

  # Merge any random parameters into our parameter list.
  def merge(params)
    params.each do |name, value|
      if @parameters.include?(name)
        Puppet::Util::Warnings.warnonce(_("The node parameter '%{param_name}' for node '%{node_name}' was already set to '%{value}'. It could not be set to '%{desired_value}'") % { param_name: name, node_name: @name, value: @parameters[name], desired_value: value })
      else
        @parameters[name] = value
      end
    end
  end

  # Add extra facts, such as facts given to lookup on the command line The
  # extra facts will override existing ones.
  # @param extra_facts [Hash{String=>Object}] the facts to tadd
  # @api private
  def add_extra_facts(extra_facts)
    @facts.add_extra_values(extra_facts)
    @parameters.merge!(extra_facts)
    nil
  end

  def add_server_facts(facts)
    # Append the current environment to the list of server facts
    @server_facts = facts.merge({ "environment" => self.environment.name.to_s})

    # Merge the server facts into the parameters for the node
    merge(facts)
  end

  # Calculate the list of names we might use for looking
  # up our node.  This is only used for AST nodes.
  def names
    return [name] if Puppet.settings[:strict_hostname_checking]

    names = []

    names += split_name(name) if name.include?(".")

    # First, get the fqdn
    fqdn = parameters["fqdn"]
    unless fqdn
      if parameters["hostname"] and parameters["domain"]
        fqdn = parameters["hostname"] + "." + parameters["domain"]
      else
        Puppet.warning _("Host is missing hostname and/or domain: %{name}") % { name: name }
      end
    end

    # Now that we (might) have the fqdn, add each piece to the name
    # list to search, in order of longest to shortest.
    names += split_name(fqdn) if fqdn

    # And make sure the node name is first, since that's the most
    # likely usage.
    #   The name is usually the Certificate CN, but it can be
    # set to the 'facter' hostname instead.
    if Puppet[:node_name] == 'cert'
      names.unshift name
    else
      names.unshift parameters["hostname"]
    end
    names.uniq
  end

  def split_name(name)
    list = name.split(".")
    tmp = []
    list.each_with_index do |short, i|
      tmp << list[0..i].join(".")
    end
    tmp.reverse
  end

  # Ensures the data is frozen
  #
  def trusted_data=(data)
    Puppet.warning(_("Trusted node data modified for node %{name}") % { name: name }) unless @trusted_data.nil?
    @trusted_data = data.freeze
  end

  # Resurrects and sanitizes trusted information in the node by modifying it and setting
  # the trusted_data in the node from parameters.
  # This modifies the node
  #
  def sanitize
    # Resurrect "trusted information" that comes from node/fact terminus.
    # The current way this is done in puppet db (currently the only one)
    # is to store the node parameter 'trusted' as a hash of the trusted information.
    #
    # Thus here there are two main cases:
    # 1. This terminus was used in a real agent call (only meaningful if someone curls the request as it would
    #  fail since the result is a hash of two catalogs).
    # 2  It is a command line call with a given node that use a terminus that:
    # 2.1 does not include a 'trusted' fact - use local from node trusted information
    # 2.2 has a 'trusted' fact - this in turn could be
    # 2.2.1 puppet db having stored trusted node data as a fact (not a great design)
    # 2.2.2 some other terminus having stored a fact called "trusted" (most likely that would have failed earlier, but could
    #       be spoofed).
    #
    # For the reasons above, the resurrection of trusted node data with authenticated => true is only performed
    # if user is running as root, else it is resurrected as unauthenticated.
    #
    trusted_param = @parameters['trusted']
    if trusted_param
      # Blows up if it is a parameter as it will be set as $trusted by the compiler as if it was a variable
      @parameters.delete('trusted')
      unless trusted_param.is_a?(Hash) && %w{authenticated certname extensions}.all? {|key| trusted_param.has_key?(key) }
        # trusted is some kind of garbage, do not resurrect
        trusted_param = nil
      end
    else
      # trusted may be Boolean false if set as a fact by someone
      trusted_param = nil
    end

    # The options for node.trusted_data in priority order are:
    # 1) node came with trusted_data so use that
    # 2) else if there is :trusted_information in the puppet context
    # 3) else if the node provided a 'trusted' parameter (parsed out above)
    # 4) last, fallback to local node trusted information
    #
    # Note that trusted_data should be a hash, but (2) and (4) are not
    # hashes, so we to_h at the end
    if !trusted_data
      trusted = Puppet.lookup(:trusted_information) do
        trusted_param || Puppet::Context::TrustedInformation.local(self)
      end

      self.trusted_data = trusted.to_h
    end
  end
end
