# frozen_string_literal: true

require_relative '../../puppet'
require_relative '../../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
  include Puppet::Util::PsychSupport
  extend Puppet::Indirector

  STATES_FOR_EXCLUSION_FROM_REPORT = [:failed, :failed_to_restart, :out_of_sync, :skipped].freeze
  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 id of the code input to the compiler.
  attr_accessor :code_id

  # The id of the job responsible for this run.
  attr_accessor :job_id

  # A master generated catalog uuid, useful for connecting a single catalog to multiple reports.
  attr_accessor :catalog_uuid

  # Whether a cached catalog was used in the run, and if so, the reason that it was used.
  # @return [String] One of the values: 'not_used', 'explicitly_requested',
  # or 'on_failure'
  attr_accessor :cached_catalog_status

  # Contains the name and port of the server that was successfully contacted
  # @return [String] a string of the format 'servername:port'
  attr_accessor :server_used

  # 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

  # The name of the environment the agent initially started in
  # @return [String] the environment name
  attr_accessor :initial_environment

  # Whether there are changes that we decided not to apply because of noop
  # @return [Boolean]
  #
  attr_accessor :noop_pending

  # A hash with a map from resource to status
  # @return [Hash{String => Puppet::Resource::Status}] Resource name to status.
  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 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

  # Whether the puppet run was started in noop mode
  # @return [Boolean]
  #
  attr_reader :noop

  # @!attribute [r] corrective_change
  #   @return [Boolean] true if the report contains any events and resources that had
  #      corrective changes, including noop corrective changes.
  attr_reader :corrective_change

  # @return [Boolean] true if one or more resources attempted to generate
  #   resources and failed
  #
  attr_accessor :resources_failed_to_generate

  # @return [Boolean] true if the transaction completed it's evaluate
  #
  attr_accessor :transaction_completed

  TOTAL = "total"

  def self.from_data_hash(data)
    obj = 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, accumulate = true)
    if @external_times[name] && accumulate
      @external_times[name] += value
    else
      @external_times[name] = value
    end
  end

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

    hash.each do |metric_name, value|
      metric.newvalue(metric_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 resources_failed_to_generate ||
       !transaction_completed ||
       (resource_metrics["failed"] || 0) > 0 ||
       (resource_metrics["failed_to_restart"] || 0) > 0
      'failed'
    elsif change_metric > 0
      'changed'
    else
      'unchanged'
    end
  end

  # @api private
  def has_noop_events?(resource)
    resource.events.any? { |event| event.status == 'noop' }
  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
    calculate_report_corrective_change

    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)
    @noop_pending = @resource_statuses.any? { |_name, res| has_noop_events?(res) }
  end

  # @api private
  def initialize(configuration_version = nil, environment = nil, transaction_uuid = nil, job_id = nil, start_time = Time.now)
    @metrics = {}
    @logs = []
    @resource_statuses = {}
    @external_times ||= {}
    @host = Puppet[:node_name_value]
    @time = start_time
    @report_format = 12
    @puppet_version = Puppet.version
    @configuration_version = configuration_version
    @transaction_uuid = transaction_uuid
    @code_id = nil
    @job_id = job_id
    @catalog_uuid = nil
    @cached_catalog_status = nil
    @server_used = nil
    @environment = environment
    @status = 'failed' # assume failed until the report is finalized
    @noop = Puppet[:noop]
    @noop_pending = false
    @corrective_change = false
    @transaction_completed = false
  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']
    @transaction_completed = data['transaction_completed']
    @noop = data['noop']
    @noop_pending = data['noop_pending']
    @host = data['host']
    @time = data['time']
    @corrective_change = data['corrective_change']

    if data['server_used']
      @server_used = data['server_used']
    elsif data['master_used']
      @server_used = data['master_used']
    end

    if data['catalog_uuid']
      @catalog_uuid = data['catalog_uuid']
    end

    if data['job_id']
      @job_id = data['job_id']
    end

    if data['code_id']
      @code_id = data['code_id']
    end

    if data['cached_catalog_status']
      @cached_catalog_status = data['cached_catalog_status']
    end

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

    @metrics = {}
    data['metrics'].each do |name, hash|
      # Older versions contain tags that causes Psych to create instances directly
      @metrics[name] = hash.is_a?(Puppet::Util::Metric) ? hash : Puppet::Util::Metric.from_data_hash(hash)
    end

    @logs = data['logs'].map do |record|
      # Older versions contain tags that causes Psych to create instances directly
      record.is_a?(Puppet::Util::Log) ? record : Puppet::Util::Log.from_data_hash(record)
    end

    @resource_statuses = {}
    data['resource_statuses'].map do |key, rs|
      @resource_statuses[key] =
        if rs == Puppet::Resource::EMPTY_HASH
          nil
        elsif rs.is_a?(Puppet::Resource::Status)
          # Older versions contain tags that causes Psych to create instances
          # directly
          rs
        else
          Puppet::Resource::Status.from_data_hash(rs)
        end
    end
  end

  def resource_unchanged?(rs)
    STATES_FOR_EXCLUSION_FROM_REPORT.each do |state|
      return false if rs.send(state)
    end
    true
  end

  def calculate_resource_statuses
    resource_statuses = if Puppet[:exclude_unchanged_resources]
                          @resource_statuses.reject { |_key, rs| resource_unchanged?(rs) }
                        else
                          @resource_statuses
                        end
    resource_statuses.transform_values { |rs| rs.nil? ? nil : rs.to_data_hash }
  end

  def to_data_hash
    hash = {
      'host' => @host,
      'time' => @time.iso8601(9),
      'configuration_version' => @configuration_version,
      'transaction_uuid' => @transaction_uuid,
      'report_format' => @report_format,
      'puppet_version' => @puppet_version,
      'status' => @status,
      'transaction_completed' => @transaction_completed,
      'noop' => @noop,
      'noop_pending' => @noop_pending,
      'environment' => @environment,
      'logs' => @logs.map(&:to_data_hash),
      'metrics' => @metrics.transform_values(&:to_data_hash),
      'resource_statuses' => calculate_resource_statuses,
      'corrective_change' => @corrective_change,
    }

    # The following is include only when set
    hash['server_used'] = @server_used unless @server_used.nil?
    hash['catalog_uuid'] = @catalog_uuid unless @catalog_uuid.nil?
    hash['code_id'] = @code_id unless @code_id.nil?
    hash['job_id'] = @job_id unless @job_id.nil?
    hash['cached_catalog_status'] = @cached_catalog_status unless @cached_catalog_status.nil?
    hash
  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 = ''.dup
    report.keys.sort_by(&: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
      },
      "application" => {
        "run_mode" => Puppet.run_mode.name.to_s,
        "initial_environment" => initial_environment,
        "converged_environment" => environment
      }
    }

    @metrics.each do |_name, metric|
      key = metric.name.to_s
      report[key] = {}
      metric.values.each do |metric_name, _label, value|
        report[key][metric_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
    if @metrics["changes"] && @metrics["changes"][TOTAL] &&
       @metrics["resources"] && @metrics["resources"]["failed"] &&
       @metrics["resources"]["failed_to_restart"]
      status |= 2 if @metrics["changes"][TOTAL] > 0
      status |= 4 if @metrics["resources"]["failed"] > 0
      status |= 4 if @metrics["resources"]["failed_to_restart"] > 0
    else
      status = -1
    end
    status
  end

  private

  # Mark the report as corrective, if there are any resource_status marked corrective.
  def calculate_report_corrective_change
    @corrective_change = resource_statuses.any? do |_name, status|
      status.corrective_change
    end
  end

  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|
      metrics[status.resource_type.downcase] += status.evaluation_time if status.evaluation_time
    end

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

    metrics
  end
end
