"""
Work with cron

.. note::
    Salt does not escape cron metacharacters automatically. You should
    backslash-escape percent characters and any other metacharacters that might
    be interpreted incorrectly by the shell.
"""

import logging
import os
import random

import salt.utils.data
import salt.utils.files
import salt.utils.functools
import salt.utils.path
import salt.utils.stringutils

TAG = "# Lines below here are managed by Salt, do not edit\n"
SALT_CRON_IDENTIFIER = "SALT_CRON_IDENTIFIER"
SALT_CRON_NO_IDENTIFIER = "NO ID SET"

log = logging.getLogger(__name__)


def __virtual__():
    if salt.utils.path.which("crontab"):
        return True
    else:
        return (False, "Cannot load cron module: crontab command not found")


def _ensure_string(val):
    # Account for cases where the identifier is not a string
    # which would cause to_unicode to fail.
    if not isinstance(val, str):
        val = str(val)
    try:
        return salt.utils.stringutils.to_unicode(val)
    except TypeError:
        return ""


def _cron_id(cron):
    """SAFETYBELT, Only set if we really have an identifier"""
    cid = None
    if cron["identifier"]:
        cid = cron["identifier"]
    else:
        cid = SALT_CRON_NO_IDENTIFIER
    if cid:
        return _ensure_string(cid)


def _cron_matched(cron, cmd, identifier=None):
    """Check if:
    - we find a cron with same cmd, old state behavior
    - but also be smart enough to remove states changed crons where we do
      not removed priorly by a cron.absent by matching on the provided
      identifier.
      We assure retrocompatibility by only checking on identifier if
      and only if an identifier was set on the serialized crontab
    """
    ret, id_matched = False, None
    cid = _cron_id(cron)
    if cid:
        if not identifier:
            identifier = SALT_CRON_NO_IDENTIFIER
        eidentifier = _ensure_string(identifier)
        # old style second round
        # after saving crontab, we must check that if
        # we have not the same command, but the default id
        # to not set that as a match
        if (
            cron.get("cmd", None) != cmd
            and cid == SALT_CRON_NO_IDENTIFIER
            and eidentifier == SALT_CRON_NO_IDENTIFIER
        ):
            id_matched = False
        else:
            # on saving, be sure not to overwrite a cron
            # with specific identifier but also track
            # crons where command is the same
            # but with the default if that we gonna overwrite
            if (
                cron.get("cmd", None) == cmd
                and cid == SALT_CRON_NO_IDENTIFIER
                and identifier
            ):
                cid = eidentifier
            id_matched = eidentifier == cid
    if ((id_matched is None) and cmd == cron.get("cmd", None)) or id_matched:
        ret = True
    return ret


def _needs_change(old, new):
    if old != new:
        if new == "random":
            # Allow switch from '*' or not present to 'random'
            if old == "*":
                return True
        elif new is not None:
            return True
    return False


def _render_tab(lst):
    """
    Takes a tab list structure and renders it to a list for applying it to
    a file
    """
    ret = []
    for pre in lst["pre"]:
        ret.append("{}\n".format(pre))
    if ret:
        if ret[-1] != TAG:
            ret.append(TAG)
    else:
        ret.append(TAG)
    for env in lst["env"]:
        if (env["value"] is None) or (env["value"] == ""):
            ret.append('{}=""\n'.format(env["name"]))
        else:
            ret.append("{}={}\n".format(env["name"], env["value"]))
    for cron in lst["crons"]:
        if cron["comment"] is not None or cron["identifier"] is not None:
            comment = "#"
            if cron["comment"]:
                comment += " {}".format(cron["comment"].replace("\n", "\n# "))
            if cron["identifier"]:
                comment += " {}:{}".format(SALT_CRON_IDENTIFIER, cron["identifier"])

            comment += "\n"
            ret.append(comment)
        ret.append(
            "{}{} {} {} {} {} {}\n".format(
                cron["commented"] is True and "#DISABLED#" or "",
                cron["minute"],
                cron["hour"],
                cron["daymonth"],
                cron["month"],
                cron["dayweek"],
                cron["cmd"],
            )
        )
    for cron in lst["special"]:
        if cron["comment"] is not None or cron["identifier"] is not None:
            comment = "#"
            if cron["comment"]:
                comment += " {}".format(cron["comment"].rstrip().replace("\n", "\n# "))
            if cron["identifier"]:
                comment += " {}:{}".format(SALT_CRON_IDENTIFIER, cron["identifier"])

            comment += "\n"
            ret.append(comment)
        ret.append(
            "{}{} {}\n".format(
                cron["commented"] is True and "#DISABLED#" or "",
                cron["spec"],
                cron["cmd"],
            )
        )
    return ret


