"""
IPS pkg support for Solaris

.. important::
    If you feel that Salt should be using this module to manage packages on a
    minion, and it is using a different module (or gives an error similar to
    *'pkg.install' is not available*), see :ref:`here
    <module-provider-override>`.

This module provides support for Solaris 11 new package management - IPS (Image Packaging System).
This is the default pkg module for Solaris 11 (and later).

If you want to use also other packaging module (e.g. pkgutil) together with IPS, you need to override the ``pkg`` provider
in sls for each package:

.. code-block:: yaml

    mypackage:
      pkg.installed:
        - provider: pkgutil

Or you can override it globally by setting the :conf_minion:`providers` parameter in your Minion config file like this:

.. code-block:: yaml

    providers:
      pkg: pkgutil

Or you can override it globally by setting the :conf_minion:`providers` parameter in your Minion config file like this:

.. code-block:: yaml

    providers:
      pkg: pkgutil

"""

import copy
import logging

import salt.utils.data
import salt.utils.functools
import salt.utils.path
import salt.utils.pkg
from salt.exceptions import CommandExecutionError

# Define the module's virtual name
__virtualname__ = "pkg"
log = logging.getLogger(__name__)


def __virtual__():
    """
    Set the virtual pkg module if the os is Solaris 11
    """
    if (
        __grains__["os_family"] == "Solaris"
        and float(__grains__["kernelrelease"]) > 5.10
        and salt.utils.path.which("pkg")
    ):
        return __virtualname__
    return (
        False,
        "The solarisips execution module failed to load: only available "
        "on Solaris >= 11.",
    )


ips_pkg_return_values = {
    0: "Command succeeded.",
    1: "An error occurred.",
    2: "Invalid command line options were specified.",
    3: "Multiple operations were requested, but only some of them succeeded.",
    4: "No changes were made - nothing to do.",
    5: "The requested operation cannot be performed on a  live image.",
    6: (
        "The requested operation cannot be completed because the licenses for "
        "the packages being installed or updated have not been accepted."
    ),
    7: "The image is currently in use by another process and cannot be modified.",
}


def _ips_get_pkgname(line):
    """
    Extracts package name from "pkg list -v" output.
    Input: one line of the command output
    Output: pkg name (e.g.: "pkg://solaris/x11/library/toolkit/libxt")
    Example use:
    line = "pkg://solaris/x11/library/toolkit/libxt@1.1.3,5.11-0.175.1.0.0.24.1317:20120904T180030Z i--"
    name = _ips_get_pkgname(line)
    """
    return line.split()[0].split("@")[0].strip()


def _ips_get_pkgversion(line):
    """
    Extracts package version from "pkg list -v" output.
    Input: one line of the command output
    Output: package version (e.g.: "1.1.3,5.11-0.175.1.0.0.24.1317:20120904T180030Z")
    Example use:
    line = "pkg://solaris/x11/library/toolkit/libxt@1.1.3,5.11-0.175.1.0.0.24.1317:20120904T180030Z i--"
    name = _ips_get_pkgversion(line)
    """
    return line.split()[0].split("@")[1].strip()


def refresh_db(full=False, **kwargs):
    """
    Updates the remote repos database.

    full : False

        Set to ``True`` to force a refresh of the pkg DB from all publishers,
        regardless of the last refresh time.

    CLI Example:

    .. code-block:: bash

        salt '*' pkg.refresh_db
        salt '*' pkg.refresh_db full=True
    """
    # Remove rtag file to keep multiple refreshes from happening in pkg states
    salt.utils.pkg.clear_rtag(__opts__)
    if full:
        return __salt__["cmd.retcode"]("/bin/pkg refresh --full") == 0
    else:
        return __salt__["cmd.retcode"]("/bin/pkg refresh") == 0


def upgrade_available(name, **kwargs):
    """
    Check if there is an upgrade available for a certain package
    Accepts full or partial FMRI. Returns all matches found.

    CLI Example:

    .. code-block:: bash

        salt '*' pkg.upgrade_available apache-22
    """
    version = None
    cmd = ["pkg", "list", "-Huv", name]
    lines = __salt__["cmd.run_stdout"](cmd).splitlines()
    if not lines:
        return {}
    ret = {}
    for line in lines:
        ret[_ips_get_pkgname(line)] = _ips_get_pkgversion(line)
    return ret


