"""Windows-only stdio lifecycle behaviors, against real subprocesses.

Each test pins a contract that exists only on Windows: Job-Object reaping of a
gracefully-exited server's children (the deliberate divergence from the POSIX
policy in test_posix.py), the SelectorEventLoop fallback wrapper, and the CRLF
line endings a native text-mode server emits. Synchronization is kernel-level
only (liveness sockets); see `_liveness`.

Per-test no-cover pragmas (as in tests/issues/test_552_windows_hang.py): bodies run
only on windows-latest CI legs, the per-job 100% gate would count them uncovered on
non-Windows runners, and strict-no-cover is skipped on Windows where they execute.
"""

import asyncio
import sys
from contextlib import AsyncExitStack
from pathlib import Path

import anyio
import anyio.abc
import pytest
from mcp_types import JSONRPCRequest, JSONRPCResponse

from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.os.win32.utilities import FallbackProcess
from mcp.shared.message import SessionMessage
from tests.transports.stdio._liveness import (
    accept_alive,
    assert_stream_closed,
    connect_back_script,
    open_liveness_listener,
)

pytestmark = [
    pytest.mark.anyio,
    pytest.mark.skipif(sys.platform != "win32", reason="Windows Job Object / event-loop semantics"),
]


@pytest.fixture(autouse=True)
def _module_runner_lease() -> None:
    """Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`."""


async def test_a_gracefully_exited_servers_child_is_reaped_when_the_job_handle_closes(  # pragma: no cover
    tmp_path: Path,
    spawned_processes: list[anyio.abc.Process | FallbackProcess],
    terminate_calls: list[anyio.abc.Process | FallbackProcess],
) -> None:
    """A gracefully-exited server's child is killed deterministically when shutdown closes the job handle.

    The server exits cleanly on stdin closure, leaving a child behind; shutdown's
    close of the server's Job Object handle (`close_process_job` +
    `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`) kills that child deterministically, not at
    GC time. Documented divergence from POSIX (docs/migration.md; the POSIX twin is
    test_posix.py::test_a_gracefully_exiting_servers_child_survives_the_client_shutdown).

    `terminate_calls == []` is the load-bearing distinction: the child died through
    the graceful path's job-handle close, not the escalation's `TerminateJobObject`;
    the two kills are indistinguishable on the socket.

    Both processes connect back and their stderr is captured via `errlog`, so a
    timeout failure can report which process never showed and the child's fate
    (xdist swallows subprocess stderr on CI).
    """
    async with AsyncExitStack() as stack:
        sock, port = await open_liveness_listener()
        stack.push_async_callback(sock.aclose)

        # The startup marker (and any child traceback, via stderr=sys.stderr below)
        # lands in errlog, splitting "never started" from "started but never connected".
        child = "import sys\nprint('child-started', file=sys.stderr, flush=True)\n" + connect_back_script(port)
        # The server spawns a child, connects back itself, then exits as soon as
        # its stdin closes: the graceful path, so the escalation never runs.
        # The child inherits Job membership: the SDK assigns the server to the Job
        # synchronously after spawn, long before the cold-starting interpreter can
        # Popen the child (membership is inherited at CreateProcess, never
        # acquired retroactively).
        #
        # The child's stdin must be DEVNULL: CPython startup queries fd 0, and
        # Windows serializes that query behind the server's pending blocking
        # `sys.stdin.read()` on the inherited pipe, so the child would freeze at
        # interpreter startup until the next inbound byte or EOF.
        #
        # After stdin EOF ends the server, it reports the child's `poll()` status:
        # `None` means alive at server exit; an exit/NTSTATUS code names the killer.
        server = (
            f"import socket, subprocess, sys\n"
            f"try:\n"
            f"    p = subprocess.Popen([sys.executable, '-c', {child!r}], "
            f"stdin=subprocess.DEVNULL, stderr=sys.stderr)\n"
            f"except BaseException as exc:\n"
            f"    print(exc, file=sys.stderr, flush=True)\n"
            f"    raise\n"
            f"s = socket.create_connection(('127.0.0.1', {port}))\n"
            f"s.sendall(b'alive')\n"
            f"sys.stdin.read()\n"
            f"print('child-rc:%s' % p.poll(), file=sys.stderr, flush=True)\n"
        )
        server_params = StdioServerParameters(command=sys.executable, args=["-c", server])

        with (tmp_path / "errlog.txt").open("w+", encoding="utf-8") as errlog:

            def server_stderr() -> str:
                errlog.seek(0)
                return errlog.read()

            streams: list[anyio.abc.SocketStream] = []
            spawn_started = anyio.current_time()
            entered_at: float | None = None
            try:
                # Two interpreter cold starts on a loaded runner; healthy runs
                # take well under a second.
                with anyio.fail_after(15.0):
                    async with stdio_client(server_params, errlog=errlog):
                        entered_at = anyio.current_time()
                        # The server and child race to connect; accept both,
                        # order-agnostic (accept_alive verifies each banner).
                        for _ in range(2):
                            stream = await accept_alive(sock)
                            stack.push_async_callback(stream.aclose)
                            streams.append(stream)
            except TimeoutError:
                # `stdio_client.__aexit__` has already completed its shielded shutdown,
                # so the stderr read carries the server's final `child-rc` line, not a
                # mid-flight snapshot.
                missing_leg = "the server never ran its connect line" if not streams else "the child never connected"
                spawn_split = (
                    "the context never entered"
                    if entered_at is None
                    else f"the context entered {entered_at - spawn_started:.1f}s after spawn began"
                )
                pytest.fail(
                    f"{len(streams)}/2 liveness connections arrived ({missing_leg}); "
                    f"{spawn_split}; server stderr: {server_stderr()!r}"
                )

            # Context exit closed the job handle: KILL_ON_JOB_CLOSE killed the
            # child and the server exited gracefully, so both sockets close.
            # The `spawned_processes` strong reference is load-bearing: `_process_jobs`
            # is weak-keyed, so without it a GC between context exit and this assert
            # could close the job handle itself and mask a regression in the
            # deterministic close.
            try:
                for stream in streams:
                    await assert_stream_closed(stream)
            except TimeoutError:
                pytest.fail(f"a socket stayed open after shutdown; server stderr: {server_stderr()!r}")

            leader = spawned_processes[0]
            # The graceful path: the server exited on stdin closure with code 0,
            # and the tree-termination escalation was never invoked.
            assert leader.returncode == 0, server_stderr()
            assert terminate_calls == [], server_stderr()


