# (c) 2017, Ansible Project
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)

# most of it copied from AWX's scan_packages module

from __future__ import annotations


DOCUMENTATION = """
module: package_facts
short_description: Package information as facts
description:
  - Return information about installed packages as facts.
options:
  manager:
    description:
      - The package manager(s) used by the system so we can query the package information.
        This is a list and can support multiple package managers per system, since version 2.8.
      - The V(portage) and V(pkg) options were added in version 2.8.
      - The V(apk) option was added in version 2.11.
      - The V(pkg_info) option was added in version 2.13.
      - Aliases were added in 2.18, to support using C(manager={{ansible_facts['pkg_mgr']}})
    default: ['auto']
    choices:
        auto: Depending on O(strategy), will match the first or all package managers provided, in order
        rpm: For RPM based distros, requires RPM Python bindings, not installed by default on Suse or Fedora 41+ (python3-rpm)
        yum: Alias to rpm
        dnf: Alias to rpm
        dnf5: Alias to rpm
        zypper: Alias to rpm
        apt: For DEB based distros, C(python-apt) package must be installed on targeted hosts
        portage: Handles ebuild packages, it requires the C(qlist) utility, which is part of 'app-portage/portage-utils'
        pkg: libpkg front end (FreeBSD)
        pkg5: Alias to pkg
        pkgng: Alias to pkg
        pacman: Archlinux package manager/builder
        apk: Alpine Linux package manager
        pkg_info: OpenBSD package manager
        openbsd_pkg: Alias to pkg_info
    type: list
    elements: str
  strategy:
    description:
      - This option controls how the module queries the package managers on the system.
    choices:
        first: returns only information for the first supported package manager available.
        all: returns information for all supported and available package managers on the system.
    default: 'first'
    type: str
    version_added: "2.8"
version_added: "2.5"
requirements:
    - See details per package manager in the O(manager) option.
author:
  - Matthew Jones (@matburt)
  - Brian Coca (@bcoca)
  - Adam Miller (@maxamillion)
extends_documentation_fragment:
  -  action_common_attributes
  -  action_common_attributes.facts
attributes:
    check_mode:
        support: full
    diff_mode:
        support: none
    facts:
        support: full
    platform:
        platforms: posix
"""

EXAMPLES = """
- name: Gather the package facts
  ansible.builtin.package_facts:
    manager: auto

- name: Print the package facts
  ansible.builtin.debug:
    var: ansible_facts.packages

- name: Check whether a package called foobar is installed
  ansible.builtin.debug:
    msg: "{{ ansible_facts.packages['foobar'] | length }} versions of foobar are installed!"
  when: "'foobar' in ansible_facts.packages"

"""

