# frozen_string_literal: true

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

Puppet::Type.type(:package).provide :nim, :parent => :aix, :source => :aix do
  desc "Installation from an AIX NIM LPP source.  The `source` parameter is required
      for this provider, and should specify the name of a NIM `lpp_source` resource
      that is visible to the puppet agent machine.  This provider supports the
      management of both BFF/installp and RPM packages.

      Note that package downgrades are *not* supported; if your resource specifies
      a specific version number and there is already a newer version of the package
      installed on the machine, the resource will fail with an error message."

  # The commands we are using on an AIX box are installed standard
  # (except nimclient) nimclient needs the bos.sysmgt.nim.client fileset.
  commands    :nimclient => "/usr/sbin/nimclient",
              :lslpp => "/usr/bin/lslpp",
              :rpm => "rpm"

  # If NIM has not been configured, /etc/niminfo will not be present.
  # However, we have no way of knowing if the NIM server is not configured
  # properly.
  confine :exists => "/etc/niminfo"

  has_feature :versionable

  attr_accessor :latest_info

  def self.srclistcmd(source)
    [command(:nimclient), "-o", "showres", "-a", "installp_flags=L", "-a", "resource=#{source}"]
  end

  def uninstall
    output = lslpp("-qLc", @resource[:name]).split(':')
    # the 6th index in the colon-delimited output contains a " " for installp/BFF
    # packages, and an "R" for RPMS.  (duh.)
    pkg_type = output[6]

    case pkg_type
    when " "
      installp "-gu", @resource[:name]
    when "R"
      rpm "-e", @resource[:name]
    else
      self.fail(_("Unrecognized AIX package type identifier: '%{pkg_type}'") % { pkg_type: pkg_type })
    end

    # installp will return an exit code of zero even if it didn't uninstall
    # anything... so let's make sure it worked.
    unless query().nil?
      self.fail _("Failed to uninstall package '%{name}'") % { name: @resource[:name] }
    end
  end

  def install(useversion = true)
    source = @resource[:source]
    unless source
      self.fail _("An LPP source location is required in 'source'")
    end

    pkg = @resource[:name]

    version_specified = (useversion and (!@resource.should(:ensure).is_a? Symbol))

    # This is unfortunate for a couple of reasons.  First, because of a subtle
    # difference in the command-line syntax for installing an RPM vs an
    # installp/BFF package, we need to know ahead of time which type of
    # package we're trying to install.  This means we have to execute an
    # extra command.
    #
    # Second, the command is easiest to deal with and runs fastest if we
    # pipe it through grep on the shell.  Unfortunately, the way that
    # the provider `make_command_methods` metaprogramming works, we can't
    # use that code path to execute the command (because it treats the arguments
    # as an array of args that all apply to `nimclient`, which fails when you
    # hit the `|grep`.)  So here we just call straight through to P::U.execute
    # with a single string argument for the full command, rather than going
    # through the metaprogrammed layer.  We could get rid of the grep and
    # switch back to the metaprogrammed stuff, and just parse all of the output
    # in Ruby... but we'd be doing an awful lot of unnecessary work.
    showres_command = "/usr/sbin/nimclient -o showres -a resource=#{source} |/usr/bin/grep -p -E "
    if version_specified
      version = @resource.should(:ensure)
      showres_command << "'#{Regexp.escape(pkg)}( |-)#{Regexp.escape(version)}'"
    else
      version = nil
      showres_command << "'#{Regexp.escape(pkg)}'"
    end
    output = Puppet::Util::Execution.execute(showres_command)

    if version_specified
      package_type = determine_package_type(output, pkg, version)
    else
      package_type, version = determine_latest_version(output, pkg)
    end

    if package_type.nil?

      errmsg = if version_specified
                 _("Unable to find package '%{package}' with version '%{version}' on lpp_source '%{source}'") %
                   { package: pkg, version: version, source: source }
               else
                 _("Unable to find package '%{package}' on lpp_source '%{source}'") % { package: pkg, source: source }
               end
      self.fail errmsg
    end

    # This part is a bit tricky.  If there are multiple versions of the
    # package available, then `version` will be set to a value, and we'll need
    # to add that value to our installation command.  However, if there is only
    # one version of the package available, `version` will be set to `nil`, and
    # we don't need to add the version string to the command.
    if version
      # Now we know if the package type is RPM or not, and we can adjust our
      # `pkg` string for passing to the install command accordingly.
      if package_type == :rpm
        # RPMs expect a hyphen between the package name and the version number
        version_separator = "-"
      else
        # installp/BFF packages expect a space between the package name and the
        # version number.
        version_separator = " "
      end

      pkg += version_separator + version
    end

    # NOTE: the installp flags here are ignored (but harmless) for RPMs
    output = nimclient "-o", "cust", "-a", "installp_flags=acgwXY", "-a", "lpp_source=#{source}", "-a", "filesets=#{pkg}"

    # If the package is superseded, it means we're trying to downgrade and we
    # can't do that.
    case package_type
    when :installp
      if output =~ /^#{Regexp.escape(@resource[:name])}\s+.*\s+Already superseded by.*$/
        self.fail _("NIM package provider is unable to downgrade packages")
      end
    when :rpm
      if output =~ /^#{Regexp.escape(@resource[:name])}.* is superseded by.*$/
        self.fail _("NIM package provider is unable to downgrade packages")
      end
    end
  end

  private

  ## UTILITY METHODS FOR PARSING `nimclient -o showres` output

  # This makes me very sad.  These regexes seem pretty fragile, but
  # I spent a lot of time trying to figure out a solution that didn't
  # require parsing the `nimclient -o showres` output and was unable to
  # do so.
  self::HEADER_LINE_REGEX      = /^([^\s]+)\s+[^@]+@@(I|R|S):(\1)\s+[^\s]+$/
  self::PACKAGE_LINE_REGEX     = /^.*@@(I|R|S):(.*)$/
  self::RPM_PACKAGE_REGEX      = /^(.*)-(.*-\d+\w*) \2$/
  self::INSTALLP_PACKAGE_REGEX = /^(.*) (.*)$/

  # Here is some sample output that shows what the above regexes will be up
  # against:
  # FOR AN INSTALLP(bff) PACKAGE:
  #
  #    mypackage.foo                                                           ALL  @@I:mypackage.foo _all_filesets
  #    + 1.2.3.4  MyPackage Runtime Environment                       @@I:mypackage.foo 1.2.3.4
  #    + 1.2.3.8  MyPackage Runtime Environment                       @@I:mypackage.foo 1.2.3.8
  #
  # FOR AN INSTALLP(bff) PACKAGE with security update:
  #
  #    bos.net                                                                 ALL  @@S:bos.net _all_filesets
  #    + 7.2.0.1  TCP/IP ntp Applications                             @@S:bos.net.tcp.ntp 7.2.0.1
  #    + 7.2.0.2  TCP/IP ntp Applications                             @@S:bos.net.tcp.ntp 7.2.0.2
  #
  # FOR AN RPM PACKAGE:
  #
  # mypackage.foo                                                                ALL  @@R:mypackage.foo _all_filesets
  #   @@R:mypackage.foo-1.2.3-1 1.2.3-1
  #   @@R:mypackage.foo-1.2.3-4 1.2.3-4
  #   @@R:mypackage.foo-1.2.3-8 1.2.3-8

  # Parse the output of a `nimclient -o showres` command.  Returns a two-dimensional
  # hash, where the first-level keys are package names, the second-level keys are
  # version number strings for all of the available version numbers for a package,
  # and the values indicate the package type (:rpm / :installp)
  def parse_showres_output(showres_output)
    paragraphs = split_into_paragraphs(showres_output)
    packages = {}
    paragraphs.each do |para|
      lines = para.split(/$/)
      parse_showres_header_line(lines.shift)
      lines.each do |l|
        package, version, type = parse_showres_package_line(l)
        packages[package] ||= {}
        packages[package][version] = type
      end
    end
    packages
  end

  # This method basically just splits the multi-line input string into chunks
  # based on lines that contain nothing but whitespace.  It also strips any
  # leading or trailing whitespace (including newlines) from the resulting
  # strings and then returns them as an array.
  def split_into_paragraphs(showres_output)
    showres_output.split(/^\s*$/).map(&:strip!)
  end

  def parse_showres_header_line(line)
    # This method doesn't produce any meaningful output; it's basically just
    # meant to validate that the header line for the package listing output
    # looks sane, so we know we're dealing with the kind of output that we
    # are capable of handling.
    unless line.match(self.class::HEADER_LINE_REGEX)
      self.fail _("Unable to parse output from nimclient showres: line does not match expected package header format:\n'%{line}'") % { line: line }
    end
  end

  def parse_installp_package_string(package_string)
    match = package_string.match(self.class::INSTALLP_PACKAGE_REGEX)
    unless match
      self.fail _("Unable to parse output from nimclient showres: package string does not match expected installp package string format:\n'%{package_string}'") % { package_string: package_string }
    end
    package_name = match.captures[0]
    version = match.captures[1]
    [package_name, version, :installp]
  end

  def parse_rpm_package_string(package_string)
    match = package_string.match(self.class::RPM_PACKAGE_REGEX)
    unless match
      self.fail _("Unable to parse output from nimclient showres: package string does not match expected rpm package string format:\n'%{package_string}'") % { package_string: package_string }
    end
    package_name = match.captures[0]
    version = match.captures[1]
    [package_name, version, :rpm]
  end

  def parse_showres_package_line(line)
    match = line.match(self.class::PACKAGE_LINE_REGEX)
    unless match
      self.fail _("Unable to parse output from nimclient showres: line does not match expected package line format:\n'%{line}'") % { line: line }
    end

    package_type_flag = match.captures[0]
    package_string = match.captures[1]

    case package_type_flag
    when "I", "S"
      parse_installp_package_string(package_string)
    when "R"
      parse_rpm_package_string(package_string)
    else
      self.fail _("Unrecognized package type specifier: '%{package_type_flag}' in package line:\n'%{line}'") % { package_type_flag: package_type_flag, line: line }
    end
  end

  # Given a blob of output from `nimclient -o showres` and a package name,
  # this method checks to see if there are multiple versions of the package
  # available on the lpp_source.  If there are, the method returns
  # [package_type, latest_version] (where package_type is one of :installp or :rpm).
  # If there is only one version of the package available, it returns
  # [package_type, nil], because the caller doesn't need to pass the version
  # string to the command-line command if there is only one version available.
  # If the package is not available at all, the method simply returns nil (instead
  # of a tuple).
  def determine_latest_version(showres_output, package_name)
    packages = parse_showres_output(showres_output)
    unless packages.has_key?(package_name)
      return nil
    end

    if packages[package_name].count == 1
      version = packages[package_name].keys[0]
      [packages[package_name][version], nil]
    else
      versions = packages[package_name].keys
      latest_version = versions.sort { |a, b| Puppet::Util::Package.versioncmp(b, a) }[0]
      [packages[package_name][latest_version], latest_version]
    end
  end

  def determine_package_type(showres_output, package_name, version)
    packages = parse_showres_output(showres_output)
    unless packages.has_key?(package_name) and packages[package_name].has_key?(version)
      return nil
    end

    packages[package_name][version]
  end
end
