require 'puppet'
require 'puppet/indirector'

# This class is used to report what happens on a client.
# There are two types of data in a report; _Logs_ and _Metrics_.
#
# * **Logs** - are the output that each change produces.
# * **Metrics** - are all of the numerical data involved in the transaction.
#
# Use {Puppet::Reports} class to create a new custom report type. This class is indirectly used
# as a source of data to report in such a registered report.
#
# ##Metrics
# There are three types of metrics in each report, and each type of metric has one or more values.
#
# * Time: Keeps track of how long things took.
#   * Total: Total time for the configuration run
#   * File:
#   * Exec:
#   * User:
#   * Group:
#   * Config Retrieval: How long the configuration took to retrieve
#   * Service:
#   * Package:
# * Resources: Keeps track of the following stats:
#   * Total: The total number of resources being managed
#   * Skipped: How many resources were skipped, because of either tagging or scheduling restrictions
#   * Scheduled: How many resources met any scheduling restrictions
#   * Out of Sync: How many resources were out of sync
#   * Applied: How many resources were attempted to be fixed
#   * Failed: How many resources were not successfully fixed
#   * Restarted: How many resources were restarted because their dependencies changed
#   * Failed Restarts: How many resources could not be restarted
# * Changes: The total number of changes in the transaction.
#
# @api public
class Puppet::Transaction::Report
  extend Puppet::Indirector

  indirects :report, :terminus_class => :processor

  # The version of the configuration
  # @todo Uncertain what this is?
  # @return [???] the configuration version
  attr_accessor :configuration_version

  # An agent generated transaction uuid, useful for connecting catalog and report
  # @return [String] uuid
  attr_accessor :transaction_uuid

  # The host name for which the report is generated
  # @return [String] the host name
  attr_accessor :host

  # The name of the environment the host is in
  # @return [String] the environment name
  attr_accessor :environment

  # A hash with a map from resource to status
  # @return [Hash<{String => String}>] Resource name to status string.
  # @todo Uncertain if the types in the hash are correct...
  attr_reader :resource_statuses

  # A list of log messages.
  # @return [Array<Puppet::Util::Log>] logged messages
  attr_reader :logs

  # A hash of metric name to metric value.
  # @return [Hash<{String => Object}>] A map of metric name to value.
  # @todo Uncertain if all values are numbers - now marked as Object.
  #
  attr_reader :metrics

  # The time when the report data was generated.
  # @return [Time] A time object indicating when the report data was generated
  #
  attr_reader :time

  # The 'kind' of report is the name of operation that triggered the report to be produced.
  # Typically "apply".
  # @return [String] the kind of operation that triggered the generation of the report.
  #
  attr_reader :kind

  # The status of the client run is an enumeration: 'failed', 'changed' or 'unchanged'
  # @return [String] the status of the run - one of the values 'failed', 'changed', or 'unchanged'
  #
  attr_reader :status

  # @return [String] The Puppet version in String form.
  # @see Puppet::version()
  #
  attr_reader :puppet_version

  # @return [Integer] report format version number.  This value is constant for
  #    a given version of Puppet; it is incremented when a new release of Puppet
  #    changes the API for the various objects that make up a report.
  #
  attr_reader :report_format

  def self.from_pson(data)
    obj = self.allocate
    obj.initialize_from_hash(data)
    obj
  end

  def as_logging_destination(&block)
    Puppet::Util::Log.with_destination(self, &block)
  end

  # @api private
  def <<(msg)
    @logs << msg
    self
  end

  # @api private
  def add_times(name, value)
    @external_times[name] = value
  end

  # @api private
  def add_metric(name, hash)
    metric = Puppet::Util::Metric.new(name)

    hash.each do |name, value|
      metric.newvalue(name, value)
    end

    @metrics[metric.name] = metric
    metric
  end

  # @api private
  def add_resource_status(status)
    @resource_statuses[status.resource] = status
  end

  # @api private
  def compute_status(resource_metrics, change_metric)
    if (resource_metrics["failed"] || 0) > 0
      'failed'
    elsif change_metric > 0
      'changed'
    else
      'unchanged'
    end
  end

  # @api private
  def prune_internal_data
    resource_statuses.delete_if {|name,res| res.resource_type == 'Whit'}
  end

  # @api private
  def finalize_report
    prune_internal_data

    resource_metrics = add_metric(:resources, calculate_resource_metrics)
    add_metric(:time, calculate_time_metrics)
    change_metric = calculate_change_metric
    add_metric(:changes, {"total" => change_metric})
    add_metric(:events, calculate_event_metrics)
    @status = compute_status(resource_metrics, change_metric)
  end

  # @api private
  def initialize(kind, configuration_version=nil, environment=nil, transaction_uuid=nil)
    @metrics = {}
    @logs = []
    @resource_statuses = {}
    @external_times ||= {}
    @host = Puppet[:node_name_value]
    @time = Time.now
    @kind = kind
    @report_format = 4
    @puppet_version = Puppet.version
    @configuration_version = configuration_version
    @transaction_uuid = transaction_uuid
    @environment = environment
    @status = 'failed' # assume failed until the report is finalized
  end

  # @api private
  def initialize_from_hash(data)
    @puppet_version = data['puppet_version']
    @report_format = data['report_format']
    @configuration_version = data['configuration_version']
    @transaction_uuid = data['transaction_uuid']
    @environment = data['environment']
    @status = data['status']
    @host = data['host']
    @time = data['time']
    if @time.is_a? String
      @time = Time.parse(@time)
    end
    @kind = data['kind']

    @metrics = {}
    data['metrics'].each do |name, hash|
      @metrics[name] = Puppet::Util::Metric.from_pson(hash)
    end

    @logs = data['logs'].map do |record|
      Puppet::Util::Log.from_pson(record)
    end

    @resource_statuses = {}
    data['resource_statuses'].map do |record|
      if record[1] == {}
        status = nil
      else
        status = Puppet::Resource::Status.from_pson(record[1])
      end
      @resource_statuses[record[0]] = status
    end
  end

  def to_data_hash
    {
      'host' => @host,
      'time' => @time.iso8601(9),
      'configuration_version' => @configuration_version,
      'transaction_uuid' => @transaction_uuid,
      'report_format' => @report_format,
      'puppet_version' => @puppet_version,
      'kind' => @kind,
      'status' => @status,
      'environment' => @environment,

      'logs' => @logs,
      'metrics' => @metrics,
      'resource_statuses' => @resource_statuses,
    }
  end

  def to_pson
    to_data_hash.to_pson
  end

  # @return [String] the host name
  # @api public
  #
  def name
    host
  end

  # Provide a human readable textual summary of this report.
  # @note This is intended for debugging purposes
  # @return [String] A string with a textual summary of this report.
  # @api public
  #
  def summary
    report = raw_summary

    ret = ""
    report.keys.sort { |a,b| a.to_s <=> b.to_s }.each do |key|
      ret += "#{Puppet::Util::Metric.labelize(key)}:\n"

      report[key].keys.sort { |a,b|
        # sort by label
        if a == :total
          1
        elsif b == :total
          -1
        else
          report[key][a].to_s <=> report[key][b].to_s
        end
      }.each do |label|
        value = report[key][label]
        next if value == 0
        value = "%0.2f" % value if value.is_a?(Float)
        ret += "   %15s %s\n" % [Puppet::Util::Metric.labelize(label) + ":", value]
      end
    end
    ret
  end

  # Provides a raw hash summary of this report.
  # @return [Hash<{String => Object}>] A hash with metrics key to value map
  # @api public
  #
  def raw_summary
    report = { "version" => { "config" => configuration_version, "puppet" => Puppet.version  } }

    @metrics.each do |name, metric|
      key = metric.name.to_s
      report[key] = {}
      metric.values.each do |name, label, value|
        report[key][name.to_s] = value
      end
      report[key]["total"] = 0 unless key == "time" or report[key].include?("total")
    end
    (report["time"] ||= {})["last_run"] = Time.now.tv_sec
    report
  end

  # Computes a single number that represents the report's status.
  # The computation is based on the contents of this report's metrics.
  # The resulting number is a bitmask where
  # individual bits represent the presence of different metrics.
  #
  # * 0x2 set if there are changes
  # * 0x4 set if there are resource failures or resources that failed to restart
  # @return [Integer] A bitmask where 0x2 is set if there are changes, and 0x4 is set of there are failures.
  # @api public
  #
  def exit_status
    status = 0
    status |= 2 if @metrics["changes"]["total"] > 0
    status |= 4 if @metrics["resources"]["failed"] > 0
    status |= 4 if @metrics["resources"]["failed_to_restart"] > 0
    status
  end

  # @api private
  #
  def to_yaml_properties
    super - [:@external_times]
  end

  def self.supported_formats
    [:pson, :yaml]
  end

  def self.default_format
    Puppet[:report_serialization_format].intern
  end

  private

  def calculate_change_metric
    resource_statuses.map { |name, status| status.change_count || 0 }.inject(0) { |a,b| a+b }
  end

  def calculate_event_metrics
    metrics = Hash.new(0)
    %w{total failure success}.each { |m| metrics[m] = 0 }
    resource_statuses.each do |name, status|
      metrics["total"] += status.events.length
      status.events.each do |event|
        metrics[event.status] += 1
      end
    end

    metrics
  end

  def calculate_resource_metrics
    metrics = {}
    metrics["total"] = resource_statuses.length

    # force every resource key in the report to be present
    # even if no resources is in this given state
    Puppet::Resource::Status::STATES.each do |state|
      metrics[state.to_s] = 0
    end

    resource_statuses.each do |name, status|
      Puppet::Resource::Status::STATES.each do |state|
        metrics[state.to_s] += 1 if status.send(state)
      end
    end

    metrics
  end

  def calculate_time_metrics
    metrics = Hash.new(0)
    resource_statuses.each do |name, status|
      type = Puppet::Resource.new(name).type
      metrics[type.to_s.downcase] += status.evaluation_time if status.evaluation_time
    end

    @external_times.each do |name, value|
      metrics[name.to_s.downcase] = value
    end

    metrics["total"] = metrics.values.inject(0) { |a,b| a+b }

    metrics
  end
end