def _get_cron_cmdstr(path, user=None):
    """
    Returns a format string, to be used to build a crontab command.
    """
    if user:
        cmd = "crontab -u {}".format(user)
    else:
        cmd = "crontab"
    return "{} {}".format(cmd, path)


def _check_instance_uid_match(user):
    """
    Returns true if running instance's UID matches the specified user UID
    """
    return os.geteuid() == __salt__["file.user_to_uid"](user)


def write_cron_file(user, path):
    """
    Writes the contents of a file to a user's crontab

    CLI Example:

    .. code-block:: bash

        salt '*' cron.write_cron_file root /tmp/new_cron

    .. versionchanged:: 2015.8.9

    .. note::

        Some OS' do not support specifying user via the `crontab` command i.e. (Solaris, AIX)
    """
    # Some OS' do not support specifying user via the `crontab` command
    if __grains__.get("os_family") in ("Solaris", "AIX"):
        return (
            __salt__["cmd.retcode"](
                _get_cron_cmdstr(path), runas=user, python_shell=False
            )
            == 0
        )
    # If Salt is running from same user as requested in cron module we don't need any user switch
    elif _check_instance_uid_match(user):
        return __salt__["cmd.retcode"](_get_cron_cmdstr(path), python_shell=False) == 0
    # If Salt is running from root user it could modify any user's crontab
    elif _check_instance_uid_match("root"):
        return (
            __salt__["cmd.retcode"](_get_cron_cmdstr(path, user), python_shell=False)
            == 0
        )
    # Edge cases here, let's try do a runas
    else:
        return (
            __salt__["cmd.retcode"](
                _get_cron_cmdstr(path), runas=user, python_shell=False
            )
            == 0
        )


def write_cron_file_verbose(user, path):
    """
    Writes the contents of a file to a user's crontab and return error message on error

    CLI Example:

    .. code-block:: bash

        salt '*' cron.write_cron_file_verbose root /tmp/new_cron

    .. versionchanged:: 2015.8.9

    .. note::

        Some OS' do not support specifying user via the `crontab` command i.e. (Solaris, AIX)
    """
    # Some OS' do not support specifying user via the `crontab` command
    if __grains__.get("os_family") in ("Solaris", "AIX"):
        return __salt__["cmd.run_all"](
            _get_cron_cmdstr(path), runas=user, python_shell=False
        )
    # If Salt is running from same user as requested in cron module we don't need any user switch
    elif _check_instance_uid_match(user):
        return __salt__["cmd.run_all"](_get_cron_cmdstr(path), python_shell=False)
    # If Salt is running from root user it could modify any user's crontab
    elif _check_instance_uid_match("root"):
        return __salt__["cmd.run_all"](_get_cron_cmdstr(path, user), python_shell=False)
    # Edge cases here, let's try do a runas
    else:
        return __salt__["cmd.run_all"](
            _get_cron_cmdstr(path), runas=user, python_shell=False
        )


def _write_cron_lines(user, lines):
    """
    Takes a list of lines to be committed to a user's crontab and writes it
    """
    lines = [salt.utils.stringutils.to_str(_l) for _l in lines]
    path = salt.utils.files.mkstemp()
    if _check_instance_uid_match("root") or __grains__.get("os_family") in (
        "Solaris",
        "AIX",
    ):
        # In some cases crontab command should be executed as user rather than root
        with salt.utils.files.fpopen(
            path, "w+", uid=__salt__["file.user_to_uid"](user), mode=0o600
        ) as fp_:
            fp_.writelines(lines)
        ret = __salt__["cmd.run_all"](
            _get_cron_cmdstr(path), runas=user, python_shell=False
        )
    else:
        with salt.utils.files.fpopen(path, "w+", mode=0o600) as fp_:
            fp_.writelines(lines)
        ret = __salt__["cmd.run_all"](_get_cron_cmdstr(path), python_shell=False)
    os.remove(path)
    return ret


