"""Concurrency + time-advance coverage for the reliability primitives.

Closes the T-001 gap from the 2026-05-29 architectural review: the
TokenBucket (refill-over-time), InMemoryIdempotencyStore (TTL +
race-free check-and-set), KeyringVault index (race-free maintenance —
F-001), and InProcessScheduler (callback actually fires, once per
minute) were previously unit-tested single-threaded only, with no
injected clock — so their *defining* behaviour was unverified.

These tests inject a deterministic clock where the primitive exposes a
seam (TokenBucket / idempotency ``time_source``; scheduler ``tick(now=)``)
and use real threads + a barrier where the property is concurrency
(idempotency, keyring index).
"""

from __future__ import annotations

import sys
import threading
import time
from datetime import UTC, datetime

import pytest

from mgf.common.idempotency import InMemoryIdempotencyStore
from mgf.common.rate_limit import TokenBucket
from mgf.common.schedule import InProcessScheduler
from mgf.common.vault._keyring_vault import KeyringVault

# ---------------------------------------------------------------------------
# Deterministic clock helper
# ---------------------------------------------------------------------------


class _Clock:
    """A manually-advanced monotonic clock."""

    def __init__(self, start: float = 1000.0) -> None:
        self._t = start

    def __call__(self) -> float:
        return self._t

    def advance(self, seconds: float) -> None:
        self._t += seconds


# ---------------------------------------------------------------------------
# TokenBucket — refill over (injected) time
# ---------------------------------------------------------------------------


class TestTokenBucketRefillOverTime:
    def test_burst_then_empty_then_refill(self) -> None:
        clock = _Clock()
        b = TokenBucket(capacity=3, refill_per_second=1.0, time_source=clock)

        # Starts full: capacity consecutive consumes succeed.
        assert b.try_consume("k") == (True, 0.0)
        assert b.try_consume("k") == (True, 0.0)
        assert b.try_consume("k") == (True, 0.0)

        # Now empty — denied, with a retry_after of ~1s (1 token / 1 per s).
        allowed, retry_after = b.try_consume("k")
        assert allowed is False
        assert retry_after == pytest.approx(1.0, abs=1e-9)

        # No real time passed → still denied.
        assert b.try_consume("k")[0] is False

        # Advance 1s → exactly one token refilled → one consume succeeds.
        clock.advance(1.0)
        assert b.try_consume("k") == (True, 0.0)
        assert b.try_consume("k")[0] is False

    def test_refill_never_exceeds_capacity(self) -> None:
        clock = _Clock()
        b = TokenBucket(capacity=2, refill_per_second=5.0, time_source=clock)
        # Drain.
        assert b.try_consume("k")[0] is True
        assert b.try_consume("k")[0] is True
        assert b.try_consume("k")[0] is False
        # Idle a long time — refill must CAP at capacity (2), not 5*100.
        clock.advance(100.0)
        assert b.try_consume("k")[0] is True
        assert b.try_consume("k")[0] is True
        assert b.try_consume("k")[0] is False  # only 2, not 500


# ---------------------------------------------------------------------------
# InMemoryIdempotencyStore — TTL (injected clock) + concurrent check-and-set
# ---------------------------------------------------------------------------


class TestIdempotencyTtl:
    def test_record_evicted_after_ttl(self) -> None:
        clock = _Clock()
        store = InMemoryIdempotencyStore(ttl_seconds=10.0, time_source=clock)
        store.put("k", body_hash="h", status_code=200, body={"ok": True})

        # Within TTL → returned.
        assert store.get("k", expected_body_hash="h") is not None
        clock.advance(9.9)
        assert store.get("k", expected_body_hash="h") is not None

        # Past TTL → evicted + None.
        clock.advance(0.2)
        assert store.get("k", expected_body_hash="h") is None
        # Second get confirms eviction (not just a transient miss).
        assert store.get("k", expected_body_hash="h") is None


