#
# The MIT License (MIT)
# Copyright (C) 2014 SUSE LLC
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.

"""
Module for managing XFS file systems.
"""


import logging
import os
import re
import time

import salt.utils.data
import salt.utils.files
import salt.utils.path
import salt.utils.platform
from salt.exceptions import CommandExecutionError

log = logging.getLogger(__name__)


def __virtual__():
    """
    Only work on POSIX-like systems
    """
    return not salt.utils.platform.is_windows() and __grains__.get("kernel") == "Linux"


def _verify_run(out, cmd=None):
    """
    Crash to the log if command execution was not successful.
    """
    if out.get("retcode", 0) and out["stderr"]:
        if cmd:
            log.debug('Command: "%s"', cmd)

        log.debug("Return code: %s", out.get("retcode"))
        log.debug("Error output:\n%s", out.get("stderr", "N/A"))

        raise CommandExecutionError(out["stderr"])


def _xfs_info_get_kv(serialized):
    """
    Parse one line of the XFS info output.
    """
    # No need to know sub-elements here
    if serialized.startswith("="):
        serialized = serialized[1:].strip()

    serialized = serialized.replace(" = ", "=*** ").replace(" =", "=")

    # Keywords has no spaces, values do
    opt = []
    for tkn in serialized.split(" "):
        if not opt or "=" in tkn:
            opt.append(tkn)
        else:
            opt[len(opt) - 1] = opt[len(opt) - 1] + " " + tkn

    # Preserve ordering
    return [tuple(items.split("=")) for items in opt]


def _parse_xfs_info(data):
    """
    Parse output from "xfs_info" or "xfs_growfs -n".
    """
    ret = {}
    spr = re.compile(r"\s+")
    entry = None
    for line in [spr.sub(" ", l).strip().replace(", ", " ") for l in data.split("\n")]:
        if not line or "=" not in line:
            continue
        nfo = _xfs_info_get_kv(line)
        if not line.startswith("="):
            entry = nfo.pop(0)
            ret[entry[0]] = {"section": entry[(entry[1] != "***" and 1 or 0)]}
        ret[entry[0]].update(dict(nfo))

    return ret


def info(device):
    """
    Get filesystem geometry information.

    CLI Example:

    .. code-block:: bash

        salt '*' xfs.info /dev/sda1
    """
    out = __salt__["cmd.run_all"](f"xfs_info {device}")
    if out.get("stderr"):
        raise CommandExecutionError(out["stderr"].replace("xfs_info:", "").strip())
    return _parse_xfs_info(out["stdout"])


def _xfsdump_output(data):
    """
    Parse CLI output of the xfsdump utility.
    """
    out = {}
    summary = []
    summary_block = False

    for line in [l.strip() for l in data.split("\n") if l.strip()]:
        line = re.sub("^xfsdump: ", "", line)
        if line.startswith("session id:"):
            out["Session ID"] = line.split(" ")[-1]
        elif line.startswith("session label:"):
            out["Session label"] = re.sub("^session label: ", "", line)
        elif line.startswith("media file size"):
            out["Media size"] = re.sub(r"^media file size\s+", "", line)
        elif line.startswith("dump complete:"):
            out["Dump complete"] = re.sub(r"^dump complete:\s+", "", line)
        elif line.startswith("Dump Status:"):
            out["Status"] = re.sub(r"^Dump Status:\s+", "", line)
        elif line.startswith("Dump Summary:"):
            summary_block = True
            continue

        if line.startswith(" ") and summary_block:
            summary.append(line.strip())
        elif not line.startswith(" ") and summary_block:
            summary_block = False

    if summary:
        out["Summary"] = " ".join(summary)

    return out


def dump(device, destination, level=0, label=None, noerase=None):
    """
    Dump filesystem device to the media (file, tape etc).

    Required parameters:

    * **device**: XFS device, content of which to be dumped.
    * **destination**: Specifies a dump destination.

    Valid options are:

    * **label**: Label of the dump. Otherwise automatically generated label is used.
    * **level**: Specifies a dump level of 0 to 9.
    * **noerase**: Pre-erase media.

    Other options are not used in order to let ``xfsdump`` use its default
    values, as they are most optimal. See the ``xfsdump(8)`` manpage for
    a more complete description of these options.

    CLI Example:

    .. code-block:: bash

        salt '*' xfs.dump /dev/sda1 /detination/on/the/client
        salt '*' xfs.dump /dev/sda1 /detination/on/the/client label='Company accountancy'
        salt '*' xfs.dump /dev/sda1 /detination/on/the/client noerase=True
    """
    if not salt.utils.path.which("xfsdump"):
        raise CommandExecutionError('Utility "xfsdump" has to be installed or missing.')

    label = (
        label
        and label
        or time.strftime(
            f'XFS dump for "{device}" of %Y.%m.%d, %H:%M', time.localtime()
        ).replace("'", '"')
    )
    cmd = ["xfsdump"]
    cmd.append("-F")  # Force
    if not noerase:
        cmd.append("-E")  # pre-erase
    cmd.append(f"-L '{label}'")  # Label
    cmd.append(f"-l {level}")  # Dump level
    cmd.append(f"-f {destination}")  # Media destination
    cmd.append(device)  # Device

    cmd = " ".join(cmd)
    out = __salt__["cmd.run_all"](cmd)
    _verify_run(out, cmd=cmd)

    return _xfsdump_output(out["stdout"])