def _date_time_match(cron, **kwargs):
    """
    Returns true if the minute, hour, etc. params match their counterparts from
    the dict returned from list_tab().
    """
    return all(
        [
            kwargs.get(x) is None
            or cron[x] == str(kwargs[x])
            or (str(kwargs[x]).lower() == "random" and cron[x] != "*")
            for x in ("minute", "hour", "daymonth", "month", "dayweek")
        ]
    )


def raw_cron(user):
    """
    Return the contents of the user's crontab

    CLI Example:

    .. code-block:: bash

        salt '*' cron.raw_cron root
    """
    # Some OS' do not support specifying user via the `crontab` command
    if __grains__.get("os_family") in ("Solaris", "AIX"):
        cmd = "crontab -l"
        # Preserve line endings
        lines = salt.utils.data.decode(
            __salt__["cmd.run_stdout"](
                cmd, runas=user, ignore_retcode=True, rstrip=False, python_shell=False
            )
        ).splitlines(True)
    # If Salt is running from same user as requested in cron module we don't need any user switch
    elif _check_instance_uid_match(user):
        cmd = "crontab -l"
        # Preserve line endings
        lines = salt.utils.data.decode(
            __salt__["cmd.run_stdout"](
                cmd, ignore_retcode=True, rstrip=False, python_shell=False
            )
        ).splitlines(True)
    # If Salt is running from root user it could modify any user's crontab
    elif _check_instance_uid_match("root"):
        cmd = "crontab -u {} -l".format(user)
        # Preserve line endings
        lines = salt.utils.data.decode(
            __salt__["cmd.run_stdout"](
                cmd, ignore_retcode=True, rstrip=False, python_shell=False
            )
        ).splitlines(True)
    # Edge cases here, let's try do a runas
    else:
        cmd = "crontab -l"
        # Preserve line endings
        lines = salt.utils.data.decode(
            __salt__["cmd.run_stdout"](
                cmd, runas=user, ignore_retcode=True, rstrip=False, python_shell=False
            )
        ).splitlines(True)

    if lines and lines[0].startswith(
        "# DO NOT EDIT THIS FILE - edit the master and reinstall."
    ):
        del lines[0:3]
    return "".join(lines)


def list_tab(user):
    """
    Return the contents of the specified user's crontab

    CLI Example:

    .. code-block:: bash

        salt '*' cron.list_tab root
    """
    data = raw_cron(user)
    ret = {"pre": [], "crons": [], "special": [], "env": []}
    flag = False
    comment = None
    identifier = None
    for line in data.splitlines():
        if line == "# Lines below here are managed by Salt, do not edit":
            flag = True
            continue
        if flag:
            commented_cron_job = False
            if line.startswith("#DISABLED#"):
                # It's a commented cron job
                line = line[10:]
                commented_cron_job = True
            if line.startswith("@"):
                # Its a "special" line
                dat = {}
                comps = line.split()
                if len(comps) < 2:
                    # Invalid line
                    continue
                dat["spec"] = comps[0]
                dat["cmd"] = " ".join(comps[1:])
                dat["identifier"] = identifier
                dat["comment"] = comment
                dat["commented"] = False
                if commented_cron_job:
                    dat["commented"] = True
                ret["special"].append(dat)
                identifier = None
                comment = None
                commented_cron_job = False
            elif line.startswith("#"):
                # It's a comment! Catch it!
                comment_line = line.lstrip("# ")

                # load the identifier if any
                if SALT_CRON_IDENTIFIER in comment_line:
                    parts = comment_line.split(SALT_CRON_IDENTIFIER)
                    comment_line = parts[0].rstrip()
                    # skip leading :
                    if len(parts[1]) > 1:
                        identifier = parts[1][1:]

                if comment is None:
                    comment = comment_line
                else:
                    comment += "\n" + comment_line
            elif line.find("=") > 0 and (
                " " not in line or line.index("=") < line.index(" ")
            ):
                # Appears to be a ENV setup line
                comps = line.split("=", 1)
                dat = {}
                dat["name"] = comps[0]
                dat["value"] = comps[1]
                ret["env"].append(dat)
            elif len(line.split(" ")) > 5:
                # Appears to be a standard cron line
                comps = line.split(" ")
                dat = {
                    "minute": comps[0],
                    "hour": comps[1],
                    "daymonth": comps[2],
                    "month": comps[3],
                    "dayweek": comps[4],
                    "identifier": identifier,
                    "cmd": " ".join(comps[5:]),
                    "comment": comment,
                    "commented": False,
                }
                if commented_cron_job:
                    dat["commented"] = True
                ret["crons"].append(dat)
                identifier = None
                comment = None
                commented_cron_job = False
        else:
            ret["pre"].append(line)
    return ret


