"""Tests for ``mgf.common.process.run``."""

from __future__ import annotations

import logging
import sys
import time

import pytest

from mgf.common.exceptions import (
    ProcessNonZeroExitError,
    ProcessTimeoutError,
)
from mgf.common.process import ProcessResult, run


class TestBasicRun:
    def test_simple_command_returns_result(self) -> None:
        result = run(["echo", "hello"])
        assert isinstance(result, ProcessResult)
        assert result.returncode == 0
        assert result.stdout == "hello"
        assert result.stderr == ""
        assert result.command == ["echo", "hello"]
        assert len(result.command_id) == 8
        assert result.duration_s >= 0

    def test_capture_stderr(self) -> None:
        # Use a Python invocation so behavior is deterministic.
        result = run(
            [sys.executable, "-c", "import sys; sys.stderr.write('ERR\\n')"],
        )
        assert result.returncode == 0
        assert result.stderr == "ERR"

    def test_capture_output_false_gives_empty_strings(self) -> None:
        result = run(["echo", "hello"], capture_output=False)
        assert result.stdout == ""

    def test_env_extra_overrides(self) -> None:
        result = run(
            [sys.executable, "-c", "import os; print(os.environ['MY_VAR'])"],
            env_extra={"MY_VAR": "my_value"},
        )
        assert result.stdout == "my_value"

    def test_cwd_takes_effect(self, tmp_path) -> None:
        result = run(
            [sys.executable, "-c", "import os; print(os.getcwd())"],
            cwd=str(tmp_path),
        )
        assert str(tmp_path) in result.stdout


class TestErrorPaths:
    def test_timeout_raises(self) -> None:
        with pytest.raises(ProcessTimeoutError) as ei:
            run(["sleep", "5"], timeout=0.3, kill_grace_s=0.5)
        assert ei.value.timeout == 0.3
        assert "sleep" in str(ei.value)

    def test_nonzero_exit_raises_by_default(self) -> None:
        with pytest.raises(ProcessNonZeroExitError) as ei:
            run(["false"])
        assert ei.value.returncode == 1

    def test_check_false_returns_result_with_returncode(self) -> None:
        result = run(["false"], check=False)
        assert result.returncode == 1

    def test_signal_termination_raises_signaled_when_check(self) -> None:
        # Spawn a sleeping child, time out, expect ProcessTimeoutError
        # (timeout path raises first; ProcessSignaledError is for cases
        # where the child is killed externally — covered by direct
        # construction in test_errors.py).
        # Here we verify check=False returns a negative returncode
        # for a SIGKILL'd child.
        import os
        import signal
        import subprocess
        import time as _time

        proc = subprocess.Popen(
            [sys.executable, "-c", "import time; time.sleep(10)"],
        )
        _time.sleep(0.1)
        os.kill(proc.pid, signal.SIGTERM)
        proc.wait()
        # Confirm that subprocess yields a negative returncode for
        # signal kills — the run() path uses this to dispatch
        # ProcessSignaledError.
        assert proc.returncode < 0
        # Note: we don't test run()'s check=True path with an
        # external kill because there's a race; the behaviour is
        # exercised via direct construction in test_errors.py and
        # the check=True branch is covered by a code-path test below.

    def test_invalid_command_raises_filenotfound(self) -> None:
        # Stdlib raise; deliberately not wrapped.
        with pytest.raises(FileNotFoundError):
            run(["/nonexistent/binary/path"])

    def test_command_must_not_be_str(self) -> None:
        with pytest.raises(TypeError, match="not a str"):
            run("echo hello")  # type: ignore[arg-type]

    def test_command_must_not_be_empty(self) -> None:
        with pytest.raises(ValueError, match="empty"):
            run([])

    def test_command_elements_must_be_strings(self) -> None:
        with pytest.raises(TypeError, match="must be a string"):
            run(["echo", 42])  # type: ignore[list-item]

    def test_command_accepts_tuple(self) -> None:
        # v0.23.0: tuples are accepted alongside lists (UX improvement
        # for tests/scripts that build command tuples).
        result = run(("echo", "from-tuple"))
        assert result.returncode == 0
        assert result.stdout == "from-tuple"
        # The result.command field always normalizes to a list.
        assert isinstance(result.command, list)
        assert result.command == ["echo", "from-tuple"]