def list_upgrades(refresh=True, **kwargs):  # pylint: disable=W0613
    """
    Lists all packages available for update.

    When run in global zone, it reports only upgradable packages for the global
    zone.

    When run in non-global zone, it can report more upgradable packages than
    ``pkg update -vn``, because ``pkg update`` hides packages that require
    newer version of ``pkg://solaris/entire`` (which means that they can be
    upgraded only from the global zone). If ``pkg://solaris/entire`` is found
    in the list of upgrades, then the global zone should be updated to get all
    possible updates. Use ``refresh=True`` to refresh the package database.

    refresh : True
        Runs a full package database refresh before listing. Set to ``False`` to
        disable running the refresh.

        .. versionchanged:: 2017.7.0

        In previous versions of Salt, ``refresh`` defaulted to ``False``. This was
        changed to default to ``True`` in the 2017.7.0 release to make the behavior
        more consistent with the other package modules, which all default to ``True``.

    CLI Example:

    .. code-block:: bash

        salt '*' pkg.list_upgrades
        salt '*' pkg.list_upgrades refresh=False
    """
    if salt.utils.data.is_true(refresh):
        refresh_db(full=True)
    upgrades = {}
    # awk is in core-os package so we can use it without checking
    lines = __salt__["cmd.run_stdout"]("/bin/pkg list -Huv").splitlines()
    for line in lines:
        upgrades[_ips_get_pkgname(line)] = _ips_get_pkgversion(line)
    return upgrades


def upgrade(refresh=False, **kwargs):
    """
    Upgrade all packages to the latest possible version.
    When run in global zone, it updates also all non-global zones.
    In non-global zones upgrade is limited by dependency constrains linked to
    the version of pkg://solaris/entire.

    Returns a dictionary containing the changes:

    .. code-block:: python

        {'<package>':  {'old': '<old-version>',
                        'new': '<new-version>'}}

    When there is a failure, an explanation is also included in the error
    message, based on the return code of the ``pkg update`` command.

    CLI Example:

    .. code-block:: bash

        salt '*' pkg.upgrade
    """
    if salt.utils.data.is_true(refresh):
        refresh_db()

    # Get a list of the packages before install so we can diff after to see
    # what got installed.
    old = list_pkgs()

    # Install or upgrade the package
    # If package is already installed
    cmd = ["pkg", "update", "-v", "--accept"]
    result = __salt__["cmd.run_all"](cmd, output_loglevel="trace", python_shell=False)
    __context__.pop("pkg.list_pkgs", None)
    new = list_pkgs()
    ret = salt.utils.data.compare_dicts(old, new)

    if result["retcode"] != 0:
        raise CommandExecutionError(
            "Problem encountered upgrading packages",
            info={
                "changes": ret,
                "retcode": ips_pkg_return_values[result["retcode"]],
                "result": result,
            },
        )

    return ret


def _list_pkgs_from_context(versions_as_list):
    """
    Use pkg list from __context__
    """
    if versions_as_list:
        return __context__["pkg.list_pkgs"]
    else:
        ret = copy.deepcopy(__context__["pkg.list_pkgs"])
        __salt__["pkg_resource.stringify"](ret)
        return ret


def list_pkgs(versions_as_list=False, **kwargs):
    """
    List the currently installed packages as a dict::

        {'<package_name>': '<version>'}

    CLI Example:

    .. code-block:: bash

        salt '*' pkg.list_pkgs
    """
    # not yet implemented or not applicable
    if any(
        [salt.utils.data.is_true(kwargs.get(x)) for x in ("removed", "purge_desired")]
    ):
        return {}

    if "pkg.list_pkgs" in __context__ and kwargs.get("use_context", True):
        return _list_pkgs_from_context(versions_as_list)

    ret = {}
    cmd = "/bin/pkg list -Hv"
    lines = __salt__["cmd.run_stdout"](cmd).splitlines()
    # column 1 is full FMRI name in form pkg://publisher/class/name@version
    for line in lines:
        name = _ips_get_pkgname(line)
        version = _ips_get_pkgversion(line)
        __salt__["pkg_resource.add_pkg"](ret, name, version)

    __salt__["pkg_resource.sort_pkglist"](ret)
    __context__["pkg.list_pkgs"] = copy.deepcopy(ret)
    if not versions_as_list:
        __salt__["pkg_resource.stringify"](ret)
    return ret


def version(*names, **kwargs):
    """
    Common interface for obtaining the version of installed packages.
    Accepts full or partial FMRI. If called using pkg_resource, full FMRI is required.
    Partial FMRI is returned if the package is not installed.

    CLI Example:

    .. code-block:: bash

        salt '*' pkg.version vim
        salt '*' pkg.version foo bar baz
        salt '*' pkg_resource.version pkg://solaris/entire

    """
    if not names:
        return ""

    cmd = ["/bin/pkg", "list", "-Hv"]
    cmd.extend(names)
    lines = __salt__["cmd.run_stdout"](cmd, ignore_retcode=True).splitlines()
    ret = {}
    for line in lines:
        ret[_ips_get_pkgname(line)] = _ips_get_pkgversion(line)

    # Append package names which are not installed/found
    for name in names:
        if name not in ret:
            ret[name] = ""

    # Return a string if only one package name passed
    if len(names) == 1:
        try:
            return next(iter(ret.values()))
        except StopIteration:
            return ""

    return ret