# For consistency's sake
ls = salt.utils.functools.alias_function(list_tab, "ls")


def get_entry(user, identifier=None, cmd=None):
    """
    Return the specified entry from user's crontab.
    identifier will be used if specified, otherwise will lookup cmd
    Either identifier or cmd should be specified.

    user:
        User's crontab to query

    identifier:
        Search for line with identifier

    cmd:
        Search for cron line with cmd

    CLI Example:

    .. code-block:: bash

        salt '*' cron.get_entry root identifier=task1
    """
    if identifier and cmd:
        log.warning("Both identifier and cmd are specified. Only using identifier.")
        cmd = None

    cron_entries = list_tab(user).get("crons", []) + list_tab(user).get("special", [])

    for cron_entry in cron_entries:
        if identifier and cron_entry.get("identifier") == identifier:
            return cron_entry
        elif cmd and cron_entry.get("cmd") == cmd:
            return cron_entry
    return False


def set_special(user, special, cmd, commented=False, comment=None, identifier=None):
    """
    Set up a special command in the crontab.

    CLI Example:

    .. code-block:: bash

        salt '*' cron.set_special root @hourly 'echo foobar'
    """
    lst = list_tab(user)
    for cron in lst["crons"] + lst["special"]:
        cid = _cron_id(cron)
        if _cron_matched(cron, cmd, identifier):
            test_setted_id = (
                cron["identifier"] is None
                and SALT_CRON_NO_IDENTIFIER
                or cron["identifier"]
            )
            tests = [
                (cron["comment"], comment),
                (cron["commented"], commented),
                (identifier, test_setted_id),
                (cron.get("minute"), None),
                (cron.get("hour"), None),
                (cron.get("daymonth"), None),
                (cron.get("month"), None),
                (cron.get("dayweek"), None),
                (cron.get("spec"), special),
            ]
            if cid or identifier:
                tests.append((cron["cmd"], cmd))
            if any([_needs_change(x, y) for x, y in tests]):
                if "spec" in cron:
                    rm_special(user, cmd, identifier=cid)
                else:
                    rm_job(user, cmd, identifier=cid)

                # Use old values when setting the new job if there was no
                # change needed for a given parameter
                if not _needs_change(cron.get("spec"), special):
                    special = cron.get("spec")
                if not _needs_change(cron.get("commented"), commented):
                    commented = cron.get("commented")
                if not _needs_change(cron.get("comment"), comment):
                    comment = cron.get("comment")
                if not _needs_change(cron["cmd"], cmd):
                    cmd = cron["cmd"]
                    if cid == SALT_CRON_NO_IDENTIFIER:
                        if identifier:
                            cid = identifier
                        if (
                            cid == SALT_CRON_NO_IDENTIFIER
                            and cron["identifier"] is None
                        ):
                            cid = None
                        cron["identifier"] = cid
                if not cid or (cid and not _needs_change(cid, identifier)):
                    identifier = cid
                jret = set_special(
                    user,
                    special,
                    cmd,
                    commented=commented,
                    comment=comment,
                    identifier=identifier,
                )
                if jret == "new":
                    return "updated"
                else:
                    return jret
            return "present"
    cron = {
        "spec": special,
        "cmd": cmd,
        "identifier": identifier,
        "comment": comment,
        "commented": commented,
    }
    lst["special"].append(cron)

    comdat = _write_cron_lines(user, _render_tab(lst))
    if comdat["retcode"]:
        # Failed to commit, return the error
        return comdat["stderr"]
    return "new"


