# frozen_string_literal: true

require_relative '../../../puppet/provider/package'
require_relative '../../../puppet/util/rpm_compare'

# RPM packaging.  Should work anywhere that has rpm installed.
Puppet::Type.type(:package).provide :rpm, :source => :rpm, :parent => Puppet::Provider::Package do
  # provides Rpm parsing and comparison
  include Puppet::Util::RpmCompare

  desc "RPM packaging support; should work anywhere with a working `rpm`
    binary.

    This provider supports the `install_options` and `uninstall_options`
    attributes, which allow command-line flags to be passed to rpm.
    These options should be specified as an array where each element is either a string or a hash."

  has_feature :versionable
  has_feature :install_options
  has_feature :uninstall_options
  has_feature :virtual_packages
  has_feature :install_only

  # Note: self:: is required here to keep these constants in the context of what will
  # eventually become this Puppet::Type::Package::ProviderRpm class.
  # The query format by which we identify installed packages
  self::NEVRA_FORMAT = '%{NAME} %|EPOCH?{%{EPOCH}}:{0}| %{VERSION} %{RELEASE} %{ARCH}\\n'
  self::NEVRA_REGEX  = /^'?(\S+) (\S+) (\S+) (\S+) (\S+)$/
  self::NEVRA_FIELDS = [:name, :epoch, :version, :release, :arch]
  self::MULTIVERSION_SEPARATOR = "; "

  commands :rpm => "rpm"

  # Mixing confine statements, control expressions, and exception handling
  # confuses Rubocop's Layout cops, so we disable them entirely.
  # rubocop:disable Layout
  if command('rpm')
    confine :true => begin
      rpm('--version')
      rescue Puppet::ExecutionFailure
        false
      else
        true
      end
  end
  # rubocop:enable Layout

  def self.current_version
    return @current_version unless @current_version.nil?

    output = rpm "--version"
    @current_version = output.gsub('RPM version ', '').strip
  end

  # rpm < 4.1 does not support --nosignature
  def self.nosignature
    '--nosignature' unless Puppet::Util::Package.versioncmp(current_version, '4.1') < 0
  end

  # rpm < 4.0.2 does not support --nodigest
  def self.nodigest
    '--nodigest' unless Puppet::Util::Package.versioncmp(current_version, '4.0.2') < 0
  end

  def self.instances
    packages = []

    # list out all of the packages
    begin
      execpipe("#{command(:rpm)} -qa #{nosignature} #{nodigest} --qf '#{self::NEVRA_FORMAT}' | sort") { |process|
        # now turn each returned line into a package object
        nevra_to_multiversion_hash(process).each { |hash| packages << new(hash) }
      }
    rescue Puppet::ExecutionFailure => e
      raise Puppet::Error, _("Failed to list packages"), e.backtrace
    end

    packages
  end

  # Find the fully versioned package name and the version alone. Returns
  # a hash with entries :instance => fully versioned package name, and
  # :ensure => version-release
  def query
    # NOTE: Prior to a fix for issue 1243, this method potentially returned a cached value
    # IF YOU CALL THIS METHOD, IT WILL CALL RPM
    # Use get(:property) to check if cached values are available
    cmd = ["-q", @resource[:name], self.class.nosignature.to_s, self.class.nodigest.to_s, "--qf", self.class::NEVRA_FORMAT.to_s]

    begin
      output = rpm(*cmd)
    rescue Puppet::ExecutionFailure
      return nil unless @resource.allow_virtual?

      # rpm -q exits 1 if package not found
      # retry the query for virtual packages
      cmd << '--whatprovides'
      begin
        output = rpm(*cmd)
      rescue Puppet::ExecutionFailure
        # couldn't find a virtual package either
        return nil
      end
    end
    @property_hash.update(self.class.nevra_to_multiversion_hash(output))

    @property_hash.dup
  end

  # Here we just retrieve the version from the file specified in the source.
  def latest
    source = @resource[:source]
    unless source
      @resource.fail _("RPMs must specify a package source")
    end

    cmd = [command(:rpm), "-q", "--qf", self.class::NEVRA_FORMAT.to_s, "-p", source]
    h = self.class.nevra_to_multiversion_hash(execute(cmd))
    h[:ensure]
  rescue Puppet::ExecutionFailure => e
    raise Puppet::Error, e.message, e.backtrace
  end

  def install
    source = @resource[:source]
    unless source
      @resource.fail _("RPMs must specify a package source")
    end

    version = @property_hash[:ensure]

    # RPM gets upset if you try to install an already installed package
    return if @resource.should(:ensure) == version || (@resource.should(:ensure) == :latest && version == latest)

    flag = ["-i"]
    flag = ["-U", "--oldpackage"] if version && (version != :absent && version != :purged)
    flag += install_options if resource[:install_options]
    rpm flag, source
  end

  def uninstall
    query
    # If version and release (or only version) is specified in the resource,
    # uninstall using them, otherwise uninstall using only the name of the package.
    name    = get(:name)
    version = get(:version)
    release = get(:release)
    nav = "#{name}-#{version}"
    nvr = "#{nav}-#{release}"
    if @resource[:name].start_with? nvr
      identifier = nvr
    elsif @resource[:name].start_with? nav
      identifier = nav
    elsif @resource[:install_only]
      identifier = get(:ensure).split(self.class::MULTIVERSION_SEPARATOR).map { |ver| "#{name}-#{ver}" }
    else
      identifier = name
    end
    # If an arch is specified in the resource, uninstall that arch,
    # otherwise uninstall the arch returned by query.
    # If multiple arches are installed and arch is not specified,
    # this will uninstall all of them after successive runs.
    #
    # rpm prior to 4.2.1 cannot accept architecture as part of the package name.
    unless Puppet::Util::Package.versioncmp(self.class.current_version, '4.2.1') < 0
      arch = ".#{get(:arch)}"
      if @resource[:name].end_with? arch
        identifier += arch
      end
    end

    flag = ['-e']
    flag += uninstall_options if resource[:uninstall_options]
    rpm flag, identifier
  end

  def update
    install
  end

  def install_options
    join_options(resource[:install_options])
  end

  def uninstall_options
    join_options(resource[:uninstall_options])
  end

  def insync?(is)
    return false if [:purged, :absent].include?(is)
    return false if is.include?(self.class::MULTIVERSION_SEPARATOR) && !@resource[:install_only]

    should = resource[:ensure]
    is.split(self.class::MULTIVERSION_SEPARATOR).any? do |version|
      0 == rpm_compare_evr(should, version)
    end
  end

  private

  # @param line [String] one line of rpm package query information
  # @return [Hash] of NEVRA_FIELDS strings parsed from package info
  # or an empty hash if we failed to parse
  # @api private
  def self.nevra_to_hash(line)
    line.strip!
    hash = {}

    match = self::NEVRA_REGEX.match(line)
    if match
      self::NEVRA_FIELDS.zip(match.captures) { |f, v| hash[f] = v }
      hash[:provider] = name
      hash[:ensure] = "#{hash[:version]}-#{hash[:release]}"
      hash[:ensure].prepend("#{hash[:epoch]}:") if hash[:epoch] != '0'
    else
      Puppet.debug("Failed to match rpm line #{line}")
    end

    hash
  end

  # @param line [String] multiple lines of rpm package query information
  # @return list of [Hash] of NEVRA_FIELDS strings parsed from package info
  # or an empty list if we failed to parse
  # @api private
  def self.nevra_to_multiversion_hash(multiline)
    list = []
    multiversion_hash = {}
    multiline.each_line do |line|
      hash = nevra_to_hash(line)
      next if hash.empty?

      if multiversion_hash.empty?
        multiversion_hash = hash.dup
        next
      end

      if multiversion_hash[:name] != hash[:name]
        list << multiversion_hash
        multiversion_hash = hash.dup
        next
      end

      unless multiversion_hash[:ensure].include?(hash[:ensure])
        multiversion_hash[:ensure].concat("#{self::MULTIVERSION_SEPARATOR}#{hash[:ensure]}")
      end
    end
    list << multiversion_hash if multiversion_hash
    if list.size == 1
      return list[0]
    end

    list
  end
end
