"""
This module contains the function calls to execute command line scripts
"""

import contextlib
import functools
import logging
import multiprocessing
import os
import signal
import subprocess
import sys
import threading
import time
import traceback
from random import randint

import salt.defaults.exitcodes
from salt._process_role import mark_as_cli
from salt.exceptions import SaltClientError, SaltReqTimeoutError, SaltSystemExit

log = logging.getLogger(__name__)


# PEP 741: Python 3.14 changed the multiprocessing default start method on
# Linux from "fork" to "forkserver".  Salt's daemons spawn many short-lived
# subprocesses (MWorker, RequestServer, EventReturn, PoolRouting, …); under
# "forkserver" each spawn re-pickles its target and re-imports Salt in a
# helper process, which both makes startup ~5× slower than 3.13 *and* leaks
# worker processes that hold ports across daemon restarts.  Pin the start
# method to "fork" for Linux Salt on 3.14+ so production and test behave
# the same as on 3.13.  ``force=True`` overrides any prior choice (pytest,
# for example, may have queried the default before this import ran, which
# would otherwise cause the pin to be skipped).  Operators who specifically
# want the PEP-741 safety property can call ``set_start_method`` again with
# their preferred value after Salt is imported.
if sys.version_info >= (3, 14) and sys.platform.startswith("linux"):
    multiprocessing.set_start_method("fork", force=True)


def _handle_signals(client, signum, sigframe):
    try:
        hardcrash = client.options.hard_crash
    except (AttributeError, KeyError):
        hardcrash = False

    if signum == signal.SIGINT:
        exit_msg = "\nExiting gracefully on Ctrl-c"
        try:
            jid = client.local_client.pub_data["jid"]
            exit_msg += (
                "\n"
                "This job's jid is: {0}\n"
                "The minions may not have all finished running and any remaining "
                "minions will return upon completion. To look up the return data "
                "for this job later, run the following command:\n\n"
                "salt-run jobs.lookup_jid {0}".format(jid)
            )
        except (AttributeError, KeyError):
            pass
    else:
        exit_msg = None

    if exit_msg is None and hardcrash:
        exit_msg = "\nExiting with hard crash on Ctrl-c"
    if exit_msg:
        print(exit_msg, file=sys.stderr, flush=True)
    if hardcrash:
        try:
            # This raises AttributeError on Python 3.4 and 3.5 if there is no current exception.
            # Ref: https://bugs.python.org/issue23003
            trace = traceback.format_exc()
            log.error(trace)
        except AttributeError:
            pass
        sys.exit(salt.defaults.exitcodes.EX_GENERIC)
    sys.exit(salt.defaults.exitcodes.EX_OK)


def _install_signal_handlers(client):
    # Install the SIGINT/SIGTERM handlers if not done so far
    if signal.getsignal(signal.SIGINT) in (signal.SIG_DFL, signal.default_int_handler):
        # No custom signal handling was added, install our own
        signal.signal(signal.SIGINT, functools.partial(_handle_signals, client))

    if signal.getsignal(signal.SIGTERM) is signal.SIG_DFL:
        # No custom signal handling was added, install our own
        signal.signal(signal.SIGTERM, functools.partial(_handle_signals, client))


def _pin_multiprocessing_fork():
    """
    Python 3.14 changed the Linux default ``multiprocessing`` start method
    from ``fork`` to ``forkserver``. Forkserver pickles the target callable
    across to a fresh interpreter, which breaks every salt daemon that
    relies on dynamically loaded modules (e.g. proxy minions register
    their callables under namespaces like
    ``dummy_proxy_one.salt.loaded.int.metaproxy.deltaproxy`` that the
    fresh child cannot import). It also makes the master spawn its
    ``multiprocessing.resource_tracker`` before ``check_user()`` drops
    privileges, leaving a root-owned child under the salt user's pid.

    Pin every salt daemon entry point to ``fork`` to restore Py3.13 Linux
    semantics. Strictly Linux-only: macOS and Windows have always defaulted
    to spawn-based start methods and salt is written for that on those
    platforms (Darwin's libdispatch makes fork after thread init unsafe and
    silently corrupts worker processes -- the symptom is minions accepting
    jobs but never responding because their fork-spawned workers die
    invisibly). Idempotent on re-entry.
    """
    if not sys.platform.startswith("linux"):
        return
    import multiprocessing

    if multiprocessing.get_start_method(allow_none=True) is None:
        multiprocessing.set_start_method("fork")


