"""Tests for tools/env_probe.py — local Python toolchain probe."""

import sys

import pytest

from tools import env_probe


@pytest.fixture(autouse=True)
def reset_probe_cache():
    """Each test starts with a clean cache."""
    env_probe._reset_cache_for_tests()
    yield
    env_probe._reset_cache_for_tests()


class TestSilentWhenHealthy:
    """The probe must emit nothing when the environment is clean — otherwise
    every prompt for every user pays an unnecessary token tax."""

    def test_clean_env_returns_empty(self, monkeypatch):
        """python3 + pip module + no PEP 668 → silent."""
        monkeypatch.setattr(env_probe, "_python_version_of",
                            lambda b: "3.13.3" if b == "python3" else None)
        monkeypatch.setattr(env_probe, "_has_pip_module", lambda b: True)
        monkeypatch.setattr(env_probe, "_detect_pep668", lambda b: False)
        monkeypatch.setattr(env_probe, "_pip_python_version", lambda: "3.13")
        monkeypatch.setattr(env_probe.shutil, "which", lambda name: None)
        assert env_probe.get_environment_probe_line() == ""

    def test_pep668_with_uv_returns_empty(self, monkeypatch):
        """PEP 668 alone shouldn't trigger output if uv is installed —
        agent has a viable install path."""
        monkeypatch.setattr(env_probe, "_python_version_of",
                            lambda b: "3.12.4" if b == "python3" else None)
        monkeypatch.setattr(env_probe, "_has_pip_module", lambda b: True)
        monkeypatch.setattr(env_probe, "_detect_pep668", lambda b: True)
        monkeypatch.setattr(env_probe, "_pip_python_version", lambda: "3.12")
        monkeypatch.setattr(env_probe.shutil, "which",
                            lambda name: "/usr/local/bin/uv" if name == "uv" else None)
        assert env_probe.get_environment_probe_line() == ""


class TestEmitsOnRealProblems:
    """The probe must produce a usable line for the real failure modes
    that drove this feature."""

    def test_allen_scenario_python_version_mismatch(self, monkeypatch):
        """python3 is 3.11 (no pip module), pip on PATH is 3.12, PEP 668 on,
        no uv — the exact scenario from the Sarasota real-estate task."""
        monkeypatch.setattr(env_probe, "_python_version_of",
                            lambda b: {"python3": "3.11.15", "python": None}.get(b))
        monkeypatch.setattr(env_probe, "_has_pip_module", lambda b: False)
        monkeypatch.setattr(env_probe, "_detect_pep668", lambda b: True)
        monkeypatch.setattr(env_probe, "_pip_python_version", lambda: "3.12")
        monkeypatch.setattr(env_probe.shutil, "which",
                            lambda name: None if name == "uv" else "/usr/bin/" + name)

        line = env_probe.get_environment_probe_line()
        assert line  # not silent
        # Single line — must not blow up the system prompt.
        assert "\n" not in line
        # Names the real toolchain state
        assert "3.11.15" in line
        assert "no pip module" in line
        assert "mismatch" in line
        assert "PEP 668" in line
        # Points at the right escape hatch
        assert "venv" in line or "uv" in line

    def test_missing_python3_is_named(self, monkeypatch):
        """If python3 isn't installed at all, say so."""
        monkeypatch.setattr(env_probe, "_python_version_of", lambda b: None)
        monkeypatch.setattr(env_probe, "_has_pip_module", lambda b: False)
        monkeypatch.setattr(env_probe, "_detect_pep668", lambda b: False)
        monkeypatch.setattr(env_probe, "_pip_python_version", lambda: None)
        monkeypatch.setattr(env_probe.shutil, "which", lambda name: None)

        line = env_probe.get_environment_probe_line()
        assert "python3=missing" in line

    def test_python_missing_but_python3_present(self, monkeypatch):
        """Common on Debian: only python3 exists, agent shouldn't type
        `python`."""
        monkeypatch.setattr(env_probe, "_python_version_of",
                            lambda b: "3.12.4" if b == "python3" else None)
        monkeypatch.setattr(env_probe, "_has_pip_module", lambda b: True)
        monkeypatch.setattr(env_probe, "_detect_pep668", lambda b: True)
        monkeypatch.setattr(env_probe, "_pip_python_version", lambda: "3.12")
        monkeypatch.setattr(env_probe.shutil, "which",
                            lambda name: None if name == "uv" else "/usr/bin/" + name)

        line = env_probe.get_environment_probe_line()
        # `python=missing` only matters in the non-silent path; PEP 668 (without
        # uv) is what brings us off-silent here, so check both signals.
        assert "PEP 668" in line
        assert "python=missing" in line


