"""
Install software from the FreeBSD ``ports(7)`` system

.. versionadded:: 2014.1.0

This module allows you to install ports using ``BATCH=yes`` to bypass
configuration prompts. It is recommended to use the :mod:`ports state
<salt.states.freebsdports>` to install ports, but it is also possible to use
this module exclusively from the command line.

.. code-block:: bash

    salt minion-id ports.config security/nmap IPV6=off
    salt minion-id ports.install security/nmap
"""

import fnmatch
import logging
import os
import re

import salt.utils.data
import salt.utils.files
import salt.utils.path
from salt.exceptions import CommandExecutionError, SaltInvocationError

log = logging.getLogger(__name__)

# Define the module's virtual name
__virtualname__ = "ports"


def __virtual__():
    """
    Only runs on FreeBSD systems
    """
    if __grains__["os"] == "FreeBSD":
        return __virtualname__
    return (
        False,
        "The freebsdports execution module cannot be loaded: "
        "only available on FreeBSD systems.",
    )


def _portsnap():
    """
    Return 'portsnap --interactive' for FreeBSD 10, otherwise 'portsnap'
    """
    ret = ["portsnap"]
    if float(__grains__["osrelease"]) >= 10:
        ret.append("--interactive")
    return ret


def _check_portname(name):
    """
    Check if portname is valid and whether or not the directory exists in the
    ports tree.
    """
    if not isinstance(name, str) or "/" not in name:
        raise SaltInvocationError(f"Invalid port name '{name}' (category required)")

    path = os.path.join("/usr/ports", name)
    if not os.path.isdir(path):
        raise SaltInvocationError(f"Path '{path}' does not exist")

    return path


def _options_dir(name):
    """
    Retrieve the path to the dir containing OPTIONS file for a given port
    """
    _check_portname(name)
    _root = "/var/db/ports"

    # New path: /var/db/ports/category_portname
    new_dir = os.path.join(_root, name.replace("/", "_"))
    # Old path: /var/db/ports/portname
    old_dir = os.path.join(_root, name.split("/")[-1])

    if os.path.isdir(old_dir):
        return old_dir
    return new_dir


def _options_file_exists(name):
    """
    Returns True/False based on whether or not the options file for the
    specified port exists.
    """
    return os.path.isfile(os.path.join(_options_dir(name), "options"))


def _write_options(name, configuration):
    """
    Writes a new OPTIONS file
    """
    _check_portname(name)

    pkg = next(iter(configuration))
    conf_ptr = configuration[pkg]

    dirname = _options_dir(name)
    if not os.path.isdir(dirname):
        try:
            os.makedirs(dirname)
        except OSError as exc:
            raise CommandExecutionError(f"Unable to make {dirname}: {exc}")

    with salt.utils.files.fopen(os.path.join(dirname, "options"), "w") as fp_:
        sorted_options = list(conf_ptr)
        sorted_options.sort()
        fp_.write(
            salt.utils.stringutils.to_str(
                "# This file was auto-generated by Salt (http://saltstack.com)\n"
                "# Options for {0}\n"
                "_OPTIONS_READ={0}\n"
                "_FILE_COMPLETE_OPTIONS_LIST={1}\n".format(
                    pkg, " ".join(sorted_options)
                )
            )
        )
        opt_tmpl = "OPTIONS_FILE_{0}SET+={1}\n"
        for opt in sorted_options:
            fp_.write(
                salt.utils.stringutils.to_str(
                    opt_tmpl.format("" if conf_ptr[opt] == "on" else "UN", opt)
                )
            )


def _normalize(val):
    """
    Fix Salt's yaml-ification of on/off, and otherwise normalize the on/off
    values to be used in writing the options file
    """
    if isinstance(val, bool):
        return "on" if val else "off"
    return str(val).lower()


