"""
Return data to local job cache

"""

import bisect
import errno
import glob
import logging
import os
import pathlib
import shutil
import time

import salt.exceptions
import salt.payload
import salt.utils.atomicfile
import salt.utils.files
import salt.utils.jid
import salt.utils.job
import salt.utils.minions
import salt.utils.msgpack
import salt.utils.stringutils

log = logging.getLogger(__name__)

# load is the published job
LOAD_P = ".load.p"
# the list of minions that the job is targeted to (best effort match on the
# master side)
MINIONS_P = ".minions.p"
# format string for minion lists forwarded from syndic masters (the placeholder
# will be replaced with the syndic master's id)
SYNDIC_MINIONS_P = ".minions.{0}.p"
# return is the "return" from the minion data
RETURN_P = "return.p"
# out is the "out" from the minion data
OUT_P = "out.p"
# endtime is the end time for a job, not stored as msgpack
ENDTIME = "endtime"


def _job_dir():
    """
    Return root of the jobs cache directory
    """
    return os.path.join(__opts__["cachedir"], "jobs")


def _log_duplicate_return(hn_dir, load):
    """
    Helper for :func:`returner`.

    The per-minion return directory for this jid already exists.  Read
    the cached return and compare it with the incoming one to decide
    how loud the log line should be:

    * identical payload -> ``debug`` (benign retry-after-timeout, or a
      syndic forwarding the same return twice).
    * differing payload -> ``warning`` (something genuinely odd; could
      be two minions sharing an id, a misconfiguration, or in rare
      cases an actual replay).  We name what differs but no longer
      lead with "this could be a replay attack" because in practice
      that wording sends operators down the wrong path.
    * cached return unreadable (partial write, missing file) ->
      ``warning`` with a clear "could not verify" message.
    """
    new_record = {
        key: load[key] for key in ("return", "retcode", "success") if key in load
    }
    if "out" in load:
        new_record["out"] = load["out"]
    cached_record_path = os.path.join(hn_dir, RETURN_P)
    cached = None
    cached_out_path = os.path.join(hn_dir, OUT_P)
    try:
        with salt.utils.files.fopen(cached_record_path, "rb") as rfh:
            cached = salt.payload.load(rfh)
        if "out" in load and os.path.isfile(cached_out_path):
            with salt.utils.files.fopen(cached_out_path, "rb") as rfh:
                cached_out = salt.payload.load(rfh)
            if isinstance(cached, dict):
                cached.setdefault("out", cached_out)
    except OSError as exc:
        log.warning(
            "Duplicate return from minion %s for jid %s; cached return "
            "could not be read for comparison (%s).",
            load["id"],
            load["jid"],
            exc,
        )
        return
    except Exception as exc:  # pylint: disable=broad-except
        log.warning(
            "Duplicate return from minion %s for jid %s; cached return "
            "could not be deserialized for comparison (%s).",
            load["id"],
            load["jid"],
            exc,
        )
        return

    if cached == new_record:
        log.debug(
            "Duplicate return from minion %s for jid %s; payload matches "
            "the cached return, ignoring (likely a retry after a master "
            "timeout or a syndic re-forward).",
            load["id"],
            load["jid"],
        )
        return

    log.warning(
        "Duplicate return from minion %s for jid %s with a *differing* "
        "payload; ignoring the second return. This may indicate two "
        "minions sharing the same id, a misconfigured returner, or in "
        "rare cases a replay attempt. Cached retcode=%r success=%r; new "
        "retcode=%r success=%r.",
        load["id"],
        load["jid"],
        cached.get("retcode") if isinstance(cached, dict) else None,
        cached.get("success") if isinstance(cached, dict) else None,
        new_record.get("retcode"),
        new_record.get("success"),
    )


def _walk_through(job_dir):
    """
    Walk though the jid dir and look for jobs
    """

    for top in os.listdir(job_dir):
        t_path = os.path.join(job_dir, top)

        if not os.path.exists(t_path):
            continue

        for final in os.listdir(t_path):
            load_path = os.path.join(t_path, final, LOAD_P)

            if not os.path.isfile(load_path):
                continue

            with salt.utils.files.fopen(load_path, "rb") as rfh:
                try:
                    job = salt.payload.load(rfh)
                except Exception:  # pylint: disable=broad-except
                    log.exception("Failed to deserialize %s", load_path)
                    continue
                if not job:
                    log.error(
                        "Deserialization of job succeded but there is no data in %s",
                        load_path,
                    )
                    continue
                jid = job["jid"]
                yield jid, job, t_path, final