def salt_master():
    """
    Start the salt master.
    """
    _pin_multiprocessing_fork()

    import salt.cli.daemons

    # Fix for setuptools generated scripts, so that it will
    # work with multiprocessing fork emulation.
    # (see multiprocessing.forking.get_preparation_data())
    if __name__ != "__main__":
        sys.modules["__main__"] = sys.modules[__name__]

    master = salt.cli.daemons.Master()
    master.start()


def minion_process():
    """
    Start a minion process
    """
    # Because the minion is going to start on a separate process,
    # salt._logging.in_mainprocess() will return False.
    # We'll just force it to return True for this particular case so
    # that proper logging can be set up.
    import salt._logging

    salt._logging.in_mainprocess.__pid__ = os.getpid()
    # Now the remaining required imports
    import salt.cli.daemons
    import salt.utils.platform

    # salt_minion spawns this function in a new process

    def handle_hup(manager, sig, frame):
        manager.minion.reload()

    lock = threading.RLock()

    def suicide_when_without_parent(parent_pid):
        """
        Have the minion suicide if the parent process is gone

        NOTE: small race issue where the parent PID could be replace
        with another process with same PID!
        """
        while lock.acquire(blocking=False):
            lock.release()
            time.sleep(5)
            try:
                # check pid alive (Unix only trick!)
                if os.getuid() == 0 and not salt.utils.platform.is_windows():
                    os.kill(parent_pid, 0)
            except OSError as exc:
                # forcibly exit, regular sys.exit raises an exception-- which
                # isn't sufficient in a thread
                log.error("Minion process encountered exception: %s", exc)
                os._exit(salt.defaults.exitcodes.EX_GENERIC)

    try:
        if not salt.utils.platform.is_windows():
            thread = threading.Thread(
                target=suicide_when_without_parent, args=(os.getppid(),)
            )
            thread.start()

        minion = salt.cli.daemons.Minion()
        signal.signal(signal.SIGHUP, functools.partial(handle_hup, minion))
        minion.start()
    except (SaltClientError, SaltReqTimeoutError, SaltSystemExit) as exc:
        lock.acquire(blocking=True)
        log.warning(
            "Fatal functionality error caught by minion handler:\n", exc_info=True
        )
        log.warning("** Restarting minion **")
        delay = 60
        if minion is not None and hasattr(minion, "config"):
            delay = minion.config.get("random_reauth_delay", 60)
        delay = randint(1, delay)
        log.info("waiting random_reauth_delay %ss", delay)
        time.sleep(delay)
        sys.exit(salt.defaults.exitcodes.SALT_KEEPALIVE)
    finally:
        lock.acquire(blocking=True)


