"""
Fileserver backend which serves files pushed to the Master

The :mod:`cp.push <salt.modules.cp.push>` function allows Minions to push files
up to the Master. Using this backend, these pushed files are exposed to other
Minions via the Salt fileserver.

To enable minionfs, :conf_master:`file_recv` needs to be set to ``True`` in the
master config file (otherwise :mod:`cp.push <salt.modules.cp.push>` will not be
allowed to push files to the Master), and ``minionfs`` must be added to the
:conf_master:`fileserver_backends` list.

.. code-block:: yaml

    fileserver_backend:
      - minionfs

.. note::
    ``minion`` also works here. Prior to the 2018.3.0 release, *only*
    ``minion`` would work.

Other minionfs settings include: :conf_master:`minionfs_whitelist`,
:conf_master:`minionfs_blacklist`, :conf_master:`minionfs_mountpoint`, and
:conf_master:`minionfs_env`.

.. seealso:: :ref:`tutorial-minionfs`

"""

import logging
import os

import salt.fileserver
import salt.utils.files
import salt.utils.gzip_util
import salt.utils.hashutils
import salt.utils.path
import salt.utils.stringutils
import salt.utils.url
import salt.utils.versions

log = logging.getLogger(__name__)


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


def __virtual__():
    """
    Only load if file_recv is enabled
    """
    if __virtualname__ not in __opts__["fileserver_backend"]:
        return False
    return __virtualname__ if __opts__["file_recv"] else False


def _is_exposed(minion):
    """
    Check if the minion is exposed, based on the whitelist and blacklist
    """
    return salt.utils.stringutils.check_whitelist_blacklist(
        minion,
        whitelist=__opts__["minionfs_whitelist"],
        blacklist=__opts__["minionfs_blacklist"],
    )


def find_file(path, tgt_env="base", **kwargs):  # pylint: disable=W0613
    """
    Search the environment for the relative path
    """
    fnd = {"path": "", "rel": ""}
    if os.path.isabs(path):
        return fnd
    if tgt_env not in envs():
        return fnd
    if os.path.basename(path) == "top.sls":
        log.debug(
            "minionfs will NOT serve top.sls for security reasons (path requested: %s)",
            path,
        )
        return fnd

    mountpoint = salt.utils.url.strip_proto(__opts__["minionfs_mountpoint"])
    # Remove the mountpoint to get the "true" path
    path = path[len(mountpoint) :].lstrip(os.path.sep)
    try:
        minion, pushed_file = path.split(os.sep, 1)
    except ValueError:
        return fnd
    if not _is_exposed(minion):
        return fnd
    full = os.path.join(__opts__["cachedir"], "minions", minion, "files", pushed_file)
    if os.path.isfile(full) and not salt.fileserver.is_file_ignored(__opts__, full):
        fnd["path"] = full
        fnd["rel"] = path
        fnd["stat"] = list(os.stat(full))
        return fnd
    return fnd


def envs():
    """
    Returns the one environment specified for minionfs in the master
    configuration.
    """
    return [__opts__["minionfs_env"]]


def serve_file(load, fnd):
    """
    Return a chunk from a file based on the data received

    CLI Example:

    .. code-block:: bash

        # Push the file to the master
        $ salt 'source-minion' cp.push /path/to/the/file
        $ salt 'destination-minion' cp.get_file salt://source-minion/path/to/the/file /destination/file
    """
    ret = {"data": "", "dest": ""}
    if not fnd["path"]:
        return ret
    ret["dest"] = fnd["rel"]
    gzip = load.get("gzip", None)
    fpath = os.path.normpath(fnd["path"])

    # AP
    # May I sleep here to slow down serving of big files?
    # How many threads are serving files?
    with salt.utils.files.fopen(fpath, "rb") as fp_:
        fp_.seek(load["loc"])
        data = fp_.read(__opts__["file_buffer_size"])
        if data and not salt.utils.files.is_binary(fpath):
            data = data.decode(__salt_system_encoding__)
        if gzip and data:
            data = salt.utils.gzip_util.compress(data, gzip)
            ret["gzip"] = gzip
        ret["data"] = data
    return ret


