# frozen_string_literal: true

require 'time'

require_relative '../../puppet/node'
require_relative '../../puppet/indirector'
require_relative '../../puppet/util/psych_support'

# Manage a given node's facts.  This either accepts facts and stores them, or
# returns facts for a given node.
class Puppet::Node::Facts
  include Puppet::Util::PsychSupport

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

  # We want to expire any cached nodes if the facts are saved.
  module NodeExpirer
    def save(instance, key = nil, options = {})
      Puppet::Node.indirection.expire(instance.name, options)
      super
    end
  end

  indirects :facts, :terminus_setting => :facts_terminus, :extend => NodeExpirer

  attr_accessor :name, :values, :timestamp

  def add_local_facts(query = [])
    query = Array(query) # some use cases result in a nil being passed in
    values["implementation"] = Puppet.implementation if query.empty? or query.include? 'implementation'
    values["clientcert"] = Puppet.settings[:certname] if query.empty? or query.include? 'clientcert'
    values["clientversion"] = Puppet.version.to_s if query.empty? or query.include? 'clientversion'
    values["clientnoop"] = Puppet.settings[:noop] if query.empty? or query.include? 'clientnoop'
  end

  def initialize(name, values = {})
    @name = name
    @values = values

    add_timestamp
  end

  def initialize_from_hash(data)
    @name = data['name']
    @values = data['values']
    # Timestamp will be here in YAML, e.g. when reading old reports
    timestamp = @values.delete('_timestamp')
    # Timestamp will be here in JSON
    timestamp ||= data['timestamp']

    if timestamp.is_a? String
      @timestamp = Time.parse(timestamp)
    else
      @timestamp = timestamp
    end

    self.expiration = data['expiration']
    if expiration.is_a? String
      self.expiration = Time.parse(expiration)
    end
  end

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

  # Sanitize fact values by converting everything not a string, Boolean
  # numeric, array or hash into strings.
  def sanitize
    values.each do |fact, value|
      values[fact] = sanitize_fact value
    end
  end

  def ==(other)
    return false unless name == other.name

    values == other.values
  end

  def self.from_data_hash(data)
    new_facts = allocate
    new_facts.initialize_from_hash(data)
    new_facts
  end

  def to_data_hash
    result = {
      'name' => name,
      'values' => values
    }

    if @timestamp
      if @timestamp.is_a? Time
        result['timestamp'] = @timestamp.iso8601(9)
      else
        result['timestamp'] = @timestamp
      end
    end

    if expiration
      if expiration.is_a? Time
        result['expiration'] = expiration.iso8601(9)
      else
        result['expiration'] = expiration
      end
    end

    result
  end

  def add_timestamp
    @timestamp = Time.now
  end

  def to_yaml
    facts_to_display = Psych.parse_stream(YAML.dump(self))
    quote_special_strings(facts_to_display)
  end

  private

  def quote_special_strings(fact_hash)
    fact_hash.grep(Psych::Nodes::Scalar).each do |node|
      next unless node.value =~ /:/

      node.plain  = false
      node.quoted = true
      node.style  = Psych::Nodes::Scalar::DOUBLE_QUOTED
    end

    fact_hash.yaml
  end

  def sanitize_fact(fact)
    case fact
    when Hash
      ret = {}
      fact.each_pair { |k, v| ret[sanitize_fact k] = sanitize_fact v }
      ret
    when Array
      fact.collect { |i| sanitize_fact i }
    when Numeric, TrueClass, FalseClass, String
      fact
    else
      result = fact.to_s
      # The result may be ascii-8bit encoded without being a binary (low level object.inspect returns ascii-8bit string)
      if result.encoding == Encoding::ASCII_8BIT
        begin
          result = result.encode(Encoding::UTF_8)
        rescue
          # return the ascii-8bit - it will be taken as a binary
          result
        end
      end
      result
    end
  end
end
