"""Tests for Issue #3787: Probe-worker pool tail-latency.

Focus on:
- Per-home worker pool size N=2
- Non-blocking acquire returns an idle worker when available
- Returns None immediately when all workers are locked (no blocking wait)
- Scoped invalidation only flushes the active home's workers
- Full invalidation (no provider_id) flushes all workers
- Cleanup iterates nested worker lists correctly
- Lock held across selector handoff prevents TOCTOU races
"""

import threading
import time
import unittest
from pathlib import Path
from unittest import mock

from api.providers import (
    _ACCOUNT_USAGE_WORKERS_PER_HOME,
    _account_usage_worker_pool,
    _account_usage_worker_pool_lock,
    _cleanup_account_usage_probe_workers,
    _close_account_usage_probe_workers,
    _get_account_usage_probe_worker,
    invalidate_account_usage_status_cache,
)


class TestProbeWorkerPoolPerHome(unittest.TestCase):
    """Test per-home worker pool with N=2 configuration."""

    def setUp(self):
        """Clear the pool before each test."""
        with _account_usage_worker_pool_lock:
            _account_usage_worker_pool.clear()

    def tearDown(self):
        """Clean up after each test."""
        with _account_usage_worker_pool_lock:
            _account_usage_worker_pool.clear()

    def test_pool_creates_two_workers_per_home_key(self):
        """Pool should create exactly N=2 workers for each home key."""
        home = Path.home() / ".hermes"
        worker = _get_account_usage_probe_worker(home)
        self.assertIsNotNone(worker)
        worker._lock.release()

        with _account_usage_worker_pool_lock:
            key = str(Path(home))
            workers_list = _account_usage_worker_pool.get(key)
            self.assertIsNotNone(workers_list)
            self.assertEqual(len(workers_list), _ACCOUNT_USAGE_WORKERS_PER_HOME)
            self.assertEqual(_ACCOUNT_USAGE_WORKERS_PER_HOME, 2)

    def test_nonblocking_acquire_returns_idle_worker(self):
        """Non-blocking acquire should return an idle worker when one is free."""
        home = Path("/tmp/test_nonblocking_idle")

        # Populate the pool
        worker_initial = _get_account_usage_probe_worker(home)
        self.assertIsNotNone(worker_initial)
        worker_initial._lock.release()

        with _account_usage_worker_pool_lock:
            key = str(Path(home))
            workers = _account_usage_worker_pool[key]
            self.assertEqual(len(workers), 2)

        # Hold workers[0] locked from a background thread to simulate actual use
        lock_holder = threading.Event()
        release_signal = threading.Event()

        def hold_lock():
            workers[0]._lock.acquire()
            lock_holder.set()
            release_signal.wait(timeout=2.0)
            workers[0]._lock.release()

        thread = threading.Thread(target=hold_lock, daemon=True)
        thread.start()
        lock_holder.wait(timeout=2.0)

        try:
            worker = _get_account_usage_probe_worker(home)
            self.assertIsNotNone(worker)
            self.assertIs(worker, workers[1])
            worker._lock.release()
        finally:
            release_signal.set()
            thread.join(timeout=1.0)

    def test_returns_none_when_all_workers_locked(self):
        """Should return None immediately when all workers are locked from other threads."""
        home = Path("/tmp/test_immediate_none")

        # Populate the pool
        worker_initial = _get_account_usage_probe_worker(home)
        self.assertIsNotNone(worker_initial)
        worker_initial._lock.release()

        with _account_usage_worker_pool_lock:
            key = str(Path(home))
            workers = _account_usage_worker_pool[key]

        # Hold both workers locked from a background thread
        lock_holder = threading.Event()
        release_signal = threading.Event()

        def hold_locks():
            workers[0]._lock.acquire()
            workers[1]._lock.acquire()
            lock_holder.set()
            release_signal.wait(timeout=2.0)
            workers[1]._lock.release()
            workers[0]._lock.release()

        thread = threading.Thread(target=hold_locks, daemon=True)
        thread.start()
        lock_holder.wait(timeout=2.0)

        try:
            start = time.time()
            result = _get_account_usage_probe_worker(home)
            elapsed = time.time() - start

            self.assertIsNone(result)
            # Should return immediately, not block
            self.assertLess(elapsed, 0.1)
        finally:
            release_signal.set()
            thread.join(timeout=1.0)

    def test_scoped_invalidation_only_flushes_active_home(self):
        """Scoped invalidation with provider_id should only flush active home."""
        home1 = Path.home() / ".hermes"
        home2 = Path("/tmp/other_home")

        worker1 = _get_account_usage_probe_worker(home1)
        worker2 = _get_account_usage_probe_worker(home2)

        self.assertIsNotNone(worker1)
        self.assertIsNotNone(worker2)
        worker1._lock.release()
        worker2._lock.release()

        with _account_usage_worker_pool_lock:
            initial_count = len(_account_usage_worker_pool)
            self.assertEqual(initial_count, 2)

        with mock.patch("api.providers._get_hermes_home", return_value=home1):
            invalidate_account_usage_status_cache(provider_id="anthropic")
            time.sleep(0.2)

        with _account_usage_worker_pool_lock:
            remaining_keys = list(_account_usage_worker_pool.keys())
            self.assertNotIn(str(Path(home1)), remaining_keys)
            self.assertIn(str(Path(home2)), remaining_keys)

    def test_full_invalidation_flushes_all_workers(self):
        """Full invalidation (no provider_id) should flush all workers."""
        home1 = Path.home() / ".hermes"
        home2 = Path("/tmp/other_home")

        worker1 = _get_account_usage_probe_worker(home1)
        worker2 = _get_account_usage_probe_worker(home2)

        self.assertIsNotNone(worker1)
        self.assertIsNotNone(worker2)
        worker1._lock.release()
        worker2._lock.release()

        with _account_usage_worker_pool_lock:
            self.assertEqual(len(_account_usage_worker_pool), 2)

        invalidate_account_usage_status_cache(provider_id=None)
        time.sleep(0.2)

        with _account_usage_worker_pool_lock:
            self.assertEqual(len(_account_usage_worker_pool), 0)

    def test_cleanup_iterates_nested_worker_lists(self):
        """Cleanup should properly iterate over nested worker lists."""
        home = Path.home() / ".hermes"

        worker = _get_account_usage_probe_worker(home)
        self.assertIsNotNone(worker)
        worker._lock.release()

        with _account_usage_worker_pool_lock:
            key = str(Path(home))
            workers_list = _account_usage_worker_pool.get(key)
            self.assertEqual(len(workers_list), 2)

        now = time.monotonic() + 100000
        _cleanup_account_usage_probe_workers(now=now, idle_seconds=1.0)

        with _account_usage_worker_pool_lock:
            if _account_usage_worker_pool.get(str(Path(home))):
                remaining = _account_usage_worker_pool.get(str(Path(home)), [])
                self.assertEqual(len(remaining), 0)
            else:
                self.assertNotIn(str(Path(home)), _account_usage_worker_pool)

    def test_partial_cleanup_replenishes_pool(self):
        """When cleanup removes one stale worker but the other is busy, pool replenishes to N=2."""
        home = Path("/tmp/test_replenish")

        worker = _get_account_usage_probe_worker(home)
        self.assertIsNotNone(worker)
        worker._lock.release()

        with _account_usage_worker_pool_lock:
            key = str(Path(home))
            workers = _account_usage_worker_pool[key]
            self.assertEqual(len(workers), 2)

        # Hold workers[1] locked from a background thread (simulating active use)
        lock_holder = threading.Event()
        release_signal = threading.Event()

        def hold_lock():
            workers[1]._lock.acquire()
            lock_holder.set()
            release_signal.wait(timeout=5.0)
            workers[1]._lock.release()

        thread = threading.Thread(target=hold_lock, daemon=True)
        thread.start()
        lock_holder.wait(timeout=2.0)

        try:
            # Run cleanup far in the future; workers[0] is idle and stale,
            # workers[1] is locked so it survives
            now = time.monotonic() + 100000
            _cleanup_account_usage_probe_workers(now=now, idle_seconds=1.0)

            with _account_usage_worker_pool_lock:
                remaining = _account_usage_worker_pool.get(key, [])
                # Pool should be replenished back to 2
                self.assertEqual(len(remaining), _ACCOUNT_USAGE_WORKERS_PER_HOME)
                # The original busy worker should still be present
                self.assertIn(workers[1], remaining)
        finally:
            release_signal.set()
            thread.join(timeout=1.0)

    def test_synchronous_close_flattens_nested_lists(self):
        """Synchronous close should flatten nested lists correctly."""
        home = Path.home() / ".hermes"

        worker = _get_account_usage_probe_worker(home)
        self.assertIsNotNone(worker)
        worker._lock.release()

        with _account_usage_worker_pool_lock:
            key = str(Path(home))
            workers_list = _account_usage_worker_pool.get(key)
            self.assertEqual(len(workers_list), 2)

        _close_account_usage_probe_workers()

        with _account_usage_worker_pool_lock:
            self.assertEqual(len(_account_usage_worker_pool), 0)

    def test_concurrent_selector_no_double_claim(self):
        """Two threads holding returned workers simultaneously never share the same instance."""
        home = Path("/tmp/test_concurrent")

        # Pre-populate pool
        initial = _get_account_usage_probe_worker(home)
        self.assertIsNotNone(initial)
        initial._lock.release()

        with _account_usage_worker_pool_lock:
            workers = _account_usage_worker_pool[str(Path(home))]

        # Lock workers[0] from a background thread so only workers[1] is free
        lock_holder = threading.Event()
        release_signal = threading.Event()

        def hold_first():
            workers[0]._lock.acquire()
            lock_holder.set()
            release_signal.wait(timeout=5.0)
            workers[0]._lock.release()

        holder_thread = threading.Thread(target=hold_first, daemon=True)
        holder_thread.start()
        lock_holder.wait(timeout=2.0)

        # Two threads race; each holds its worker until both have finished grabbing
        results = [None, None]
        barrier = threading.Barrier(2, timeout=2.0)
        both_done = threading.Barrier(2, timeout=2.0)

        def grab(idx):
            barrier.wait()
            w = _get_account_usage_probe_worker(home)
            results[idx] = w
            try:
                both_done.wait()
            except threading.BrokenBarrierError:
                pass
            if w is not None:
                w._lock.release()

        t0 = threading.Thread(target=grab, args=(0,), daemon=True)
        t1 = threading.Thread(target=grab, args=(1,), daemon=True)
        t0.start()
        t1.start()
        t0.join(timeout=3.0)
        t1.join(timeout=3.0)

        try:
            got = [r for r in results if r is not None]
            # At most one thread should have gotten a worker (workers[1]);
            # the other gets None because the lock is already held
            self.assertEqual(len(got), 1, "Expected exactly one winner, got %d" % len(got))
            self.assertIs(got[0], workers[1])
        finally:
            release_signal.set()
            holder_thread.join(timeout=1.0)

    def test_invalidation_cannot_pop_worker_between_lookup_and_claim(self):
        """Getter keeps the pool lock through worker claim, so invalidation cannot pop first."""
        home = Path("/tmp/test_claim_under_pool_lock")

        initial = _get_account_usage_probe_worker(home)
        self.assertIsNotNone(initial)
        initial._lock.release()

        with _account_usage_worker_pool_lock:
            workers = _account_usage_worker_pool[str(Path(home))]

        workers[1]._lock.acquire()
        self.addCleanup(workers[1]._lock.release)

        acquire_entered = threading.Event()
        allow_acquire = threading.Event()
        claimed = threading.Event()
        release_claim = threading.Event()
        invalidation_done = threading.Event()
        result: dict[str, object] = {}
        target = workers[0]
        original_lock = target._lock
        self_outer = self

        class GateLock:
            def __init__(self, inner):
                self._inner = inner

            def acquire(self, blocking=True, timeout=-1):
                if blocking is False:
                    self_outer.assertTrue(
                        _account_usage_worker_pool_lock.locked(),
                        "worker claim must happen while the pool lock is still held",
                    )
                    acquire_entered.set()
                    allow_acquire.wait(timeout=2.0)
                if timeout == -1:
                    return self._inner.acquire(blocking)
                return self._inner.acquire(blocking, timeout)

            def release(self):
                return self._inner.release()

            def __enter__(self):
                self._inner.acquire()
                return self

            def __exit__(self, exc_type, exc, tb):
                self._inner.release()
                return False

        target._lock = GateLock(original_lock)

        def run_getter():
            result["worker"] = _get_account_usage_probe_worker(home)
            claimed.set()
            release_claim.wait(timeout=2.0)
            if result["worker"] is not None:
                result["worker"]._lock.release()

        def run_invalidation():
            with mock.patch("api.providers._get_hermes_home", return_value=home):
                invalidate_account_usage_status_cache(provider_id="anthropic")
            invalidation_done.set()

        fetch_thread = threading.Thread(target=run_getter, daemon=True)
        invalidation_thread = threading.Thread(target=run_invalidation, daemon=True)
        fetch_thread.start()
        self.assertTrue(acquire_entered.wait(timeout=2.0))

        invalidation_thread.start()
        time.sleep(0.05)
        self.assertFalse(invalidation_done.is_set())

        allow_acquire.set()
        self.assertTrue(claimed.wait(timeout=2.0))
        self.assertIs(result["worker"], target)

        release_claim.set()
        fetch_thread.join(timeout=2.0)

        invalidation_thread.join(timeout=2.0)
        self.assertTrue(invalidation_done.is_set())
        with _account_usage_worker_pool_lock:
            self.assertNotIn(str(Path(home)), _account_usage_worker_pool)


class TestWorkerPoolConfiguration(unittest.TestCase):
    """Test that constants are properly configured."""

    def test_workers_per_home_is_two(self):
        """Should have exactly 2 workers per home."""
        self.assertEqual(_ACCOUNT_USAGE_WORKERS_PER_HOME, 2)


if __name__ == "__main__":
    unittest.main()
