"""The 5-session ceiling.

A sixth Control Room login silently evicts an existing session, and the symptom
lands on an unrelated tool call as a 401. These tests pin the guard.
"""

import os

import pytest

from a360_mcp.auth._paths import account_key
from a360_mcp.auth._session_budget import (
    MAX_CONCURRENT_SESSIONS,
    SessionBudget,
    SessionBudgetExceeded,
    SessionSlot,
)

KEY = account_key("https://cr.example.com", "svc_mcp")


@pytest.fixture
def budget() -> SessionBudget:
    return SessionBudget(KEY)


class TestClaim:
    def test_first_claim_succeeds(self, budget):
        slot = budget.claim("server-1")
        assert slot.owner == "server-1"
        assert slot.pid == os.getpid()

    def test_claims_up_to_the_limit(self, budget):
        for index in range(MAX_CONCURRENT_SESSIONS):
            budget.claim(f"server-{index}")
        assert len(budget.active()) == MAX_CONCURRENT_SESSIONS

    def test_rejects_one_past_the_limit(self, budget):
        for index in range(MAX_CONCURRENT_SESSIONS):
            budget.claim(f"server-{index}")
        with pytest.raises(SessionBudgetExceeded, match="already held"):
            budget.claim("one-too-many")

    def test_refusal_names_the_holders(self, budget):
        for index in range(MAX_CONCURRENT_SESSIONS):
            budget.claim(f"server-{index}")
        with pytest.raises(SessionBudgetExceeded) as excinfo:
            budget.claim("one-too-many")
        assert "server-0" in str(excinfo.value)

    def test_reclaiming_same_owner_renews_instead_of_consuming(self, budget):
        # A retrying caller must not be able to exhaust its own budget.
        for index in range(MAX_CONCURRENT_SESSIONS):
            budget.claim(f"server-{index}", now=1_000.0)
        renewed = budget.claim("server-0", now=1_500.0)
        assert renewed.renewed_at == 1_500.0
        assert len(budget.active(now=1_500.0)) == MAX_CONCURRENT_SESSIONS


class TestRelease:
    def test_release_frees_a_slot(self, budget):
        for index in range(MAX_CONCURRENT_SESSIONS):
            budget.claim(f"server-{index}")
        budget.release("server-2")
        assert len(budget.active()) == MAX_CONCURRENT_SESSIONS - 1
        budget.claim("newcomer")  # no raise

    def test_release_of_unheld_owner_is_a_noop(self, budget):
        budget.claim("server-1")
        budget.release("never-claimed")
        assert len(budget.active()) == 1


class TestStaleSlots:
    def test_slot_of_dead_process_is_not_counted(self, budget, isolate_state_dir):
        budget.claim("live-one")
        ledger = budget._path
        payload = {
            "slots": [
                SessionSlot(
                    owner="ghost", pid=999_999, created_at=1.0, renewed_at=1.0
                ).to_payload(),
                *[s.to_payload() for s in budget.active()],
            ]
        }
        from a360_mcp.utils.atomic_io import write_json_atomic

        write_json_atomic(ledger, payload)
        owners = [s.owner for s in budget.active()]
        assert "ghost" not in owners
        assert "live-one" in owners

    def test_slot_that_stopped_checking_in_is_stale(self):
        slot = SessionSlot(owner="idle", pid=os.getpid(), created_at=0.0, renewed_at=0.0)
        assert slot.is_stale(now=10_000.0) is True

    def test_recently_renewed_slot_is_not_stale(self):
        slot = SessionSlot(owner="busy", pid=os.getpid(), created_at=0.0, renewed_at=9_990.0)
        assert slot.is_stale(now=10_000.0) is False


class TestSummary:
    def test_summary_reports_usage(self, budget):
        budget.claim("server-1")
        assert budget.summary() == {
            "used": 1,
            "limit": MAX_CONCURRENT_SESSIONS,
            "owners": ["server-1"],
        }


class TestLedgerRobustness:
    def test_corrupt_ledger_is_treated_as_empty(self, budget):
        budget._path.parent.mkdir(parents=True, exist_ok=True)
        budget._path.write_text("{not json", encoding="utf-8")
        assert budget.active() == ()
        budget.claim("server-1")  # recovers rather than dying


class TestSlotPidBinding:
    """A slot's pid is how staleness is judged — it must name the right process.

    The debug window is launched detached and outlives the MCP server. A slot
    stamped with the server's pid is reaped the moment the server restarts,
    while the window's Control Room session is still live — the ledger then
    undercounts and admits a login that evicts a real session.
    """

    def test_claim_defaults_to_the_calling_process(self, budget):
        slot = budget.claim("owner-a")
        assert slot.pid == os.getpid()

    def test_claim_records_an_explicit_pid(self, budget):
        slot = budget.claim("owner-a", pid=424242)
        assert slot.pid == 424242

    def test_reclaim_rebinds_the_pid_without_consuming_a_slot(self, budget):
        budget.claim("owner-a")
        # A live pid other than our own, so the slot stays visible in active().
        other_live_pid = os.getppid()
        rebound = budget.claim("owner-a", pid=other_live_pid)
        assert rebound.pid == other_live_pid
        assert len(budget.active()) == 1

    def test_a_slot_bound_to_a_dead_pid_is_reaped(self, budget):
        # The flip side: once the window's process is gone, the slot frees.
        budget.claim("owner-a", pid=424242)
        assert budget.active() == ()

    def test_reclaim_without_a_pid_keeps_the_recorded_one(self, budget):
        budget.claim("owner-a", pid=os.getpid())
        renewed = budget.claim("owner-a")
        assert renewed.pid == os.getpid()