# TODO: add to returner docs-- this is a new one
def prep_jid(nocache=False, passed_jid=None, recurse_count=0):
    """
    Return a job id and prepare the job id directory.

    This is the function responsible for making sure jids don't collide (unless
    it is passed a jid).
    So do what you have to do to make sure that stays the case
    """
    if recurse_count >= 5:
        err = f"prep_jid could not store a jid after {recurse_count} tries."
        log.error(err)
        raise salt.exceptions.SaltCacheError(err)
    if passed_jid is None:  # this can be a None or an empty string.
        jid = salt.utils.jid.gen_jid(__opts__)
    else:
        jid = passed_jid

    jid_dir = salt.utils.jid.jid_dir(jid, _job_dir(), __opts__["hash_type"])

    # Make sure we create the jid dir, otherwise someone else is using it,
    # meaning we need a new jid.
    if not os.path.isdir(jid_dir):
        try:
            os.makedirs(jid_dir)
        except OSError:
            time.sleep(0.1)
            if passed_jid is None:
                return prep_jid(nocache=nocache, recurse_count=recurse_count + 1)

    try:
        with salt.utils.files.fopen(os.path.join(jid_dir, "jid"), "wb+") as fn_:
            fn_.write(salt.utils.stringutils.to_bytes(jid))
        if nocache:
            with salt.utils.files.fopen(os.path.join(jid_dir, "nocache"), "wb+"):
                pass
    except OSError:
        log.warning("Could not write out jid file for job %s. Retrying.", jid)
        time.sleep(0.1)
        return prep_jid(
            passed_jid=jid, nocache=nocache, recurse_count=recurse_count + 1
        )

    return jid


def returner(load):
    """
    Return data to the local job cache
    """

    # if a minion is returning a standalone job, get a jobid
    if load["jid"] == "req":
        load["jid"] = prep_jid(nocache=load.get("nocache", False))

    jid_dir = salt.utils.jid.jid_dir(load["jid"], _job_dir(), __opts__["hash_type"])
    if os.path.exists(os.path.join(jid_dir, "nocache")):
        return

    hn_dir = os.path.join(jid_dir, load["id"])

    try:
        os.makedirs(hn_dir)
    except OSError as err:
        if err.errno == errno.EEXIST:
            # The minion's per-jid return directory already exists.  In the
            # overwhelming majority of cases this is *not* a replay attack;
            # it is a benign duplicate return caused by:
            #
            #   * the minion retrying the return after a transient master
            #     timeout (``return_retry_tries`` defaults to 3), or
            #   * multiple syndic masters forwarding the same return to a
            #     master-of-masters, or
            #   * another race where ``_return`` runs more than once for
            #     the same ``(jid, minion_id)``.
            #
            # Compare the incoming return to the cached one and log
            # accordingly so operators are not misled.  See #65301.
            _log_duplicate_return(hn_dir, load)
            return False
        elif err.errno == errno.ENOENT:
            log.error(
                "An inconsistency occurred, a job was received with a job id "
                "(%s) that is not present in the local cache",
                load["jid"],
            )
            return False
        raise

    salt.payload.dump(
        {key: load[key] for key in ["return", "retcode", "success"] if key in load},
        # Use atomic open here to avoid the file being read before it's
        # completely written to. Refs #1935
        salt.utils.atomicfile.atomic_open(os.path.join(hn_dir, RETURN_P), "w+b"),
    )

    if "out" in load:
        salt.payload.dump(
            load["out"],
            # Use atomic open here to avoid the file being read before
            # it's completely written to. Refs #1935
            salt.utils.atomicfile.atomic_open(os.path.join(hn_dir, OUT_P), "w+b"),
        )