def salt_minion():
    """
    Start the salt minion in a subprocess.
    Auto restart minion on error.
    """
    _pin_multiprocessing_fork()

    import signal

    import salt.utils.debug
    import salt.utils.platform
    import salt.utils.process

    salt.utils.debug.enable_sigusr1_handler()

    salt.utils.process.notify_systemd()

    import multiprocessing

    import salt.cli.daemons

    # Fix for setuptools generated scripts, so that it will
    # work with multiprocessing fork emulation.
    # (see multiprocessing.forking.get_preparation_data())
    if __name__ != "__main__":
        sys.modules["__main__"] = sys.modules[__name__]

    if "" in sys.path:
        sys.path.remove("")

    if salt.utils.platform.is_windows():
        minion = salt.cli.daemons.Minion()
        minion.start()
        return

    if "--disable-keepalive" in sys.argv:
        minion = salt.cli.daemons.Minion()
        minion.start()
        return

    def escalate_signal_to_process(
        pid, signum, sigframe
    ):  # pylint: disable=unused-argument
        """
        Escalate the signal received to the multiprocessing process that
        is actually running the minion
        """
        # escalate signal
        os.kill(pid, signum)

    # keep one minion subprocess running
    prev_sigint_handler = signal.getsignal(signal.SIGINT)
    prev_sigterm_handler = signal.getsignal(signal.SIGTERM)
    supervisor_privileges_dropped = False
    while True:
        try:
            process = multiprocessing.Process(
                target=minion_process, name="MinionKeepAlive"
            )
            process.start()
            signal.signal(
                signal.SIGTERM,
                functools.partial(escalate_signal_to_process, process.pid),
            )
            signal.signal(
                signal.SIGINT,
                functools.partial(escalate_signal_to_process, process.pid),
            )
            signal.signal(
                signal.SIGHUP,
                functools.partial(escalate_signal_to_process, process.pid),
            )
        except Exception:  # pylint: disable=broad-except
            # if multiprocessing does not work
            minion = salt.cli.daemons.Minion()
            minion.start()
            break

        # Drop the supervisor's privileges now that the child has been
        # forked. The child inherits root and performs its own
        # verify_env/pidfile/check_user dance; this parent only needs
        # to forward signals and restart on keepalive exits. Only do
        # this once: subsequent loop iterations re-fork from an already
        # unprivileged parent, which is fine because the dirs created
        # by the first child already have the right ownership.
        if not supervisor_privileges_dropped:
            import salt.config

            _supervisor_drop_privileges(salt.config.minion_config, "minion")
            supervisor_privileges_dropped = True

        process.join()

        # Process exited or was terminated. Since we're going to try to restart
        # it, we MUST, reset signal handling to the previous handlers
        signal.signal(signal.SIGINT, prev_sigint_handler)
        signal.signal(signal.SIGTERM, prev_sigterm_handler)

        if not process.exitcode == salt.defaults.exitcodes.SALT_KEEPALIVE:
            sys.exit(process.exitcode)
        # ontop of the random_reauth_delay already preformed
        # delay extra to reduce flooding and free resources
        # NOTE: values are static but should be fine.
        time.sleep(2 + randint(1, 10))
        # need to reset logging because new minion objects
        # cause extra log handlers to accumulate
        rlogger = logging.getLogger()
        for handler in rlogger.handlers:
            rlogger.removeHandler(handler)
        logging.basicConfig()


def proxy_minion_process(queue):
    """
    Start a proxy minion process
    """
    import salt.cli.daemons
    import salt.utils.platform

    # salt_minion spawns this function in a new process

    lock = threading.RLock()

    def suicide_when_without_parent(parent_pid):
        """
        Have the minion suicide if the parent process is gone

        NOTE: there is a small race issue where the parent PID could be replace
        with another process with the same PID!
        """
        while lock.acquire(blocking=False):
            lock.release()
            time.sleep(5)
            try:
                # check pid alive (Unix only trick!)
                os.kill(parent_pid, 0)
            except OSError:
                # forcibly exit, regular sys.exit raises an exception-- which
                # isn't sufficient in a thread
                os._exit(999)

    try:
        if not salt.utils.platform.is_windows():
            thread = threading.Thread(
                target=suicide_when_without_parent, args=(os.getppid(),)
            )
            thread.start()

        restart = False
        proxyminion = None
        status = salt.defaults.exitcodes.EX_OK
        proxyminion = salt.cli.daemons.ProxyMinion()
        proxyminion.start()
        # pylint: disable=broad-except
    except (
        Exception,
        SaltClientError,
        SaltReqTimeoutError,
        SaltSystemExit,
    ) as exc:
        # pylint: enable=broad-except
        log.error("Proxy Minion failed to start: ", exc_info=True)
        restart = True
        # status is superfluous since the process will be restarted
        status = salt.defaults.exitcodes.SALT_KEEPALIVE
    except SystemExit as exc:
        restart = False
        status = exc.code
    finally:
        lock.acquire(blocking=True)

    if restart is True:
        log.warning("** Restarting proxy minion **")
        delay = 60
        if proxyminion is not None:
            if hasattr(proxyminion, "config"):
                delay = proxyminion.config.get("random_reauth_delay", 60)
        random_delay = randint(1, delay)
        log.info("Sleeping random_reauth_delay of %s seconds", random_delay)
        # preform delay after minion resources have been cleaned
        queue.put(random_delay)
    else:
        queue.put(0)
    sys.exit(status)


