"""Disk-backed, bounded, transactional state series for the scaffold core.

`StateManager` is the `ctx.state` capability the supervised external scanner
author sees: a list-like series of dict records, persisted between ticks and
bounded by `state_history_max_count`.

Shape (per scanner-and-ctx-design.md §2.3):

    StateManager(state_path, state_history_max_count)

  Author surface:
    append(record)   record MUST be a dict; a reserved `recorded_at` (float
                     SECONDS, time.time() magnitude — never ms) is injected.
                     scaffold wins on a `recorded_at` collision. returns None.
                     RAISES when state_history_max_count is 0/unset — history
                     disabled is a hard error, never a silent no-op.
    recent(n)        last n entries, chronological (oldest->newest); n<=0 -> [].
    last()           most recent entry, or None if empty.
    __len__ / __getitem__ (index + slice) / __iter__ (oldest -> newest).

  Internals: backed by collections.deque(maxlen=state_history_max_count).
  Loading an on-disk series longer than the bound keeps the NEWEST entries
  (deque eviction drops the oldest).

  Transactional (one commit per tick):
    commit()    persists the working buffer via the atomic write_state.
                JSON-serializability is enforced LOUD here — a failed persist
                RAISES and does NOT advance the committed snapshot, leaving the
                prior on-disk state intact.
    rollback()  discards mid-tick mutations: the working buffer is restored to
                the last committed snapshot.
    read-your-writes within a tick: appended records are visible via
    len/recent/last/iter/getitem BEFORE commit.
"""

from __future__ import annotations

import copy
import time
from collections import deque

from .scaffold_state import read_state, write_state

# Reserved, scaffold-owned key injected into every record. Float seconds.
RECORDED_AT_KEY = "recorded_at"


class StateManager:
    """A disk-backed, bounded, transactional series of dict records."""

    def __init__(self, state_path: str, state_history_max_count):
        self._state_path = state_path
        # 0 / None / falsy => history disabled. Kept as-is so append can raise.
        self._max_count = state_history_max_count

        # Load any persisted series. read_state never raises: missing/corrupt
        # -> None. A non-list payload is treated as "no usable history".
        # state_path None => no disk persistence (commit advances the in-memory
        # snapshot but never writes). read_state needs a real path.
        loaded = read_state(state_path) if state_path is not None else None
        if not isinstance(loaded, list):
            loaded = []

        # The committed snapshot is the last durably-persisted series; the
        # working buffer is what the current tick reads/mutates. Both are
        # bounded the same way (deque(maxlen=...)); loading a longer-than-bound
        # series keeps the NEWEST entries (deque evicts from the left).
        self._committed = self._new_deque(loaded)
        self._working = self._snapshot(self._committed)

    # -- internals ---------------------------------------------------------

    def _new_deque(self, initial=()):
        """A deque bounded by max_count. When history is disabled (max_count is
        0/None) the bound is 0 — nothing can ever be buffered."""
        maxlen = self._max_count if self._max_count else 0
        return deque(initial, maxlen=maxlen)

    def _snapshot(self, source):
        """An independent bounded deque whose records share NO mutable state
        with `source`. Committed and working buffers must never alias the same
        dict objects — otherwise a mid-tick mutation of a record (an author
        scribbling on a dict handed back by last()/recent()/getitem) would
        survive a rollback and corrupt the very snapshot the rollback restores.
        Deep-copy at every snapshot boundary (load / commit / rollback)."""
        return self._new_deque(copy.deepcopy(entry) for entry in source)

    # -- author surface ----------------------------------------------------

    def append(self, record) -> None:
        """Append a dict record to the working buffer.

        Injects the reserved `recorded_at` (float seconds, scaffold wins on
        collision). Raises TypeError if record is not a dict. Raises if history
        is disabled (state_history_max_count 0/unset) — never a silent no-op.
        """
        if not self._max_count:
            raise ValueError(
                "state is disabled: state_history_max_count is 0 or unset "
                "(set it > 0 to use ctx.state.append)"
            )
        # bool is an int subclass but never a dict; explicit dict check rejects
        # lists/tuples/sets/strings/numbers/None/objects/bool alike.
        if not isinstance(record, dict):
            raise TypeError(
                f"state record must be a dict, got {type(record).__name__}"
            )

        # Copy so the author's dict is never mutated, then stamp recorded_at.
        # scaffold wins on collision: assign AFTER copying author keys.
        entry = dict(record)
        entry[RECORDED_AT_KEY] = time.time()
        self._working.append(entry)
        return None

    def recent(self, n) -> list:
        """The last n entries, chronological (oldest->newest).

        n <= 0 returns []; n greater than the buffer returns the whole buffer.
        Negative n is NOT a from-the-end slice — it is meaningless for "last n"
        and yields [].
        """
        if n <= 0:
            return []
        items = list(self._working)
        return items[-n:]

    def last(self):
        """The most recent entry, or None if empty."""
        if not self._working:
            return None
        return self._working[-1]

    # -- sequence protocol -------------------------------------------------

    def __len__(self) -> int:
        return len(self._working)

    def __getitem__(self, key):
        # deque supports integer indexing (raising IndexError out of range) but
        # not slicing; materialise a list so slices work with Python semantics
        # (clamping, steps, negative bounds) and index errors still propagate.
        if isinstance(key, slice):
            return list(self._working)[key]
        return self._working[key]

    def __iter__(self):
        return iter(self._working)

    # -- transaction -------------------------------------------------------

    def commit(self) -> None:
        """Persist the working buffer atomically via write_state.

        JSON-serializability is enforced LOUD: write_state returns False on a
        non-serializable value (or IO failure) without touching the existing
        file. We raise on that False so a failed persist never silently
        advances the committed snapshot — the prior on-disk state stays intact.
        """
        payload = list(self._working)
        # No disk path => commit advances the in-memory snapshot only.
        ok = True if self._state_path is None else write_state(self._state_path, payload)
        if not ok:
            raise RuntimeError(
                f"failed to persist state to {self._state_path} "
                "(non-serializable value or IO error); state not advanced"
            )
        # Durable: advance the committed snapshot to match what is on disk.
        # Deep-copy so the committed snapshot does not alias working records.
        self._committed = self._snapshot(self._working)

    def rollback(self) -> None:
        """Discard mid-tick mutations: restore the working buffer to the last
        committed snapshot. Never touches disk."""
        self._working = self._snapshot(self._committed)
