"""Concurrent Reader/Writer Architecture for RBSP.

Provides synchronization primitives for concurrent index access,
enabling multiple readers during writes — a pattern used by modern
search engines like Lucene and Tantivy.

Three components:

- **ReadWriteLock**:    Readers-writer lock with writer preference.
- **ConcurrentIndex**:  Wraps any index object with read-write locking.
- **IndexGeneration**:  Generation-based concurrency (Lucene-style).

Uses only ``threading`` from the standard library.  Zero external deps.
"""

from __future__ import annotations

import copy
import threading
from contextlib import contextmanager
from typing import Any, Callable, Iterator, TypeVar

T = TypeVar("T")


# ---------------------------------------------------------------------------
# Readers-Writer Lock (writer-preference)
# ---------------------------------------------------------------------------

class ReadWriteLock:
    """Readers-writer lock with writer preference.

    Multiple threads can hold the read lock simultaneously, but a write
    lock is exclusive.  When a writer is waiting, new readers are blocked
    until the writer completes — this prevents writer starvation.

    Usage::

        rw = ReadWriteLock()
        with rw.read_locked():
            data = index.search(query)
        with rw.write_locked():
            index.add(doc)
    """

    def __init__(self) -> None:
        self._lock = threading.Lock()
        self._readers_ok = threading.Condition(self._lock)
        self._writers_ok = threading.Condition(self._lock)
        self._readers: int = 0
        self._writer: bool = False
        self._waiting_writers: int = 0

    # -- Properties ---------------------------------------------------------

    @property
    def readers(self) -> int:
        """Number of active readers."""
        with self._lock:
            return self._readers

    @property
    def writer(self) -> bool:
        """Whether a writer currently holds the lock."""
        with self._lock:
            return self._writer

    # -- Read lock ----------------------------------------------------------

    def acquire_read(self) -> None:
        """Acquire a shared read lock.

        Blocks if a writer holds the lock or a writer is waiting
        (writer-preference policy).
        """
        with self._readers_ok:
            while self._writer or self._waiting_writers > 0:
                self._readers_ok.wait()
            self._readers += 1

    def release_read(self) -> None:
        """Release a shared read lock."""
        with self._lock:
            self._readers -= 1
            if self._readers == 0:
                self._writers_ok.notify()

    # -- Write lock ---------------------------------------------------------

    def acquire_write(self) -> None:
        """Acquire an exclusive write lock.

        Blocks until all readers and any other writer release the lock.
        """
        with self._writers_ok:
            self._waiting_writers += 1
            while self._writer or self._readers > 0:
                self._writers_ok.wait()
            self._waiting_writers -= 1
            self._writer = True

    def release_write(self) -> None:
        """Release the exclusive write lock."""
        with self._lock:
            self._writer = False
            self._readers_ok.notify_all()
            self._writers_ok.notify()

    # -- Context managers ---------------------------------------------------

    @contextmanager
    def read_locked(self) -> Iterator[None]:
        """Context manager for the read lock."""
        self.acquire_read()
        try:
            yield
        finally:
            self.release_read()

    @contextmanager
    def write_locked(self) -> Iterator[None]:
        """Context manager for the write lock."""
        self.acquire_write()
        try:
            yield
        finally:
            self.release_write()


# ---------------------------------------------------------------------------
# Concurrent Index Wrapper
# ---------------------------------------------------------------------------

class ConcurrentIndex:
    """Wrap any index object with read-write locking.

    This is a thin concurrency adapter — it does **not** know about the
    underlying index's API.  Callers supply a callable that receives the
    raw index and performs operations under the appropriate lock.

    Usage::

        ci = ConcurrentIndex(my_inverted_index)
        results = ci.read(lambda idx: idx.search("hello"))
        ci.write(lambda idx: idx.add("doc1", tokens))
        snap = ci.snapshot()
    """

    def __init__(self, index: Any) -> None:
        self._index = index
        self._rw = ReadWriteLock()

    def read(self, fn: Callable[[Any], T]) -> T:
        """Execute *fn* under a shared read lock.

        Args:
            fn: Callable that receives the wrapped index.

        Returns:
            Whatever *fn* returns.
        """
        with self._rw.read_locked():
            return fn(self._index)

    def write(self, fn: Callable[[Any], T]) -> T:
        """Execute *fn* under an exclusive write lock.

        Args:
            fn: Callable that receives the wrapped index.

        Returns:
            Whatever *fn* returns.
        """
        with self._rw.write_locked():
            return fn(self._index)

    def snapshot(self) -> Any:
        """Create a point-in-time snapshot (deep copy under read lock).

        Returns:
            A deep copy of the wrapped index object.
        """
        with self._rw.read_locked():
            return copy.deepcopy(self._index)


# ---------------------------------------------------------------------------
# Generation-Based Concurrency (Lucene-style)
# ---------------------------------------------------------------------------

class IndexGeneration:
    """Generation-based concurrency control.

    Modeled after Lucene's ``SearcherManager``: each commit increments a
    generation counter.  Searchers are pinned to the generation that was
    current when they were acquired, preventing them from seeing
    partially-committed data.  A generation is only eligible for cleanup
    once all searchers referencing it have been released.

    Usage::

        gen = IndexGeneration()
        g, searcher = gen.acquire_searcher()
        try:
            results = searcher.search(query)
        finally:
            gen.release_searcher(g)

        # After indexing new documents:
        gen.commit()
        gen.refresh()
    """

    def __init__(self) -> None:
        self._lock = threading.Lock()
        self._generation: int = 0
        self._visible_generation: int = 0
        # generation -> reference count
        self._ref_counts: dict[int, int] = {0: 0}
        # generation -> snapshot data (None = current / not yet snapshotted)
        self._snapshots: dict[int, Any] = {0: None}

    def current_generation(self) -> int:
        """Return the latest committed generation."""
        with self._lock:
            return self._generation

    def acquire_searcher(self) -> tuple[int, Any]:
        """Acquire a searcher pinned to the current visible generation.

        Returns:
            A ``(generation, snapshot)`` tuple.  The snapshot is
            ``None`` if no data has been attached to the generation.
        """
        with self._lock:
            gen = self._visible_generation
            self._ref_counts[gen] = self._ref_counts.get(gen, 0) + 1
            snapshot = self._snapshots.get(gen)
            return gen, snapshot

    def release_searcher(self, generation: int) -> None:
        """Release a searcher that was pinned to *generation*.

        Raises:
            ValueError: If *generation* has no outstanding references.
        """
        with self._lock:
            count = self._ref_counts.get(generation, 0)
            if count <= 0:
                raise ValueError(
                    f"No outstanding searchers for generation {generation}"
                )
            count -= 1
            if count == 0:
                del self._ref_counts[generation]
                # Clean up old snapshots that are no longer referenced
                if generation < self._visible_generation:
                    self._snapshots.pop(generation, None)
            else:
                self._ref_counts[generation] = count

    def commit(self) -> None:
        """Increment the generation counter.

        The new generation is not visible to ``acquire_searcher`` until
        :meth:`refresh` is called.
        """
        with self._lock:
            self._generation += 1
            self._ref_counts.setdefault(self._generation, 0)
            self._snapshots[self._generation] = None

    def refresh(self) -> None:
        """Make the latest committed generation visible to new searchers."""
        with self._lock:
            self._visible_generation = self._generation