def salt_proxy():
    """
    Start a proxy minion.
    """
    _pin_multiprocessing_fork()

    import multiprocessing

    import salt.cli.daemons
    import salt.utils.platform

    if "" in sys.path:
        sys.path.remove("")

    if salt.utils.platform.is_windows():
        proxyminion = salt.cli.daemons.ProxyMinion()
        proxyminion.start()
        return

    if "--disable-keepalive" in sys.argv:
        proxyminion = salt.cli.daemons.ProxyMinion()
        proxyminion.start()
        return

    # keep one minion subprocess running
    supervisor_privileges_dropped = False
    while True:
        try:
            queue = multiprocessing.Queue()
        except Exception:  # pylint: disable=broad-except
            # This breaks in containers
            proxyminion = salt.cli.daemons.ProxyMinion()
            proxyminion.start()
            return
        process = multiprocessing.Process(
            target=proxy_minion_process, args=(queue,), name="ProxyMinion"
        )
        process.start()
        # See the matching note in salt_minion(); the supervisor only
        # needs root for the first fork's verify_env/pidfile setup, and
        # leaving it as root afterwards is the bug described in #68115.
        if not supervisor_privileges_dropped:
            import salt.config

            _supervisor_drop_privileges(salt.config.proxy_config, "proxy")
            supervisor_privileges_dropped = True
        try:
            process.join()
            try:
                restart_delay = queue.get(block=False)
            except Exception:  # pylint: disable=broad-except
                if process.exitcode == 0:
                    # Minion process ended naturally, Ctrl+C or --version
                    break
                restart_delay = 60
            if restart_delay == 0:
                # Minion process ended naturally, Ctrl+C, --version, etc.
                sys.exit(process.exitcode)
            # delay restart to reduce flooding and allow network resources to close
            time.sleep(restart_delay)
        except KeyboardInterrupt:
            break
        # need to reset logging because new minion objects
        # cause extra log handlers to accumulate
        rlogger = logging.getLogger()
        for handler in rlogger.handlers:
            rlogger.removeHandler(handler)
        logging.basicConfig()


def salt_syndic():
    """
    Start the salt syndic.
    """
    _pin_multiprocessing_fork()

    import salt.utils.process

    salt.utils.process.notify_systemd()

    import salt.cli.daemons

    pid = os.getpid()
    try:
        syndic = salt.cli.daemons.Syndic()
        syndic.start()
    except KeyboardInterrupt:
        os.kill(pid, 15)


def salt_key():
    """
    Manage the authentication keys with salt-key.
    """
    mark_as_cli()
    import salt.cli.key

    try:
        client = salt.cli.key.SaltKey()
        _install_signal_handlers(client)
        client.run()
    except Exception as err:  # pylint: disable=broad-except
        sys.stderr.write(f"Error: {err}\n")


def salt_cp():
    """
    Publish commands to the salt system from the command line on the
    master.
    """
    mark_as_cli()
    import salt.cli.cp

    client = salt.cli.cp.SaltCPCli()
    _install_signal_handlers(client)
    client.run()


def salt_call():
    """
    Directly call a salt command in the modules, does not require a running
    salt minion to run.
    """
    mark_as_cli()
    _pin_multiprocessing_fork()

    import salt.cli.call

    if "" in sys.path:
        sys.path.remove("")
    client = salt.cli.call.SaltCall()
    _install_signal_handlers(client)
    client.run()


def salt_run():
    """
    Execute a salt convenience routine.
    """
    mark_as_cli()
    import salt.cli.run

    if "" in sys.path:
        sys.path.remove("")
    client = salt.cli.run.SaltRun()
    _install_signal_handlers(client)
    client.run()


