"""Conversation-scoped context manager backed by three-tier collection."""

from __future__ import annotations

import hashlib
from datetime import UTC, datetime
from typing import Any
from uuid import UUID, uuid7

from threetears.agent.memory.collections import MemoryRefsCollection
from threetears.agent.tools.collections import ContextItemCollection

__all__ = [
    "ToolContextManager",
    "make_tool_result_dedup_key",
]

_MEMORY_REF_FIFO_CAPACITY = 50


def make_tool_result_dedup_key(tool_name: str, input_fingerprint: str) -> str:
    """Deterministic dedup key for a tool result: ``tool_name`` + input hash.

    A repeated call with the same ``input_fingerprint`` yields the same
    key, so :meth:`ToolContextManager.save_tool_result` upserts (refreshes)
    the existing row on the v004 partial-unique index instead of appending
    a duplicate. Shared with consumers that LOOK UP a cached result by the
    same key (e.g. a consumer's per-tool TTL reuse), so storage and lookup
    can never disagree on the key shape.

    :param tool_name: the tool's registered name
    :ptype tool_name: str
    :param input_fingerprint: a stable string derived from the tool input
    :ptype input_fingerprint: str
    :return: ``"<tool_name>:<sha256(input)[:16]>"``
    :rtype: str
    """
    digest = hashlib.sha256(input_fingerprint.encode()).hexdigest()[:16]
    return f"{tool_name}:{digest}"