RETURN = """
ansible_facts:
  description: Facts to add to ansible_facts.
  returned: always
  type: complex
  contains:
    packages:
      description:
        - Maps the package name to a non-empty list of dicts with package information.
        - Every dict in the list corresponds to one installed version of the package.
        - The fields described below are present for all package managers. Depending on the
          package manager, there might be more fields for a package.
      returned: when operating system level package manager is specified or auto detected manager
      type: dict
      contains:
        name:
          description: The package's name.
          returned: always
          type: str
        version:
          description: The package's version.
          returned: always
          type: str
        source:
          description: Where information on the package came from.
          returned: always
          type: str
      sample: |-
        {
          "packages": {
            "kernel": [
              {
                "name": "kernel",
                "source": "rpm",
                "version": "3.10.0",
                ...
              },
              {
                "name": "kernel",
                "source": "rpm",
                "version": "3.10.0",
                ...
              },
              ...
            ],
            "kernel-tools": [
              {
                "name": "kernel-tools",
                "source": "rpm",
                "version": "3.10.0",
                ...
              }
            ],
            ...
          }
        }
        # Sample rpm
        {
          "packages": {
            "kernel": [
              {
                "arch": "x86_64",
                "epoch": null,
                "name": "kernel",
                "release": "514.26.2.el7",
                "source": "rpm",
                "version": "3.10.0"
              },
              {
                "arch": "x86_64",
                "epoch": null,
                "name": "kernel",
                "release": "514.16.1.el7",
                "source": "rpm",
                "version": "3.10.0"
              },
              {
                "arch": "x86_64",
                "epoch": null,
                "name": "kernel",
                "release": "514.10.2.el7",
                "source": "rpm",
                "version": "3.10.0"
              },
              {
                "arch": "x86_64",
                "epoch": null,
                "name": "kernel",
                "release": "514.21.1.el7",
                "source": "rpm",
                "version": "3.10.0"
              },
              {
                "arch": "x86_64",
                "epoch": null,
                "name": "kernel",
                "release": "693.2.2.el7",
                "source": "rpm",
                "version": "3.10.0"
              }
            ],
            "kernel-tools": [
              {
                "arch": "x86_64",
                "epoch": null,
                "name": "kernel-tools",
                "release": "693.2.2.el7",
                "source": "rpm",
                "version": "3.10.0"
              }
            ],
            "kernel-tools-libs": [
              {
                "arch": "x86_64",
                "epoch": null,
                "name": "kernel-tools-libs",
                "release": "693.2.2.el7",
                "source": "rpm",
                "version": "3.10.0"
              }
            ],
          }
        }
        # Sample deb
        {
          "packages": {
            "libbz2-1.0": [
              {
                "version": "1.0.6-5",
                "source": "apt",
                "arch": "amd64",
                "name": "libbz2-1.0"
              }
            ],
            "patch": [
              {
                "version": "2.7.1-4ubuntu1",
                "source": "apt",
                "arch": "amd64",
                "name": "patch"
              }
            ],
          }
        }
        # Sample pkg_info
        {
          "packages": {
            "curl": [
              {
                  "name": "curl",
                  "source": "pkg_info",
                  "version": "7.79.0"
              }
            ],
            "intel-firmware": [
              {
                  "name": "intel-firmware",
                  "source": "pkg_info",
                  "version": "20210608v0"
              }
            ],
          }
        }
"""

import re
import json

from ansible.module_utils.common.text.converters import to_native, to_text
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.common.locale import get_best_parsable_locale
from ansible.module_utils.facts.packages import CLIMgr, RespawningLibMgr, get_all_pkg_managers


ALIASES = {
    'rpm': ['dnf', 'dnf5', 'yum' , 'zypper'],
    'pkg': ['pkg5', 'pkgng'],
    'pkg_info': ['openbsd_pkg'],
}


class RPM(CLIMgr):

    CLI = 'rpm'

    @staticmethod
    def _none(v):
        # When this class used the python3-rpm language bindings,
        # `None` was converted to `str(None)` during output.
        # The CLI does this conversion from `None` -> `(none)`,
        # so this function is used to maintain the previous API output
        # behavior.
        return 'None' if v == '(none)' else v

    def list_installed(self):
        rc, stdout, stderr = module.run_command(
            ['rpm', '-qa', '--qf', '%{NAME}|%{VERSION}|%{RELEASE}|%{EPOCH}|%{ARCH}\n'],
            handle_exceptions=False,
        )
        if rc != 0:
            raise Exception("Unable to list packages rc=%s : %s" % (rc, stderr))
        return stdout.strip().split('\n')

    def get_package_details(self, package):
        name, version, release, epoch, arch = package.split('|')
        return {
            'name': self._none(name),
            'version': self._none(version),
            'release': self._none(release),
            'epoch': self._none(epoch),
            'arch': self._none(arch),
        }