def install(name, clean=True):
    """
    Install a port from the ports tree. Installs using ``BATCH=yes`` for
    non-interactive building. To set config options for a given port, use
    :mod:`ports.config <salt.modules.freebsdports.config>`.

    clean : True
        If ``True``, cleans after installation. Equivalent to running ``make
        install clean BATCH=yes``.

    .. note::

        It may be helpful to run this function using the ``-t`` option to set a
        higher timeout, since compiling a port may cause the Salt command to
        exceed the default timeout.

    CLI Example:

    .. code-block:: bash

        salt -t 1200 '*' ports.install security/nmap
    """
    portpath = _check_portname(name)
    old = __salt__["pkg.list_pkgs"]()
    if old.get(name.rsplit("/")[-1]):
        deinstall(name)
    cmd = ["make", "install"]
    if clean:
        cmd.append("clean")
    cmd.append("BATCH=yes")
    result = __salt__["cmd.run_all"](
        cmd, cwd=portpath, reset_system_locale=False, python_shell=False
    )
    if result["retcode"] != 0:
        __context__["ports.install_error"] = result["stderr"]
    __context__.pop("pkg.list_pkgs", None)
    new = __salt__["pkg.list_pkgs"]()
    ret = salt.utils.data.compare_dicts(old, new)
    if not ret and result["retcode"] == 0:
        # No change in package list, but the make install was successful.
        # Assume that the installation was a recompile with new options, and
        # set return dict so that changes are detected by the ports.installed
        # state.
        ret = {name: {"old": old.get(name, ""), "new": new.get(name, "")}}
    return ret


def deinstall(name):
    """
    De-install a port.

    CLI Example:

    .. code-block:: bash

        salt '*' ports.deinstall security/nmap
    """
    portpath = _check_portname(name)
    old = __salt__["pkg.list_pkgs"]()
    result = __salt__["cmd.run_all"](
        ["make", "deinstall", "BATCH=yes"], cwd=portpath, python_shell=False
    )
    __context__.pop("pkg.list_pkgs", None)
    new = __salt__["pkg.list_pkgs"]()
    return salt.utils.data.compare_dicts(old, new)


def rmconfig(name):
    """
    Clear the cached options for the specified port; run a ``make rmconfig``

    name
        The name of the port to clear

    CLI Example:

    .. code-block:: bash

        salt '*' ports.rmconfig security/nmap
    """
    portpath = _check_portname(name)
    return __salt__["cmd.run"](["make", "rmconfig"], cwd=portpath, python_shell=False)


def showconfig(name, default=False, dict_return=False):
    """
    Show the configuration options for a given port.

    default : False
        Show the default options for a port (not necessarily the same as the
        current configuration)

    dict_return : False
        Instead of returning the output of ``make showconfig``, return the data
        in an dictionary

    CLI Example:

    .. code-block:: bash

        salt '*' ports.showconfig security/nmap
        salt '*' ports.showconfig security/nmap default=True
    """
    portpath = _check_portname(name)

    if default and _options_file_exists(name):
        saved_config = showconfig(name, default=False, dict_return=True)
        rmconfig(name)
        if _options_file_exists(name):
            raise CommandExecutionError("Unable to get default configuration")
        default_config = showconfig(name, default=False, dict_return=dict_return)
        _write_options(name, saved_config)
        return default_config

    try:
        result = __salt__["cmd.run_all"](
            ["make", "showconfig"], cwd=portpath, python_shell=False
        )
        output = result["stdout"].splitlines()
        if result["retcode"] != 0:
            error = result["stderr"]
        else:
            error = ""
    except TypeError:
        error = result

    if error:
        msg = f"Error running 'make showconfig' for {name}: {error}"
        log.error(msg)
        raise SaltInvocationError(msg)

    if not dict_return:
        return "\n".join(output)

    if (not output) or ("configuration options" not in output[0]):
        return {}

    try:
        pkg = output[0].split()[-1].rstrip(":")
    except (IndexError, AttributeError, TypeError) as exc:
        log.error("Unable to get pkg-version string: %s", exc)
        return {}

    ret = {pkg: {}}
    output = output[1:]
    for line in output:
        try:
            opt, val, desc = re.match(r"\s+([^=]+)=(off|on): (.+)", line).groups()
        except AttributeError:
            continue
        ret[pkg][opt] = val

    if not ret[pkg]:
        return {}
    return ret