def save_load(jid, clear_load, minions=None, recurse_count=0):
    """
    Save the load to the specified jid

    minions argument is to provide a pre-computed list of matched minions for
    the job, for cases when this function can't compute that list itself (such
    as for salt-ssh)
    """
    if recurse_count >= 5:
        err = "save_load could not write job cache file after {} retries.".format(
            recurse_count
        )
        log.error(err)
        raise salt.exceptions.SaltCacheError(err)

    jid_dir = salt.utils.jid.jid_dir(jid, _job_dir(), __opts__["hash_type"])

    # Save the invocation information
    try:
        if not os.path.exists(jid_dir):
            os.makedirs(jid_dir)
    except OSError as exc:
        if exc.errno == errno.EEXIST:
            # rarely, the directory can be already concurrently created between
            # the os.path.exists and the os.makedirs lines above
            pass
        else:
            raise
    try:
        # When multiple Salt Syndic masters return, a race condition can occur when writing to this same file.
        with salt.utils.atomicfile.atomic_open(
            os.path.join(jid_dir, LOAD_P), "w+b"
        ) as wfh:
            salt.payload.dump(clear_load, wfh)
    except OSError as exc:
        log.warning("Could not write job invocation cache file: %s", exc)
        time.sleep(0.1)
        return save_load(
            jid=jid, clear_load=clear_load, recurse_count=recurse_count + 1
        )

    # if you have a tgt, save that for the UI etc
    if "tgt" in clear_load and clear_load["tgt"] != "":
        if minions is None:
            ckminions = salt.utils.minions.CkMinions(__opts__)
            # Retrieve the minions list
            _res = ckminions.check_minions(
                clear_load["tgt"], clear_load.get("tgt_type", "glob")
            )
            minions = _res["minions"]
        # save the minions to a cache so we can see in the UI
        save_minions(jid, minions)


def save_minions(jid, minions, syndic_id=None):
    """
    Save/update the serialized list of minions for a given job
    """
    import salt.utils.verify

    # Ensure we have a list for Python 3 compatibility
    minions = list(minions)

    log.debug(
        "Adding minions for job %s%s: %s",
        jid,
        f" from syndic master '{syndic_id}'" if syndic_id else "",
        minions,
    )

    jid_dir = salt.utils.jid.jid_dir(jid, _job_dir(), __opts__["hash_type"])

    try:
        if not os.path.exists(jid_dir):
            os.makedirs(jid_dir)
    except OSError as exc:
        if exc.errno == errno.EEXIST:
            # rarely, the directory can be already concurrently created between
            # the os.path.exists and the os.makedirs lines above
            pass
        else:
            raise

    try:
        if syndic_id is not None:
            name = SYNDIC_MINIONS_P.format(syndic_id)
        else:
            name = MINIONS_P
        minions_path = salt.utils.verify.clean_join(jid_dir, name)
        target_name = pathlib.Path(minions_path).resolve().name
        if name != target_name:
            raise salt.exceptions.SaltValidationError(
                f"Filenames do not match: {name} != {target_name}"
            )
    except salt.exceptions.SaltValidationError as exc:
        log.error("Error %s", exc)
        return

    try:
        if not os.path.exists(jid_dir):
            try:
                os.makedirs(jid_dir)
            except OSError:
                pass

        # Merge with any existing minion list (e.g. from prior batch
        # iterations sharing the same JID, or syndic masters).
        existing_minions = set()
        if os.path.isfile(minions_path):
            try:
                with salt.utils.files.fopen(minions_path, "rb") as rfh:
                    existing_data = salt.payload.load(rfh)
                    if existing_data is not None:
                        try:
                            existing_minions.update(existing_data)
                        except (TypeError, ValueError):
                            pass
            except (OSError, salt.exceptions.SaltDeserializationError):
                pass

        merged_minions = sorted(existing_minions.union(minions))

        with salt.utils.atomicfile.atomic_open(minions_path, "w+b") as wfh:
            salt.payload.dump(merged_minions, wfh)
    except OSError as exc:
        log.error(
            "Failed to write minion list %s to job cache file %s: %s",
            minions,
            minions_path,
            exc,
        )