def salt_ssh():
    """
    Execute the salt-ssh system
    """
    import salt.cli.ssh

    if "" in sys.path:
        sys.path.remove("")
    try:
        client = salt.cli.ssh.SaltSSH()
        _install_signal_handlers(client)
        client.run()
    except SaltClientError as err:
        print(str(err), file=sys.stderr, flush=True)
        try:
            if client.options.hard_crash:
                trace = traceback.format_exc()
                log.error(trace)
        except (AttributeError, KeyError):
            pass
        sys.exit(salt.defaults.exitcodes.EX_GENERIC)


def salt_cloud():
    """
    The main function for salt-cloud
    """
    mark_as_cli()
    try:
        # Late-imports for CLI performance
        import salt.cloud
        import salt.cloud.cli
    except ImportError as e:
        # No salt cloud on Windows
        import salt.defaults.exitcodes

        log.error("Error importing salt cloud: %s", e)
        print("salt-cloud is not available in this system")
        sys.exit(salt.defaults.exitcodes.EX_UNAVAILABLE)
    if "" in sys.path:
        sys.path.remove("")

    client = salt.cloud.cli.SaltCloud()
    _install_signal_handlers(client)
    client.run()


def salt_api():
    """
    The main function for salt-api
    """
    _pin_multiprocessing_fork()

    import salt.utils.process

    salt.utils.process.notify_systemd()

    import salt.cli.api

    sapi = salt.cli.api.SaltAPI()  # pylint: disable=E1120
    sapi.start()


def salt_main():
    """
    Publish commands to the salt system from the command line on the
    master.
    """
    mark_as_cli()
    import salt.cli.salt

    if "" in sys.path:
        sys.path.remove("")
    client = salt.cli.salt.SaltCMD()
    _install_signal_handlers(client)
    client.run()


def salt_spm():
    """
    The main function for spm, the Salt Package Manager

    .. versionadded:: 2015.8.0
    """
    import salt.cli.spm

    spm = salt.cli.spm.SPM()  # pylint: disable=E1120
    spm.run()


def salt_extend(extension, name, description, salt_dir, merge):
    """
    Quickstart for developing on the saltstack installation

    .. versionadded:: 2016.11.0
    """
    import salt.utils.extend

    salt.utils.extend.run(
        extension=extension,
        name=name,
        description=description,
        salt_dir=salt_dir,
        merge=merge,
    )


def salt_unity():
    """
    Change the args and redirect to another salt script
    """
    avail = []
    for fun in dir(sys.modules[__name__]):
        if fun.startswith("salt"):
            avail.append(fun[5:])
    if len(sys.argv) < 2:
        msg = "Must pass in a salt command, available commands are:"
        for cmd in avail:
            msg += f"\n{cmd}"
        print(msg)
        sys.exit(1)
    cmd = sys.argv[1]
    if cmd not in avail:
        # Fall back to the salt command
        sys.argv[0] = "salt"
        s_fun = salt_main
    else:
        sys.argv[0] = f"salt-{cmd}"
        sys.argv.pop(1)
        s_fun = getattr(sys.modules[__name__], f"salt_{cmd}")
    s_fun()


def _pip_args(args, target):
    new_args = args[:]
    target_in_args = False
    for arg in args:
        if "--target" in arg:
            target_in_args = True
    if "install" in args and not target_in_args:
        new_args.append(f"--target={target}")
    return new_args


def _pip_environment(env, extras):
    new_env = env.copy()
    if "PYTHONPATH" in env:
        new_env["PYTHONPATH"] = f"{extras}{os.pathsep}{env['PYTHONPATH']}"
    else:
        new_env["PYTHONPATH"] = extras
    return new_env


def _get_onedir_env_path():
    # This function only exists to simplify testing.
    with contextlib.suppress(AttributeError):
        return sys.RELENV
    return None