class TestSkipsRemoteBackends:
    """Remote backends have their own probe; this one must stay out."""

    def test_docker_returns_empty(self, monkeypatch):
        monkeypatch.setenv("TERMINAL_ENV", "docker")
        # Even with a broken local env, docker must emit nothing.
        monkeypatch.setattr(env_probe, "_python_version_of", lambda b: None)
        monkeypatch.setattr(env_probe, "_has_pip_module", lambda b: False)
        assert env_probe.get_environment_probe_line() == ""

    def test_modal_returns_empty(self, monkeypatch):
        monkeypatch.setenv("TERMINAL_ENV", "modal")
        assert env_probe.get_environment_probe_line() == ""

    def test_ssh_returns_empty(self, monkeypatch):
        monkeypatch.setenv("TERMINAL_ENV", "ssh")
        assert env_probe.get_environment_probe_line() == ""


class TestCaching:
    """The probe runs once per process — the result is deterministic for
    the lifetime of the agent."""

    def test_result_cached(self, monkeypatch):
        calls = []

        def counting_version(b):
            calls.append(b)
            return "3.12.4" if b == "python3" else None

        monkeypatch.setattr(env_probe, "_python_version_of", counting_version)
        monkeypatch.setattr(env_probe, "_has_pip_module", lambda b: True)
        monkeypatch.setattr(env_probe, "_detect_pep668", lambda b: False)
        monkeypatch.setattr(env_probe, "_pip_python_version", lambda: "3.12")
        monkeypatch.setattr(env_probe.shutil, "which", lambda name: None)

        env_probe.get_environment_probe_line()
        env_probe.get_environment_probe_line()
        env_probe.get_environment_probe_line()

        # Only the first call probes — caller-counting confirms it.
        # Two calls (python3 + python) on first invocation, zero after.
        assert len(calls) == 2


class TestRobustness:
    """The probe must NEVER crash the prompt build."""

    def test_subprocess_failure_returns_empty(self, monkeypatch):
        """If every subprocess fails, just stay silent."""
        def boom(*a, **kw):
            raise OSError("simulated")
        monkeypatch.setattr(env_probe.subprocess, "run", boom)
        monkeypatch.setattr(env_probe.subprocess, "Popen", boom)
        # Should not raise, should just return ""
        result = env_probe.get_environment_probe_line()
        # Whatever the result is, it must be a string
        assert isinstance(result, str)


class TestStuckProbeNeverBlocksCallers:
    """Regression for #67964: on Windows an orphaned pip descendant kept
    the probe's capture pipes open, wedging the warm thread inside
    subprocess._communicate while it held the module lock — every new
    session's prompt build then blocked forever.  Callers must fail open
    within a bounded time no matter what the probe subprocesses do."""

    def test_hung_probe_fails_open_for_concurrent_callers(self, monkeypatch):
        """Concurrent get_environment_probe_line() callers return "" within
        a bounded wall-clock time while the probe worker stays stuck."""
        import threading as _threading
        import time

        release = _threading.Event()

        def stuck_probe():
            # Simulate the wedged pipe read: blocks until released.
            release.wait(timeout=30)
            return "Python toolchain: late-result."

        monkeypatch.setattr(env_probe, "_build_probe_line", stuck_probe)
        # Keep the test fast — the bound just has to exist, not be 10s.
        monkeypatch.setattr(env_probe, "_PROBE_WAIT_TIMEOUT", 0.5)

        env_probe.warm_environment_probe_async()

        results: list[str] = []
        errors: list[BaseException] = []

        def caller():
            try:
                results.append(env_probe.get_environment_probe_line())
            except BaseException as exc:  # noqa: BLE001
                errors.append(exc)

        threads = [_threading.Thread(target=caller, daemon=True) for _ in range(4)]
        start = time.monotonic()
        for t in threads:
            t.start()
        for t in threads:
            t.join(timeout=10)
        elapsed = time.monotonic() - start

        try:
            assert not errors
            assert all(not t.is_alive() for t in threads), "caller blocked on stuck probe"
            # All callers failed open with the empty line.
            assert results == ["", "", "", ""]
            # Bounded: nowhere near the 30s the probe is stuck for.
            assert elapsed < 8
        finally:
            release.set()

    def test_late_probe_result_published_after_recovery(self, monkeypatch):
        """If the stuck worker eventually finishes, later callers get the
        line — recovery without restart, matching the incident (killing
        the orphan un-wedged everything)."""
        import threading as _threading

        release = _threading.Event()

        def slow_probe():
            release.wait(timeout=30)
            return "Python toolchain: recovered."

        monkeypatch.setattr(env_probe, "_build_probe_line", slow_probe)
        monkeypatch.setattr(env_probe, "_PROBE_WAIT_TIMEOUT", 0.2)

        # First caller times out and fails open.
        assert env_probe.get_environment_probe_line() == ""

        # Worker un-wedges (the operator killed the orphan).
        release.set()
        assert env_probe._PROBE_DONE.wait(timeout=10)

        # Later callers see the published line.
        assert env_probe.get_environment_probe_line() == "Python toolchain: recovered."

    def test_repeat_callers_do_not_pay_full_wait_after_first_timeout(self, monkeypatch):
        """After one caller burns the full wait, subsequent callers only
        peek — a permanently stuck probe costs the timeout once, not
        per-session."""
        import threading as _threading
        import time

        release = _threading.Event()

        def stuck_probe():
            release.wait(timeout=30)
            return ""

        monkeypatch.setattr(env_probe, "_build_probe_line", stuck_probe)
        monkeypatch.setattr(env_probe, "_PROBE_WAIT_TIMEOUT", 0.5)

        try:
            assert env_probe.get_environment_probe_line() == ""  # pays 0.5s

            # Crank the timeout way up: if the peek short-circuit is broken,
            # the next call blocks ~30s; if it works, it returns in ~0.05s.
            monkeypatch.setattr(env_probe, "_PROBE_WAIT_TIMEOUT", 30.0)
            start = time.monotonic()
            assert env_probe.get_environment_probe_line() == ""
            assert time.monotonic() - start < 5  # peek, not a full wait
        finally:
            release.set()