def update():
    """
    When we are asked to update (regular interval) lets reap the cache
    """
    try:
        salt.fileserver.reap_fileserver_cache_dir(
            os.path.join(__opts__["cachedir"], "minionfs/hash"), find_file
        )
    except OSError:
        # Hash file won't exist if no files have yet been served up
        pass


def file_hash(load, fnd):
    """
    Return a file hash, the hash type is set in the master config file
    """
    path = fnd["path"]
    ret = {}

    if "env" in load:
        # "env" is not supported; Use "saltenv".
        load.pop("env")

    if load["saltenv"] not in envs():
        return {}

    # if the file doesn't exist, we can't get a hash
    if not path or not os.path.isfile(path):
        return ret

    # set the hash_type as it is determined by config-- so mechanism won't change that
    ret["hash_type"] = __opts__["hash_type"]

    # check if the hash is cached
    # cache file's contents should be "hash:mtime"
    cache_path = os.path.join(
        __opts__["cachedir"],
        "minionfs",
        "hash",
        load["saltenv"],
        "{}.hash.{}".format(fnd["rel"], __opts__["hash_type"]),
    )
    # if we have a cache, serve that if the mtime hasn't changed
    if os.path.exists(cache_path):
        try:
            with salt.utils.files.fopen(cache_path, "rb") as fp_:
                try:
                    hsum, mtime = salt.utils.stringutils.to_unicode(fp_.read()).split(
                        ":"
                    )
                except ValueError:
                    log.debug(
                        "Fileserver attempted to read incomplete cache file. Retrying."
                    )
                    file_hash(load, fnd)
                    return ret
                if os.path.getmtime(path) == mtime:
                    # check if mtime changed
                    ret["hsum"] = hsum
                    return ret
        # Can't use Python select() because we need Windows support
        except OSError:
            log.debug("Fileserver encountered lock when reading cache file. Retrying.")
            file_hash(load, fnd)
            return ret

    # if we don't have a cache entry-- lets make one
    ret["hsum"] = salt.utils.hashutils.get_hash(path, __opts__["hash_type"])
    cache_dir = os.path.dirname(cache_path)
    # make cache directory if it doesn't exist
    if not os.path.exists(cache_dir):
        os.makedirs(cache_dir)
    # save the cache object "hash:mtime"
    cache_object = "{}:{}".format(ret["hsum"], os.path.getmtime(path))
    with salt.utils.files.flopen(cache_path, "w") as fp_:
        fp_.write(cache_object)
    return ret


def file_list(load):
    """
    Return a list of all files on the file server in a specified environment
    """
    if "env" in load:
        # "env" is not supported; Use "saltenv".
        load.pop("env")

    if load["saltenv"] not in envs():
        return []
    mountpoint = salt.utils.url.strip_proto(__opts__["minionfs_mountpoint"])
    prefix = load.get("prefix", "").strip("/")
    if mountpoint and prefix.startswith(mountpoint + os.path.sep):
        prefix = prefix[len(mountpoint + os.path.sep) :]

    minions_cache_dir = os.path.join(__opts__["cachedir"], "minions")
    if not os.path.isdir(minions_cache_dir):
        # The minions cache dir may not exist yet (e.g. under the salt-ssh
        # shim, where the cachedir is a fresh temp dir with no pushed files).
        log.debug(
            "minionfs: minions cache directory %s does not exist", minions_cache_dir
        )
        return []
    minion_dirs = os.listdir(minions_cache_dir)

    # If the prefix is not an empty string, then get the minion id from it. The
    # minion ID will be the part before the first slash, so if there is no
    # slash, this is an invalid path.
    if prefix:
        tgt_minion, _, prefix = prefix.partition("/")
        if not prefix:
            # No minion ID in path
            return []
        # Reassign minion_dirs so we don't unnecessarily walk every minion's
        # pushed files
        if tgt_minion not in minion_dirs:
            log.warning(
                "No files found in minionfs cache for minion ID '%s'", tgt_minion
            )
            return []
        minion_dirs = [tgt_minion]

    ret = []
    for minion in minion_dirs:
        if not _is_exposed(minion):
            continue
        minion_files_dir = os.path.join(minions_cache_dir, minion, "files")
        if not os.path.isdir(minion_files_dir):
            log.debug(
                "minionfs: could not find files directory under %s!",
                os.path.join(minions_cache_dir, minion),
            )
            continue
        walk_dir = os.path.join(minion_files_dir, prefix)
        # Do not follow links for security reasons
        for root, _, files in salt.utils.path.os_walk(walk_dir, followlinks=False):
            for fname in files:
                # Ignore links for security reasons
                if os.path.islink(os.path.join(root, fname)):
                    continue
                relpath = os.path.relpath(os.path.join(root, fname), minion_files_dir)
                if relpath.startswith("../"):
                    continue
                rel_fn = os.path.join(mountpoint, minion, relpath)
                if not salt.fileserver.is_file_ignored(__opts__, rel_fn):
                    ret.append(rel_fn)
    return ret