def _xr_to_keyset(line):
    """
    Parse xfsrestore output keyset elements.
    """
    tkns = [elm for elm in line.strip().split(":", 1) if elm]
    if len(tkns) == 1:
        return f"'{tkns[0]}': "
    else:
        key, val = tkns
        return f"'{key.strip()}': '{val.strip()}',"


def _xfs_inventory_output(out):
    """
    Transform xfsrestore inventory data output to a Python dict source and evaluate it.
    """
    data = []
    out = [line for line in out.split("\n") if line.strip()]

    # No inventory yet
    if len(out) == 1 and "restore status" in out[0].lower():
        return {"restore_status": out[0]}

    ident = 0
    data.append("{")
    for line in out[:-1]:
        if len([elm for elm in line.strip().split(":") if elm]) == 1:
            n_ident = len(re.sub("[^\t]", "", line))
            if ident > n_ident:
                for step in range(ident):
                    data.append("},")
            ident = n_ident
            data.append(_xr_to_keyset(line))
            data.append("{")
        else:
            data.append(_xr_to_keyset(line))
    for step in range(ident + 1):
        data.append("},")
    data.append("},")

    # We are evaling into a python dict, a json load
    # would be safer
    data = eval("\n".join(data))[0]  # pylint: disable=W0123
    data["restore_status"] = out[-1]

    return data


def inventory():
    """
    Display XFS dump inventory without restoration.

    CLI Example:

    .. code-block:: bash

        salt '*' xfs.inventory
    """
    out = __salt__["cmd.run_all"]("xfsrestore -I")
    _verify_run(out)

    return _xfs_inventory_output(out["stdout"])


def _xfs_prune_output(out, uuid):
    """
    Parse prune output.
    """
    data = {}
    cnt = []
    cutpoint = False
    for line in [l.strip() for l in out.split("\n") if l]:
        if line.startswith("-"):
            if cutpoint:
                break
            else:
                cutpoint = True
                continue

        if cutpoint:
            cnt.append(line)

    for kset in [e for e in cnt[1:] if ":" in e]:
        key, val = (t.strip() for t in kset.split(":", 1))
        data[key.lower().replace(" ", "_")] = val

    return data.get("uuid") == uuid and data or {}


def prune_dump(sessionid):
    """
    Prunes the dump session identified by the given session id.

    CLI Example:

    .. code-block:: bash

        salt '*' xfs.prune_dump b74a3586-e52e-4a4a-8775-c3334fa8ea2c

    """
    out = __salt__["cmd.run_all"](f"xfsinvutil -s {sessionid} -F")
    _verify_run(out)

    data = _xfs_prune_output(out["stdout"], sessionid)
    if data:
        return data

    raise CommandExecutionError(f'Session UUID "{sessionid}" was not found.')


def _blkid_output(out):
    """
    Parse blkid output.
    """

    def flt(data):
        return [el for el in data if el.strip()]

    data = {}
    for dev_meta in flt(out.split("\n\n")):
        dev = {}
        for items in flt(dev_meta.strip().split("\n")):
            key, val = items.split("=", 1)
            dev[key.lower()] = val
        if dev.pop("type", None) == "xfs":
            dev["label"] = dev.get("label")
            data[dev.pop("devname")] = dev

    mounts = _get_mounts()
    for device in mounts:
        if data.get(device):
            data[device].update(mounts[device])

    return data


def devices():
    """
    Get known XFS formatted devices on the system.

    CLI Example:

    .. code-block:: bash

        salt '*' xfs.devices
    """
    out = __salt__["cmd.run_all"]("blkid -o export")
    _verify_run(out)

    return _blkid_output(out["stdout"])


def _xfs_estimate_output(out):
    """
    Parse xfs_estimate output.
    """
    spc = re.compile(r"\s+")
    data = {}
    for line in [l for l in out.split("\n") if l.strip()][1:]:
        directory, bsize, blocks, megabytes, logsize = spc.sub(" ", line).split(" ")
        data[directory] = {
            "block _size": bsize,
            "blocks": blocks,
            "megabytes": megabytes,
            "logsize": logsize,
        }

    return data


def estimate(path):
    """
    Estimate the space that an XFS filesystem will take.
    For each directory estimate the space that directory would take
    if it were copied to an XFS filesystem.
    Estimation does not cross mount points.

    CLI Example:

    .. code-block:: bash

        salt '*' xfs.estimate /path/to/file
        salt '*' xfs.estimate /path/to/dir/*
    """
    if not os.path.exists(path):
        raise CommandExecutionError(f'Path "{path}" was not found.')

    out = __salt__["cmd.run_all"](f"xfs_estimate -v {path}")
    _verify_run(out)

    return _xfs_estimate_output(out["stdout"])