def latest_version(*names, **kwargs):
    """
    The available version of packages in the repository.
    Accepts full or partial FMRI. Partial FMRI is returned if the full FMRI
    could not be resolved.

    If the latest version of a given package is already installed, an empty
    string will be returned for that package.

    Please use pkg.latest_version as pkg.available_version is being deprecated.

    .. versionchanged:: 2019.2.0
        Support for multiple package names added.

    CLI Example:

    .. code-block:: bash

        salt '*' pkg.latest_version bash
        salt '*' pkg.latest_version pkg://solaris/entire
        salt '*' pkg.latest_version postfix sendmail
    """

    if not names:
        return ""

    cmd = ["/bin/pkg", "list", "-Hnv"]
    cmd.extend(names)
    lines = __salt__["cmd.run_stdout"](cmd, ignore_retcode=True).splitlines()
    ret = {}
    for line in lines:
        ret[_ips_get_pkgname(line)] = _ips_get_pkgversion(line)

    installed = version(*names)

    if len(names) == 1:
        # Convert back our result in a dict if only one name is passed
        installed = {list(ret)[0] if ret else names[0]: installed}

    for name in ret:
        if name not in installed:
            continue
        if ret[name] == installed[name]:
            ret[name] = ""

    # Append package names which are not found
    for name in names:
        if name not in ret:
            ret[name] = ""

    # Return a string if only one package name passed
    if len(names) == 1:
        try:
            return next(iter(ret.values()))
        except StopIteration:
            return ""

    return ret


# available_version is being deprecated
available_version = salt.utils.functools.alias_function(
    latest_version, "available_version"
)


def get_fmri(name, **kwargs):
    """
    Returns FMRI from partial name. Returns empty string ('') if not found.
    In case of multiple match, the function returns list of all matched packages.

    CLI Example:

    .. code-block:: bash

        salt '*' pkg.get_fmri bash
    """
    if name.startswith("pkg://"):
        # already full fmri
        return name
    cmd = ["/bin/pkg", "list", "-aHv", name]
    # there can be more packages matching the name
    lines = __salt__["cmd.run_stdout"](cmd).splitlines()
    if not lines:
        # empty string = package not found
        return ""
    ret = []
    for line in lines:
        ret.append(_ips_get_pkgname(line))

    return ret


def normalize_name(name, **kwargs):
    """
    Internal function. Normalizes pkg name to full FMRI before running
    pkg.install. In case of multiple matches or no match, it returns the name
    without modifications.

    CLI Example:

    .. code-block:: bash

        salt '*' pkg.normalize_name vim
    """
    if name.startswith("pkg://"):
        # already full fmri
        return name
    cmd = ["/bin/pkg", "list", "-aHv", name]
    # there can be more packages matching the name
    lines = __salt__["cmd.run_stdout"](cmd).splitlines()
    # if we get more lines, it's multiple match (name not unique)
    # if we get zero lines, pkg is not installed
    # in both ways it's safer to return original (unmodified) name and let "pkg install" to deal with it
    if len(lines) != 1:
        return name
    # return pkg name
    return _ips_get_pkgname(lines[0])


def is_installed(name, **kwargs):
    """
    Returns True if the package is installed. Otherwise returns False.
    Name can be full or partial FMRI.
    In case of multiple match from partial FMRI name, it returns True.

    CLI Example:

    .. code-block:: bash

        salt '*' pkg.is_installed bash
    """

    cmd = ["/bin/pkg", "list", "-Hv", name]
    return __salt__["cmd.retcode"](cmd) == 0


def search(name, versions_as_list=False, **kwargs):
    """
    Searches the repository for given pkg name.
    The name can be full or partial FMRI. All matches are printed. Globs are
    also supported.

    CLI Example:

    .. code-block:: bash

        salt '*' pkg.search bash
    """

    ret = {}
    cmd = ["/bin/pkg", "list", "-aHv", name]
    out = __salt__["cmd.run_all"](cmd, ignore_retcode=True)
    if out["retcode"] != 0:
        # error = nothing found
        return {}
    # no error, processing pkg listing
    # column 1 is full FMRI name in form pkg://publisher/pkg/name@version
    for line in out["stdout"].splitlines():
        name = _ips_get_pkgname(line)
        version = _ips_get_pkgversion(line)
        __salt__["pkg_resource.add_pkg"](ret, name, version)

    if not versions_as_list:
        __salt__["pkg_resource.stringify"](ret)
    return ret