class APT(RespawningLibMgr):

    LIB = 'apt'
    CLI_BINARIES = ['apt', 'apt-get', 'aptitude']

    def __init__(self):
        self._cache = None
        super(APT, self).__init__()

    @property
    def pkg_cache(self):
        if self._cache is not None:
            return self._cache

        self._cache = self._lib.Cache()
        return self._cache

    def list_installed(self):
        # Store the cache to avoid running pkg_cache() for each item in the comprehension, which is very slow
        cache = self.pkg_cache
        return [pk for pk in cache.keys() if cache[pk].is_installed]

    def get_package_details(self, package):
        ac_pkg = self.pkg_cache[package].installed
        return dict(name=package, version=ac_pkg.version, arch=ac_pkg.architecture, category=ac_pkg.section, origin=ac_pkg.origins[0].origin)


class PACMAN(CLIMgr):

    CLI = 'pacman'

    def list_installed(self):
        locale = get_best_parsable_locale(module)
        rc, out, err = module.run_command([self._cli, '-Qi'], environ_update=dict(LC_ALL=locale), handle_exceptions=False)
        if rc != 0 or err:
            raise Exception("Unable to list packages rc=%s : %s" % (rc, err))
        return out.split("\n\n")[:-1]

    def get_package_details(self, package):
        # parse values of details that might extend over several lines
        raw_pkg_details = {}
        last_detail = None
        for line in package.splitlines():
            m = re.match(r"([\w ]*[\w]) +: (.*)", line)
            if m:
                last_detail = m.group(1)
                raw_pkg_details[last_detail] = m.group(2)
            else:
                # append value to previous detail
                raw_pkg_details[last_detail] = raw_pkg_details[last_detail] + "  " + line.lstrip()

        provides = None
        if raw_pkg_details['Provides'] != 'None':
            provides = [
                p.split('=')[0]
                for p in raw_pkg_details['Provides'].split('  ')
            ]

        return {
            'name': raw_pkg_details['Name'],
            'version': raw_pkg_details['Version'],
            'arch': raw_pkg_details['Architecture'],
            'provides': provides,
        }


class PKG(CLIMgr):

    CLI = 'pkg'
    atoms = ['name', 'version', 'origin', 'installed', 'automatic', 'arch', 'category', 'prefix', 'vital']

    def list_installed(self):
        rc, out, err = module.run_command([self._cli, 'query', "%%%s" % '\t%'.join(['n', 'v', 'R', 't', 'a', 'q', 'o', 'p', 'V'])], handle_exceptions=False)
        if rc != 0 or err:
            raise Exception("Unable to list packages rc=%s : %s" % (rc, err))
        return out.splitlines()

    def get_package_details(self, package):

        pkg = dict(zip(self.atoms, package.split('\t')))

        if 'arch' in pkg:
            try:
                pkg['arch'] = pkg['arch'].split(':')[2]
            except IndexError:
                pass

        if 'automatic' in pkg:
            pkg['automatic'] = bool(int(pkg['automatic']))

        if 'category' in pkg:
            pkg['category'] = pkg['category'].split('/', 1)[0]

        if 'version' in pkg:
            if ',' in pkg['version']:
                pkg['version'], pkg['port_epoch'] = pkg['version'].split(',', 1)
            else:
                pkg['port_epoch'] = 0

            if '_' in pkg['version']:
                pkg['version'], pkg['revision'] = pkg['version'].split('_', 1)
            else:
                pkg['revision'] = '0'

        if 'vital' in pkg:
            pkg['vital'] = bool(int(pkg['vital']))

        return pkg


class PORTAGE(CLIMgr):

    CLI = 'qlist'
    atoms = ['category', 'name', 'version', 'ebuild_revision', 'slots', 'prefixes', 'sufixes']

    def list_installed(self):
        rc, out, err = module.run_command(' '.join([self._cli, '-Iv', '|', 'xargs', '-n', '1024', 'qatom']), use_unsafe_shell=True, handle_exceptions=False)
        if rc != 0:
            raise RuntimeError("Unable to list packages rc=%s : %s" % (rc, to_native(err)))
        return out.splitlines()

    def get_package_details(self, package):
        return dict(zip(self.atoms, package.split()))