def _get_cron_date_time(**kwargs):
    """
    Returns a dict of date/time values to be used in a cron entry
    """
    # Define ranges (except daymonth, as it depends on the month)
    range_max = {
        "minute": list(list(range(60))),
        "hour": list(list(range(24))),
        "month": list(list(range(1, 13))),
        "dayweek": list(list(range(7))),
    }

    ret = {}
    for param in ("minute", "hour", "month", "dayweek"):
        value = str(kwargs.get(param, "1")).lower()
        if value == "random":
            ret[param] = str(random.sample(range_max[param], 1)[0])
        elif len(value.split(":")) == 2:
            cron_range = sorted(value.split(":"))
            start, end = int(cron_range[0]), int(cron_range[1])
            ret[param] = str(random.randint(start, end))
        else:
            ret[param] = value

    if ret["month"] in "1 3 5 7 8 10 12".split():
        daymonth_max = 31
    elif ret["month"] in "4 6 9 11".split():
        daymonth_max = 30
    else:
        # This catches both '2' and '*'
        daymonth_max = 28

    daymonth = str(kwargs.get("daymonth", "1")).lower()
    if daymonth == "random":
        ret["daymonth"] = str(
            random.sample(list(list(range(1, (daymonth_max + 1)))), 1)[0]
        )
    else:
        ret["daymonth"] = daymonth

    return ret


def set_job(
    user,
    minute,
    hour,
    daymonth,
    month,
    dayweek,
    cmd,
    commented=False,
    comment=None,
    identifier=None,
):
    """
    Sets a cron job up for a specified user.

    CLI Example:

    .. code-block:: bash

        salt '*' cron.set_job root '*' '*' '*' '*' 1 /usr/local/weekly
    """
    # Scrub the types
    minute = str(minute).lower()
    hour = str(hour).lower()
    daymonth = str(daymonth).lower()
    month = str(month).lower()
    dayweek = str(dayweek).lower()
    lst = list_tab(user)
    for cron in lst["crons"] + lst["special"]:
        cid = _cron_id(cron)
        if _cron_matched(cron, cmd, identifier):
            test_setted_id = (
                cron["identifier"] is None
                and SALT_CRON_NO_IDENTIFIER
                or cron["identifier"]
            )
            tests = [
                (cron["comment"], comment),
                (cron["commented"], commented),
                (identifier, test_setted_id),
                (cron.get("minute"), minute),
                (cron.get("hour"), hour),
                (cron.get("daymonth"), daymonth),
                (cron.get("month"), month),
                (cron.get("dayweek"), dayweek),
                (cron.get("spec"), None),
            ]
            if cid or identifier:
                tests.append((cron["cmd"], cmd))
            if any([_needs_change(x, y) for x, y in tests]):
                if "spec" in cron:
                    rm_special(user, cmd, identifier=cid)
                else:
                    rm_job(user, cmd, identifier=cid)

                # Use old values when setting the new job if there was no
                # change needed for a given parameter
                if not _needs_change(cron.get("minute"), minute):
                    minute = cron.get("minute")
                if not _needs_change(cron.get("hour"), hour):
                    hour = cron.get("hour")
                if not _needs_change(cron.get("daymonth"), daymonth):
                    daymonth = cron.get("daymonth")
                if not _needs_change(cron.get("month"), month):
                    month = cron.get("month")
                if not _needs_change(cron.get("dayweek"), dayweek):
                    dayweek = cron.get("dayweek")
                if not _needs_change(cron["commented"], commented):
                    commented = cron["commented"]
                if not _needs_change(cron["comment"], comment):
                    comment = cron["comment"]
                if not _needs_change(cron["cmd"], cmd):
                    cmd = cron["cmd"]
                    if cid == SALT_CRON_NO_IDENTIFIER:
                        if identifier:
                            cid = identifier
                        if (
                            cid == SALT_CRON_NO_IDENTIFIER
                            and cron["identifier"] is None
                        ):
                            cid = None
                        cron["identifier"] = cid
                if not cid or (cid and not _needs_change(cid, identifier)):
                    identifier = cid
                jret = set_job(
                    user,
                    minute,
                    hour,
                    daymonth,
                    month,
                    dayweek,
                    cmd,
                    commented=commented,
                    comment=comment,
                    identifier=identifier,
                )
                if jret == "new":
                    return "updated"
                else:
                    return jret
            return "present"
    cron = {
        "cmd": cmd,
        "identifier": identifier,
        "comment": comment,
        "commented": commented,
    }
    cron.update(
        _get_cron_date_time(
            minute=minute, hour=hour, daymonth=daymonth, month=month, dayweek=dayweek
        )
    )
    lst["crons"].append(cron)

    comdat = _write_cron_lines(user, _render_tab(lst))
    if comdat["retcode"]:
        # Failed to commit, return the error
        return comdat["stderr"]
    return "new"


