require_relative 'filetype'
require 'puppet/provider/parsedfile'

Puppet::Type.type(:cron).provide(:crontab, parent: Puppet::Provider::ParsedFile, default_target: ENV['USER'] || 'root', raise_prefetch_errors: true) do
  commands crontab: 'crontab'

  text_line :comment, match: %r{^\s*#}, post_parse: proc { |record|
    record[:name] = Regexp.last_match(1) if record[:line] =~ %r{Puppet Name: (.+)\s*$}
  }

  text_line :blank, match: %r{^\s*$}

  text_line :environment, match: %r{^\s*\w+\s*=}

  def self.filetype
    tabname = case Facter.value(:osfamily)
              when 'Solaris'
                :suntab
              when 'AIX'
                :aixtab
              else
                :crontab
              end

    Puppet::Provider::Cron::FileType.filetype(tabname)
  end

  self::TIME_FIELDS = [:minute, :hour, :monthday, :month, :weekday].freeze

  record_line :crontab,
              fields: ['time', 'command'],
              match: %r{^\s*(@\w+|\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+(.+)$},
              absent: '*',
              block_eval: :instance do

    def post_parse(record)
      time = record.delete(:time)
      match = %r{@(\S+)}.match(time)
      if match
        # is there another way to access the constant?
        Puppet::Type::Cron::ProviderCrontab::TIME_FIELDS.each { |f| record[f] = :absent }
        record[:special] = match.captures[0]
        return record
      end

      match = %r{(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)}.match(time)
      if match
        record[:special] = :absent
        Puppet::Type::Cron::ProviderCrontab::TIME_FIELDS.zip(match.captures).each do |field, value|
          record[field] = if value == absent
                            :absent
                          else
                            value.split(',')
                          end
        end

        return record
      end

      raise Puppet::Error, _('Line got parsed as a crontab entry but cannot be handled. Please file a bug with the contents of your crontab')
    end

    def pre_gen(record)
      if record[:special] && record[:special] != :absent
        record[:special] = "@#{record[:special]}"
      end

      Puppet::Type::Cron::ProviderCrontab::TIME_FIELDS.each do |field|
        if (vals = record[field]) && vals.is_a?(Array)
          record[field] = vals.join(',')
        end
      end
      record
    end

    def to_line(record)
      str = ''
      record[:name] = nil if record[:unmanaged]
      str = "# Puppet Name: #{record[:name]}\n" if record[:name]
      if record[:environment] && record[:environment] != :absent
        str += record[:environment].map { |line| "#{line}\n" }.join('')
      end
      fields = if record[:special] && record[:special] != :absent
                 [:special, :command]
               else
                 Puppet::Type::Cron::ProviderCrontab::TIME_FIELDS + [:command]
               end
      str += record.values_at(*fields).map { |field|
        if field.nil? || field == :absent
          absent
        else
          field
        end
      }.join(joiner)
      str
    end
  end

  def create
    if resource.should(:command)
      super
    else
      resource.err _('no command specified, cannot create')
    end
  end

  # Look up a resource with a given name whose user matches a record target
  #
  # @api private
  #
  # @note This overrides the ParsedFile method for finding resources by name,
  #   so that only records for a given user are matched to resources of the
  #   same user so that orphaned records in other crontabs don't get falsely
  #   matched (#2251)
  #
  # @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)
    resource = super

    target = resource[:target] || resource[:user] if resource
    return resource if record[:target] == target
  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} by puppet.