def install(name=None, refresh=False, pkgs=None, version=None, test=False, **kwargs):
    """
    Install the named package using the IPS pkg command.
    Accepts full or partial FMRI.

    Returns a dict containing the new package names and versions::

        {'<package>': {'old': '<old-version>',
                       'new': '<new-version>'}}


    Multiple Package Installation Options:

    pkgs
        A list of packages to install. Must be passed as a python list.

    CLI Example:

    .. code-block:: bash

        salt '*' pkg.install vim
        salt '*' pkg.install pkg://solaris/editor/vim
        salt '*' pkg.install pkg://solaris/editor/vim refresh=True
        salt '*' pkg.install pkgs='["foo", "bar"]'
    """
    if not pkgs:
        if is_installed(name):
            return {}

    if refresh:
        refresh_db(full=True)

    pkg2inst = ""
    if pkgs:  # multiple packages specified
        pkg2inst = []
        for pkg in pkgs:
            if getattr(pkg, "items", False):
                if list(pkg.items())[0][1]:  # version specified
                    pkg2inst.append(
                        f"{list(pkg.items())[0][0]}@{list(pkg.items())[0][1]}"
                    )
                else:
                    pkg2inst.append(list(pkg.items())[0][0])
            else:
                pkg2inst.append(f"{pkg}")
        log.debug("Installing these packages instead of %s: %s", name, pkg2inst)

    else:  # install single package
        if version:
            pkg2inst = f"{name}@{version}"
        else:
            pkg2inst = f"{name}"

    cmd = ["pkg", "install", "-v", "--accept"]
    if test:
        cmd.append("-n")

    # Get a list of the packages before install so we can diff after to see
    # what got installed.
    old = list_pkgs()

    # Install or upgrade the package
    # If package is already installed
    if isinstance(pkg2inst, str):
        cmd.append(pkg2inst)
    elif isinstance(pkg2inst, list):
        cmd = cmd + pkg2inst

    out = __salt__["cmd.run_all"](cmd, output_loglevel="trace")

    # Get a list of the packages again, including newly installed ones.
    __context__.pop("pkg.list_pkgs", None)
    new = list_pkgs()
    ret = salt.utils.data.compare_dicts(old, new)

    if out["retcode"] != 0:
        raise CommandExecutionError(
            "Error occurred installing package(s)",
            info={
                "changes": ret,
                "retcode": ips_pkg_return_values[out["retcode"]],
                "errors": [out["stderr"]],
            },
        )

    # No error occurred
    if test:
        return "Test succeeded."

    return ret


def remove(name=None, pkgs=None, **kwargs):
    """
    Remove specified package. Accepts full or partial FMRI.
    In case of multiple match, the command fails and won't modify the OS.

    name
        The name of the package to be deleted.


    Multiple Package Options:

    pkgs
        A list of packages to delete. Must be passed as a python list. The
        ``name`` parameter will be ignored if this option is passed.


    Returns a list containing the removed packages.

    CLI Example:

    .. code-block:: bash

        salt '*' pkg.remove <package name>
        salt '*' pkg.remove tcsh
        salt '*' pkg.remove pkg://solaris/shell/tcsh
        salt '*' pkg.remove pkgs='["foo", "bar"]'
    """
    targets = salt.utils.args.split_input(pkgs) if pkgs else [name]
    if not targets:
        return {}

    if pkgs:
        log.debug("Removing these packages instead of %s: %s", name, targets)

    # Get a list of the currently installed pkgs.
    old = list_pkgs()

    # Remove the package(s)
    cmd = ["/bin/pkg", "uninstall", "-v"] + targets
    out = __salt__["cmd.run_all"](cmd, output_loglevel="trace")

    # Get a list of the packages after the uninstall
    __context__.pop("pkg.list_pkgs", None)
    new = list_pkgs()
    ret = salt.utils.data.compare_dicts(old, new)

    if out["retcode"] != 0:
        raise CommandExecutionError(
            "Error occurred removing package(s)",
            info={
                "changes": ret,
                "retcode": ips_pkg_return_values[out["retcode"]],
                "errors": [out["stderr"]],
            },
        )

    return ret


def purge(name, **kwargs):
    """
    Remove specified package. Accepts full or partial FMRI.

    Returns a list containing the removed packages.

    CLI Example:

    .. code-block:: bash

        salt '*' pkg.purge <package name>
    """
    return remove(name, **kwargs)