# There should be no emptydirs
# def file_list_emptydirs(load):


def dir_list(load):
    """
    Return a list of all directories on the master

    CLI Example:

    .. code-block:: bash

        $ salt 'source-minion' cp.push /absolute/path/file  # Push the file to the master
        $ salt 'destination-minion' cp.list_master_dirs
        destination-minion:
            - source-minion/absolute
            - source-minion/absolute/path
    """
    if "env" in load:
        # "env" is not supported; Use "saltenv".
        load.pop("env")

    if load["saltenv"] not in envs():
        return []
    mountpoint = salt.utils.url.strip_proto(__opts__["minionfs_mountpoint"])
    prefix = load.get("prefix", "").strip("/")
    if mountpoint and prefix.startswith(mountpoint + os.path.sep):
        prefix = prefix[len(mountpoint + os.path.sep) :]

    minions_cache_dir = os.path.join(__opts__["cachedir"], "minions")
    if not os.path.isdir(minions_cache_dir):
        # The minions cache dir may not exist yet (e.g. under the salt-ssh
        # shim, where the cachedir is a fresh temp dir with no pushed files).
        log.debug(
            "minionfs: minions cache directory %s does not exist", minions_cache_dir
        )
        return []
    minion_dirs = os.listdir(minions_cache_dir)

    # If the prefix is not an empty string, then get the minion id from it. The
    # minion ID will be the part before the first slash, so if there is no
    # slash, this is an invalid path.
    if prefix:
        tgt_minion, _, prefix = prefix.partition("/")
        if not prefix:
            # No minion ID in path
            return []
        # Reassign minion_dirs so we don't unnecessarily walk every minion's
        # pushed files
        if tgt_minion not in minion_dirs:
            log.warning(
                "No files found in minionfs cache for minion ID '%s'", tgt_minion
            )
            return []
        minion_dirs = [tgt_minion]

    ret = []
    for minion in os.listdir(minions_cache_dir):
        if not _is_exposed(minion):
            continue
        minion_files_dir = os.path.join(minions_cache_dir, minion, "files")
        if not os.path.isdir(minion_files_dir):
            log.warning(
                "minionfs: could not find files directory under %s!",
                os.path.join(minions_cache_dir, minion),
            )
            continue
        walk_dir = os.path.join(minion_files_dir, prefix)
        # Do not follow links for security reasons
        for root, _, _ in salt.utils.path.os_walk(walk_dir, followlinks=False):
            relpath = os.path.relpath(root, minion_files_dir)
            # Ensure that the current directory and directories outside of
            # the minion dir do not end up in return list
            if relpath in (".", "..") or relpath.startswith("../"):
                continue
            ret.append(os.path.join(mountpoint, minion, relpath))
    return ret