class ToolContextManager:
    """Manages conversation context: variables, tool results, media slots, and workflows.

    Backed by a :class:`ContextItemCollection` for three-tier persistence
    (L1 SQLite -> L2 NATS KV -> L3 PostgreSQL) and -- for memory-ref
    surfacing tracking -- by a :class:`MemoryRefsCollection` on the
    composite-pk ``conversation_memory_refs`` table. workflow state is
    transient (in-memory, single-turn only).

    the surfaced-items FIFO projection lives in memory (capped at 50)
    and is backed by the collection: cold-start rehydrates via
    :meth:`MemoryRefsCollection.find_by_conversation`; writes go through
    :meth:`MemoryRefsCollection.save_entity` so L1 / L2 / L3 and cross-
    pod invalidation all stay coherent. retires the bespoke
    :class:`MemoryLedger` wrapper under namespace-task-01 phase 8.5l-2.

    Call :meth:`load_context` after construction to populate from storage.
    """

    def __init__(
        self,
        collection: ContextItemCollection,
        conversation_id: UUID,
        user_id: UUID,
        *,
        var_limit: int = 50,
        var_max_chars: int = 50_000,
        result_limit: int | None = None,
        memory_refs_collection: MemoryRefsCollection | None = None,
    ) -> None:
        """initialize context manager with required context collection.

        :param collection: three-tier ContextItemCollection for variables
            / tool results / media slots
        :ptype collection: ContextItemCollection
        :param conversation_id: conversation scope (first pk column of
            memory refs)
        :ptype conversation_id: UUID
        :param user_id: invoking user UUID for ownership checks
        :ptype user_id: UUID
        :param var_limit: maximum variables before set rejects new keys
        :ptype var_limit: int
        :param var_max_chars: truncation cap for variable values
        :ptype var_max_chars: int
        :param result_limit: optional LRU cap for tool_result items
        :ptype result_limit: int | None
        :param memory_refs_collection: optional three-tier memory-refs
            collection for cross-pod surfacing tracking. when absent,
            surfacing writes through :meth:`add_ledger_ref` become
            no-ops (tests + tool smoke fixtures without an agent pod
            configured do not need surfacing persistence)
        :ptype memory_refs_collection: MemoryRefsCollection | None
        :return: nothing
        :rtype: None
        :raises TypeError: if ``collection`` is ``None`` -- a wiring omission
            that would otherwise surface as an opaque ``NoneType`` error
            mid-stream in :meth:`load_context`.
        """
        # The context collection is required and dereferenced unconditionally
        # (``load_context`` -> ``collection.find_by_conversation``). Reject a
        # ``None`` here so a caller that fails to thread it fails loudly at
        # construction, not with an opaque ``NoneType`` ``AttributeError`` on
        # the first load. (``memory_refs_collection`` stays genuinely optional.)
        if collection is None:
            raise TypeError(
                "ToolContextManager requires a non-null ContextItemCollection; "
                "got None (a caller failed to thread the context collection)."
            )
        self._collection = collection
        self.conversation_id = conversation_id
        self.user_id = user_id
        self._var_limit = var_limit
        self._var_max_chars = var_max_chars
        self._result_limit = result_limit
        self._memory_refs = memory_refs_collection

        # Local projection of collection data for this conversation.
        # Populated by load_context(), updated by write methods.
        self.items: list[dict[str, Any]] = []

        # Ordered projection of surfaced memory-ref rows (FIFO), mirror
        # of the rows persisted via :class:`MemoryRefsCollection`. The
        # list preserves insertion order so the prompt renders items
        # chronologically and FIFO eviction drops the oldest first.
        # Each entry is a dict with keys
        # ``{item_id, item_type, short_desc, date_added}``.
        self._memory_refs_projection: list[dict[str, Any]] = []

        # Workflow state (transient, single-turn, in-memory only)
        self._workflow: dict[str, Any] | None = None

    # ------------------------------------------------------------------
    # Loading
    # ------------------------------------------------------------------

    async def load_context(self) -> None:
        """Load all context items and surfaced-refs projection for this conversation.

        pulls the persisted ``conversation_memory_refs`` rows through
        :meth:`MemoryRefsCollection.find_by_conversation` into the
        in-memory FIFO projection (capped at 50, oldest first). when
        no collection is wired (unit tests, smoke fixtures) the
        projection stays empty and surfacing operations become no-ops.
        """
        entities = await self._collection.find_by_conversation(self.conversation_id)
        self.items = []
        for entity in entities:
            self.items.append(entity.to_dict())
        self._memory_refs_projection = []
        if self._memory_refs is not None:
            refs = await self._memory_refs.find_by_conversation(
                self.conversation_id,
            )
            for ref in refs:
                # entity.date_created post-v014 (was date_added pre-rename).
                # the in-memory projection key stays "date_added" because
                # the prompt-builder format references it; the in-memory
                # dict shape is decoupled from the L3 column names.
                self._memory_refs_projection.append(
                    {
                        "item_id": str(ref.item_id),
                        "item_type": ref.item_type,
                        "short_desc": ref.short_desc,
                        "date_added": ref.date_created.isoformat() if ref.date_created else "",
                    },
                )

    # ------------------------------------------------------------------
    # Variables
    # ------------------------------------------------------------------

    async def set_variable(self, key: str, value: str, value_type: str = "string") -> str:
        """Set or update a variable.  Returns the context_id."""
        var_count = sum(1 for i in self.items if i["context_type"] == "variable")
        existing = next(
            (i for i in self.items if i["context_type"] == "variable" and i["key"] == key),
            None,
        )
        if existing is None and var_count >= self._var_limit:
            raise ValueError(
                f"Variable limit reached ({self._var_limit}). Delete unused variables before adding new ones."
            )
        if len(value) > self._var_max_chars:
            value = value[: self._var_max_chars]

        now = datetime.now(UTC)
        data = {
            "context_id": uuid7(),
            "conversation_id": self.conversation_id,
            "context_type": "variable",
            "key": key,
            "short_desc": value[:200],
            "long_desc": "",
            "content": value,
            "metadata": {"value_type": value_type},
            "date_accessed": now,
            "date_created": now,
            "date_updated": now,
        }

        returned_id = await self._collection.upsert_variable(self.conversation_id, data)
        context_id_str = str(returned_id)  # convert at border: tool-facing context-id string handle returned to caller

        # Update local projection
        self.items = [i for i in self.items if not (i["context_type"] == "variable" and i["key"] == key)]
        data["context_id"] = returned_id
        self.items.append(data)

        return context_id_str

    async def get_variable(self, key: str) -> dict[str, Any] | None:
        """Get a variable by key, or ``None`` if not found."""
        for item in self.items:
            if item["context_type"] == "variable" and item["key"] == key:
                vtype = (item.get("metadata") or {}).get("value_type", "string")
                return {"value": item["content"], "value_type": vtype}
        return None

    async def get_all_variables(self) -> list[dict[str, Any]]:
        """Return all variables as a list of dicts."""
        return [i for i in self.items if i["context_type"] == "variable"]

    async def delete_variable(self, key: str) -> bool:
        """Delete a variable by key.  Returns ``True`` if it existed."""
        target = next(
            (i for i in self.items if i["context_type"] == "variable" and i["key"] == key),
            None,
        )
        if target is None:
            return False

        self.items = [i for i in self.items if i is not target]
        await self._collection.delete((self.conversation_id, target["context_id"]))
        return True

    # ------------------------------------------------------------------
    # Tool results
    # ------------------------------------------------------------------

    async def save_context_item(
        self,
        context_type: str,
        key: str,
        short_desc: str,
        content: str,
        long_desc: str = "",
        metadata: dict[str, Any] | None = None,
    ) -> str:
        """Save a context item with full 3-tier descriptions.

        General-purpose save that covers variables, tool results, and
        any other context type. Returns the context_id.

        :param context_type: type of context item (variable, tool_result, scan_result, etc.)
        :ptype context_type: str
        :param key: unique key within the conversation
        :ptype key: str
        :param short_desc: token-efficient summary (≤200 chars)
        :ptype short_desc: str
        :param content: full content for on-demand retrieval
        :ptype content: str
        :param long_desc: expanded description (≤1000 chars)
        :ptype long_desc: str
        :param metadata: optional metadata dict
        :ptype metadata: dict[str, Any] | None
        :return: context_id of the saved item
        :rtype: str
        """
        return await self.save_tool_result(
            tool_name=key,
            result=content,
            short_desc=short_desc,
            long_desc=long_desc,
            context_type=context_type,
            metadata=metadata,
        )

    async def save_tool_result(
        self,
        tool_name: str,
        result: str,
        *,
        short_desc: str | None = None,
        long_desc: str = "",
        context_type: str = "tool_result",
        metadata: dict[str, Any] | None = None,
        status: str | None = None,
        duration_ms: int | None = None,
        error: str | None = None,
        input_fingerprint: str | None = None,
    ) -> str:
        """Save a tool result.  Returns the authoritative context_id.

        If ``result_limit`` is set and the count of tool_result items
        exceeds it, the least-recently-accessed items are evicted.

        :param input_fingerprint: when given (and ``context_type`` is
            ``'tool_result'``), the row is keyed
            ``tool_name + ':' + sha256(input_fingerprint)`` and upserted on
            the v004 dedup index -- a repeat call with the same input
            refreshes the existing row (bumping it to the top of the LRU)
            instead of appending a duplicate. When ``None`` the key is made
            unique per call (``tool_name:context_id``) so no dedup occurs
            but the unique index never rejects the insert. The returned id
            is the authoritative row id (the existing row's on a dedup hit).
        :ptype input_fingerprint: str | None

        :param status: optional invocation status (e.g. ``"completed"``,
            ``"failed"``). When provided, merged into ``metadata["status"]``.
        :ptype status: str | None
        :param duration_ms: optional wall-clock duration of the invocation
            in milliseconds. When provided, merged into
            ``metadata["duration_ms"]``.
        :ptype duration_ms: int | None
        :param error: optional error message when the invocation failed.
            When provided, merged into ``metadata["error"]`` (truncated to
            1000 chars). When ``status`` is ``"failed"`` and ``error`` is
            given, ``short_desc`` falls back to ``"FAILED: <error>"``
            instead of a slice of ``result`` so the prompt-side rendering
            surfaces the failure cause directly.
        :ptype error: str | None
        """
        now = datetime.now(UTC)
        context_id = uuid7()
        # Merge observability fields into metadata when present so callers
        # do not have to construct the metadata dict themselves.
        merged_meta: dict[str, Any] = dict(metadata) if metadata else {}
        if status is not None:
            merged_meta.setdefault("status", status)
        if duration_ms is not None:
            merged_meta.setdefault("duration_ms", duration_ms)
        if error is not None:
            merged_meta.setdefault("error", error[:1000])
        if short_desc is None and status == "failed" and error:
            short_desc = f"FAILED: {error[:280]}"
        # Key derivation. tool_result rows dedup on the v004 partial-unique
        # index: with an input fingerprint, repeats with the same input
        # collide and upsert (refresh); without one, the key is made unique
        # per call so the index never rejects it. Non-tool_result types
        # keep their bare-tool_name key and the legacy insert path (they
        # have no dedup index).
        if context_type == "tool_result":
            if input_fingerprint is not None:
                key = make_tool_result_dedup_key(tool_name, input_fingerprint)
            else:
                key = f"{tool_name}:{context_id}"
        else:
            key = tool_name

        data = {
            "context_id": context_id,
            "conversation_id": self.conversation_id,
            "context_type": context_type,
            "key": key,
            "short_desc": short_desc or result[:200],
            "long_desc": long_desc[:1000] if long_desc else "",
            "content": result,
            "metadata": merged_meta,
            "date_accessed": now,
            "date_created": now,
            "date_updated": now,
        }

        if context_type == "tool_result":
            returned_id = await self._collection.upsert_tool_result(
                self.conversation_id,
                data,
            )
        else:
            await self._collection.save_entity(
                self._collection.entity_class(
                    data,
                    is_new=True,
                    collection=self._collection,
                )
            )
            returned_id = context_id

        # Reconcile the in-memory projection. On a dedup hit the upsert
        # refreshes an existing row (``returned_id`` differs from the
        # freshly-minted ``context_id``), so replace that row rather than
        # appending a duplicate. ``date_updated`` carries the refresh time
        # that a TTL check reads.
        data["context_id"] = returned_id
        if returned_id != context_id:
            # Drop the pre-existing projection row for the upserted id
            # (UUID comparison -- identifiers stay UUID internally per the
            # border discipline) before re-appending the refreshed data.
            self.items = [i for i in self.items if i.get("context_id") != returned_id]
        self.items.append(data)

        # LRU eviction
        if self._result_limit is not None:
            evicted = await self._collection.evict_lru(self.conversation_id, self._result_limit)
            if evicted:
                # Refresh local projection to drop evicted items
                evicted_ids = {
                    str(i["context_id"])
                    for i in self.items
                    if i["context_type"] == "tool_result"
                    and not self._collection.exists_in_cache_sync(
                        (self.conversation_id, i["context_id"]),
                    )
                }
                if evicted_ids:
                    self.items = [i for i in self.items if str(i["context_id"]) not in evicted_ids]

        return str(returned_id)  # convert at border: tool-facing context-id string handle returned to caller

    async def get_context_item(self, context_id: str) -> dict[str, Any] | None:
        """Retrieve a context item by id.

        Accessing an item updates its ``date_accessed`` for LRU tracking.
        """
        cid = context_id
        if cid.startswith("ctx:"):
            cid = cid[4:]
        for item in self.items:
            if str(item["context_id"]) == cid:
                await self._collection.touch(self.conversation_id, cid)
                item["date_accessed"] = datetime.now(UTC)
                return item
        return None

    def build_context_detail(self, context_id: str) -> str | None:
        """Return the long_desc for a specific context item.

        Used by two-tier enrichment to get more detail without
        loading the full content.

        :param context_id: UUID string of the context item
        :ptype context_id: str
        :return: long_desc or None if not found
        :rtype: str | None
        """
        cid = context_id
        for item in self.items:
            if str(item["context_id"]) == cid:
                val: str | None = item.get("long_desc", "")
                return val
        return None

    def get_by_context_id(self, context_id: str) -> dict[str, Any] | None:
        """Retrieve a context item by its context_id from the local projection.

        :param context_id: UUID string of the context item
        :ptype context_id: str
        :return: context item data or None
        :rtype: dict[str, Any] | None
        """
        cid = context_id
        for item in self.items:
            if str(item["context_id"]) == cid:
                return item
        return None

    # ------------------------------------------------------------------
    # Arbitrary context items keyed by (context_type, key)
    # ------------------------------------------------------------------

    async def get_item_by_type_and_key(
        self,
        context_type: str,
        key: str,
    ) -> dict[str, Any] | None:
        """Fetch a single context item by ``(context_type, key)`` pair.

        General-purpose lookup for callers that store arbitrary items
        (workspace pin, per-agent bookmarks, etc.) outside the variable
        / tool_result taxonomies. Reads the local projection; returns
        ``None`` when no item matches.

        :param context_type: context taxonomy tag (e.g. ``"workspace_pin"``)
        :ptype context_type: str
        :param key: unique key within ``context_type`` scope
        :ptype key: str
        :return: item dict or ``None`` if absent
        :rtype: dict[str, Any] | None
        """
        result: dict[str, Any] | None = None
        for item in self.items:
            if item["context_type"] == context_type and item["key"] == key:
                result = item
                break
        return result

    async def save_item_by_type_and_key(
        self,
        *,
        context_type: str,
        key: str,
        content: str,
        short_desc: str = "",
        long_desc: str = "",
        metadata: dict[str, Any] | None = None,
    ) -> str:
        """Save (or replace) a context item by ``(context_type, key)`` pair.

        Mirrors :meth:`set_variable`'s delete-then-save pattern so any
        existing item with the same ``(context_type, key)`` is replaced
        atomically in the local projection, L1, L2, and L3 tiers.
        Returns the fresh ``context_id`` as a string.

        :param context_type: context taxonomy tag (e.g. ``"workspace_pin"``)
        :ptype context_type: str
        :param key: unique key within ``context_type`` scope
        :ptype key: str
        :param content: item content
        :ptype content: str
        :param short_desc: optional token-efficient summary
        :ptype short_desc: str
        :param long_desc: optional expanded description
        :ptype long_desc: str
        :param metadata: optional metadata dict
        :ptype metadata: dict[str, Any] | None
        :return: context_id of the saved item as string
        :rtype: str
        """
        # drop any prior entry so we enforce single-item semantics across
        # every storage tier (local projection, L1, L2, L3).
        existing = await self.get_item_by_type_and_key(context_type, key)
        if existing is not None:
            self.items = [i for i in self.items if i is not existing]
            await self._collection.delete(
                (self.conversation_id, existing["context_id"]),
            )

        now = datetime.now(UTC)
        context_id = uuid7()
        data: dict[str, Any] = {
            "context_id": context_id,
            "conversation_id": self.conversation_id,
            "context_type": context_type,
            "key": key,
            "short_desc": short_desc,
            "long_desc": long_desc,
            "content": content,
            "metadata": metadata or {},
            "date_accessed": now,
            "date_created": now,
            "date_updated": now,
        }
        await self._collection.save_entity(
            self._collection.entity_class(
                data,
                is_new=True,
                collection=self._collection,
            )
        )
        self.items.append(data)
        return str(context_id)  # convert at border: tool-facing context-id string handle returned to caller

    async def delete_item_by_type_and_key(
        self,
        context_type: str,
        key: str,
    ) -> bool:
        """Delete a context item by ``(context_type, key)`` pair.

        Idempotent: returns ``False`` when no item matches rather than
        raising, so callers can invoke it unconditionally.

        :param context_type: context taxonomy tag (e.g. ``"workspace_pin"``)
        :ptype context_type: str
        :param key: unique key within ``context_type`` scope
        :ptype key: str
        :return: ``True`` if an item was deleted, ``False`` if absent
        :rtype: bool
        """
        existing = await self.get_item_by_type_and_key(context_type, key)
        result: bool
        if existing is None:
            result = False
        else:
            self.items = [i for i in self.items if i is not existing]
            await self._collection.delete(
                (self.conversation_id, existing["context_id"]),
            )
            result = True
        return result

    # ------------------------------------------------------------------
    # Media slots
    # ------------------------------------------------------------------

    async def register_media(self, slot_name: str, **kwargs: Any) -> str:
        """Register a media slot.  Returns the context_id."""
        now = datetime.now(UTC)
        context_id = uuid7()
        data = {
            "context_id": context_id,
            "conversation_id": self.conversation_id,
            "context_type": "media_slot",
            "key": slot_name,
            "short_desc": kwargs.get("description", slot_name),
            "long_desc": "",
            "content": "",
            "metadata": kwargs,
            "date_accessed": now,
            "date_created": now,
            "date_updated": now,
        }

        await self._collection.save_entity(
            self._collection.entity_class(data, is_new=True, collection=self._collection)
        )
        self.items.append(data)
        return str(context_id)  # convert at border: tool-facing context-id string handle returned to caller

    def get_slots(self) -> dict[str, dict[str, Any]]:
        """Return all registered media slots."""
        return {i["key"]: i for i in self.items if i["context_type"] == "media_slot"}

    def build_media_context(self) -> str | None:
        """Format media slots into a prompt string, or ``None`` if empty."""
        slots = [i for i in self.items if i["context_type"] == "media_slot"]
        if not slots:
            return None
        lines = ["[Active Media Slots]"]
        for slot in slots:
            lines.append(f"- {slot['key']}: {slot.get('metadata', {})}")
        return "\n".join(lines)

    # ------------------------------------------------------------------
    # Workflow
    # ------------------------------------------------------------------

    def declare_workflow(self, plan: str, steps: list[str]) -> dict[str, Any]:
        """Declare a new workflow with a plan and steps."""
        self._workflow = {
            "plan": plan,
            "steps": steps,
            "current_step": 0,
            "status": "active",
            "declared_at": datetime.now(UTC).isoformat(),
        }
        return self._workflow

    def advance_workflow_step(self) -> dict[str, Any] | None:
        """Advance to the next workflow step.  Returns updated state or ``None``."""
        if self._workflow is None or self._workflow["status"] != "active":
            return None
        self._workflow["current_step"] += 1
        if self._workflow["current_step"] >= len(self._workflow["steps"]):
            self._workflow["status"] = "completed"
        return self._workflow

    def complete_workflow(self) -> dict[str, Any] | None:
        """Mark the workflow as completed.  Returns final state or ``None``."""
        if self._workflow is None:
            return None
        self._workflow["status"] = "completed"
        return self._workflow

    @property
    def has_active_workflow(self) -> bool:
        """Whether there is an active workflow."""
        return self._workflow is not None and self._workflow["status"] == "active"

    @property
    def workflow_state(self) -> dict[str, Any] | None:
        """Current workflow state, or ``None``."""
        return self._workflow

    def build_workflow_prompt(self) -> str:
        """Format the active workflow as a system prompt section.

        Returns empty string if no workflow is active.

        :return: formatted workflow prompt section
        :rtype: str
        """
        if not self.has_active_workflow or self._workflow is None:
            return ""
        lines = [f"## Active Workflow: {self._workflow['plan']}"]
        for i, step in enumerate(self._workflow["steps"]):
            if isinstance(step, dict):
                desc = step.get("description", str(step))
                status = step.get("status", "pending")
            else:
                desc = str(step)
                status = "completed" if i < self._workflow["current_step"] else "pending"
            if status == "completed":
                check = "[x]"
            elif status == "skipped":
                check = "[-]"
            else:
                check = "[ ]"
            lines.append(f"{i + 1}. {check} {desc}")
        return "\n".join(lines)

    # ------------------------------------------------------------------
    # Memory refs (tracks surfaced items to prevent re-retrieval)
    # ------------------------------------------------------------------

    @property
    def memory_refs_count(self) -> int:
        """return the number of surfaced memory refs in the projection.

        replaces ``len(self.ledger)`` — graph-node gating keeps its
        shape (``memory_refs_count >= min_coverage``) without reaching
        through an intermediate object.

        :return: current surfaced-refs count
        :rtype: int
        """
        return len(self._memory_refs_projection)

    @property
    def surfaced_item_ids(self) -> set[str]:
        """return the set of surfaced item IDs (as strings).

        callers passing this to :meth:`MemoryRetriever.retrieve` as
        ``surfaced_ids`` get dedup on already-shown items without
        reaching into the internal projection.

        :return: surfaced item IDs
        :rtype: set[str]
        """
        return {ref["item_id"] for ref in self._memory_refs_projection}

    async def add_ledger_ref(
        self,
        item_id: str,
        item_type: str,
        short_desc: str,
    ) -> None:
        """Track a surfaced item by persisting through MemoryRefsCollection.

        FIFO eviction at capacity 50. truncates ``short_desc`` to 150
        chars at the write boundary so the L1 / L3 rows satisfy the
        migration-v002 VARCHAR(150) bound. when
        ``memory_refs_collection`` was not wired, the projection still
        updates (so in-conversation dedup works) but no cross-pod
        persistence happens.

        :param item_id: UUID string of the surfaced item
        :ptype item_id: str
        :param item_type: type of the item (memory / media / chunk /
            finding / scan)
        :ptype item_type: str
        :param short_desc: short description (truncated to 150 chars)
        :ptype short_desc: str
        :return: nothing
        :rtype: None
        """
        if any(ref["item_id"] == item_id for ref in self._memory_refs_projection):
            return

        desc = short_desc[:150] if len(short_desc) > 150 else short_desc
        now = datetime.now(UTC)

        if len(self._memory_refs_projection) >= _MEMORY_REF_FIFO_CAPACITY:
            oldest = self._memory_refs_projection.pop(0)
            if self._memory_refs is not None:
                await self._memory_refs.delete(
                    (self.conversation_id, UUID(oldest["item_id"])),
                )

        # in-memory projection key (``date_added``) is preserved on
        # purpose: the prompt-builder format already references this
        # key and the in-memory dict is not the L3 schema. canonical
        # storage uses ``date_created`` / ``date_updated`` per v014.
        self._memory_refs_projection.append(
            {
                "item_id": item_id,
                "item_type": item_type,
                "short_desc": desc,
                "date_added": now.isoformat(),
            },
        )

        if self._memory_refs is not None:
            entity = self._memory_refs.create(
                {
                    "conversation_id": self.conversation_id,
                    "item_id": UUID(item_id),
                    "item_type": item_type,
                    "short_desc": desc,
                    "date_created": now,
                    "date_updated": now,
                },
            )
            await self._memory_refs.save_entity(entity)

    def is_known(self, item_id: str) -> bool:
        """Check if an item is already tracked as surfaced.

        :param item_id: UUID string to check
        :ptype item_id: str
        :return: True if already surfaced this conversation
        :rtype: bool
        """
        return any(ref["item_id"] == item_id for ref in self._memory_refs_projection)

    def build_ledger_prompt(self) -> str:
        """Format surfaced memory refs for inclusion in system prompt.

        :return: formatted section or empty string
        :rtype: str
        """
        if not self._memory_refs_projection:
            return ""
        lines = ["Previously recalled in this conversation (use memory_recall with the ID and type shown):"]
        for ref in self._memory_refs_projection:
            itype = ref["item_type"]
            tag = f"[{itype}:{ref['item_id']}]"
            lines.append(f"- {tag} type: {itype} — {ref['short_desc']}")
        return "\n".join(lines)

    # ------------------------------------------------------------------
    # Context building
    # ------------------------------------------------------------------

    def build_context_prompt(self) -> str:
        """Return a non-None prompt suitable for system-prompt injection.

        Compatibility shim for the canonical
        :func:`threetears.langgraph.agent_node`, which expects a sync
        ``build_context_prompt() -> str`` on the ``context_manager``
        slot in ``configurable``. Returns the empty string when there
        is no context, instead of ``None``, so callers can concatenate
        the result into the system prompt unconditionally.

        :return: formatted context string, or "" when no context exists
        :rtype: str
        """
        raw = self.build_conversation_context()
        return raw or ""

    def build_conversation_context(self) -> str | None:
        """Format variables and tool results into a prompt string.

        Returns ``None`` if there is no context to include.
        """
        variables = [i for i in self.items if i["context_type"] == "variable"]
        tool_results = [i for i in self.items if i["context_type"] == "tool_result"]

        if not variables and not tool_results:
            return None

        sections: list[str] = []

        if variables:
            lines = ["[Conversation Variables]"]
            for var in variables:
                vtype = (var.get("metadata") or {}).get("value_type", "string")
                lines.append(f"- {var['key']} ({vtype}): {var['content']}")
            sections.append("\n".join(lines))

        if tool_results:
            lines = ["[Tool Results]"]
            for item in tool_results:
                lines.append(f"- [{item['context_id']}] {item['key']}: {item['short_desc']}")
            sections.append("\n".join(lines))

        return "\n\n".join(sections)

    @property
    def has_context(self) -> bool:
        """Whether there is any context (variables, tool results, or media)."""
        return bool(self.items)