def mkfs(
    device,
    label=None,
    ssize=None,
    noforce=None,
    bso=None,
    gmo=None,
    ino=None,
    lso=None,
    rso=None,
    nmo=None,
    dso=None,
):
    """
    Create a file system on the specified device. By default wipes out with force.

    General options:

    * **label**: Specify volume label.
    * **ssize**: Specify the fundamental sector size of the filesystem.
    * **noforce**: Do not force create filesystem, if disk is already formatted.

    Filesystem geometry options:

    * **bso**: Block size options.
    * **gmo**: Global metadata options.
    * **dso**: Data section options. These options specify the location, size,
               and other parameters of the data section of the filesystem.
    * **ino**: Inode options to specify the inode size of the filesystem, and other inode allocation parameters.
    * **lso**: Log section options.
    * **nmo**: Naming options.
    * **rso**: Realtime section options.

    See the ``mkfs.xfs(8)`` manpage for a more complete description of corresponding options description.

    CLI Example:

    .. code-block:: bash

        salt '*' xfs.mkfs /dev/sda1
        salt '*' xfs.mkfs /dev/sda1 dso='su=32k,sw=6' noforce=True
        salt '*' xfs.mkfs /dev/sda1 dso='su=32k,sw=6' lso='logdev=/dev/sda2,size=10000b'
    """

    def getopts(args):
        return dict(
            (args and ("=" in args) and args or None)
            and [kw.split("=") for kw in args.split(",")]
            or []
        )

    cmd = ["mkfs.xfs"]
    if label:
        cmd.append("-L")
        cmd.append(f"'{label}'")

    if ssize:
        cmd.append("-s")
        cmd.append(ssize)

    for switch, opts in [
        ("-b", bso),
        ("-m", gmo),
        ("-n", nmo),
        ("-i", ino),
        ("-d", dso),
        ("-l", lso),
        ("-r", rso),
    ]:
        try:
            if getopts(opts):
                cmd.append(switch)
                cmd.append(opts)
        except Exception:  # pylint: disable=broad-except
            raise CommandExecutionError(
                f'Wrong parameters "{opts}" for option "{switch}"'
            )

    if not noforce:
        cmd.append("-f")
    cmd.append(device)

    cmd = " ".join(cmd)
    out = __salt__["cmd.run_all"](cmd)
    _verify_run(out, cmd=cmd)

    return _parse_xfs_info(out["stdout"])


def modify(device, label=None, lazy_counting=None, uuid=None):
    """
    Modify parameters of an XFS filesystem.

    CLI Example:

    .. code-block:: bash

        salt '*' xfs.modify /dev/sda1 label='My backup' lazy_counting=False
        salt '*' xfs.modify /dev/sda1 uuid=False
        salt '*' xfs.modify /dev/sda1 uuid=True
    """
    if not label and lazy_counting is None and uuid is None:
        raise CommandExecutionError(
            f'Nothing specified for modification for "{device}" device'
        )

    cmd = ["xfs_admin"]
    if label:
        cmd.append("-L")
        cmd.append(f"'{label}'")

    if lazy_counting is False:
        cmd.append("-c")
        cmd.append("0")
    elif lazy_counting:
        cmd.append("-c")
        cmd.append("1")

    if uuid is False:
        cmd.append("-U")
        cmd.append("nil")
    elif uuid:
        cmd.append("-U")
        cmd.append("generate")
    cmd.append(device)

    cmd = " ".join(cmd)
    _verify_run(__salt__["cmd.run_all"](cmd), cmd=cmd)

    out = __salt__["cmd.run_all"](f"blkid -o export {device}")
    _verify_run(out)

    return _blkid_output(out["stdout"])


def _get_mounts():
    """
    List mounted filesystems.
    """
    mounts = {}
    with salt.utils.files.fopen("/proc/mounts") as fhr:
        for line in salt.utils.data.decode(fhr.readlines()):
            device, mntpnt, fstype, options, fs_freq, fs_passno = line.strip().split(
                " "
            )
            if fstype != "xfs":
                continue
            mounts[device] = {
                "mount_point": mntpnt,
                "options": options.split(","),
            }

    return mounts


def defragment(device):
    """
    Defragment mounted XFS filesystem.
    In order to mount a filesystem, device should be properly mounted and writable.

    CLI Example:

    .. code-block:: bash

        salt '*' xfs.defragment /dev/sda1
    """
    if device == "/":
        raise CommandExecutionError("Root is not a device.")

    if not _get_mounts().get(device):
        raise CommandExecutionError(f'Device "{device}" is not mounted')

    out = __salt__["cmd.run_all"](f"xfs_fsr {device}")
    _verify_run(out)

    return {"log": out["stdout"]}