def get_load(jid):
    """
    Return the load data that marks a specified jid
    """
    jid_dir = salt.utils.jid.jid_dir(jid, _job_dir(), __opts__["hash_type"])
    load_fn = os.path.join(jid_dir, LOAD_P)
    if not os.path.exists(jid_dir) or not os.path.exists(load_fn):
        return {}
    ret = {}
    load_p = os.path.join(jid_dir, LOAD_P)
    num_tries = 5
    exc = None
    for index in range(1, num_tries + 1):
        with salt.utils.files.fopen(load_p, "rb") as rfh:
            try:
                ret = salt.payload.load(rfh)
                break
            except Exception as e:  # pylint: disable=broad-except
                exc = e
                if index < num_tries:
                    time.sleep(0.25)
    else:
        log.critical("Failed to unpack %s", load_p)
        if exc is not None:
            raise exc
    if ret is None:
        ret = {}
    minions_cache = [os.path.join(jid_dir, MINIONS_P)]
    minions_cache.extend(glob.glob(os.path.join(jid_dir, SYNDIC_MINIONS_P.format("*"))))
    all_minions = set()
    for minions_path in minions_cache:
        log.debug("Reading minion list from %s", minions_path)
        try:
            with salt.utils.files.fopen(minions_path, "rb") as rfh:
                minions_data = salt.payload.load(rfh)
                if minions_data is not None:
                    try:
                        all_minions.update(minions_data)
                    except (TypeError, ValueError) as e:
                        log.warning(
                            "Job cache file %s contains invalid minion data, skipping.",
                            minions_path,
                        )
        except OSError as exc:
            salt.utils.files.process_read_exception(exc, minions_path)

    if all_minions:
        ret["Minions"] = sorted(all_minions)

    return ret


def get_jid(jid):
    """
    Return the information returned when the specified job id was executed
    """
    jid_dir = salt.utils.jid.jid_dir(jid, _job_dir(), __opts__["hash_type"])

    ret = {}
    # Check to see if the jid is real, if not return the empty dict
    if not os.path.isdir(jid_dir):
        return ret
    for fn_ in os.listdir(jid_dir):
        if fn_.startswith("."):
            continue
        if fn_ not in ret:
            retp = os.path.join(jid_dir, fn_, RETURN_P)
            outp = os.path.join(jid_dir, fn_, OUT_P)
            if not os.path.isfile(retp):
                continue
            while fn_ not in ret:
                try:
                    with salt.utils.files.fopen(retp, "rb") as rfh:
                        ret_data = salt.payload.load(rfh)
                    if not isinstance(ret_data, dict) or "return" not in ret_data:
                        # Convert the old format in which return.p contains the only return data to
                        # the new that is dict containing 'return' and optionally 'retcode' and
                        # 'success'.
                        ret_data = {"return": ret_data}
                    ret[fn_] = ret_data
                    if os.path.isfile(outp):
                        with salt.utils.files.fopen(outp, "rb") as rfh:
                            ret[fn_]["out"] = salt.payload.load(rfh)
                except Exception as exc:  # pylint: disable=broad-except
                    if "Permission denied:" in str(exc):
                        raise
    return ret


def get_jids():
    """
    Return a dict mapping all job ids to job information
    """
    ret = {}
    for jid, job, _, _ in _walk_through(_job_dir()):
        ret[jid] = salt.utils.jid.format_jid_instance(jid, job)

        if __opts__.get("job_cache_store_endtime"):
            endtime = get_endtime(jid)
            if endtime:
                ret[jid]["EndTime"] = endtime

    return ret


def get_jids_filter(count, filter_find_job=True):
    """
    Return a list of all jobs information filtered by the given criteria.
    :param int count: show not more than the count of most recent jobs
    :param bool filter_find_jobs: filter out 'saltutil.find_job' jobs
    """
    keys = []
    ret = []
    for jid, job, _, _ in _walk_through(_job_dir()):
        job = salt.utils.jid.format_jid_instance_ext(jid, job)
        if filter_find_job and job["Function"] == "saltutil.find_job":
            continue
        i = bisect.bisect(keys, jid)
        if len(keys) == count and i == 0:
            continue
        keys.insert(i, jid)
        ret.insert(i, job)
        if len(keys) > count:
            del keys[0]
            del ret[0]
    return ret


def _remove_job_dir(job_path):
    """
    Try to remove job dir. In rare cases NotADirectoryError can raise because node corruption.
    :param job_path: Path to job
    """
    # Remove job dir
    try:
        shutil.rmtree(job_path)
    except (NotADirectoryError, OSError) as err:
        log.error("Unable to remove %s: %s", job_path, err)
        return False
    return True


