"""Stress: 1000 claim/release iterations across N concurrent clients.

Picks up where the Zig-side `core_test.zig::stress` leaves off:
that test pins the Core's claim/release machinery in-process; this
one pins the SAME promise AT THE WIRE, with N independent Clients
sharing the broker.

What we pin:

  * After 1000 iterations of claim → release across N clients on
    two shared URNs (`hw:/test/dev0`, `hw:/test/dev1`), the lease
    table is empty (G3 no ghost leases survives the churn).
  * No deadlock: every worker completes within the soft budget.
    A Core mutex held across an op would manifest here.
  * Every successful claim was honoured exclusively (no observer
    ever saw two leases against the same URN). Confirms the
    serialisation property under contention.
  * `ConflictError` is the ONLY expected failure mode. Any other
    exception (transport reset, typed broker error other than
    Conflict) fails the test.
"""

from __future__ import annotations

import time
from concurrent.futures import ThreadPoolExecutor, as_completed

import pytest

from mgf.hrb import ClaimMode, Client, ConflictError

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


_N_CLIENTS = 8
_ITERATIONS_PER_CLIENT = 125  # 8 * 125 = 1000 total claim attempts
_URNS = ("hw:/test/dev0", "hw:/test/dev1")


def test_claim_release_churn_ends_with_empty_lease_table(broker: Client, sock_path: str) -> None:
    """1000 iterations of claim → release across 8 clients on two
    shared URNs. End state: zero leases, broker still responsive."""

    def churn(worker_id: int) -> tuple[int, int]:
        c = Client.connect(sock_path)
        try:
            grants = 0
            conflicts = 0
            for i in range(_ITERATIONS_PER_CLIENT):
                # Alternate URN choice so contention is real (both
                # devices are hot) but every worker eventually wins
                # at least sometimes.
                urn = _URNS[(worker_id + i) % 2]
                try:
                    with c.claim(urn, ClaimMode.EXCLUSIVE):
                        grants += 1
                except ConflictError:
                    conflicts += 1
            return grants, conflicts
        finally:
            c.close()

    t0 = time.monotonic()
    results: list[tuple[int, int]] = []
    with ThreadPoolExecutor(max_workers=_N_CLIENTS) as ex:
        futs = [ex.submit(churn, i) for i in range(_N_CLIENTS)]
        # Soft deadline: 30s is generous. Real wall on a quiet laptop
        # is ~1-2s; tripping this means a deadlock or pathological
        # contention. Per-future timeout (not the executor's) so we
        # surface which worker hung.
        for f in as_completed(futs, timeout=30):
            results.append(f.result())
    elapsed = time.monotonic() - t0

    total_grants = sum(g for g, _ in results)
    total_conflicts = sum(c for _, c in results)
    total_attempts = total_grants + total_conflicts
    assert total_attempts == _N_CLIENTS * _ITERATIONS_PER_CLIENT, (
        f"lost attempts under churn — expected "
        f"{_N_CLIENTS * _ITERATIONS_PER_CLIENT}, got {total_attempts}"
    )

    # End state: the lease table is empty (G3). The fixture's client
    # is still alive and sees an empty world.
    assert broker.list_leases("hw:/**") == [], (
        f"ghost leases survived the churn (elapsed {elapsed:.2f}s): {broker.list_leases('hw:/**')}"
    )

    # Sanity print for nightly logs — helpful when the budget tightens.
    print(
        f"\n[claim-churn] {total_attempts} attempts in {elapsed:.2f}s "
        f"({total_attempts / elapsed:.0f}/s) — "
        f"{total_grants} granted, {total_conflicts} conflicts"
    )


def test_no_observer_ever_sees_two_leases_on_same_urn(broker: Client, sock_path: str) -> None:
    """While N workers churn claims, a separate observer thread
    polls list_leases. It must NEVER see two leases against the
    same URN (exclusive-mode serialisation). A broker-side race
    that double-granted would surface here as an assertion fail."""
    n_workers = 4
    n_iterations = 50  # smaller — observer poll rate matters more
    stop = [False]  # list-wrapped so closure updates are visible to threads

    def churn(worker_id: int) -> None:
        c = Client.connect(sock_path)
        try:
            for i in range(n_iterations):
                urn = _URNS[(worker_id + i) % 2]
                try:
                    with c.claim(urn, ClaimMode.EXCLUSIVE):
                        pass
                except ConflictError:
                    pass
        finally:
            c.close()

    def observe() -> list[str]:
        c = Client.connect(sock_path)
        errors: list[str] = []
        try:
            while not stop[0]:
                leases = c.list_leases("hw:/test/**")
                urns_held = [r.urn for r in leases]
                # The invariant: every URN appears at most once.
                if len(urns_held) != len(set(urns_held)):
                    errors.append(f"observer saw duplicate URN in lease list: {urns_held}")
        finally:
            c.close()
        return errors

    with ThreadPoolExecutor(max_workers=n_workers + 1) as ex:
        obs_fut = ex.submit(observe)
        worker_futs = [ex.submit(churn, i) for i in range(n_workers)]
        for f in as_completed(worker_futs, timeout=30):
            f.result()
        stop[0] = True
        observer_errors = obs_fut.result(timeout=5)

    assert observer_errors == [], (
        "exclusive-mode serialisation violated under churn:\n" + "\n".join(observer_errors)
    )

    # After all workers finished, the lease table is empty.
    assert broker.list_leases("hw:/**") == []