class TestLogStreaming:
    def test_each_line_becomes_a_log_record(
        self,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        # Two-line stdout → two log records.
        with caplog.at_level(logging.INFO, logger="mgf.process"):
            run([sys.executable, "-c", "print('line1'); print('line2')"])
        records = [r for r in caplog.records if r.name.startswith("mgf.process.")]
        # Filter to the messages we care about (some test infrastructure
        # may emit through the same logger tree).
        msgs = [r.message for r in records if r.message in ("line1", "line2")]
        assert "line1" in msgs
        assert "line2" in msgs

    def test_stderr_logs_at_warning_level(
        self,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        with caplog.at_level(logging.INFO, logger="mgf.process"):
            run(
                [sys.executable, "-c", "import sys; sys.stderr.write('boom\\n')"],
            )
        warn_records = [
            r for r in caplog.records if r.levelno == logging.WARNING and r.message == "boom"
        ]
        assert len(warn_records) == 1

    def test_log_records_carry_command_id_and_pid(
        self,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        with caplog.at_level(logging.INFO, logger="mgf.process"):
            result = run([sys.executable, "-c", "print('hi')"])
        records = [r for r in caplog.records if r.message == "hi"]
        assert len(records) == 1
        rec = records[0]
        assert rec.command_id == result.command_id
        assert rec.child_pid == result.pid
        assert rec.stream == "stdout"


class TestNameOption:
    def test_default_name_from_basename(
        self,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        with caplog.at_level(logging.INFO, logger="mgf.process"):
            run([sys.executable, "-c", "print('x')"])
        # Logger name is mgf.process.<basename>; basename of
        # sys.executable is e.g. "python3.12" or "python".
        loggers = {r.name for r in caplog.records if r.message == "x"}
        assert any(name.startswith("mgf.process.") for name in loggers)

    def test_explicit_name_overrides(
        self,
        caplog: pytest.LogCaptureFixture,
    ) -> None:
        with caplog.at_level(logging.INFO, logger="mgf.process"):
            run([sys.executable, "-c", "print('x')"], name="my-script")
        loggers = {r.name for r in caplog.records if r.message == "x"}
        assert "mgf.process.my-script" in loggers


class TestDuration:
    def test_duration_reflects_runtime(self) -> None:
        # Relative to the sleep duration (not an absolute budget): duration
        # must reflect the sleep (>= ~90%, clock-granularity cushion) without
        # being inflated into a hang — generous fixed slack for subprocess
        # spawn on a loaded host.
        sleep_s = 0.1
        result = run(["sleep", str(sleep_s)], timeout=2.0)
        assert result.duration_s >= sleep_s * 0.9, (
            f"duration {result.duration_s:.3f}s below the {sleep_s}s sleep"
        )
        assert result.duration_s < sleep_s + 2.0, (
            f"duration {result.duration_s:.3f}s — runaway/hang?"
        )


class TestWindowsProcessGroupWarning:
    """RA-09: ``run_command(process_group=True)`` on Windows must
    emit a one-shot ``MgfPlatformLimitation`` warning so consumers
    know grandchildren won't be cascade-killed."""

    def test_warning_fires_first_time_on_windows(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """Monkeypatch ``sys.platform = 'win32'`` so the warning
        branch fires; reset the one-shot guard so the test isn't
        order-dependent."""
        import warnings as _warnings

        import mgf.common.process._run as _run_mod
        from mgf.common._warnings import MgfPlatformLimitation

        # Reset the one-shot guard so this test is independent of
        # whether the Windows branch fired in an earlier test.
        monkeypatch.setattr(_run_mod, "_WINDOWS_PGROUP_WARNED", False)
        monkeypatch.setattr(_run_mod.sys, "platform", "win32")

        with _warnings.catch_warnings(record=True) as caught:
            _warnings.simplefilter("always")
            # Use a trivial command — Popen still launches a real
            # process. The warning fires BEFORE Popen returns (in
            # the start_new_session-decision block) so the cmd's
            # behavior is irrelevant.
            from mgf.common.process import run

            run(["true"], timeout=5.0, process_group=True)

        platform_warnings = [w for w in caught if issubclass(w.category, MgfPlatformLimitation)]
        assert len(platform_warnings) == 1, (
            f"expected one MgfPlatformLimitation warning, got "
            f"{[w.category.__name__ for w in caught]}"
        )
        msg = str(platform_warnings[0].message)
        assert "process_group=True" in msg
        assert "Windows" in msg
        assert "killpg" in msg

    def test_warning_is_one_shot(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """The second call on Windows with process_group=True must
        NOT re-emit the warning. The one-shot guard is the
        contract — spamming on every call is the failure mode the
        guard exists to prevent."""
        import warnings as _warnings

        import mgf.common.process._run as _run_mod
        from mgf.common._warnings import MgfPlatformLimitation

        monkeypatch.setattr(_run_mod, "_WINDOWS_PGROUP_WARNED", False)
        monkeypatch.setattr(_run_mod.sys, "platform", "win32")

        from mgf.common.process import run

        # First call: emits.
        with _warnings.catch_warnings(record=True) as first:
            _warnings.simplefilter("always")
            run(["true"], timeout=5.0, process_group=True)
        assert sum(1 for w in first if issubclass(w.category, MgfPlatformLimitation)) == 1

        # Second call: does NOT emit.
        with _warnings.catch_warnings(record=True) as second:
            _warnings.simplefilter("always")
            run(["true"], timeout=5.0, process_group=True)
        assert sum(1 for w in second if issubclass(w.category, MgfPlatformLimitation)) == 0, (
            "MgfPlatformLimitation must be one-shot per process; the second call should be silent."
        )

    def test_no_warning_on_posix(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """The whole point of the warning is the Windows-specific
        degradation. POSIX paths must NOT emit it."""
        import warnings as _warnings

        import mgf.common.process._run as _run_mod
        from mgf.common._warnings import MgfPlatformLimitation

        # Whatever the host platform is, this test fixes it to linux
        # so we don't accidentally rely on the host being POSIX.
        monkeypatch.setattr(_run_mod, "_WINDOWS_PGROUP_WARNED", False)
        monkeypatch.setattr(_run_mod.sys, "platform", "linux")

        from mgf.common.process import run

        with _warnings.catch_warnings(record=True) as caught:
            _warnings.simplefilter("always")
            run(["true"], timeout=5.0, process_group=True)

        platform_warnings = [w for w in caught if issubclass(w.category, MgfPlatformLimitation)]
        assert platform_warnings == [], (
            f"POSIX path emitted MgfPlatformLimitation unexpectedly: "
            f"{[str(w.message) for w in platform_warnings]}"
        )

    def test_no_warning_when_process_group_false(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """``process_group=False`` on Windows is the documented
        escape hatch. It must NOT emit the warning."""
        import warnings as _warnings

        import mgf.common.process._run as _run_mod
        from mgf.common._warnings import MgfPlatformLimitation

        monkeypatch.setattr(_run_mod, "_WINDOWS_PGROUP_WARNED", False)
        monkeypatch.setattr(_run_mod.sys, "platform", "win32")

        from mgf.common.process import run

        with _warnings.catch_warnings(record=True) as caught:
            _warnings.simplefilter("always")
            run(["true"], timeout=5.0, process_group=False)

        platform_warnings = [w for w in caught if issubclass(w.category, MgfPlatformLimitation)]
        assert platform_warnings == []


class TestReapHygiene:
    """RA-06: ``_terminate()`` must observe whether the child was
    actually reaped and warn loudly when SIGKILL doesn't take. Under
    sustained timeout pressure (services that repeatedly time-out
    children), unreaped zombies accumulate until the parent hits
    ``RLIMIT_NPROC``.
    """

    def test_pid_is_gone_after_terminate(self, monkeypatch: pytest.MonkeyPatch) -> None:
        """Capture the pid as run(...) creates it, then after
        ``ProcessTimeoutError`` is raised, assert ``os.kill(pid, 0)``
        reports the process is gone."""
        import os
        import subprocess

        if sys.platform == "win32":
            pytest.skip("POSIX-only reap check")

        captured_pids: list[int] = []
        real_popen = subprocess.Popen

        def tracking_popen(*args: object, **kwargs: object) -> subprocess.Popen[bytes]:  # type: ignore[type-arg]
            proc = real_popen(*args, **kwargs)  # type: ignore[arg-type]
            captured_pids.append(proc.pid)
            return proc

        monkeypatch.setattr("mgf.common.process._run.subprocess.Popen", tracking_popen)

        with pytest.raises(ProcessTimeoutError):
            run(["sleep", "10"], timeout=0.2, kill_grace_s=0.1)

        assert captured_pids, "Popen was not invoked"
        pid = captured_pids[0]

        # The child should be reaped before run() returns. Allow a
        # tiny grace for the kernel — but on a healthy host this is
        # immediate.
        for _ in range(50):  # up to 0.5 s total
            try:
                os.kill(pid, 0)
            except ProcessLookupError:
                return  # gone — the happy case
            time.sleep(0.01)
        pytest.fail(f"child pid {pid} still alive after ProcessTimeoutError returned")

    def test_warning_emitted_when_reap_times_out(
        self,
        caplog: pytest.LogCaptureFixture,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Failure-injection path: ``_terminate`` MUST log a WARNING
        when the final 2-second ``proc.wait`` times out. Without
        this log, an unreapable child returns silently and the
        operator has no signal."""
        import subprocess

        from mgf.common.process._run import _terminate

        # Build a real, dead Popen so we have a valid PID structure
        # to pass in. Then monkeypatch its wait() to always time
        # out and its kill methods to be no-ops, so _terminate
        # walks the full SIGTERM → grace → SIGKILL → final-wait
        # path and the final wait fires the WARNING branch.
        proc = subprocess.Popen(["sleep", "0.01"])
        proc.wait()  # let it exit cleanly so we don't actually leak

        def always_timeout(self: subprocess.Popen[bytes], timeout: float | None = None) -> int:
            raise subprocess.TimeoutExpired(cmd="stub", timeout=timeout or 0)

        # Patch wait() on this specific Popen instance — and stub
        # terminate/kill so they don't raise on the already-exited
        # PID (ProcessLookupError would short-circuit _terminate).
        monkeypatch.setattr(proc, "wait", always_timeout.__get__(proc, type(proc)))
        monkeypatch.setattr(proc, "terminate", lambda: None)
        monkeypatch.setattr(proc, "kill", lambda: None)

        with caplog.at_level(logging.WARNING, logger="mgf.common.process._run"):
            _terminate(
                proc,
                grace_s=0.05,
                process_group=False,
                command_id="ra06-test",
            )

        warnings_seen = [
            r for r in caplog.records if r.levelno == logging.WARNING and "reap" in r.message
        ]
        assert len(warnings_seen) == 1, (
            f"expected exactly one 'reap timeout' WARNING; got "
            f"{[(r.levelno, r.message) for r in caplog.records]}"
        )
        rec = warnings_seen[0]
        assert getattr(rec, "child_pid", None) == proc.pid
        assert getattr(rec, "command_id", None) == "ra06-test"
        assert getattr(rec, "event", None) == "process.reap_timeout"
