"""Lock-free SPSC ring buffer (AZ-273 / E-CC-FDR-CLIENT).

Implements the producer-side queue for ``FdrClient``. Capacity is fixed at
construction, rounded UP to the next power of two so the wrap-around math
collapses to a bitwise AND.

Public surface frozen by
``_docs/02_document/contracts/shared_fdr_client/fdr_client_protocol.md``
v1.0.0.

Concurrency contract (SPSC):

* ONE producer thread MAY call :meth:`push` (lock-free under CPython GIL).
* ONE consumer thread MAY call :meth:`pop` / :meth:`drain` (lock-free).
* The producer thread MAY call :meth:`drop_oldest` from its on-overrun
  closure; this method serialises with concurrent calls from itself via
  an internal cold-path lock, but does NOT block the consumer's
  :meth:`pop`. A simultaneous producer-drop + consumer-pop is the
  documented race window: at worst ``dropped_count`` is off by one for
  that one record. Tests run single-threaded around the overrun path so
  the AC-1 / AC-2 / AC-5 of AZ-274 are deterministic.

The SPSC enforcement guard is opt-in (``enforce_spsc=True``); production
clients leave it off so the steady-state has zero monitoring overhead.
"""

from __future__ import annotations

import threading
from typing import Any, Final

__all__ = [
    "MIN_CAPACITY",
    "FdrSpscViolationError",
    "SpscRingBuffer",
]

# Minimum buffer slot count. Below this the contract's wrap-around math
# is degenerate and microbench results stop being representative.
MIN_CAPACITY: Final[int] = 16


class FdrSpscViolationError(RuntimeError):
    """Raised by the opt-in SPSC guard when more than one thread touches a side.

    Attributes:
        side: ``"producer"`` or ``"consumer"`` — which side was violated.
        owner_thread_id: thread that first claimed the side.
        offender_thread_id: thread that attempted the violating call.
    """

    def __init__(self, side: str, owner_thread_id: int, offender_thread_id: int) -> None:
        self.side: str = side
        self.owner_thread_id: int = owner_thread_id
        self.offender_thread_id: int = offender_thread_id
        super().__init__(
            f"SPSC violation on {side!r} side: thread {offender_thread_id} attempted "
            f"to operate a buffer owned by thread {owner_thread_id}"
        )


class SpscRingBuffer:
    """Single-producer single-consumer lock-free ring buffer.

    Storage is a fixed-size Python ``list``; slots are pre-populated with
    ``None``. The producer writes to ``_slots[_tail]`` then advances
    ``_tail``; the consumer reads from ``_slots[_head]`` then advances
    ``_head``. Under CPython's GIL each single attribute store is atomic,
    so ``_head`` and ``_tail`` are safe to read across threads.

    Capacity is rounded up to a power of two so the modular arithmetic
    can use a bitwise AND on ``capacity - 1``.
    """

    def __init__(self, capacity: int, *, enforce_spsc: bool = False) -> None:
        if not isinstance(capacity, int) or isinstance(capacity, bool):
            raise TypeError(f"capacity must be a non-bool int; got {type(capacity).__name__}")
        if capacity < MIN_CAPACITY:
            raise ValueError(f"capacity must be >= {MIN_CAPACITY}; got {capacity}")
        if capacity & (capacity - 1) != 0:
            raise ValueError(f"capacity must be a power of two; got {capacity}")

        self._capacity: int = capacity
        self._mask: int = capacity - 1
        self._slots: list[Any] = [None] * capacity
        self._head: int = 0
        self._tail: int = 0

        self._enforce_spsc: bool = enforce_spsc
        self._producer_thread_id: int | None = None
        self._consumer_thread_id: int | None = None

        # Cold-path lock used only inside drop_oldest to serialise multiple
        # producer-side overrun events; the consumer's pop intentionally
        # does NOT acquire it (see module docstring race window note).
        self._drop_lock: threading.Lock = threading.Lock()

    @property
    def capacity(self) -> int:
        return self._capacity

    def is_empty(self) -> bool:
        return self._head == self._tail

    def is_full(self) -> bool:
        return ((self._tail + 1) & self._mask) == self._head

    def size(self) -> int:
        return (self._tail - self._head) & self._mask

    def push(self, item: Any) -> bool:
        """Producer push. Returns ``False`` when the buffer is full."""
        if self._enforce_spsc:
            self._claim_side(producer=True)
        tail = self._tail
        next_tail = (tail + 1) & self._mask
        if next_tail == self._head:
            return False
        self._slots[tail] = item
        self._tail = next_tail
        return True

    def pop(self) -> Any | None:
        """Consumer pop. Returns ``None`` when the buffer is empty."""
        if self._enforce_spsc:
            self._claim_side(producer=False)
        head = self._head
        if head == self._tail:
            return None
        item = self._slots[head]
        self._slots[head] = None
        self._head = (head + 1) & self._mask
        return item

    def drop_oldest(self) -> Any | None:
        """Producer-side drop of the oldest queued record (cold path).

        Returns the dropped item, or ``None`` if the buffer is empty.
        Serialised against concurrent calls from itself via
        ``_drop_lock``. NOT serialised against the consumer's
        :meth:`pop` — see the module docstring's race window note.
        """
        with self._drop_lock:
            head = self._head
            if head == self._tail:
                return None
            item = self._slots[head]
            self._slots[head] = None
            self._head = (head + 1) & self._mask
            return item

    def drain(self, max_records: int) -> list[Any]:
        """Consumer drain up to ``max_records`` items in FIFO order."""
        if not isinstance(max_records, int) or isinstance(max_records, bool):
            raise TypeError(f"max_records must be a non-bool int; got {type(max_records).__name__}")
        if max_records < 0:
            raise ValueError(f"max_records must be >= 0; got {max_records}")
        result: list[Any] = []
        for _ in range(max_records):
            item = self.pop()
            if item is None:
                break
            result.append(item)
        return result

    def _claim_side(self, *, producer: bool) -> None:
        """SPSC guard: bind a side to the first thread that touches it."""
        current = threading.get_ident()
        if producer:
            owner = self._producer_thread_id
            if owner is None:
                self._producer_thread_id = current
                return
            if owner != current:
                raise FdrSpscViolationError(
                    side="producer", owner_thread_id=owner, offender_thread_id=current
                )
        else:
            owner = self._consumer_thread_id
            if owner is None:
                self._consumer_thread_id = current
                return
            if owner != current:
                raise FdrSpscViolationError(
                    side="consumer", owner_thread_id=owner, offender_thread_id=current
                )