def rm_special(user, cmd, special=None, identifier=None):
    """
    Remove a special cron job for a specified user.

    CLI Example:

    .. code-block:: bash

        salt '*' cron.rm_special root /usr/bin/foo
    """
    lst = list_tab(user)
    ret = "absent"
    rm_ = None
    for ind, val in enumerate(lst["special"]):
        if rm_ is not None:
            break
        if _cron_matched(val, cmd, identifier=identifier):
            if special is None:
                # No special param was specified
                rm_ = ind
            else:
                if val["spec"] == special:
                    rm_ = ind
    if rm_ is not None:
        lst["special"].pop(rm_)
        ret = "removed"
    comdat = _write_cron_lines(user, _render_tab(lst))
    if comdat["retcode"]:
        # Failed to commit, return the error
        return comdat["stderr"]
    return ret


def rm_job(
    user,
    cmd,
    minute=None,
    hour=None,
    daymonth=None,
    month=None,
    dayweek=None,
    identifier=None,
):
    """
    Remove a cron job for a specified user. If any of the day/time params are
    specified, the job will only be removed if the specified params match.

    CLI Example:

    .. code-block:: bash

        salt '*' cron.rm_job root /usr/local/weekly
        salt '*' cron.rm_job root /usr/bin/foo dayweek=1
    """
    lst = list_tab(user)
    ret = "absent"
    rm_ = None
    for ind, val in enumerate(lst["crons"]):
        if rm_ is not None:
            break
        if _cron_matched(val, cmd, identifier=identifier):
            if not any(
                [x is not None for x in (minute, hour, daymonth, month, dayweek)]
            ):
                # No date/time params were specified
                rm_ = ind
            else:
                if _date_time_match(
                    val,
                    minute=minute,
                    hour=hour,
                    daymonth=daymonth,
                    month=month,
                    dayweek=dayweek,
                ):
                    rm_ = ind
    if rm_ is not None:
        lst["crons"].pop(rm_)
        ret = "removed"
        comdat = _write_cron_lines(user, _render_tab(lst))
        if comdat["retcode"]:
            # Failed to commit, return the error
            return comdat["stderr"]
    return ret


rm = salt.utils.functools.alias_function(rm_job, "rm")


def set_env(user, name, value=None):
    """
    Set up an environment variable in the crontab.

    CLI Example:

    .. code-block:: bash

        salt '*' cron.set_env root MAILTO user@example.com
    """
    lst = list_tab(user)
    for env in lst["env"]:
        if name == env["name"]:
            if value != env["value"]:
                rm_env(user, name)
                jret = set_env(user, name, value)
                if jret == "new":
                    return "updated"
                else:
                    return jret
            return "present"
    env = {"name": name, "value": value}
    lst["env"].append(env)
    comdat = _write_cron_lines(user, _render_tab(lst))
    if comdat["retcode"]:
        # Failed to commit, return the error
        return comdat["stderr"]
    return "new"


def rm_env(user, name):
    """
    Remove cron environment variable for a specified user.

    CLI Example:

    .. code-block:: bash

        salt '*' cron.rm_env root MAILTO
    """
    lst = list_tab(user)
    ret = "absent"
    rm_ = None
    for ind, val in enumerate(lst["env"]):
        if name == val["name"]:
            rm_ = ind
    if rm_ is not None:
        lst["env"].pop(rm_)
        ret = "removed"
    comdat = _write_cron_lines(user, _render_tab(lst))
    if comdat["retcode"]:
        # Failed to commit, return the error
        return comdat["stderr"]
    return ret
