# frozen_string_literal: true

require_relative '../puppet'
require_relative '../puppet/util/tagging'
require_relative '../puppet/util/skip_tags'
require_relative '../puppet/application'
require 'digest/sha1'
require 'set'

# the class that actually walks our resource/property tree, collects the changes,
# and performs them
#
# @api private
class Puppet::Transaction
  require_relative 'transaction/additional_resource_generator'
  require_relative 'transaction/event'
  require_relative 'transaction/event_manager'
  require_relative 'transaction/resource_harness'
  require_relative '../puppet/resource/status'
  require_relative 'transaction/persistence'

  attr_accessor :catalog, :ignoreschedules, :for_network_device

  # The report, once generated.
  attr_reader :report

  # Routes and stores any events and subscriptions.
  attr_reader :event_manager

  # Handles most of the actual interacting with resources
  attr_reader :resource_harness

  attr_reader :prefetched_providers, :prefetch_failed_providers

  # @!attribute [r] persistence
  #   @return [Puppet::Transaction::Persistence] persistence object for cross
  #      transaction storage.
  attr_reader :persistence

  include Puppet::Util
  include Puppet::Util::Tagging

  def initialize(catalog, report, prioritizer)
    @catalog = catalog

    @persistence = Puppet::Transaction::Persistence.new

    @report = report || Puppet::Transaction::Report.new(catalog.version, catalog.environment)

    @prioritizer = prioritizer

    @report.add_times(:config_retrieval, @catalog.retrieval_duration || 0)

    @event_manager = Puppet::Transaction::EventManager.new(self)

    @resource_harness = Puppet::Transaction::ResourceHarness.new(self)

    @prefetched_providers = Hash.new { |h, k| h[k] = {} }

    @prefetch_failed_providers = Hash.new { |h, k| h[k] = {} }

    # With merge_dependency_warnings, notify and warn about class dependency failures ... just once per class. TJK 2019-09-09
    @merge_dependency_warnings = Puppet[:merge_dependency_warnings]
    @failed_dependencies_already_notified = Set.new()
    @failed_class_dependencies_already_notified = Set.new()
    @failed_class_dependencies_already_warned = Set.new()
  end

  # Invoke the pre_run_check hook in every resource in the catalog.
  # This should (only) be called by Transaction#evaluate before applying
  # the catalog.
  #
  # @see Puppet::Transaction#evaluate
  # @see Puppet::Type#pre_run_check
  # @raise [Puppet::Error] If any pre-run checks failed.
  # @return [void]
  def perform_pre_run_checks
    prerun_errors = {}

    @catalog.vertices.each do |res|
      res.pre_run_check
    rescue Puppet::Error => detail
      prerun_errors[res] = detail
    end

    unless prerun_errors.empty?
      prerun_errors.each do |res, detail|
        res.log_exception(detail)
      end
      raise Puppet::Error, _("Some pre-run checks failed")
    end
  end

  # This method does all the actual work of running a transaction.  It
  # collects all of the changes, executes them, and responds to any
  # necessary events.
  def evaluate(&block)
    block ||= method(:eval_resource)
    generator = AdditionalResourceGenerator.new(@catalog, nil, @prioritizer)
    @catalog.vertices.each { |resource| generator.generate_additional_resources(resource) }

    perform_pre_run_checks

    persistence.load if persistence.enabled?(catalog)

    Puppet.info _("Applying configuration version '%{version}'") % { version: catalog.version } if catalog.version

    continue_while = -> { !stop_processing? }

    post_evalable_providers = Set.new
    pre_process = lambda do |resource|
      prov_class = resource.provider.class
      post_evalable_providers << prov_class if prov_class.respond_to?(:post_resource_eval)

      prefetch_if_necessary(resource)

      # If we generated resources, we don't know what they are now
      # blocking, so we opt to recompute it, rather than try to track every
      # change that would affect the number.
      relationship_graph.clear_blockers if generator.eval_generate(resource)
    end

    providerless_types = []
    overly_deferred_resource_handler = lambda do |resource|
      # We don't automatically assign unsuitable providers, so if there
      # is one, it must have been selected by the user.
      return if missing_tags?(resource)

      if resource.provider
        resource.err _("Provider %{name} is not functional on this host") % { name: resource.provider.class.name }
      else
        providerless_types << resource.type
      end

      resource_status(resource).failed = true
    end

    canceled_resource_handler = lambda do |resource|
      resource_status(resource).skipped = true
      resource.debug "Transaction canceled, skipping"
    end

    teardown = lambda do
      # Just once per type. No need to punish the user.
      providerless_types.uniq.each do |type|
        Puppet.err _("Could not find a suitable provider for %{type}") % { type: type }
      end

      post_evalable_providers.each do |provider|
        provider.post_resource_eval
      rescue => detail
        Puppet.log_exception(detail, _("post_resource_eval failed for provider %{provider}") % { provider: provider })
      end

      persistence.save if persistence.enabled?(catalog)
    end

    # Graph cycles are returned as an array of arrays
    # - outer array is an array of cycles
    # - each inner array is an array of resources involved in a cycle
    # Short circuit resource evaluation if we detect cycle(s) in the graph. Mark
    # each corresponding resource as failed in the report before we fail to
    # ensure accurate reporting.
    graph_cycle_handler = lambda do |cycles|
      cycles.flatten.uniq.each do |resource|
        # We add a failed resource event to the status to ensure accurate
        # reporting through the event manager.
        resource_status(resource).fail_with_event(_('resource is part of a dependency cycle'))
      end
      raise Puppet::Error, _('One or more resource dependency cycles detected in graph')
    end

    # Generate the relationship graph, set up our generator to use it
    # for eval_generate, then kick off our traversal.
    generator.relationship_graph = relationship_graph
    progress = 0
    relationship_graph.traverse(:while => continue_while,
                                :pre_process => pre_process,
                                :overly_deferred_resource_handler => overly_deferred_resource_handler,
                                :canceled_resource_handler => canceled_resource_handler,
                                :graph_cycle_handler => graph_cycle_handler,
                                :teardown => teardown) do |resource|
      progress += 1
      if resource.is_a?(Puppet::Type::Component)
        Puppet.warning _("Somehow left a component in the relationship graph")
      else
        if Puppet[:evaltrace] && @catalog.host_config?
          resource.info _("Starting to evaluate the resource (%{progress} of %{total})") % { progress: progress, total: relationship_graph.size }
        end
        seconds = thinmark { block.call(resource) }
        resource.info _("Evaluated in %{seconds} seconds") % { seconds: "%0.2f" % seconds } if Puppet[:evaltrace] && @catalog.host_config?
      end
    end

    # if one or more resources has attempted and failed to generate resources,
    # report it
    if generator.resources_failed_to_generate
      report.resources_failed_to_generate = true
    end

    # mark the end of transaction evaluate.
    report.transaction_completed = true

    Puppet.debug { "Finishing transaction #{object_id}" }
  end

  # Wraps application run state check to flag need to interrupt processing
  def stop_processing?
    Puppet::Application.stop_requested? && catalog.host_config?
  end

  # Are there any failed resources in this transaction?
  def any_failed?
    report.resource_statuses.values.detect { |status|
      status.failed? || status.failed_to_restart?
    }
  end

  # Find all of the changed resources.
  def changed?
    report.resource_statuses.values.find_all(&:changed).collect { |status| catalog.resource(status.resource) }
  end

  def relationship_graph
    catalog.relationship_graph(@prioritizer)
  end

  def resource_status(resource)
    report.resource_statuses[resource.to_s] || add_resource_status(Puppet::Resource::Status.new(resource))
  end

  # The tags we should be checking.
  def tags
    self.tags = Puppet[:tags] unless defined?(@tags)

    super
  end

  def skip_tags
    @skip_tags ||= Puppet::Util::SkipTags.new(Puppet[:skip_tags]).tags
  end

  def prefetch_if_necessary(resource)
    provider_class = resource.provider.class
    if !provider_class.respond_to?(:prefetch) or
       prefetched_providers[resource.type][provider_class.name] or
       prefetch_failed_providers[resource.type][provider_class.name]
      return
    end

    resources = resources_by_provider(resource.type, provider_class.name)

    if provider_class == resource.class.defaultprovider
      providerless_resources = resources_by_provider(resource.type, nil)
      providerless_resources.values.each { |res| res.provider = provider_class.name }
      resources.merge! providerless_resources
    end

    prefetch(provider_class, resources)
  end

  private

  # Apply all changes for a resource
  def apply(resource, ancestor = nil)
    status = resource_harness.evaluate(resource)
    add_resource_status(status)
    ancestor ||= resource
    unless status.failed? || status.failed_to_restart?
      event_manager.queue_events(ancestor, status.events)
    end
  rescue => detail
    resource.err _("Could not evaluate: %{detail}") % { detail: detail }
  end

  # Evaluate a single resource.
  def eval_resource(resource, ancestor = nil)
    resolve_resource(resource)
    propagate_failure(resource)
    if skip?(resource)
      resource_status(resource).skipped = true
      resource.debug("Resource is being skipped, unscheduling all events")
      event_manager.dequeue_all_events_for_resource(resource)
      persistence.copy_skipped(resource.ref)
    else
      resource_status(resource).scheduled = true
      apply(resource, ancestor)
      event_manager.process_events(resource)
    end
  end

  # Does this resource have any failed dependencies?
  def failed_dependencies?(resource)
    # When we introduced the :whit into the graph, to reduce the combinatorial
    # explosion of edges, we also ended up reporting failures for containers
    # like class and stage.  This is undesirable; while just skipping the
    # output isn't perfect, it is RC-safe. --daniel 2011-06-07
    is_puppet_class = resource.instance_of?(Puppet::Type.type(:whit))
    # With merge_dependency_warnings, notify about class dependency failures ... just once per class. TJK 2019-09-09
    s = resource_status(resource)
    if s && s.dependency_failed?
      if @merge_dependency_warnings && is_puppet_class
        # Notes: Puppet::Resource::Status.failed_dependencies() is an Array of
        # Puppet::Resource(s) and Puppet::Resource.ref() calls
        # Puppet::Resource.to_s() which is: "#{type}[#{title}]" and
        # Puppet::Resource.resource_status(resource) calls
        # Puppet::Resource.to_s()
        class_dependencies_to_be_notified = (s.failed_dependencies.map(&:ref) - @failed_class_dependencies_already_notified.to_a)
        class_dependencies_to_be_notified.each do |dep_ref|
          resource.notice _("Class dependency %{dep} has failures: %{status}") % { dep: dep_ref, status: resource_status(dep_ref).failed }
        end
        @failed_class_dependencies_already_notified.merge(class_dependencies_to_be_notified)
      else
        unless @merge_dependency_warnings || is_puppet_class
          s.failed_dependencies.find_all { |d| !@failed_dependencies_already_notified.include?(d.ref) }.each do |dep|
            resource.notice _("Dependency %{dep} has failures: %{status}") % { dep: dep, status: resource_status(dep).failed }
            @failed_dependencies_already_notified.add(dep.ref)
          end
        end
      end
    end

    s && s.dependency_failed?
  end

  # We need to know if a resource has any failed dependencies before
  # we try to process it. We keep track of this by keeping a list on
  # each resource of the failed dependencies, and incrementally
  # computing it as the union of the failed dependencies of each
  # first-order dependency. We have to do this as-we-go instead of
  # up-front at failure time because the graph may be mutated as we
  # walk it.
  def propagate_failure(resource)
    provider_class = resource.provider.class
    s = resource_status(resource)
    if prefetch_failed_providers[resource.type][provider_class.name] && !s.nil?
      message = _("Prefetch failed for %{type_name} provider '%{name}'") % { type_name: resource.type, name: provider_class.name }
      s.fail_with_event(message)
    end

    failed = Set.new
    relationship_graph.direct_dependencies_of(resource).each do |dep|
      s = resource_status(dep)
      next if s.nil?

      failed.merge(s.failed_dependencies) if s.dependency_failed?
      failed.add(dep) if s.failed? || s.failed_to_restart?
    end
    resource_status(resource).failed_dependencies = failed.to_a
  end

  # Should we ignore tags?
  def ignore_tags?
    !@catalog.host_config?
  end

  def resources_by_provider(type_name, provider_name)
    unless @resources_by_provider
      @resources_by_provider = Hash.new { |h, k| h[k] = Hash.new { |h1, k1| h1[k1] = {} } }

      @catalog.vertices.each do |resource|
        if resource.class.attrclass(:provider)
          prov = resource.provider && resource.provider.class.name
          @resources_by_provider[resource.type][prov][resource.name] = resource
        end
      end
    end

    @resources_by_provider[type_name][provider_name] || {}
  end

  # Prefetch any providers that support it, yo.  We don't support prefetching
  # types, just providers.
  def prefetch(provider_class, resources)
    type_name = provider_class.resource_type.name
    return if @prefetched_providers[type_name][provider_class.name] ||
              @prefetch_failed_providers[type_name][provider_class.name]

    Puppet.debug { "Prefetching #{provider_class.name} resources for #{type_name}" }
    begin
      provider_class.prefetch(resources)
    rescue LoadError, StandardError => detail
      # TRANSLATORS `prefetch` is a function name and should not be translated
      message = _("Could not prefetch %{type_name} provider '%{name}': %{detail}") % { type_name: type_name, name: provider_class.name, detail: detail }
      Puppet.log_exception(detail, message)
      @prefetch_failed_providers[type_name][provider_class.name] = true
    end
    @prefetched_providers[type_name][provider_class.name] = true
  end

  def add_resource_status(status)
    report.add_resource_status(status)
  end

  # Is the resource currently scheduled?
  def scheduled?(resource)
    ignoreschedules || resource_harness.scheduled?(resource)
  end

  # Should this resource be skipped?
  def skip?(resource)
    if skip_tags?(resource)
      resource.debug "Skipping with skip tags #{skip_tags.join(', ')}"
    elsif missing_tags?(resource)
      resource.debug "Not tagged with #{tags.join(', ')}"
    elsif !scheduled?(resource)
      resource.debug "Not scheduled"
    elsif failed_dependencies?(resource)
      # When we introduced the :whit into the graph, to reduce the combinatorial
      # explosion of edges, we also ended up reporting failures for containers
      # like class and stage.  This is undesirable; while just skipping the
      # output isn't perfect, it is RC-safe. --daniel 2011-06-07
      # With merge_dependency_warnings, warn about class dependency failures ... just once per class. TJK 2019-09-09
      unless resource.instance_of?(Puppet::Type.type(:whit))
        if @merge_dependency_warnings && resource.parent && failed_dependencies?(resource.parent)
          ps = resource_status(resource.parent)
          ps.failed_dependencies.find_all { |d| !@failed_class_dependencies_already_warned.include?(d.ref) }.each do |dep|
            resource.parent.warning _("Skipping resources in class because of failed class dependencies")
            @failed_class_dependencies_already_warned.add(dep.ref)
          end
        else
          resource.warning _("Skipping because of failed dependencies")
        end
      end
    elsif resource_status(resource).failed? && @prefetch_failed_providers[resource.type][resource.provider.class.name]
      # Do not try to evaluate a resource with a known failed provider
      resource.warning _("Skipping because provider prefetch failed")
    elsif resource.virtual?
      resource.debug "Skipping because virtual"
    elsif !host_and_device_resource?(resource) && resource.appliable_to_host? && for_network_device
      resource.debug "Skipping host resources because running on a device"
    elsif !host_and_device_resource?(resource) && resource.appliable_to_device? && !for_network_device
      resource.debug "Skipping device resources because running on a posix host"
    else
      return false
    end
    true
  end

  def host_and_device_resource?(resource)
    resource.appliable_to_host? && resource.appliable_to_device?
  end

  # Is this resource tagged appropriately?
  def missing_tags?(resource)
    return false if ignore_tags?
    return false if tags.empty?

    !resource.tagged?(*tags)
  end

  def skip_tags?(resource)
    return false if ignore_tags?
    return false if skip_tags.empty?

    resource.tagged?(*skip_tags)
  end

  def split_qualified_tags?
    false
  end

  # These two methods are only made public to enable the existing spec tests to run
  # under rspec 3 (apparently rspec 2 didn't enforce access controls?). Please do not
  # treat these as part of a public API.
  # Possible future improvement: rewrite to not require access to private methods.
  public :skip?
  public :missing_tags?

  def resolve_resource(resource)
    return unless catalog.host_config?

    deferred_validate = false

    resource.eachparameter do |param|
      if param.value.instance_of?(Puppet::Pops::Evaluator::DeferredValue)
        # Puppet::Parameter#value= triggers validation and munging. Puppet::Property#value=
        # overrides the method, but also triggers validation and munging, since we're
        # setting the desired/should value.
        resolved = param.value.resolve
        # resource.notice("Resolved deferred value to #{resolved}")
        param.value = resolved
        deferred_validate = true
      end
    end

    if deferred_validate
      resource.validate_resource
    end
  end
end

require_relative 'transaction/report'
