require "inspec/resources/directory"
require "inspec/utils/simpleconfig"

# Resource to determine package information
#
# Usage:
# describe package('nginx') do
#   it { should be_installed }
# end
module Inspec::Resources
  class Package < Inspec.resource(1)
    name "package"
    supports platform: "unix"
    supports platform: "windows"
    desc "Use the package InSpec audit resource to test if the named package and/or package version is installed on the system."
    example <<~EXAMPLE
      describe package('nginx') do
        it { should be_installed }
        it { should_not be_held } # for dpkg platforms that support holding a version from being upgraded
        its('version') { should eq 1.9.5 }
      end
    EXAMPLE
    def initialize(package_name, opts = {}) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
      @package_name = package_name
      @name = @package_name
      @cache = nil
      # select package manager
      @pkgman = nil
      @latest_version = nil

      os = inspec.os
      if os.debian?
        @pkgman = Deb.new(inspec)
      elsif os.redhat? || %w{suse amazon fedora}.include?(os[:family])
        @pkgman = Rpm.new(inspec, opts)
      elsif ["arch"].include?(os[:name])
        @pkgman = Pacman.new(inspec)
      elsif ["darwin"].include?(os[:family])
        @pkgman = Brew.new(inspec)
      elsif os.windows?
        @pkgman = WindowsPkg.new(inspec)
      elsif ["aix"].include?(os[:family])
        @pkgman = BffPkg.new(inspec)
      elsif os.solaris?
        @pkgman = SolarisPkg.new(inspec)
      elsif ["hpux"].include?(os[:family])
        @pkgman = HpuxPkg.new(inspec)
      elsif ["alpine"].include?(os[:name])
        @pkgman = AlpinePkg.new(inspec)
      elsif ["freebsd"].include?(os[:name])
        @pkgman = FreebsdPkg.new(inspec)
      else
        raise Inspec::Exceptions::ResourceSkipped, "The `package` resource is not supported on your OS yet."
      end

      evaluate_missing_requirements
    end

    # returns true if the package is installed
    def installed?(_provider = nil, _version = nil)
      info[:installed] == true
    end

    def latest?(_provider = nil, _version = nil)
      os = inspec.os
      if os.solaris? || (%w{hpux aix}.include? os[:family])
        raise Inspec::Exceptions::ResourceSkipped, "The `be_latest` matcher is not supported on your OS yet."
      end

      (!info[:only_version_no].nil? && !latest_version.nil?) && (info[:only_version_no] == latest_version)
    end

    # returns true it the package is held (if the OS supports it)
    def held?(_provider = nil, _version = nil)
      info[:held] == true
    end

    # returns the package description
    def info
      return @cache unless @cache.nil?
      # All `@pkgman.info` methods return `{}`. This matches that
      # behavior if `@pkgman` can't be determined, thus avoiding the
      # `undefined method 'info' for nil:NilClass` error
      return {} if @pkgman.nil?

      @pkgman.info(@package_name)
    end

    # return the package version
    def version
      info = @pkgman.info(@package_name)
      info[:version]
    end

    def latest_version
      @latest_version ||= ( @pkgman.latest_version(@package_name) || info[:latest_version] )
    end

    def resource_id
      @package_name || "System Package"
    end

    def to_s
      "System Package #{@package_name}"
    end

    private

    def evaluate_missing_requirements
      missing_requirements_string = @pkgman.missing_requirements.uniq.join(", ")
      return if missing_requirements_string.empty?

      raise Inspec::Exceptions::ResourceSkipped, "The following requirements are not met for this resource: #{missing_requirements_string}"
    end
  end

  class PkgManagement
    attr_reader :inspec
    def initialize(inspec)
      @inspec = inspec
    end

    def missing_requirements
      # Each provider can provide an Array of missing requirements that will be
      # combined into a `ResourceSkipped` exception message.
      []
    end

    private

    def fetch_latest_version(cmd_string)
      cmd = inspec.command(cmd_string)
      if cmd.exit_status != 0
        raise Inspec::Exceptions::ResourceFailed, "Failed to fetch latest version. Error: #{cmd.stderr}"
      else
        fetch_version_no(cmd.stdout)
      end
    end

    def fetch_version_no(output)
      output.scan(/(?:(?:\d+)[.]){2,}(?:\d+)/).max_by { |s| Gem::Version.new(s) } unless output.nil?
    end
  end

  # Debian / Ubuntu
  class Deb < PkgManagement
    def info(package_name)
      cmd = inspec.command("dpkg -s #{package_name}")
      return {} if cmd.exit_status.to_i != 0

      params = SimpleConfig.new(
        cmd.stdout.chomp,
        assignment_regex: /^\s*([^:]*?)\s*:\s*(.*?)\s*$/,
        multiple_values: false
      ).params
      # If the package is installed, Status is "install ok installed"
      # If the package is installed and marked hold, Status is "hold ok installed"
      # If the package is removed and not purged, Status is "deinstall ok config-files" with exit_status 0
      # If the package is purged cmd fails with non-zero exit status

      {
        name: params["Package"],
        installed: params["Status"].split(" ")[2] == "installed",
        held: params["Status"].split(" ")[0] == "hold",
        version: params["Version"],
        type: "deb",
        only_version_no: fetch_version_no(params["Version"]),
      }
    end

    def latest_version(package_name)
      cmd_string = "apt list #{package_name} -a"
      fetch_latest_version(cmd_string)
    end
  end

  # RHEL family
  class Rpm < PkgManagement
    def initialize(inspec, opts)
      super(inspec)

      @dbpath = opts.fetch(:rpm_dbpath, nil)
    end

    def missing_requirements
      missing_requirements = []

      unless @dbpath.nil? || inspec.directory(@dbpath).directory?
        missing_requirements << "RPMDB #{@dbpath} does not exist"
      end

      missing_requirements
    end

    def info(package_name)
      rpm_cmd = rpm_command(package_name)
      cmd = inspec.command(rpm_cmd)
      # CentOS does not return an error code if the package is not installed,
      # therefore we need to check for emptyness
      return {} if cmd.exit_status.to_i != 0 || cmd.stdout.chomp.empty?

      params = SimpleConfig.new(
        cmd.stdout.chomp,
        assignment_regex: /^\s*([^:]*?)\s*:\s*(.*?)\s*$/,
        multiple_values: false
      ).params
      # On some (all?) systems, the linebreak before the vendor line is missing
      if params["Version"] =~ /\s*Vendor:/
        v = params["Version"].split(" ")[0]
      else
        v = params["Version"]
      end
      # On some (all?) systems, the linebreak before the build line is missing
      if params["Release"] =~ /\s*Build Date:/
        r = params["Release"].split(" ")[0]
      else
        r = params["Release"]
      end
      {
        name: params["Name"],
        installed: true,
        version: "#{v}-#{r}",
        type: "rpm",
        only_version_no: "#{v}",
      }
    end

    def latest_version(package_name)
      cmd_string = "yum list #{package_name}"
      fetch_latest_version(cmd_string)
    end

    private

    def rpm_command(package_name)
      cmd = ""
      cmd += "rpm -qi"
      cmd += " --dbpath #{@dbpath}" if @dbpath
      cmd += " " + package_name

      cmd
    end
  end

  # MacOS / Darwin implementation
  class Brew < PkgManagement
    def info(package_name)
      brew_path = inspec.command("brew").exist? ? "brew" : "/usr/local/bin/brew"
      cmd = inspec.command("#{brew_path} info --json=v1 #{package_name}")

      # If no available formula exists, then `brew` will exit non-zero
      return {} if cmd.exit_status.to_i != 0

      pkg = JSON.parse(cmd.stdout)[0]

      # If package exists but is not installed, then `brew` output will not
      # contain `pkg['installed'][0]['version']
      return {} unless pkg.dig("installed", 0, "version")

      {
        name: pkg["name"],
        installed: true,
        version: pkg["installed"][0]["version"],
        type: "brew",
        latest_version: pkg["versions"]["stable"],
        only_version_no: pkg["installed"][0]["version"],
      }
    rescue JSON::ParserError => e
      raise Inspec::Exceptions::ResourceFailed,
            "Failed to parse JSON from `brew` command. Error: #{e}"
    end

    def latest_version(package_name)
      nil
    end
  end

  # Arch Linux
  class Pacman < PkgManagement
    def info(package_name)
      cmd = inspec.command("pacman -Qi #{package_name}")
      return {} if cmd.exit_status.to_i != 0

      params = SimpleConfig.new(
        cmd.stdout.chomp,
        assignment_regex: /^\s*([^:]*?)\s*:\s*(.*?)\s*$/,
        multiple_values: false
      ).params

      {
        name: params["Name"],
        installed: true,
        version: params["Version"],
        type: "pacman",
        only_version_no: fetch_version_no(params["Version"]),
      }
    end

    def latest_version(package_name)
      cmd_string = "pacman -Ss #{package_name} | grep #{package_name} | grep installed"
      fetch_latest_version(cmd_string)
    end
  end

  class HpuxPkg < PkgManagement
    def info(package_name)
      cmd = inspec.command("swlist -l product | grep #{package_name}")
      return {} if cmd.exit_status.to_i != 0

      pkg = cmd.stdout.strip.split(" ")
      {
        name: pkg[0],
        installed: true,
        version: pkg[1],
        type: "pkg",
      }
    end
  end

  class AlpinePkg < PkgManagement
    def info(package_name)
      cmd = inspec.command("apk info -vv --no-network | grep #{package_name}")
      return {} if cmd.exit_status.to_i != 0

      pkg_info = cmd.stdout.split("\n").delete_if { |e| e =~ /^WARNING/i }
      pkg = pkg_info[0].split(" - ")[0]

      version = pkg.partition("-")[2]
      {
        name: pkg.partition("-")[0],
        installed: true,
        version: version,
        type: "pkg",
        only_version_no: fetch_version_no(version),
      }
    end

    def latest_version(package_name)
      cmd_string = "apk info #{package_name}"
      fetch_latest_version(cmd_string)
    end
  end

  class FreebsdPkg < PkgManagement
    def info(package_name)
      cmd = inspec.command("pkg info #{package_name}")
      return {} if cmd.exit_status.to_i != 0

      params = SimpleConfig.new(
        cmd.stdout.chomp,
        assignment_regex: /^\s*([^:]*?)\s*:\s*(.*?)\s*$/,
        multiple_values: false
      ).params

      {
        name: params["Name"],
        installed: true,
        version: params["Version"],
        type: "pkg",
        only_version_no: params["Version"],
      }
    end

    def latest_version(package_name)
      cmd_string = "pkg version -v | grep #{package_name}"
      fetch_latest_version(cmd_string)
    end
  end

  # Determines the installed packages on Windows using the Windows package registry entries.
  # @see: http://blogs.technet.com/b/heyscriptingguy/archive/2013/11/15/use-powershell-to-find-installed-software.aspx
  class WindowsPkg < PkgManagement
    def info(package_name)
      search_paths = [
        'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
        'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
      ]

      # Add WOW6432Node paths for 32-bit apps on 64-bit Windows.
      # Include these unless the system is explicitly 32-bit (i386) — on 32-bit
      # Windows, WOW6432Node does not exist since all apps register under the
      # main Uninstall key.
      # When os.arch is nil (e.g., train falls back to read_cmd_os in a PowerShell
      # WinRM session where %PROCESSOR_ARCHITECTURE% is not expanded), we still
      # include the WOW6432Node paths since virtually all modern Windows systems
      # are 64-bit.
      unless inspec.os.arch == "i386"
        search_paths << 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
        search_paths << 'HKCU:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
      end

      # Find the package
      cmd = inspec.command <<-EOF.gsub(/^\s*/, "")
        Get-ItemProperty (@("#{search_paths.join('", "')}") | Where-Object { Test-Path $_ }) |
        Where-Object { $_.DisplayName -like "#{package_name}" -or $_.PSChildName -like "#{package_name}" } |
        Select-Object -Property DisplayName,DisplayVersion | ConvertTo-Json
      EOF

      # We cannot rely on `exit_status` since PowerShell always exits 0 from the
      # above command. Instead, if no package is found the output of the command
      # will be `''` so we can use that to return `{}` to match the behavior of
      # other package managers.
      return {} if cmd.stdout == ""

      begin
        package = JSON.parse(cmd.stdout)
      rescue JSON::ParserError => e
        raise Inspec::Exceptions::ResourceFailed,
              "Failed to parse JSON from PowerShell. Error: #{e}"
      end

      # What if we match multiple packages?  just pick the first one for now.
      package = package[0] if package.is_a?(Array)

      {
        name: package["DisplayName"],
        installed: true,
        version: package["DisplayVersion"],
        type: "windows",
        only_version_no: package["DisplayVersion"],
      }
    end

    def latest_version(package_name)
      cmd_string = "Get-Package #{package_name} -AllVersions"
      fetch_latest_version(cmd_string)
    end
  end

  # AIX
  class BffPkg < PkgManagement
    def info(package_name)
      cmd = inspec.command("lslpp -cL #{package_name}")
      return {} if cmd.exit_status.to_i != 0

      bff_pkg = cmd.stdout.split("\n").last.split(":")
      {
        name: bff_pkg[1],
        installed: true,
        version: bff_pkg[2],
        type: "bff",
      }
    end
  end

  # Solaris
  class SolarisPkg < PkgManagement
    def info(package_name)
      if inspec.os[:release].to_i <= 10
        solaris10_info(package_name)
      else
        solaris11_info(package_name)
      end
    end

    # solaris 10
    def solaris10_info(package_name)
      cmd = inspec.command("pkginfo -l #{package_name}")
      return {} if cmd.exit_status.to_i != 0

      params = SimpleConfig.new(
        cmd.stdout.chomp,
        assignment_regex: /^\s*([^:]*?)\s*:\s*(.*?)\s*$/,
        multiple_values: false
      ).params

      # parse 11.10.0,REV=2006.05.18.01.46
      v = params["VERSION"].split(",")
      {
        name: params["PKGINST"],
        installed: true,
        version: v[0] + "-" + v[1].split("=")[1],
        type: "pkg",
      }
    end

    # solaris 11
    def solaris11_info(package_name)
      cmd = inspec.command("pkg info #{package_name}")
      return {} if cmd.exit_status.to_i != 0

      params = SimpleConfig.new(
        cmd.stdout.chomp,
        assignment_regex: /^\s*([^:]*?)\s*:\s*(.*?)\s*$/,
        multiple_values: false
      ).params

      {
        name: params["Name"],
        installed: true,
        # 0.5.11-0.175.3.1.0.5.0
        version: "#{params["Version"]}-#{params["Branch"]}",
        type: "pkg",
      }
    end
  end
end
