# Copyright 2019-present MongoDB, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Support for spawning a daemon process.

PyMongo only attempts to spawn the mongocryptd daemon process when automatic
client-side field level encryption is enabled. See
`Client-side Field Level Encryption <https://www.mongodb.com/docs/languages/python/pymongo-driver/current/security/in-use-encryption/#client-side-field-level-encryption>`_ for more info.
"""
from __future__ import annotations

import os
import subprocess
import sys
import warnings
from typing import Any, Optional, Sequence

# The maximum amount of time to wait for the intermediate subprocess.
_WAIT_TIMEOUT = 10
_THIS_FILE = os.path.realpath(__file__)


def _popen_wait(popen: subprocess.Popen[Any], timeout: Optional[float]) -> Optional[int]:
    """Implement wait timeout support for Python 3."""
    try:
        return popen.wait(timeout=timeout)
    except subprocess.TimeoutExpired:
        # Silence TimeoutExpired errors.
        return None


def _silence_resource_warning(popen: Optional[subprocess.Popen[Any]]) -> None:
    """Silence Popen's ResourceWarning.

    Note this should only be used if the process was created as a daemon.
    """
    # Set the returncode to avoid this warning when popen is garbage collected:
    # "ResourceWarning: subprocess XXX is still running".
    # See https://bugs.python.org/issue38890 and
    # https://bugs.python.org/issue26741.
    # popen is None when mongocryptd spawning fails
    if popen is not None:
        popen.returncode = 0


if sys.platform == "win32":
    # On Windows we spawn the daemon process simply by using DETACHED_PROCESS.
    _DETACHED_PROCESS = getattr(subprocess, "DETACHED_PROCESS", 0x00000008)

    def _spawn_daemon(args: Sequence[str]) -> None:
        """Spawn a daemon process (Windows)."""
        try:
            with open(os.devnull, "r+b") as devnull:
                popen = subprocess.Popen(
                    args,  # noqa: S603
                    creationflags=_DETACHED_PROCESS,
                    stdin=devnull,
                    stderr=devnull,
                    stdout=devnull,
                )
                _silence_resource_warning(popen)
        except FileNotFoundError as exc:
            warnings.warn(
                f"Failed to start {args[0]}: is it on your $PATH?\nOriginal exception: {exc}",
                RuntimeWarning,
                stacklevel=2,
            )

else:
    # On Unix we spawn the daemon process with a double Popen.
    # 1) The first Popen runs this file as a Python script using the current
    #    interpreter.
    # 2) The script then decouples itself and performs the second Popen to
    #    spawn the daemon process.
    # 3) The original process waits up to 10 seconds for the script to exit.
    #
    # Note that we do not call fork() directly because we want this procedure
    # to be safe to call from any thread. Using Popen instead of fork also
    # avoids triggering the application's os.register_at_fork() callbacks when
    # we spawn the mongocryptd daemon process.
    def _spawn(args: Sequence[str]) -> Optional[subprocess.Popen[Any]]:
        """Spawn the process and silence stdout/stderr."""
        try:
            with open(os.devnull, "r+b") as devnull:
                return subprocess.Popen(
                    args,  # noqa: S603
                    close_fds=True,
                    stdin=devnull,
                    stderr=devnull,
                    stdout=devnull,
                )
        except FileNotFoundError as exc:
            warnings.warn(
                f"Failed to start {args[0]}: is it on your $PATH?\nOriginal exception: {exc}",
                RuntimeWarning,
                stacklevel=2,
            )
        return None

    def _spawn_daemon_double_popen(args: Sequence[str]) -> None:
        """Spawn a daemon process using a double subprocess.Popen."""
        spawner_args = [sys.executable, _THIS_FILE]
        spawner_args.extend(args)
        temp_proc = subprocess.Popen(spawner_args, close_fds=True)  # noqa: S603
        # Reap the intermediate child process to avoid creating zombie
        # processes.
        _popen_wait(temp_proc, _WAIT_TIMEOUT)

    def _spawn_daemon(args: Sequence[str]) -> None:
        """Spawn a daemon process (Unix)."""
        # "If Python is unable to retrieve the real path to its executable,
        # sys.executable will be an empty string or None".
        if sys.executable:
            _spawn_daemon_double_popen(args)
        else:
            # Fallback to spawn a non-daemon process without silencing the
            # resource warning. We do not use fork here because it is not
            # safe to call from a thread on all systems.
            # Unfortunately, this means that:
            # 1) If the parent application is killed via Ctrl-C, the
            #    non-daemon process will also be killed.
            # 2) Each non-daemon process will hang around as a zombie process
            #    until the main application exits.
            _spawn(args)

    if __name__ == "__main__":
        # Attempt to start a new session to decouple from the parent.
        if hasattr(os, "setsid"):
            try:
                os.setsid()
            except OSError:
                pass

        # We are performing a double fork (Popen) to spawn the process as a
        # daemon so it is safe to ignore the resource warning.
        _silence_resource_warning(_spawn(sys.argv[1:]))
        os._exit(0)
