"""Tests for rbsp.indexer.concurrent -- ReadWriteLock, ConcurrentIndex, IndexGeneration."""

import os
import sys
import threading
import time
import unittest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))

from rbsp.indexer.concurrent import ReadWriteLock, ConcurrentIndex, IndexGeneration


# ---------------------------------------------------------------------------
# ReadWriteLock tests
# ---------------------------------------------------------------------------

class TestReadWriteLockBasic(unittest.TestCase):
    """Basic properties and context manager semantics for ReadWriteLock."""

    def setUp(self):
        self.rw = ReadWriteLock()

    def test_initial_readers_zero(self):
        self.assertEqual(self.rw.readers, 0)

    def test_initial_writer_false(self):
        self.assertFalse(self.rw.writer)

    def test_read_locked_context_manager(self):
        with self.rw.read_locked():
            self.assertEqual(self.rw.readers, 1)
        self.assertEqual(self.rw.readers, 0)

    def test_write_locked_context_manager(self):
        with self.rw.write_locked():
            self.assertTrue(self.rw.writer)
        self.assertFalse(self.rw.writer)

    def test_multiple_readers_allowed(self):
        """Multiple threads can hold read locks simultaneously."""
        barrier = threading.Barrier(3, timeout=5)
        max_readers = []
        lock = threading.Lock()

        def reader():
            with self.rw.read_locked():
                barrier.wait()
                with lock:
                    max_readers.append(self.rw.readers)
                time.sleep(0.05)

        threads = [threading.Thread(target=reader) for _ in range(3)]
        for t in threads:
            t.start()
        for t in threads:
            t.join(timeout=5)

        # All three readers should have been active simultaneously
        self.assertTrue(any(r >= 2 for r in max_readers))

    def test_read_locked_releases_on_exception(self):
        try:
            with self.rw.read_locked():
                raise RuntimeError("boom")
        except RuntimeError:
            pass
        self.assertEqual(self.rw.readers, 0)

    def test_write_locked_releases_on_exception(self):
        try:
            with self.rw.write_locked():
                raise RuntimeError("boom")
        except RuntimeError:
            pass
        self.assertFalse(self.rw.writer)


class TestReadWriteLockExclusion(unittest.TestCase):
    """Write lock exclusion semantics."""

    def setUp(self):
        self.rw = ReadWriteLock()

    def test_writer_excludes_readers(self):
        """While a writer holds the lock, readers must wait."""
        writer_entered = threading.Event()
        writer_done = threading.Event()
        reader_entered = threading.Event()

        def writer_fn():
            with self.rw.write_locked():
                writer_entered.set()
                writer_done.wait(timeout=5)

        def reader_fn():
            writer_entered.wait(timeout=5)
            with self.rw.read_locked():
                reader_entered.set()

        wt = threading.Thread(target=writer_fn)
        rt = threading.Thread(target=reader_fn)
        wt.start()
        rt.start()

        writer_entered.wait(timeout=5)
        # Reader should NOT have entered yet while writer holds the lock
        time.sleep(0.05)
        self.assertFalse(reader_entered.is_set())

        writer_done.set()
        rt.join(timeout=5)
        self.assertTrue(reader_entered.is_set())
        wt.join(timeout=5)

    def test_writer_excludes_other_writers(self):
        """Only one writer at a time."""
        order = []
        lock = threading.Lock()

        def writer_fn(label):
            with self.rw.write_locked():
                with lock:
                    order.append(f"{label}_enter")
                time.sleep(0.05)
                with lock:
                    order.append(f"{label}_exit")

        t1 = threading.Thread(target=writer_fn, args=("w1",))
        t2 = threading.Thread(target=writer_fn, args=("w2",))
        t1.start()
        time.sleep(0.01)
        t2.start()
        t1.join(timeout=5)
        t2.join(timeout=5)

        # Writers should be serialized: one enters and exits before the other enters
        self.assertEqual(len(order), 4)
        # The first writer should exit before the second enters
        first_exit = order.index(f"{order[0][:2]}_exit")
        second_enter_label = order[first_exit + 1] if first_exit + 1 < len(order) else None
        self.assertIsNotNone(second_enter_label)
        self.assertTrue(second_enter_label.endswith("_enter"))


# ---------------------------------------------------------------------------
# ConcurrentIndex tests
# ---------------------------------------------------------------------------