class TestIdempotencyConcurrency:
    @pytest.mark.concurrency
    def test_concurrent_put_get_is_consistent(self) -> None:
        store = InMemoryIdempotencyStore(ttl_seconds=3600.0)
        n = 32
        barrier = threading.Barrier(n)
        errors: list[BaseException] = []

        def worker(i: int) -> None:
            key = f"key-{i}"
            barrier.wait()  # maximise contention
            try:
                store.put(key, body_hash=f"h{i}", status_code=200, body=i)
                rec = store.get(key, expected_body_hash=f"h{i}")
                assert rec is not None
                assert rec.body == i
            except BaseException as e:
                errors.append(e)

        threads = [threading.Thread(target=worker, args=(i,)) for i in range(n)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert errors == []
        # Every distinct key survived the concurrent storm.
        for i in range(n):
            assert store.get(f"key-{i}", expected_body_hash=f"h{i}") is not None


# ---------------------------------------------------------------------------
# KeyringVault index — race-free maintenance (F-001)
# ---------------------------------------------------------------------------


class _SlowFakeKeyring:
    """In-memory keyring whose READ side yields, widening the index
    read-modify-write window so an UNLOCKED implementation reliably
    loses entries. With the F-001 lock, all entries survive."""

    class _Errors:
        class PasswordDeleteError(Exception):
            pass

    errors = _Errors

    def __init__(self) -> None:
        self.store: dict[tuple[str, str], str] = {}

    def get_password(self, service: str, username: str) -> str | None:
        # Yield the GIL mid-read-modify-write to widen the race window.
        time.sleep(0.0005)
        return self.store.get((service, username))

    def set_password(self, service: str, username: str, password: str) -> None:
        self.store[(service, username)] = password

    def delete_password(self, service: str, username: str) -> None:
        if (service, username) not in self.store:
            raise self._Errors.PasswordDeleteError("missing")
        del self.store[(service, username)]


@pytest.fixture
def slow_keyring(monkeypatch: pytest.MonkeyPatch) -> _SlowFakeKeyring:
    fake = _SlowFakeKeyring()
    monkeypatch.setitem(sys.modules, "keyring", fake)
    monkeypatch.setitem(sys.modules, "keyring.errors", _SlowFakeKeyring._Errors)
    return fake


class TestKeyringIndexConcurrency:
    @pytest.mark.concurrency
    def test_concurrent_set_keeps_every_key_in_index(
        self,
        slow_keyring: _SlowFakeKeyring,
    ) -> None:
        v = KeyringVault(service="t001")
        n = 16
        barrier = threading.Barrier(n)

        def worker(i: int) -> None:
            barrier.wait()
            v.set(f"key-{i}", f"val-{i}")

        threads = [threading.Thread(target=worker, args=(i,)) for i in range(n)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        # The index must contain ALL keys — none lost to the
        # read-modify-write race the F-001 lock serialises.
        listed = sorted(v.list_keys())
        assert listed == sorted(f"key-{i}" for i in range(n)), (
            f"index lost entries under concurrent set(): {listed}"
        )


# ---------------------------------------------------------------------------
# InProcessScheduler — callback actually fires, once per minute
# ---------------------------------------------------------------------------


class TestSchedulerFiring:
    def test_matching_task_callback_actually_runs(self) -> None:
        sched = InProcessScheduler()
        calls: list[int] = []
        sched.schedule("* * * * *", fn=lambda: calls.append(1), name="every-min")

        fired = sched.tick(now=datetime(2026, 5, 29, 8, 30, tzinfo=UTC))
        assert [t.name for t in fired] == ["every-min"]
        assert calls == [1], "the scheduled callback must actually execute"

    def test_does_not_fire_twice_in_same_minute(self) -> None:
        sched = InProcessScheduler()
        calls: list[int] = []
        sched.schedule("* * * * *", fn=lambda: calls.append(1), name="every-min")

        moment = datetime(2026, 5, 29, 8, 30, 15, tzinfo=UTC)
        sched.tick(now=moment)
        # A second sub-minute tick must NOT re-fire.
        sched.tick(now=moment.replace(second=45))
        assert calls == [1]

        # Next minute → fires again.
        sched.tick(now=moment.replace(minute=31, second=2))
        assert calls == [1, 1]

    def test_callback_exception_does_not_kill_the_loop(self) -> None:
        sched = InProcessScheduler()
        survived: list[int] = []

        def boom() -> None:
            raise RuntimeError("task blew up")

        sched.schedule("* * * * *", fn=boom, name="bad")
        sched.schedule("* * * * *", fn=lambda: survived.append(1), name="good")

        # tick must not raise even though one task does; the other runs.
        sched.tick(now=datetime(2026, 5, 29, 8, 30, tzinfo=UTC))
        assert survived == [1]
