"""Stress: the broker's DoS-hardening caps refuse abuse cleanly.

Regression coverage for the 2026-05-26 cluster B fixes
(`81cb5ae` — bound pending buffer + active clients +
CLOEXEC). Without these, a single malicious client could
either:

* stream bytes forever without `\\n` and OOM the broker (B1)
* open connections until the kernel refused another thread (B2)

This file pins both refusal paths: the broker must respond
with a typed error and survive both scenarios. The fixture's
`broker` Client must STILL be usable afterward — proof the
caps are enforced at the connection boundary, not by killing
the whole broker.
"""

from __future__ import annotations

import json
import socket
import time
from typing import TYPE_CHECKING

import pytest

if TYPE_CHECKING:
    # Only used in test-function signature annotations; runtime
    # client connections are created via the conftest `broker` fixture.
    from mgf.hrb import Client

pytestmark = [pytest.mark.stress, pytest.mark.timeout(60)]


def _raw_send_recv(sock_path: str, payload: bytes, recv_max: int = 4096) -> bytes:
    """Open a raw socket, write payload, read response, close."""
    s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    s.settimeout(5.0)
    s.connect(sock_path)
    # Drain the hello line first (handshake from server) — its
    # presence is the "broker is ready" signal.
    hello = b""
    while b"\n" not in hello:
        chunk = s.recv(1024)
        if not chunk:
            break
        hello += chunk
    s.sendall(payload)
    out = b""
    deadline = time.monotonic() + 5.0
    while time.monotonic() < deadline:
        try:
            chunk = s.recv(recv_max)
        except TimeoutError:
            break
        if not chunk:
            break
        out += chunk
        if b"\n" in out:
            break
    try:
        s.close()
    except OSError:
        pass
    return out


def test_pending_buffer_cap_rejects_oversized_request(broker: Client, sock_path: str) -> None:
    """B1: send ~80 KiB without a newline → broker writes a typed
    BadRequest and closes the connection. The 64 KiB cap fires
    *before* the broker tries to parse the (would-be) JSON.

    Critically the broker MUST survive — the fixture's `broker`
    Client (a separate connection) keeps working."""
    # 80 KiB of valid JSON-prefix bytes, no `\n` until well past
    # the 64 KiB MAX_PENDING_BYTES cap.
    payload = b"a" * (80 * 1024)
    resp = _raw_send_recv(sock_path, payload)
    assert b"BadRequest" in resp, f"expected BadRequest, got: {resp!r}"
    parsed = json.loads(resp.decode().splitlines()[-1])
    assert parsed["ok"] is False
    assert parsed["err"] == "BadRequest"
    assert "64 KiB" in parsed.get("detail", "")

    # Broker survived — fixture client still usable.
    urns = broker.list("hw:/test/**")
    assert len(urns) > 0


def test_active_clients_cap_returns_too_busy(broker: Client, sock_path: str) -> None:
    """B2: opening MAX_ACTIVE_CLIENTS+5 connections — the last 5
    get a typed TooBusy response and clean close.

    The cap is 128; we open 135 to push past with margin. The
    fixture's `broker` Client counts toward the cap, so the
    surplus is 134 here (134 + 1 fixture = 135 attempts; cap is
    128, so ~7 should be rejected). We assert *at least one* is
    rejected — exact count depends on how fast accepted clients
    are recycled."""

    # Open connections serially with a brief inter-connect pause
    # so the kernel's listen backlog (=8 in server.zig) can drain
    # via accept() between attempts — the B2 cap fires after
    # accept, so we need accept to run for the cap to count us.
    # Each accepted socket stays open in held_sockets to keep its
    # client thread alive (active_clients counter held high).
    held_sockets: list[socket.socket] = []
    too_busy_count = 0
    accepted_count = 0

    def open_and_classify() -> tuple[bool, bytes, socket.socket | None]:
        s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        s.settimeout(3.0)
        try:
            s.connect(sock_path)
        except OSError:
            return (False, b"connect-failed", None)
        first = b""
        deadline = time.monotonic() + 2.0
        while time.monotonic() < deadline and b"\n" not in first:
            try:
                chunk = s.recv(512)
            except TimeoutError:
                break
            if not chunk:
                break
            first += chunk
        if b"TooBusy" in first:
            s.close()
            return (False, first, None)
        return (True, first, s)

    try:
        # 135 attempts > 128 cap + 1 fixture = 8+ should be rejected.
        # Serialised so the kernel backlog (8) doesn't reject us first.
        # Accepted sockets are HELD so the server's handleClient thread
        # stays in its read loop and the active_clients counter stays
        # bumped — closing immediately would defeat the cap test.
        for _ in range(135):
            accepted, payload, held = open_and_classify()
            if accepted and held is not None:
                accepted_count += 1
                held_sockets.append(held)
            elif b"TooBusy" in payload:
                too_busy_count += 1
            # Tiny pause so kernel backlog drains between bursts.
            time.sleep(0.001)

        # The B2 cap fired at least once. accepted_count + too_busy_count
        # may sum less than 135 if some connects timed out without
        # a clean classification — that's fine; we only need proof
        # the cap MECHANISM works.
        assert too_busy_count >= 1, (
            f"expected at least one TooBusy refusal; "
            f"got {too_busy_count} (accepted={accepted_count})"
        )
        # And the broker is still alive — the cap rejected new
        # connections rather than killing the broker.
        urns = broker.list("hw:/test/**")
        assert len(urns) > 0
    finally:
        for s in held_sockets:
            try:
                s.close()
            except OSError:
                pass
