# frozen_string_literal: true

require_relative '../../puppet'
require_relative '../../puppet/util/filetype'
require_relative '../../puppet/util/fileparsing'

# This provider can be used as the parent class for a provider that
# parses and generates files.  Its content must be loaded via the
# 'prefetch' method, and the file will be written when 'flush' is called
# on the provider instance.  At this point, the file is written once
# for every provider instance.
#
# Once the provider prefetches the data, it's the resource's job to copy
# that data over to the @is variables.
#
# NOTE: The prefetch method swallows FileReadErrors by treating the
# corresponding target as an empty file. If you would like to turn this
# behavior off, then set the raise_prefetch_errors class variable to
# true. Doing so will error all resources associated with the failed
# target.
class Puppet::Provider::ParsedFile < Puppet::Provider
  extend Puppet::Util::FileParsing

  class << self
    attr_accessor :default_target, :target, :raise_prefetch_errors
  end

  attr_accessor :property_hash

  def self.clean(hash)
    newhash = hash.dup
    [:record_type, :on_disk].each do |p|
      newhash.delete(p) if newhash.include?(p)
    end

    newhash
  end

  def self.clear
    @target_objects.clear
    @records.clear
  end

  def self.filetype
    @filetype ||= Puppet::Util::FileType.filetype(:flat)
  end

  def self.filetype=(type)
    if type.is_a?(Class)
      @filetype = type
    else
      klass = Puppet::Util::FileType.filetype(type)
      if klass
        @filetype = klass
      else
        raise ArgumentError, _("Invalid filetype %{type}") % { type: type }
      end
    end
  end

  # Flush all of the targets for which there are modified records.  The only
  # reason we pass a record here is so that we can add it to the stack if
  # necessary -- it's passed from the instance calling 'flush'.
  def self.flush(record)
    # Make sure this record is on the list to be flushed.
    unless record[:on_disk]
      record[:on_disk] = true
      @records << record

      # If we've just added the record, then make sure our
      # target will get flushed.
      modified(record[:target] || default_target)
    end

    return unless defined?(@modified) and !@modified.empty?

    flushed = []
    begin
      @modified.sort_by(&:to_s).uniq.each do |target|
        Puppet.debug "Flushing #{@resource_type.name} provider target #{target}"
        flushed << target
        flush_target(target)
      end
    ensure
      @modified.reject! { |t| flushed.include?(t) }
    end
  end

  # Make sure our file is backed up, but only back it up once per transaction.
  # We cheat and rely on the fact that @records is created on each prefetch.
  def self.backup_target(target)
    return nil unless target_object(target).respond_to?(:backup)

    @backup_stats ||= {}
    return nil if @backup_stats[target] == @records.object_id

    target_object(target).backup
    @backup_stats[target] = @records.object_id
  end

  # Flush all of the records relating to a specific target.
  def self.flush_target(target)
    if @raise_prefetch_errors && @failed_prefetch_targets.key?(target)
      raise Puppet::Error, _("Failed to read %{target}'s records when prefetching them. Reason: %{detail}") % { target: target, detail: @failed_prefetch_targets[target] }
    end

    backup_target(target)

    records = target_records(target).reject { |r|
      r[:ensure] == :absent
    }

    target_object(target).write(to_file(records))
  end

  # Return the header placed at the top of each generated file, warning
  # users that modifying this file manually is probably a bad idea.
  def self.header
    %(# HEADER: This file was autogenerated at #{Time.now}
# HEADER: by puppet.  While it can still be managed manually, it
# HEADER: is definitely not recommended.\n)
  end

  # An optional regular expression matched by third party headers.
  #
  # For example, this can be used to filter the vixie cron headers as
  # erroneously exported by older cron versions.
  #
  # @api private
  # @abstract Providers based on ParsedFile may implement this to make it
  #   possible to identify a header maintained by a third party tool.
  #   The provider can then allow that header to remain near the top of the
  #   written file, or remove it after composing the file content.
  #   If implemented, the function must return a Regexp object.
  #   The expression must be tailored to match exactly one third party header.
  # @see drop_native_header
  # @note When specifying regular expressions in multiline mode, avoid
  #   greedy repetitions such as '.*' (use .*? instead). Otherwise, the
  #   provider may drop file content between sparse headers.
  def self.native_header_regex
    nil
  end

  # How to handle third party headers.
  # @api private
  # @abstract Providers based on ParsedFile that make use of the support for
  #   third party headers may override this method to return +true+.
  #   When this is done, headers that are matched by the native_header_regex
  #   are not written back to disk.
  # @see native_header_regex
  def self.drop_native_header
    false
  end

  # Add another type var.
  def self.initvars
    @records = []
    @target_objects = {}

    # Hash of <target> => <failure reason>.
    @failed_prefetch_targets = {}
    @raise_prefetch_errors = false

    @target = nil

    # Default to flat files
    @filetype ||= Puppet::Util::FileType.filetype(:flat)
    super
  end

  # Return a list of all of the records we can find.
  def self.instances
    targets.collect do |target|
      prefetch_target(target)
    end.flatten.reject { |r| skip_record?(r) }.collect do |record|
      new(record)
    end
  end

  # Override the default method with a lot more functionality.
  def self.mk_resource_methods
    [resource_type.validproperties, resource_type.parameters].flatten.each do |attr|
      attr = attr.intern
      define_method(attr) do
        # If it's not a valid field for this record type (which can happen
        # when different platforms support different fields), then just
        # return the should value, so the resource shuts up.
        if @property_hash[attr] or self.class.valid_attr?(self.class.name, attr)
          @property_hash[attr] || :absent
        elsif defined?(@resource)
          @resource.should(attr)
        else
          nil
        end
      end

      define_method(attr.to_s + "=") do |val|
        mark_target_modified
        @property_hash[attr] = val
      end
    end
  end

  # Always make the resource methods.
  def self.resource_type=(resource)
    super
    mk_resource_methods
  end

  # Mark a target as modified so we know to flush it.  This only gets
  # used within the attr= methods.
  def self.modified(target)
    @modified ||= []
    @modified << target unless @modified.include?(target)
  end

  # Retrieve all of the data from disk.  There are three ways to know
  # which files to retrieve:  We might have a list of file objects already
  # set up, there might be instances of our associated resource and they
  # will have a path parameter set, and we will have a default path
  # set.  We need to turn those three locations into a list of files,
  # prefetch each one, and make sure they're associated with each appropriate
  # resource instance.
  def self.prefetch(resources = nil)
    # Reset the record list.
    @records = prefetch_all_targets(resources)

    match_providers_with_resources(resources)
  end

  # Match a list of catalog resources with provider instances
  #
  # @api private
  #
  # @param [Array<Puppet::Resource>] resources A list of resources using this class as a provider
  def self.match_providers_with_resources(resources)
    return unless resources

    matchers = resources.dup
    @records.each do |record|
      # Skip things like comments and blank lines
      next if skip_record?(record)

      if (resource = resource_for_record(record, resources))
        resource.provider = new(record)
      elsif respond_to?(:match)
        resource = match(record, matchers)
        if resource
          matchers.delete(resource.title)
          record[:name] = resource[:name]
          resource.provider = new(record)
        end
      end
    end
  end

  # Look up a resource based on a parsed file record
  #
  # @api private
  #
  # @param [Hash<Symbol, Object>] record
  # @param [Array<Puppet::Resource>] resources
  #
  # @return [Puppet::Resource, nil] The resource if found, else nil
  def self.resource_for_record(record, resources)
    name = record[:name]
    if name
      resources[name]
    end
  end

  def self.prefetch_all_targets(resources)
    records = []
    targets(resources).each do |target|
      records += prefetch_target(target)
    end
    records
  end

  # Prefetch an individual target.
  def self.prefetch_target(target)
    begin
      target_records = retrieve(target)
      unless target_records
        raise Puppet::DevError, _("Prefetching %{target} for provider %{name} returned nil") % { target: target, name: name }
      end
    rescue Puppet::Util::FileType::FileReadError => detail
      if @raise_prefetch_errors
        # We will raise an error later in flush_target. This way,
        # only the resources linked to our target will fail
        # evaluation.
        @failed_prefetch_targets[target] = detail.to_s
      else
        puts detail.backtrace if Puppet[:trace]
        Puppet.err _("Could not prefetch %{resource} provider '%{name}' target '%{target}': %{detail}. Treating as empty") % { resource: resource_type.name, name: name, target: target, detail: detail }
      end

      target_records = []
    end

    target_records.each do |r|
      r[:on_disk] = true
      r[:target] = target
      r[:ensure] = :present
    end

    target_records = prefetch_hook(target_records) if respond_to?(:prefetch_hook)

    raise Puppet::DevError, _("Prefetching %{target} for provider %{name} returned nil") % { target: target, name: name } unless target_records

    target_records
  end

  # Is there an existing record with this name?
  def self.record?(name)
    return nil unless @records

    @records.find { |r| r[:name] == name }
  end

  # Retrieve the text for the file. Returns nil in the unlikely
  # event that it doesn't exist.
  def self.retrieve(path)
    # XXX We need to be doing something special here in case of failure.
    text = target_object(path).read
    if text.nil? or text == ""
      # there is no file
      []
    else
      # Set the target, for logging.
      old = @target
      begin
        @target = path
        parse(text)
      rescue Puppet::Error => detail
        detail.file = @target if detail.respond_to?(:file=)
        raise detail
      ensure
        @target = old
      end
    end
  end

  # Should we skip the record?  Basically, we skip text records.
  # This is only here so subclasses can override it.
  def self.skip_record?(record)
    record_type(record[:record_type]).text?
  end

  # The mode for generated files if they are newly created.
  # No mode will be set on existing files.
  #
  # @abstract Providers inheriting parsedfile can override this method
  #   to provide a mode. The value should be suitable for File.chmod
  def self.default_mode
    nil
  end

  # Initialize the object if necessary.
  def self.target_object(target)
    # only send the default mode if the actual provider defined it,
    # because certain filetypes (e.g. the crontab variants) do not
    # expect it in their initialize method
    if default_mode
      @target_objects[target] ||= filetype.new(target, default_mode)
    else
      @target_objects[target] ||= filetype.new(target)
    end

    @target_objects[target]
  end

  # Find all of the records for a given target
  def self.target_records(target)
    @records.find_all { |r| r[:target] == target }
  end

  # Find a list of all of the targets that we should be reading.  This is
  # used to figure out what targets we need to prefetch.
  def self.targets(resources = nil)
    targets = []
    # First get the default target
    raise Puppet::DevError, _("Parsed Providers must define a default target") unless default_target

    targets << default_target

    # Then get each of the file objects
    targets += @target_objects.keys

    # Lastly, check the file from any resource instances
    if resources
      resources.each do |_name, resource|
        value = resource.should(:target)
        if value
          targets << value
        end
      end
    end

    targets.uniq.compact
  end

  # Compose file contents from the set of records.
  #
  # If self.native_header_regex is not nil, possible vendor headers are
  # identified by matching the return value against the expression.
  # If one (or several consecutive) such headers, are found, they are
  # either moved in front of the self.header if self.drop_native_header
  # is false (this is the default), or removed from the return value otherwise.
  #
  # @api private
  def self.to_file(records)
    text = super
    if native_header_regex and (match = text.match(native_header_regex))
      if drop_native_header
        # concatenate the text in front of and after the native header
        text = match.pre_match + match.post_match
      else
        native_header = match[0]
        return native_header + header + match.pre_match + match.post_match
      end
    end
    header + text
  end

  def create
    @resource.class.validproperties.each do |property|
      value = @resource.should(property)
      if value
        @property_hash[property] = value
      end
    end
    mark_target_modified
    (@resource.class.name.to_s + "_created").intern
  end

  def destroy
    # We use the method here so it marks the target as modified.
    self.ensure = :absent
    (@resource.class.name.to_s + "_deleted").intern
  end

  def exists?
    !(@property_hash[:ensure] == :absent or @property_hash[:ensure].nil?)
  end

  # Write our data to disk.
  def flush
    # Make sure we've got a target and name set.

    # If the target isn't set, then this is our first modification, so
    # mark it for flushing.
    unless @property_hash[:target]
      @property_hash[:target] = @resource.should(:target) || self.class.default_target
      self.class.modified(@property_hash[:target])
    end
    @resource.class.key_attributes.each do |attr|
      @property_hash[attr] ||= @resource[attr]
    end

    self.class.flush(@property_hash)
  end

  def initialize(record)
    super

    # The 'record' could be a resource or a record, depending on how the provider
    # is initialized.  If we got an empty property hash (probably because the resource
    # is just being initialized), then we want to set up some defaults.
    @property_hash = self.class.record?(resource[:name]) || { :record_type => self.class.name, :ensure => :absent } if @property_hash.empty?
  end

  # Retrieve the current state from disk.
  def prefetch
    raise Puppet::DevError, _("Somehow got told to prefetch with no resource set") unless @resource

    self.class.prefetch(@resource[:name] => @resource)
  end

  def record_type
    @property_hash[:record_type]
  end

  private

  # Mark both the resource and provider target as modified.
  def mark_target_modified
    restarget = @resource.should(:target) if defined?(@resource)
    if restarget && restarget != @property_hash[:target]
      self.class.modified(restarget)
    end
    self.class.modified(@property_hash[:target]) if @property_hash[:target] != :absent and @property_hash[:target]
  end
end