class APK(CLIMgr):

    CLI = 'apk'

    def list_installed(self):
        cmd = [self._cli, 'query', '--installed', '--fields', 'name,version', '--format', 'json', '*']
        rc, out, err = module.run_command(cmd, handle_exceptions=False)
        if rc != 0:
            raise Exception(f"Unable to list packages rc={rc} : {err}")
        return json.loads(out)

    def get_package_details(self, package):
        version, release = package.get('version', '').rsplit("-", 1)
        return {'name': package.get('name', ''), 'version': version, 'release': release}


class PKG_INFO(CLIMgr):

    CLI = 'pkg_info'

    def list_installed(self):
        rc, out, err = module.run_command([self._cli, '-a'], handle_exceptions=False)
        if rc != 0 or err:
            raise Exception("Unable to list packages rc=%s : %s" % (rc, err))
        return out.splitlines()

    def get_package_details(self, package):
        raw_pkg_details = {'name': package, 'version': ''}
        details = package.split(maxsplit=1)[0].rsplit('-', maxsplit=1)

        try:
            return {
                'name': details[0],
                'version': details[1],
            }
        except IndexError:
            return raw_pkg_details


def main():

    # get supported pkg managers
    PKG_MANAGERS = get_all_pkg_managers()
    PKG_MANAGER_NAMES = sorted([x.lower() for x in PKG_MANAGERS.keys()])
    # add aliases
    PKG_MANAGER_NAMES.extend([alias for alist in ALIASES.values() for alias in alist])

    # start work
    global module

    # choices are not set for 'manager' as they are computed dynamically and validated below instead of in argspec
    module = AnsibleModule(argument_spec=dict(manager={'type': 'list', 'elements': 'str', 'default': ['auto']},
                                              strategy={'choices': ['first', 'all'], 'default': 'first'}),
                           supports_check_mode=True)
    packages = {}
    results = {'ansible_facts': {}}
    managers = [x.lower() for x in module.params['manager']]
    strategy = module.params['strategy']

    if 'auto' in managers:
        # keep order from user, we do dedupe below
        managers.extend(PKG_MANAGER_NAMES)
        managers.remove('auto')

    unsupported = set(managers).difference(PKG_MANAGER_NAMES)
    if unsupported:
        if 'auto' in module.params['manager']:
            msg = 'Could not auto detect a usable package manager, check warnings for details.'
        else:
            msg = 'Unsupported package managers requested: %s' % (', '.join(unsupported))
        module.fail_json(msg=msg)

    found = 0
    seen = set()
    for pkgmgr in managers:

        if strategy == 'first' and found:
            break

        # substitute aliases for aliased
        for aliased in ALIASES.keys():
            if pkgmgr in ALIASES[aliased]:
                pkgmgr = aliased
                break

        # dedupe as per above
        if pkgmgr in seen:
            continue

        seen.add(pkgmgr)

        manager = PKG_MANAGERS[pkgmgr]()
        try:
            packages_found = {}
            if manager.is_available(handle_exceptions=False):
                try:
                    packages_found = manager.get_packages()
                except Exception as e:
                    module.warn('Failed to retrieve packages with %s: %s' % (pkgmgr, to_text(e)))

            # only consider 'found' if it results in something
            if packages_found:
                found += 1
                for k in packages_found.keys():
                    if k in packages:
                        packages[k].extend(packages_found[k])
                    else:
                        packages[k] = packages_found[k]
            else:
                module.warn('Found "%s" but no associated packages' % (pkgmgr))

        except Exception as e:
            if pkgmgr in module.params['manager']:
                module.warn('Requested package manager %s was not usable by this module: %s' % (pkgmgr, to_text(e)))

    if found == 0:
        msg = ('Could not detect a supported package manager from the following list: %s, '
               'or the required Python library is not installed. Check warnings for details.' % managers)
        module.fail_json(msg=msg)

    # Set the facts, this will override the facts in ansible_facts that might exist from previous runs
    # when using operating system level or distribution package managers
    results['ansible_facts']['packages'] = packages

    module.exit_json(**results)


if __name__ == '__main__':
    main()