# Overrides the suite-wide anyio_backend fixture for this test only: a selector
# event loop cannot run asyncio subprocesses, forcing stdio_client onto FallbackProcess.
@pytest.mark.parametrize("anyio_backend", [("asyncio", {"loop_factory": asyncio.SelectorEventLoop})])
async def test_a_selector_event_loop_session_uses_the_fallback_process_and_exits_cleanly(  # pragma: no cover
    spawned_processes: list[anyio.abc.Process | FallbackProcess],
    terminate_calls: list[anyio.abc.Process | FallbackProcess],
) -> None:
    """Under a `SelectorEventLoop`, `stdio_client` falls back to `FallbackProcess` and still exits cleanly.

    A selector event loop has no asyncio subprocess support, so `stdio_client`
    falls back to the Popen-based `FallbackProcess` wrapper; a well-behaved server
    still completes the full clean lifecycle: spawn, liveness, exit on stdin
    closure, reaped, never escalated against.

    The `isinstance` check is the engagement proof: if a future anyio gains selector
    subprocess support, the spawn would silently return a normal Process. A hang here
    most likely means the known fallback hazard documented in `stdio_client`'s
    shutdown comment (reader thread parked in a synchronous `ReadFile`), which is
    why this test pins only the clean-exit path, never a kill path.
    """
    async with AsyncExitStack() as stack:
        sock, port = await open_liveness_listener()
        stack.push_async_callback(sock.aclose)

        # Connect back for liveness, then exit as soon as stdin closes: the
        # well-behaved server, so shutdown's first step suffices.
        server = (
            f"import socket, sys\n"
            f"s = socket.create_connection(('127.0.0.1', {port}))\n"
            f"s.sendall(b'alive')\n"
            f"sys.stdin.read()\n"
        )
        server_params = StdioServerParameters(command=sys.executable, args=["-c", server])

        # One interpreter cold start on a loaded runner; healthy runs take ~0.3s.
        with anyio.fail_after(10.0):
            async with stdio_client(server_params):
                stream = await accept_alive(sock)
                stack.push_async_callback(stream.aclose)
                # The engagement proof, asserted while the session is live.
                assert isinstance(spawned_processes[0], FallbackProcess)

        # The server exited on stdin closure: socket closed, exit code 0, and the
        # escalation never fired.
        await assert_stream_closed(stream)
        assert spawned_processes[0].returncode == 0
        assert terminate_calls == []


async def test_a_native_server_emitting_crlf_line_endings_round_trips_messages() -> None:  # pragma: no cover
    """The client round-trips messages from a text-mode Windows server that frames its output with \\r\\n.

    `TextIOWrapper`'s `newline=None` translates "\\n" to `os.linesep`, so such a
    server emits \\r\\n; the client still parses each line because the reader
    splits on "\\n" only and the JSON parser tolerates the trailing "\\r" as
    whitespace. The SDK's own server writes through such a wrapper, so this
    tolerance is load-bearing for Windows interop.

    tests/issues/test_552_windows_hang.py exercises the same wire form implicitly
    through `initialize()`; this test is the explicit owner of the framing claim.
    """
    # Read one request, answer it via print() (which emits \r\n on Windows), then
    # exit when stdin closes. json.loads/dumps keep the script free of SDK imports.
    server = (
        "import json, sys\n"
        "line = sys.stdin.readline()\n"
        "request = json.loads(line)\n"
        "print(json.dumps({'jsonrpc': '2.0', 'id': request['id'], 'result': {}}))\n"
        "sys.stdout.flush()\n"
        "sys.stdin.read()\n"
    )
    server_params = StdioServerParameters(command=sys.executable, args=["-c", server])

    ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")

    # One interpreter cold start on a loaded runner; healthy runs take ~0.3s.
    with anyio.fail_after(10.0):
        async with stdio_client(server_params) as (read_stream, write_stream):
            await write_stream.send(SessionMessage(ping))
            received = await read_stream.receive()
            # A reader that choked on the trailing \r would deliver a ValueError
            # here instead of a parsed message.
            assert isinstance(received, SessionMessage)
            assert received.message == JSONRPCResponse(jsonrpc="2.0", id=1, result={})
