"""R-T1-9C (survivability §3.7, Tier-1 #9, S5) — per-connection
progress deadline (slowloris guard).

A connection that opens but never COMPLETES a request line (a partial
line then silence, or bytes trickling in) holds a connection slot doing
no work. The broker sets `SO_RCVTIMEO` so its recv loop wakes even with
no traffic; a request line that hasn't completed within the deadline of
its first byte is dropped with a typed `Timeout`. A fully idle
connection (nothing buffered) is NOT penalised — a legitimate
long-lived client.

The production deadline is 10 s; this suite spawns the broker with
`MGF_HRB_SLOWLORIS_MS` set short so the drop is observable without a long
wait (and to pin that the env override works).
"""

from __future__ import annotations

import json
import os
import socket
import subprocess
import time
from pathlib import Path
from typing import TYPE_CHECKING

import pytest

from tests import _broker

if TYPE_CHECKING:
    from collections.abc import Iterator

pytestmark = pytest.mark.contract

_REPO = Path(__file__).resolve().parents[2]
_SERVER = _REPO / "hrb-code" / "zig-out" / "bin" / "mgf-hrb-server"
_SOCK = _broker.default_sock_path()  # per-xdist-worker socket
_SLOWLORIS_MS = 400


@pytest.fixture
def slowloris_broker() -> Iterator[None]:
    """Spawn the broker with a short slowloris deadline via the
    `MGF_HRB_SLOWLORIS_MS` env override. Own spawn (not the shared
    fixture) because that fixture doesn't pass env."""
    if Path(_SOCK).exists():
        Path(_SOCK).unlink()
    # Minimal env (PATH + the override) so the var is read deterministically
    # regardless of how large the inherited environment is.
    env = {
        "PATH": os.environ.get("PATH", "/usr/bin:/bin"),
        "MGF_HRB_SOCK": _SOCK,
        "MGF_HRB_SLOWLORIS_MS": str(_SLOWLORIS_MS),
    }
    proc = subprocess.Popen(  # noqa: S603
        [str(_SERVER)],
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        env=env,
    )
    # Connect-probe readiness (not file existence): a Unix socket appears at
    # bind() but refuses connects until listen(), so an existence check races
    # under contention (ECONNREFUSED). Shared helper.
    try:
        _broker.wait_for_socket(_SOCK, 5.0)
    except TimeoutError:
        proc.kill()
        proc.wait(timeout=2)
        pytest.fail("broker never started accepting connections")
    try:
        yield
    finally:
        proc.terminate()
        try:
            proc.wait(timeout=2)
        except subprocess.TimeoutExpired:
            proc.kill()
        if proc.stdout is not None:
            proc.stdout.close()
        if Path(_SOCK).exists():
            Path(_SOCK).unlink()


def _connect_and_hello(timeout: float = 2.0) -> socket.socket:
    s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    s.settimeout(timeout)
    s.connect(_SOCK)
    buf = b""
    while b"\n" not in buf:
        buf += s.recv(4096)
    hello = json.loads(buf.split(b"\n", 1)[0])
    assert hello.get("hello") == "mgf-hrb"
    return s


def test_partial_request_is_dropped_with_timeout(
    slowloris_broker: None,
) -> None:
    """A partial request line (no trailing newline) that never completes
    is dropped with Err.Timeout once the progress deadline elapses."""
    s = _connect_and_hello()
    try:
        # No trailing '\n' — this request never completes.
        s.send(b'{"op":"list","scope":"hw:/**"')
        s.settimeout(_SLOWLORIS_MS / 1000 + 3.0)
        buf = b""
        while b"\n" not in buf:
            chunk = s.recv(4096)
            if not chunk:
                break
            buf += chunk
        line = buf.split(b"\n", 1)[0]
        resp = json.loads(line) if line else {}
        assert resp.get("err") == "Timeout", (
            f"a stalled partial request must be dropped with Timeout; got {resp!r} (raw={buf!r})"
        )
    finally:
        s.close()


def test_idle_connection_survives_past_the_deadline(
    slowloris_broker: None,
) -> None:
    """An idle connection (nothing buffered) must NOT be killed by the
    guard — only mid-request stalls are slowloris. Wait well past the
    deadline, then prove the connection still serves a request."""
    s = _connect_and_hello()
    try:
        time.sleep(_SLOWLORIS_MS / 1000 * 3)  # idle, well past the deadline
        s.send(b'{"op":"list","scope":"hw:/**"}\n')
        s.settimeout(2.0)
        buf = b""
        while b"\n" not in buf:
            chunk = s.recv(4096)
            if not chunk:
                break
            buf += chunk
        resp = json.loads(buf.split(b"\n", 1)[0])
        assert resp.get("ok") is True, (
            f"an idle-but-healthy connection must still serve a request "
            f"after the deadline window; got {resp!r}"
        )
    finally:
        s.close()