class TestRunTimeoutIsBounded:
    """_run() itself must return within a bounded time even when the child
    spawns a descendant that inherits the capture pipes and outlives it —
    the exact Windows pip.exe launcher shape from #67964, reproduced
    cross-platform with a shell child."""

    def test_run_returns_promptly_despite_pipe_holding_descendant(self):
        import time

        # Child exits quickly; grandchild inherits stdout/stderr and sleeps
        # far beyond the timeout, keeping the pipe write-ends open.
        script = (
            "import subprocess, sys, time\n"
            "subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(20)'])\n"
            "time.sleep(20)\n"
        )
        start = time.monotonic()
        rc, out, err = env_probe._run([sys.executable, "-c", script], timeout=1.0)
        elapsed = time.monotonic() - start

        assert rc == -1
        assert err == "timeout"
        # stdlib subprocess.run on Windows would hang here for the full 20s
        # (unbounded post-kill communicate).  Our bound: timeout + reap slack.
        assert elapsed < 6


class TestRunBoundedByTimeout:
    """``_run`` must return as soon as the *direct* child exits, even when a
    descendant inherited the captured stdout/stderr handles and outlives it.

    This is the deadlock from #67964: on native Windows a ``pip.exe`` launcher
    can leave a grandchild holding the captured pipe open, and ``capture_output``
    reader threads then block far past the timeout while ``_CACHE_LOCK`` is held,
    wedging every new session. Capturing through temp files removes the reader
    threads, so a lingering grandchild can't block the parent's ``wait()``.

    Cross-platform repro: the direct child prints ``ok`` and exits immediately
    after spawning a long-sleeping grandchild that inherits its stdout. With the
    old pipe-based capture, ``_run`` blocks until the grandchild exits (or hits
    the 3s timeout and returns ``-1``); with temp-file capture it returns the
    child's real output within a few milliseconds.
    """

    def test_returns_before_inheriting_grandchild_exits(self):
        import time

        grandchild_sleep = 20  # far longer than _run's timeout
        # Direct child: emit "ok", spawn a detached grandchild that inherits
        # this process's stdout (no stdout= redirect), then exit right away.
        child_code = (
            "import subprocess, sys; "
            "subprocess.Popen([sys.executable, '-c', "
            f"'import time; time.sleep({grandchild_sleep})']); "
            "sys.stdout.write('ok'); sys.stdout.flush()"
        )

        start = time.monotonic()
        rc, out, err = env_probe._run([sys.executable, "-c", child_code], timeout=3.0)
        elapsed = time.monotonic() - start

        # Must not wait on the grandchild, and must not have hit the timeout.
        assert elapsed < 3.0, f"_run blocked on grandchild for {elapsed:.1f}s"
        assert rc == 0, f"expected clean exit, got rc={rc} err={err!r}"
        assert out == "ok"