def config(name, reset=False, **kwargs):
    """
    Modify configuration options for a given port. Multiple options can be
    specified. To see the available options for a port, use
    :mod:`ports.showconfig <salt.modules.freebsdports.showconfig>`.

    name
        The port name, in ``category/name`` format

    reset : False
        If ``True``, runs a ``make rmconfig`` for the port, clearing its
        configuration before setting the desired options

    CLI Examples:

    .. code-block:: bash

        salt '*' ports.config security/nmap IPV6=off
    """
    portpath = _check_portname(name)

    if reset:
        rmconfig(name)

    configuration = showconfig(name, dict_return=True)

    if not configuration:
        raise CommandExecutionError(f"Unable to get port configuration for '{name}'")

    # Get top-level key for later reference
    pkg = next(iter(configuration))
    conf_ptr = configuration[pkg]

    opts = {str(x): _normalize(kwargs[x]) for x in kwargs if not x.startswith("_")}

    bad_opts = [x for x in opts if x not in conf_ptr]
    if bad_opts:
        raise SaltInvocationError(
            "The following opts are not valid for port {}: {}".format(
                name, ", ".join(bad_opts)
            )
        )

    bad_vals = [f"{x}={y}" for x, y in opts.items() if y not in ("on", "off")]
    if bad_vals:
        raise SaltInvocationError(
            "The following key/value pairs are invalid: {}".format(", ".join(bad_vals))
        )

    conf_ptr.update(opts)
    _write_options(name, configuration)

    new_config = showconfig(name, dict_return=True)
    try:
        new_config = new_config[next(iter(new_config))]
    except (StopIteration, TypeError):
        return False

    return all(conf_ptr[x] == new_config.get(x) for x in conf_ptr)


def update(extract=False):
    """
    Update the ports tree

    extract : False
        If ``True``, runs a ``portsnap extract`` after fetching, should be used
        for first-time installation of the ports tree.

    CLI Example:

    .. code-block:: bash

        salt '*' ports.update
    """
    result = __salt__["cmd.run_all"](_portsnap() + ["fetch"], python_shell=False)
    if not result["retcode"] == 0:
        raise CommandExecutionError(
            "Unable to fetch ports snapshot: {}".format(result["stderr"])
        )

    ret = []
    try:
        patch_count = re.search(r"Fetching (\d+) patches", result["stdout"]).group(1)
    except AttributeError:
        patch_count = 0

    try:
        new_port_count = re.search(
            r"Fetching (\d+) new ports or files", result["stdout"]
        ).group(1)
    except AttributeError:
        new_port_count = 0

    ret.append(f"Applied {patch_count} new patches")
    ret.append(f"Fetched {new_port_count} new ports or files")

    if extract:
        result = __salt__["cmd.run_all"](_portsnap() + ["extract"], python_shell=False)
        if not result["retcode"] == 0:
            raise CommandExecutionError(
                "Unable to extract ports snapshot {}".format(result["stderr"])
            )

    result = __salt__["cmd.run_all"](_portsnap() + ["update"], python_shell=False)
    if not result["retcode"] == 0:
        raise CommandExecutionError(
            "Unable to apply ports snapshot: {}".format(result["stderr"])
        )

    __context__.pop("ports.list_all", None)
    return "\n".join(ret)


def list_all():
    """
    Lists all ports available.

    CLI Example:

    .. code-block:: bash

        salt '*' ports.list_all

    .. warning::

        Takes a while to run, and returns a **LOT** of output
    """
    if "ports.list_all" not in __context__:
        __context__["ports.list_all"] = []
        for path, dirs, files in salt.utils.path.os_walk("/usr/ports"):
            stripped = path[len("/usr/ports") :]
            if stripped.count("/") != 2 or stripped.endswith("/CVS"):
                continue
            __context__["ports.list_all"].append(stripped[1:])
    return __context__["ports.list_all"]


def search(name):
    """
    Search for matches in the ports tree. Globs are supported, and the category
    is optional

    CLI Examples:

    .. code-block:: bash

        salt '*' ports.search 'security/*'
        salt '*' ports.search 'security/n*'
        salt '*' ports.search nmap

    .. warning::

        Takes a while to run
    """
    name = str(name)
    all_ports = list_all()
    if "/" in name:
        if name.count("/") > 1:
            raise SaltInvocationError(
                "Invalid search string '{0}'. Port names cannot have more "
                "than one slash"
            )
        else:
            return fnmatch.filter(all_ports, name)
    else:
        ret = []
        for port in all_ports:
            if fnmatch.fnmatch(port.rsplit("/")[-1], name):
                ret.append(port)
        return ret