# HEADER: While it can still be managed manually, it is definitely not recommended.
# HEADER: Note particularly that the comments starting with 'Puppet Name' should
# HEADER: not be deleted, as doing so could cause duplicate cron jobs.\n)
  end

  # Regex for finding one vixie cron header.
  def self.native_header_regex
    %r{# DO NOT EDIT THIS FILE.*?Cron version.*?vixie.*?\n}m
  end

  # If a vixie cron header is found, it should be dropped, cron will insert
  # a new one in any case, so we need to avoid duplicates.
  def self.drop_native_header
    true
  end

  # See if we can match the record against an existing cron job.
  def self.match(record, resources)
    # if the record is named, do not even bother (#19876)
    # except the resource name was implicitly generated (#3220)
    return false if record[:name] && !record[:unmanaged]
    resources.each do |_name, resource|
      # Match the command first, since it's the most important one.
      next unless record[:target] == resource[:target]
      next unless record[:command] == resource.value(:command)

      # Now check the time fields
      compare_fields = self::TIME_FIELDS + [:special]

      matched = true
      compare_fields.each do |field|
        # If the resource does not manage a property (say monthday) it should
        # always match. If it is the other way around (e.g. resource defines
        # a should value for :special but the record does not have it, we do
        # not match
        next unless resource[field]
        unless record.include?(field)
          matched = false
          break
        end

        if (record_value = record[field]) && (resource_value = resource.value(field))
          # The record translates '*' into absent in the post_parse hook and
          # the resource type does exactly the opposite (alias :absent to *)
          next if resource_value == '*' && record_value == :absent
          next if resource_value == record_value
        end
        matched = false
        break
      end
      return resource if matched
    end
    false
  end

  @name_index = 0

  # Collapse name and env records.
  def self.prefetch_hook(records)
    name = nil
    envs = nil
    result = []
    records.each do |record|
      case record[:record_type]
      when :comment
        if record[:name]
          name = record[:name]
          record[:skip] = true

          # Start collecting env values
          envs = []
        end
      when :environment
        # If we're collecting env values (meaning we're in a named cronjob),
        # store the line and skip the record.
        if envs
          envs << record[:line]
          record[:skip] = true
        end
      when :blank # rubocop: disable Lint/EmptyWhen
        # nothing
      else
        if name
          record[:name] = name
          name = nil
        else
          cmd_string = record[:command].gsub(%r{\s+}, '_')
          index = (@name_index += 1)
          record[:name] = "unmanaged:#{cmd_string}-#{index}"
          record[:unmanaged] = true
        end
        if envs.nil? || envs.empty?
          record[:environment] = :absent
        else
          # Collect all of the environment lines, and mark the records to be skipped,
          # since their data is included in our crontab record.
          record[:environment] = envs

          # And turn off env collection again
          envs = nil
        end
      end
      result << record unless record[:skip]
    end
    result
  end

  def self.to_file(records)
    text = super
    # Apparently Freebsd will "helpfully" add a new TZ line to every
    # single cron line, but not in all cases (e.g., it doesn't do it
    # on my machine).  This is my attempt to fix it so the TZ lines don't
    # multiply.
    if text =~ %r{(^TZ=.+\n)}
      tz = Regexp.last_match(1)
      text.sub!(tz, '')
      text = tz + text
    end
    text
  end

  def user=(user)
    # we have to mark the target as modified first, to make sure that if
    # we move a cronjob from userA to userB, userA's crontab will also
    # be rewritten
    mark_target_modified
    @property_hash[:user] = user
    @property_hash[:target] = user
  end

  def user
    @property_hash[:user] || @property_hash[:target]
  end

  CRONTAB_DIR = case Facter.value('osfamily')
                when 'Debian', 'HP-UX', 'Solaris'
                  '/var/spool/cron/crontabs'
                when %r{BSD}
                  '/var/cron/tabs'
                when 'Darwin'
                  '/usr/lib/cron/tabs/'
                else
                  '/var/spool/cron'
                end

  # Yield the names of all crontab files stored on the local system.
  #
  # @note Ignores files that are not writable for the puppet process.
  #
  # @api private
  def self.enumerate_crontabs
    Puppet.debug "looking for crontabs in #{CRONTAB_DIR}"
    return unless File.readable?(CRONTAB_DIR)
    Dir.foreach(CRONTAB_DIR) do |file|
      path = "#{CRONTAB_DIR}/#{file}"
      yield(file) if File.file?(path) && File.writable?(path)
    end
  end

  # Include all plausible crontab files on the system
  # in the list of targets (#11383 / PUP-1381)
  def self.targets(resources = nil)
    targets = super(resources)
    enumerate_crontabs do |target|
      targets << target
    end
    targets.uniq
  end
end