def _supervisor_config_dir():
    """
    Return the salt config directory to use when the keepalive supervisor
    reads minion or proxy options.

    Respects ``-c``/``--config-dir`` on the command line first, then the
    ``SALT_CONFIG_DIR`` env var, then falls back to the compiled-in
    default. Keeping this lookup simple and ``argparse``-free avoids
    pulling in the full option parser (which the child process already
    runs) and keeps the parent's privilege drop cheap.
    """
    import salt.syspaths

    argv = sys.argv[1:]
    skip_next = False
    for idx, arg in enumerate(argv):
        if skip_next:
            skip_next = False
            continue
        if arg in ("-c", "--config-dir") and idx + 1 < len(argv):
            return argv[idx + 1]
        if arg.startswith("--config-dir="):
            return arg.split("=", 1)[1]
        if arg.startswith("-c") and len(arg) > 2:
            return arg[2:]
    env_dir = os.environ.get("SALT_CONFIG_DIR")
    if env_dir:
        return env_dir
    return salt.syspaths.CONFIG_DIR


def _supervisor_drop_privileges(config_loader, config_name):
    """
    Drop privileges in the salt-minion / salt-proxy keepalive supervisor.

    The supervisor forks a child process that runs the actual minion;
    only the child needs root to perform ``verify_env`` chowns and to
    create the pidfile, and the child drops to the configured user via
    ``salt.cli.daemons.Minion._real_start``. The parent only needs to
    forward signals to the child and restart it on
    ``SALT_KEEPALIVE``-flavoured exits -- neither requires privileges.

    Leaving the parent as root meant an unprivileged minion process tree
    still had a privileged root process at its head, defeating the
    purpose of configuring ``user: <something other than root>``. See
    issue #68115.

    ``config_loader`` is ``salt.config.minion_config`` or
    ``salt.config.proxy_config``; ``config_name`` is the corresponding
    config file basename (``minion`` or ``proxy``).
    """
    import salt.utils.user
    import salt.utils.verify

    config_dir = _supervisor_config_dir()
    config_file = os.path.join(config_dir, config_name)
    try:
        opts = config_loader(config_file)
    except Exception:  # pylint: disable=broad-except
        log.debug(
            "Supervisor could not load %s config from %s; skipping privilege drop",
            config_name,
            config_file,
            exc_info=True,
        )
        return
    user = opts.get("user")
    if not user:
        return
    if user == salt.utils.user.get_user():
        return
    salt.utils.verify.check_user(user)


def salt_pip(config_dir=None):
    """
    Proxy to current python's pip
    """
    import salt.config
    import salt.utils.user
    import salt.utils.verify

    relenv_path = _get_onedir_env_path()
    if relenv_path is None:
        print(
            "'salt-pip' is only meant to be used from a Salt onedir. You probably "
            "want to use the system 'pip` binary.",
            file=sys.stderr,
            flush=True,
        )
        sys.exit(salt.defaults.exitcodes.EX_GENERIC)
    else:
        extras = str(relenv_path / "extras-{}.{}".format(*sys.version_info))

    # Use provided config_dir, or SALT_CONFIG_DIR env var, or SALT_MINION_CONFIG env var, or fall back to default location
    if config_dir:
        config_file = os.path.join(config_dir, "minion")
    elif os.environ.get("SALT_CONFIG_DIR"):
        salt_config_dir = os.environ.get("SALT_CONFIG_DIR")
        config_file = os.path.join(salt_config_dir, "minion")
    elif os.environ.get("SALT_MINION_CONFIG"):
        config_file = os.environ.get("SALT_MINION_CONFIG")
    else:
        config_file = salt.config.DEFAULT_MINION_OPTS["conf_file"]
    opts = salt.config.minion_config(config_file)

    user = opts.get("user")
    current_user = salt.utils.user.get_user()

    # Switch to the configured user if it's not root
    if user and user != "root" and user != current_user:
        salt.utils.verify.check_user(user)

    env = _pip_environment(os.environ.copy(), extras)
    args = _pip_args(sys.argv[1:], extras)
    command = [
        sys.executable,
        "-m",
        "pip",
    ] + _pip_args(sys.argv[1:], extras)
    ret = subprocess.run(command, shell=False, check=False, env=env)
    sys.exit(ret.returncode)
