#
# Copyright (c) 2014 deanishe@deanishe.net
#
# MIT Licence. See http://opensource.org/licenses/MIT
#
# Created on 2014-04-06
#

"""This module provides an API to run commands in background processes.

Combine with the :ref:`caching API <caching-data>` to work from cached data
while you fetch fresh data in the background.

See :ref:`the User Manual <background-processes>` for more information
and examples.
"""
from __future__ import annotations

import os
import pickle
import signal
import subprocess
import sys
from typing import Any

from ualfred import Workflow

__all__ = ["is_running", "run_in_background"]

_wf = None


def wf():
    global _wf
    if _wf is None:
        _wf = Workflow()
    return _wf


def _log():
    return wf().logger


def _arg_cache(name: str) -> str:
    """Return path to pickle cache file for arguments.

    :param name: name of task
    :type name: ``unicode``
    :returns: Path to cache file
    :rtype: ``unicode`` filepath

    """
    return wf().cachefile(name + ".argcache")


def _pid_file(name: str) -> str:
    """Return path to PID file for ``name``.

    :param name: name of task
    :type name: ``unicode``
    :returns: Path to PID file for task
    :rtype: ``unicode`` filepath

    """
    return wf().cachefile(name + ".pid")


def _process_exists(pid: int) -> bool:
    """Check if a process with PID ``pid`` exists.

    :param pid: PID to check
    :type pid: ``int``
    :returns: ``True`` if process exists, else ``False``
    :rtype: ``Boolean``

    """
    try:
        os.kill(pid, 0)
    except OSError:  # not running
        return False
    return True


def _job_pid(name: str) -> int | None:
    """Get PID of job or `None` if job does not exist.

    Args:
        name (str): Name of job.

    Returns:
        int: PID of job process (or `None` if job doesn't exist).
    """
    pidfile = _pid_file(name)
    if not os.path.exists(pidfile):
        return

    with open(pidfile, "rb") as fp:
        pid = int(fp.read())

        if _process_exists(pid):
            return pid

    os.unlink(pidfile)


def is_running(name: str) -> bool:
    """Test whether task ``name`` is currently running.

    :param name: name of task
    :type name: unicode
    :returns: ``True`` if task with name ``name`` is running, else ``False``
    :rtype: bool

    """
    if _job_pid(name) is not None:
        return True

    return False


def _background(pidfile, stdin="/dev/null", stdout="/dev/null", stderr="/dev/null"):  # pragma: no cover
    """Fork the current process into a background daemon.

    :param pidfile: file to write PID of daemon process to.
    :type pidfile: filepath
    :param stdin: where to read input
    :type stdin: filepath
    :param stdout: where to write stdout output
    :type stdout: filepath
    :param stderr: where to write stderr output
    :type stderr: filepath

    """

    def _fork_and_exit_parent(errmsg, wait=False, write=False):
        try:
            pid = os.fork()
            if pid > 0:
                if write:  # write PID of child process to `pidfile`
                    tmp = pidfile + ".tmp"
                    with open(tmp, "w") as fp:
                        fp.write(str(pid))
                    os.rename(tmp, pidfile)
                if wait:  # wait for child process to exit
                    os.waitpid(pid, 0)
                os._exit(0)
        except OSError as err:
            _log().critical("%s: (%d) %s", errmsg, err.errno, err.strerror)
            raise err

    # Do first fork and wait for second fork to finish.
    _fork_and_exit_parent("fork #1 failed", wait=True)

    # Decouple from parent environment.
    os.chdir(wf().workflowdir)
    os.setsid()

    # Do second fork and write PID to pidfile.
    _fork_and_exit_parent("fork #2 failed", write=True)

    # Now I am a daemon!
    # Redirect standard file descriptors.
    si = open(stdin, "r", 0)
    so = open(stdout, "a+", 0)
    se = open(stderr, "a+", 0)
    if hasattr(sys.stdin, "fileno"):
        os.dup2(si.fileno(), sys.stdin.fileno())
    if hasattr(sys.stdout, "fileno"):
        os.dup2(so.fileno(), sys.stdout.fileno())
    if hasattr(sys.stderr, "fileno"):
        os.dup2(se.fileno(), sys.stderr.fileno())


def kill(name, sig=signal.SIGTERM):
    """Send a signal to job ``name`` via :func:`os.kill`.

    .. versionadded:: 1.29

    Args:
        name (str): Name of the job
        sig (int, optional): Signal to send (default: SIGTERM)

    Returns:
        bool: `False` if job isn't running, `True` if signal was sent.
    """
    pid = _job_pid(name)
    if pid is None:
        return False

    os.kill(pid, sig)
    return True


def run_in_background(name: str, args: list[str], **kwargs: Any) -> int | None:
    r"""Cache arguments then call this script again via :func:`subprocess.call`.

    :param name: name of job
    :type name: unicode
    :param args: arguments passed as first argument to :func:`subprocess.call`
    :param \**kwargs: keyword arguments to :func:`subprocess.call`
    :returns: exit code of sub-process
    :rtype: int

    When you call this function, it caches its arguments and then calls
    ``background.py`` in a subprocess. The Python subprocess will load the
    cached arguments, fork into the background, and then run the command you
    specified.

    This function will return as soon as the ``background.py`` subprocess has
    forked, returning the exit code of *that* process (i.e. not of the command
    you're trying to run).

    If that process fails, an error will be written to the log file.

    If a process is already running under the same name, this function will
    return immediately and will not run the specified command.

    """
    if is_running(name):
        _log().info("[%s] job already running", name)
        return

    argcache = _arg_cache(name)

    # Cache arguments
    with open(argcache, "wb") as fp:
        pickle.dump({"args": args, "kwargs": kwargs}, fp)
        _log().debug("[%s] command cached: %s", name, argcache)

    # Call this script
    cmd = ["/usr/bin/python3", __file__, name]
    _log().debug("[%s] passing job to background runner: %r", name, cmd)
    retcode = subprocess.call(cmd)

    if retcode:  # pragma: no cover
        _log().error("[%s] background runner failed with %d", name, retcode)
    else:
        _log().debug("[%s] background job started", name)

    return retcode


def main(wf):  # pragma: no cover
    """Run command in a background process.

    Load cached arguments, fork into background, then call
    :meth:`subprocess.call` with cached arguments.

    """
    log = wf.logger
    name = wf.args[0]
    argcache = _arg_cache(name)
    if not os.path.exists(argcache):
        msg = "[{0}] command cache not found: {1}".format(name, argcache)
        log.critical(msg)
        raise IOError(msg)

    # Fork to background and run command
    pidfile = _pid_file(name)
    _background(pidfile)

    # Load cached arguments
    with open(argcache, "r") as fp:
        data = pickle.load(fp)

    # Cached arguments
    args = data["args"]
    kwargs = data["kwargs"]

    # Delete argument cache file
    os.unlink(argcache)

    try:
        # Run the command
        log.debug("[%s] running command: %r", name, args)

        retcode = subprocess.call(args, **kwargs)

        if retcode:
            log.error("[%s] command failed with status %d", name, retcode)
    finally:
        os.unlink(pidfile)

    log.debug("[%s] job complete", name)


if __name__ == "__main__":  # pragma: no cover
    wf().run(main)