class TestConcurrentIndex(unittest.TestCase):
    """Tests for ConcurrentIndex wrapper."""

    def test_read_returns_fn_result(self):
        ci = ConcurrentIndex({"a": 1, "b": 2})
        result = ci.read(lambda idx: idx["a"])
        self.assertEqual(result, 1)

    def test_write_mutates_underlying_index(self):
        data = {"count": 0}
        ci = ConcurrentIndex(data)
        ci.write(lambda idx: idx.__setitem__("count", 42))
        result = ci.read(lambda idx: idx["count"])
        self.assertEqual(result, 42)

    def test_write_returns_fn_result(self):
        ci = ConcurrentIndex([1, 2, 3])
        result = ci.write(lambda idx: idx.append(4) or len(idx))
        self.assertEqual(result, 4)

    def test_snapshot_is_deep_copy(self):
        original = {"items": [1, 2, 3]}
        ci = ConcurrentIndex(original)
        snap = ci.snapshot()

        # Snapshot should equal original
        self.assertEqual(snap, original)

        # Mutating the snapshot should not affect the original
        snap["items"].append(4)
        result = ci.read(lambda idx: len(idx["items"]))
        self.assertEqual(result, 3)

    def test_snapshot_independent_of_future_writes(self):
        ci = ConcurrentIndex({"val": 10})
        snap = ci.snapshot()
        ci.write(lambda idx: idx.__setitem__("val", 999))
        self.assertEqual(snap["val"], 10)

    def test_concurrent_reads_and_writes(self):
        """Multiple reads and writes interleave without data corruption."""
        ci = ConcurrentIndex({"counter": 0})
        errors = []

        def increment():
            try:
                for _ in range(100):
                    ci.write(lambda idx: idx.__setitem__(
                        "counter", idx["counter"] + 1
                    ))
            except Exception as e:
                errors.append(e)

        threads = [threading.Thread(target=increment) for _ in range(4)]
        for t in threads:
            t.start()
        for t in threads:
            t.join(timeout=10)

        self.assertEqual(errors, [])
        result = ci.read(lambda idx: idx["counter"])
        self.assertEqual(result, 400)


# ---------------------------------------------------------------------------
# IndexGeneration tests
# ---------------------------------------------------------------------------

class TestIndexGeneration(unittest.TestCase):
    """Tests for generation-based concurrency control."""

    def setUp(self):
        self.gen = IndexGeneration()

    def test_initial_generation_is_zero(self):
        self.assertEqual(self.gen.current_generation(), 0)

    def test_commit_increments_generation(self):
        self.gen.commit()
        self.assertEqual(self.gen.current_generation(), 1)
        self.gen.commit()
        self.assertEqual(self.gen.current_generation(), 2)

    def test_acquire_returns_current_visible_generation(self):
        g, snap = self.gen.acquire_searcher()
        self.assertEqual(g, 0)
        self.assertIsNone(snap)
        self.gen.release_searcher(g)

    def test_commit_without_refresh_not_visible(self):
        """After commit but before refresh, searchers see the old generation."""
        self.gen.commit()
        g, snap = self.gen.acquire_searcher()
        self.assertEqual(g, 0)  # still sees gen 0
        self.gen.release_searcher(g)

    def test_refresh_makes_commit_visible(self):
        self.gen.commit()
        self.gen.refresh()
        g, snap = self.gen.acquire_searcher()
        self.assertEqual(g, 1)
        self.gen.release_searcher(g)

    def test_multiple_commits_single_refresh(self):
        self.gen.commit()
        self.gen.commit()
        self.gen.commit()
        self.gen.refresh()
        g, snap = self.gen.acquire_searcher()
        self.assertEqual(g, 3)
        self.gen.release_searcher(g)

    def test_release_without_acquire_raises(self):
        with self.assertRaises(ValueError):
            self.gen.release_searcher(999)

    def test_double_release_raises(self):
        g, _ = self.gen.acquire_searcher()
        self.gen.release_searcher(g)
        with self.assertRaises(ValueError):
            self.gen.release_searcher(g)

    def test_multiple_searchers_same_generation(self):
        g1, _ = self.gen.acquire_searcher()
        g2, _ = self.gen.acquire_searcher()
        self.assertEqual(g1, g2)
        # Both should release without error
        self.gen.release_searcher(g1)
        self.gen.release_searcher(g2)

    def test_old_generation_cleaned_up_after_all_released(self):
        """Old generations are cleaned from snapshots once all searchers release."""
        g0, _ = self.gen.acquire_searcher()
        self.gen.commit()
        self.gen.refresh()
        g1, _ = self.gen.acquire_searcher()

        # Release the old generation searcher
        self.gen.release_searcher(g0)
        # Release the new generation searcher
        self.gen.release_searcher(g1)

        # The internal snapshots for gen 0 should be cleaned up
        # (gen 0 < visible gen 1, and ref count dropped to 0)
        self.assertNotIn(0, self.gen._ref_counts)

    def test_current_generation_reflects_all_commits(self):
        for i in range(5):
            self.gen.commit()
        self.assertEqual(self.gen.current_generation(), 5)


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