def clean_old_jobs():
    """
    Clean out the old jobs from the job cache
    """
    keep_jobs_seconds = salt.utils.job.get_keep_jobs_seconds(__opts__)
    if keep_jobs_seconds != 0:
        jid_root = _job_dir()

        if not os.path.exists(jid_root):
            return

        # Keep track of any empty t_path dirs that need to be removed later
        dirs_to_remove = set()

        for top in os.listdir(jid_root):
            t_path = os.path.join(jid_root, top)

            if not os.path.exists(t_path):
                continue

            # Check if there are any stray/empty JID t_path dirs
            t_path_dirs = os.listdir(t_path)
            if not t_path_dirs and t_path not in dirs_to_remove:
                dirs_to_remove.add(t_path)
                continue

            for final in t_path_dirs:
                f_path = os.path.join(t_path, final)
                jid_file = os.path.join(f_path, "jid")
                if not os.path.isfile(jid_file) and os.path.exists(f_path):
                    # No jid file means corrupted cache entry, scrub it
                    # by removing the entire f_path directory
                    _remove_job_dir(f_path)
                elif os.path.isfile(jid_file):
                    # Use mtime instead of ctime: a package upgrade's
                    # `chown -R /var/cache/salt/master` resets ctime on every
                    # existing jid file, which otherwise makes pre-upgrade
                    # jobs look freshly created and prevents cleanup until
                    # keep_jobs_seconds elapses (see #68351). mtime is
                    # written once when the jid file is created and is
                    # preserved across chown/chmod.
                    jid_mtime = os.stat(jid_file).st_mtime
                    seconds_difference = time.time() - jid_mtime
                    if seconds_difference > keep_jobs_seconds and os.path.exists(
                        t_path
                    ):
                        # Remove the entire f_path from the original JID dir
                        _remove_job_dir(f_path)

        # Remove empty JID dirs from job cache, if they're old enough.
        # JID dirs may be empty either from a previous cache-clean with the bug
        # Listed in #29286 still present, or the JID dir was only recently made
        # And the jid file hasn't been created yet.
        if dirs_to_remove:
            for t_path in dirs_to_remove:
                # Checking the time again prevents a possible race condition where
                # t_path JID dirs were created, but not yet populated by a jid file.
                # Use mtime for the same reason as above (#68351).
                t_path_mtime = os.stat(t_path).st_mtime
                seconds_difference = time.time() - t_path_mtime
                if seconds_difference > keep_jobs_seconds:
                    _remove_job_dir(t_path)


def update_endtime(jid, time):
    """
    Update (or store) the end time for a given job

    Endtime is stored as a plain text string
    """
    jid_dir = salt.utils.jid.jid_dir(jid, _job_dir(), __opts__["hash_type"])
    try:
        if not os.path.exists(jid_dir):
            os.makedirs(jid_dir)
        with salt.utils.files.fopen(os.path.join(jid_dir, ENDTIME), "w") as etfile:
            etfile.write(salt.utils.stringutils.to_str(time))
    except OSError as exc:
        log.warning("Could not write job invocation cache file: %s", exc)


def get_endtime(jid):
    """
    Retrieve the stored endtime for a given job

    Returns False if no endtime is present
    """
    jid_dir = salt.utils.jid.jid_dir(jid, _job_dir(), __opts__["hash_type"])
    etpath = os.path.join(jid_dir, ENDTIME)
    if not os.path.exists(etpath):
        return False
    with salt.utils.files.fopen(etpath, "r") as etfile:
        endtime = salt.utils.stringutils.to_unicode(etfile.read()).strip("\n")
    return endtime


def _reg_dir():
    """
    Return the reg_dir for the given job id
    """
    return os.path.join(__opts__["cachedir"], "thorium")


def save_reg(data):
    """
    Save the register to msgpack files
    """
    reg_dir = _reg_dir()
    regfile = os.path.join(reg_dir, "register")
    try:
        if not os.path.exists(reg_dir):
            os.makedirs(reg_dir)
    except OSError as exc:
        if exc.errno == errno.EEXIST:
            pass
        else:
            raise
    try:
        with salt.utils.files.fopen(regfile, "a") as fh_:
            salt.utils.msgpack.dump(data, fh_)
    except Exception:  # pylint: disable=broad-except
        log.error("Could not write to msgpack file %s", __opts__["outdir"])
        raise


def load_reg():
    """
    Load the register from msgpack files
    """
    reg_dir = _reg_dir()
    regfile = os.path.join(reg_dir, "register")
    try:
        with salt.utils.files.fopen(regfile, "r") as fh_:
            return salt.utils.msgpack.load(fh_)
    except Exception:  # pylint: disable=broad-except
        log.error("Could not write to msgpack file %s", __opts__["outdir"])
        raise
