"""SQLite + sqlite-vec durable memory store (Bifrost memory plane, v1 basic plane).

Contract: docs/contracts/bifrost_memory_provider.contract.md

Worldtree writes Tier-3 agent memory chunks here (`upsert_many`) and recalls them
by vector similarity (`search`), with point reads (`get`/`get_many`) and deletes
(`delete_many`). We persist each chunk verbatim and read only its structural
surface — embedding (rank), scope (isolation), id + revision (optimistic lock),
origin/injection_source (consistency rule). Semantic content is never interpreted.
We implement bifrost's OWN MemoryDataStore Protocol; conformance is #195 parity
vs InMemoryMemoryStore.
"""
from __future__ import annotations

import hashlib
import json
import logging
import sqlite3
import sys
import time
from typing import Any

import sqlite_vec
from bifrost.consumer import ConsumerRegistration, build_memory_app
from bifrost.memory import (
    IdempotencyConflict,
    InvalidArguments,
    InvalidFilter,
    RevisionMismatch,
    StoreCapabilities,
)
from bifrost.reference_server import JwtVerifier
from starlette.requests import Request
from starlette.responses import JSONResponse

_SHORT_RETRY_TTL_SECONDS = 300
_DURABLE_JOB_TTL_SECONDS = 24 * 60 * 60

# bifrost wire v0.6 scope lattice: three subject axes + agent_self (the #248
# agent-identity axis, canonical since #10/v0.5). An axis outside it is InvalidFilter
# (-> memory.invalid_filter 400), matching bifrost's reference _validate_scope.
_SCOPE_LATTICE = {"end_user", "group", "tenant", "agent_self"}

# Inbound memory-call observability (#17 observe brick). A self-contained
# stdout handler so the lines reliably reach the provider's stdout regardless
# of uvicorn's logging config. INFO-level, no propagation to root.
_log = logging.getLogger("ratatoskr.provider.memory")
if not _log.handlers:
    _h = logging.StreamHandler(sys.stdout)
    _h.setFormatter(logging.Formatter("%(asctime)s [memory-provider] %(message)s"))
    _log.addHandler(_h)
    _log.setLevel(logging.INFO)
    _log.propagate = False


def _ctx_actor(ctx: Any) -> str:
    """Reference `_ctx_actor`: actor = job_id | jwt_sub | session_id (never the record)."""
    return str(
        getattr(ctx, "job_id", None)
        or getattr(ctx, "jwt_sub", None)
        or getattr(ctx, "session_id", "")
    )


def _payload_digest(value: Any) -> str:
    """Reference digest: sha256 of canonical JSON (sorted keys, compact, str-coerced)."""
    blob = json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
    return hashlib.sha256(blob.encode("utf-8")).hexdigest()


def _idempotency_ttl_seconds(idempotency_class: str | None) -> int:
    if idempotency_class == "durable-job":
        return _DURABLE_JOB_TTL_SECONDS
    return _SHORT_RETRY_TTL_SECONDS


def _chunk_id(record: dict) -> str:
    """Reference `_chunk_id`: first non-empty of id / chunk_id / memory_id."""
    for key in ("id", "chunk_id", "memory_id"):
        value = record.get(key)
        if isinstance(value, str) and value:
            return value
    raise InvalidArguments("record missing id")


def _record_vector(record: dict) -> list[float]:
    """Reference `_record_vector`: embedding, falling back to vector, else []."""
    value = record.get("embedding", record.get("vector", []))
    return [float(v) for v in value] if isinstance(value, list) else []


def _scope_subset(record_scope: dict, filter_dict: dict) -> bool:
    """True iff record_scope has EVERY axis of filter_dict (AND). Empty filter = match."""
    return all(record_scope.get(axis) == value for axis, value in filter_dict.items())


def _matches_scope(record_scope: Any, scope_all: dict, scope_any: list) -> bool:
    """INV-005 (wire v0.6, #11): a record passes iff
    `(scope_all empty OR record ⊇ scope_all) AND (scope_any empty OR it matches ≥1 element)`.
    scope_any is OR/union over a LIST of conjunctive scope dicts (each AND-matched as a whole),
    never single axes flattened together (the Worldtree #297 over-broadening foot-gun).
    Byte-faithful to bifrost reference `reference_server/memory.py:_matches_scope`.
    """
    scope = record_scope if isinstance(record_scope, dict) else {}
    if not _scope_subset(scope, scope_all):
        return False
    if scope_any and not any(_scope_subset(scope, element) for element in scope_any):
        return False
    return True


def _validate_scope(scope_all: dict, scope_any: list) -> None:
    """STEP 1: scope_all is a flat dict, scope_any a list of flat dicts; every axis in BOTH
    fields ∈ the v0.6 lattice {end_user, group, tenant, agent_self} (else InvalidFilter),
    matching the reference `_validate_scope`."""
    if not isinstance(scope_all, dict):
        raise InvalidArguments("scope_all must be a flat {axis: value} dict")
    if not isinstance(scope_any, list) or any(not isinstance(e, dict) for e in scope_any):
        raise InvalidArguments("scope_any must be a list of {axis: value} dicts")
    for scope in (scope_all, *scope_any):
        if any(axis not in _SCOPE_LATTICE for axis in scope):
            raise InvalidFilter("scope_filter contains unsupported axis")


def _validate_injection(record: dict) -> None:
    """INV-007: injected_context requires injection_source; non-injected forbids it."""
    origin = record.get("origin")
    injection_source = record.get("injection_source")
    if origin == "injected_context" and not injection_source:
        raise InvalidArguments("injected_context record requires injection_source")
    if injection_source and origin != "injected_context":
        raise InvalidArguments("injection_source only valid for injected_context origin")


# bifrost handshake_response SortableChunkField requires BOTH name + type
# (additionalProperties:false); omitting `type` fails wire-schema validation and
# breaks the whole handshake. `type` is advisory-only (the wire never interprets it).
_SORTABLE_CHUNK_FIELDS: list[dict] = [{"name": "updated_at", "type": "timestamp"}]
_SORTABLE_FIELD_NAMES = frozenset(f["name"] for f in _SORTABLE_CHUNK_FIELDS)


def _is_live(record: dict) -> bool:
    """INV-009: a chunk is live unless a lifecycle/governance marker says otherwise.
    scan returns live-only server-side (person-prime's `lifecycle_state=live` does not
    ride the scan wire, so this is authoritative — a dead fact can never inject)."""
    if record.get("superseded") is True:  # INV-011: mark_superseded top-level flag (#364 retirement)
        return False
    state = record.get("lifecycle_state")
    if isinstance(state, str) and state and state != "live":
        return False
    verbatim = record.get("verbatim")
    gov = verbatim.get("governance_state") if isinstance(verbatim, dict) else None
    return gov not in ("superseded", "tombstoned")


def _chunk_content_preview(record: dict) -> str:
    """Best-effort human-readable content for the DEBUG memory viewer only. Prefers an
    explicit text field, then the distillate summary, and last-resorts to a compact JSON
    of the record MINUS the (large, non-human) embedding — never a fabricated blank, so
    the viewer shows whatever IS there. Read-only; the store's normal bifrost verbs stay
    conduit/index-faithful (this is a separate debug read, not an interpretation of the
    chunk on the recall path)."""
    for key in ("content", "text", "body", "summary"):
        value = record.get(key)
        if isinstance(value, str) and value:
            return value
    distillate = record.get("distillate")
    if isinstance(distillate, str) and distillate:
        return distillate
    if isinstance(distillate, dict):
        for key in ("summary", "text", "content"):
            value = distillate.get(key)
            if isinstance(value, str) and value:
                return value
    trimmed = {k: v for k, v in record.items() if k not in ("embedding", "vector")}
    return json.dumps(trimmed, separators=(",", ":"), default=str)


class RatatoskrMemoryStore:
    """The MemoryDataStore-shaped store handed to bifrost's build_memory_app."""

    def __init__(self, conn: sqlite3.Connection, embedding_dim: int):
        self._conn = conn
        self._dim = embedding_dim

    def describe_store(self) -> dict:
        # INV-006: advertise ONLY the v1 basic-plane capabilities (advertise-=>-implement).
        return StoreCapabilities(
            relational_edges_supported=False,
            optimistic_locking_supported=True,
            atomic_supersede_supported=False,
            transaction_supported=False,
            filterable_metadata_fields=[],
            sortable_chunk_fields=list(_SORTABLE_CHUNK_FIELDS),  # INV-006: gates scan sort + #349 person-prime
        ).to_dict()

    async def upsert_many(
        self,
        records: list[dict],
        *,
        idempotency_key: str,
        ctx: Any,
        expected_revisions: dict | None = None,
        idempotency_class: str | None = None,
    ) -> dict:
        if not (isinstance(idempotency_key, str) and idempotency_key):  # PRE-001
            raise InvalidArguments("idempotency_key required")
        _log.info(
            "memory-call upsert_many REQUEST: %d record(s) idempotency_key=%s actor=%s scopes=%s",
            len(records), idempotency_key, _ctx_actor(ctx),
            [r.get("scope") for r in records],
        )
        # INV-002: idempotency_id = ("default", verb, actor-from-ctx, key); digest over payload.
        digest = _payload_digest({"records": records, "expected_revisions": expected_revisions})
        idempotency_id = "|".join(("default", "upsert_many", _ctx_actor(ctx), idempotency_key))
        cached = self._conn.execute(
            "SELECT digest FROM memory_idempotency WHERE idempotency_id = ?",
            (idempotency_id,),
        ).fetchone()
        if cached is not None:
            if cached[0] == digest:
                return {"upserted": len(records), "replayed": True}
            raise IdempotencyConflict("idempotency key reused with different payload")
        for record in records:  # INV-007 / PRE-002: validate before any write
            _validate_injection(record)
        # INV-004: all chunk rows + vec rows + the idempotency record in one transaction;
        # a RevisionMismatch raised here rolls the whole batch back.
        with self._conn:
            if expected_revisions:  # INV-003: optimistic lock, checked before any write
                for record in records:
                    cid = _chunk_id(record)
                    if cid in expected_revisions:
                        row = self._conn.execute(
                            "SELECT revision FROM memory_chunks WHERE chunk_id = ?", (cid,)
                        ).fetchone()
                        current = row[0] if row is not None else 0
                        if current != expected_revisions[cid]:
                            raise RevisionMismatch(
                                f"stale expected revision for {cid}: "
                                f"{expected_revisions[cid]} != {current}"
                            )
            for record in records:
                chunk_id = _chunk_id(record)
                # INV-003: first insert -> revision 1; re-upsert -> revision + 1.
                self._conn.execute(
                    "INSERT INTO memory_chunks "
                    "(chunk_id, record_json, revision, scope_json, origin) "
                    "VALUES (?, ?, 1, ?, ?) "
                    "ON CONFLICT(chunk_id) DO UPDATE SET "
                    "record_json=excluded.record_json, revision=memory_chunks.revision + 1, "
                    "scope_json=excluded.scope_json, origin=excluded.origin",
                    (
                        chunk_id,
                        json.dumps(record),
                        json.dumps(record.get("scope")),
                        record.get("origin"),
                    ),
                )
                self._conn.execute("DELETE FROM memory_vec WHERE chunk_id = ?", (chunk_id,))
                self._conn.execute(
                    "INSERT INTO memory_vec(chunk_id, embedding) VALUES (?, ?)",
                    (chunk_id, sqlite_vec.serialize_float32(_record_vector(record))),
                )
            self._conn.execute(
                "INSERT INTO memory_idempotency (idempotency_id, digest, expires_at) "
                "VALUES (?, ?, ?)",
                (idempotency_id, digest, time.time() + _idempotency_ttl_seconds(idempotency_class)),
            )
        return {"upserted": len(records), "replayed": False}

    async def search(
        self,
        vector: list[float],
        *,
        top_k: int,
        scope_all: dict | None = None,
        scope_any: list | None = None,
        metadata_filter: dict | None = None,
        include: dict | None = None,
        fidelity_target: Any = None,
    ) -> list[dict]:
        if len(vector) != self._dim:  # PRE-001
            raise InvalidArguments(f"vector length {len(vector)} != embedding_dim {self._dim}")
        if metadata_filter:  # PRE-002: v1 advertises no filterable metadata fields
            raise InvalidArguments("metadata_filter is unsupported in v1")
        scope_all = scope_all or {}
        scope_any = scope_any or []
        _validate_scope(scope_all, scope_any)  # STEP 1 (raises InvalidArguments / InvalidFilter)
        _log.info(
            "memory-call search REQUEST: scope_all=%r scope_any=%r top_k=%s vec_dim=%d",
            scope_all, scope_any, top_k, len(vector),
        )

        def _emit(rs: list[dict]) -> list[dict]:
            _log.info(
                "memory-call search RESPONSE: %d hit(s) %s",
                len(rs),
                [
                    {
                        "chunk_id": r["chunk_id"],
                        "score": round(r["score"], 4),
                        "scope": r["chunk"].get("scope"),
                    }
                    for r in rs
                ],
            )
            return rs

        if top_k <= 0:  # POST-001: at most top_k
            return _emit([])
        total = self._conn.execute("SELECT COUNT(*) FROM memory_vec").fetchone()[0]
        if total == 0:
            return _emit([])
        # Over-fetch every candidate ranked by cosine distance, then scope-filter and
        # take top_k — so top_k counts IN-SCOPE hits (INV-005), not pre-filter hits.
        rows = self._conn.execute(
            "SELECT v.chunk_id, v.distance, c.record_json, c.revision "
            "FROM memory_vec v JOIN memory_chunks c ON c.chunk_id = v.chunk_id "
            f"WHERE v.embedding MATCH ? AND k = {total} ORDER BY v.distance",
            (sqlite_vec.serialize_float32([float(x) for x in vector]),),
        ).fetchall()
        results: list[dict] = []
        for chunk_id, distance, record_json, revision in rows:
            record = json.loads(record_json)
            if not _matches_scope(record.get("scope"), scope_all, scope_any):
                continue
            results.append(
                {
                    "chunk": record,
                    "chunk_id": chunk_id,
                    "score": 1.0 - distance,  # vec0 cosine distance -> similarity
                    "recalled_view": record.get("distillate", record),
                    "revision": revision,
                }
            )
            if len(results) >= top_k:
                break
        return _emit(results)

    async def get(self, chunk_id: str) -> dict | None:
        # INV-001: verbatim round-trip + an attached revision key, or None.
        row = self._conn.execute(
            "SELECT record_json, revision FROM memory_chunks WHERE chunk_id = ?",
            (chunk_id,),
        ).fetchone()
        if row is None:
            return None
        record = json.loads(row[0])
        record["revision"] = row[1]
        return record

    async def get_many(self, ids: list[str]) -> list[dict]:
        # List form of get: found records only (absent ids are skipped).
        found = []
        for chunk_id in ids:
            record = await self.get(chunk_id)
            if record is not None:
                found.append(record)
        return found

    async def delete_many(self, ids: list[str]) -> dict:
        # One transaction: chunk row + its vec row leave together (no orphan vec rows).
        _log.info("memory-call delete_many REQUEST: ids=%s", ids)
        deleted = 0
        with self._conn:
            for chunk_id in ids:
                cur = self._conn.execute(
                    "DELETE FROM memory_chunks WHERE chunk_id = ?", (chunk_id,)
                )
                if cur.rowcount > 0:
                    deleted += 1
                self._conn.execute("DELETE FROM memory_vec WHERE chunk_id = ?", (chunk_id,))
        return {"deleted": deleted}

    async def mark_superseded(
        self, ids: list[str], *, superseded_by: str | None = None, reason: str | None = None
    ) -> dict:
        # Worldtree #364 retirement (INV-011). Mirrors the reference `_mark_lifecycle`:
        # sets TOP-LEVEL `superseded`/`superseded_by`/`superseded_reason` (only non-None fields),
        # increments revision. NON-destructive — get still returns; scan (live-only) excludes via
        # `_is_live`'s `superseded` short-circuit. The sole supersession verb #364 uses.
        _log.info("memory-call mark_superseded REQUEST: ids=%r superseded_by=%s", ids, superseded_by)
        fields = {"superseded": True, "superseded_by": superseded_by, "superseded_reason": reason}
        marked = 0
        with self._conn:
            for chunk_id in ids:
                row = self._conn.execute(
                    "SELECT record_json FROM memory_chunks WHERE chunk_id = ?", (chunk_id,)
                ).fetchone()
                if row is None:  # unknown id -> skip (never error), mirrors reference + delete_many
                    continue
                record = json.loads(row[0])
                for key, value in fields.items():
                    if value is not None:  # reference writes only non-None fields
                        record[key] = value
                self._conn.execute(
                    "UPDATE memory_chunks SET record_json = ?, revision = revision + 1 "
                    "WHERE chunk_id = ?",
                    (json.dumps(record), chunk_id),
                )
                marked += 1
        _log.info("memory-call mark_superseded RESPONSE: marked=%d", marked)
        return {"marked": marked}

    async def scan(
        self,
        *,
        scope_all: dict | None = None,
        scope_any: list | None = None,
        cursor: str | None = None,
        limit: int,
        sort: dict | None = None,
        lifecycle_state: Any = None,
    ) -> dict:
        # #349 person-prime: query-LESS, LIVE-only (INV-009), globally-ordered (INV-010) scan.
        if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:  # PRE-001
            raise InvalidArguments("limit must be a positive int")
        scope_all = scope_all or {}
        scope_any = scope_any or []
        _validate_scope(scope_all, scope_any)  # PRE-002 (same lattice as search)
        if sort is not None and not isinstance(sort, dict):  # PRE-003: malformed sort => reject, never crash
            raise InvalidArguments("sort must be an object with field and direction")
        field = (sort or {}).get("field", "updated_at")
        direction = (sort or {}).get("direction", "desc")
        if field not in _SORTABLE_FIELD_NAMES or direction not in ("asc", "desc"):  # PRE-003
            raise InvalidArguments(f"sort.field {field!r} is not globally sortable")
        _log.info(
            "memory-call scan REQUEST: scope_all=%r scope_any=%r limit=%s sort=%s",
            scope_all, scope_any, limit, sort,
        )
        # INV-010: global order by the INDEXED sort field, missing-last, chunk_id tiebreak
        # (field is whitelisted above, so the interpolation is injection-safe).
        order = "DESC" if direction == "desc" else "ASC"
        rows = self._conn.execute(
            "SELECT record_json FROM memory_chunks "
            f"ORDER BY (json_extract(record_json, '$.{field}') IS NULL), "
            f"json_extract(record_json, '$.{field}') {order}, chunk_id ASC"
        ).fetchall()
        skip = 0
        if cursor is not None:
            try:
                skip = int(cursor)
            except (TypeError, ValueError):
                raise InvalidArguments("invalid scan cursor")
            if skip < 0:
                raise InvalidArguments("invalid scan cursor")
        records: list[dict] = []
        matched = 0
        has_more = False
        for (record_json,) in rows:
            record = json.loads(record_json)
            if not _matches_scope(record.get("scope"), scope_all, scope_any):  # INV-005
                continue
            if not _is_live(record):  # INV-009
                continue
            matched += 1
            if matched <= skip:  # cursor is an offset into the GLOBAL order (INV-010)
                continue
            if len(records) >= limit:  # POST-001: single limit page; one more match => next page exists
                has_more = True
                break
            records.append(record)
        # Emit a cursor ONLY when a further match exists — so a page that exactly exhausts
        # the matched set returns cursor=None (no empty trailing page), matching the reference.
        next_cursor = str(skip + len(records)) if has_more else None
        _log.info("memory-call scan RESPONSE: %d record(s) next_cursor=%s", len(records), next_cursor)
        return {"records": records, "cursor": next_cursor}

    def count_chunks(self) -> int:
        """DEBUG read seam: total stored chunk rows (unfiltered). Lets the memory
        viewer distinguish 'store is empty' (total 0 — no upsert ever landed) from
        'scope mismatch' (total > 0 but 0 matched the queried partition)."""
        return int(self._conn.execute("SELECT COUNT(*) FROM memory_chunks").fetchone()[0])

    def list_chunks(
        self, *, agent_id: str | None = None, end_user_id: str | None = None
    ) -> list[dict]:
        """DEBUG read (non-bifrost): list stored chunks as a content·scope·origin view
        for the web memory pane, filtered by the `end_user` (strict) and `agent_self`
        (lenient) scope axes. bifrost's memory protocol has NO list-all verb, so this is
        OUR read on OUR store (per the debug-surface-uses-canonical-surface principle:
        we read only our own store, never a dep's private). Returns [] when nothing
        matches — an empty list is a valid, visible answer (the 0-chunks state).

        - `end_user_id`: strict — a chunk passes only if `scope.end_user == end_user_id`
          (the partition boundary; the route requires it, the web proxy supplies it).
        - `agent_id`: lenient — a chunk is excluded only if it CARRIES an `agent_self`
          axis that differs; chunks written without one are not hidden (so a chunk
          scoped `{end_user}`-only stays visible for diagnosis).
        """
        rows = self._conn.execute(
            "SELECT chunk_id, record_json, revision, scope_json, origin FROM memory_chunks"
        ).fetchall()
        out: list[dict] = []
        for chunk_id, record_json, revision, scope_json, origin in rows:
            scope = json.loads(scope_json) if scope_json else {}
            if not isinstance(scope, dict):
                scope = {}
            if end_user_id is not None and scope.get("end_user") != end_user_id:
                continue
            if agent_id is not None:
                chunk_agent = scope.get("agent_self")
                if chunk_agent is not None and chunk_agent != agent_id:
                    continue
            record = json.loads(record_json)
            out.append(
                {
                    "chunk_id": chunk_id,
                    "content": _chunk_content_preview(record),
                    "scope": scope,
                    "origin": origin,
                    "revision": revision,
                }
            )
        return out


def open_memory_store(db_path: str, *, embedding_dim: int) -> RatatoskrMemoryStore:
    """Open the SQLite+sqlite-vec memory store, creating schema + the vec index on first use."""
    if not (isinstance(embedding_dim, int) and embedding_dim > 0):  # PRE-002
        raise ValueError("embedding_dim must be a positive int")
    # check_same_thread=False: the memory provider is an ASGI app; uvicorn/Starlette
    # (and TestClient always) may run a handler off the connection's creating thread.
    # The event loop serializes the sync sqlite calls, so this is safe. Mirrors the
    # affect store (bifrost_affect_provider INV-006); surfaced by a TestClient-driven
    # memory-call search through the combined provider (#18 D1).
    conn = sqlite3.connect(db_path, check_same_thread=False)
    conn.enable_load_extension(True)
    sqlite_vec.load(conn)
    conn.enable_load_extension(False)
    conn.execute("PRAGMA busy_timeout=5000")  # wait up to 5s, don't fail SQLITE_BUSY at once
    if db_path != ":memory:":
        conn.execute("PRAGMA journal_mode=WAL")
    conn.execute(
        "CREATE TABLE IF NOT EXISTS memory_chunks ("
        "chunk_id TEXT PRIMARY KEY, record_json TEXT NOT NULL, "
        "revision INTEGER NOT NULL, scope_json TEXT, origin TEXT)"
    )
    # INV-010: expression index on the scan sort field (updated_at) so the globally-ordered
    # person-prime scan stays within its 500ms fail-open budget.
    conn.execute(
        "CREATE INDEX IF NOT EXISTS idx_chunks_updated_at "
        "ON memory_chunks (json_extract(record_json, '$.updated_at'))"
    )
    conn.execute(
        "CREATE TABLE IF NOT EXISTS memory_idempotency ("
        "idempotency_id TEXT PRIMARY KEY, digest TEXT NOT NULL, expires_at REAL)"
    )
    # INV-005 / cosine recall: vec0 index at the fixed PINNED_EMBEDDER_DIM, cosine metric.
    conn.execute(
        "CREATE VIRTUAL TABLE IF NOT EXISTS memory_vec USING vec0("
        f"chunk_id TEXT PRIMARY KEY, embedding float[{embedding_dim}] distance_metric=cosine)"
    )
    conn.commit()
    return RatatoskrMemoryStore(conn, embedding_dim)


def add_memory_read_route(app, store: RatatoskrMemoryStore) -> None:
    """Mount the non-bifrost DEBUG read route GET /memory/chunks?agent_id=&end_user_id=
    on `app`, reading store.list_chunks. SHARED by build_memory_provider_app and the
    combined provider (mirrors the affect D2 add_affect_read_route). add_route (NOT Mount)
    keeps /bifrost/* top-level so the op-feed path check still matches them and passes
    this route through untouched. No JWT (internal-LAN trust model).

    end_user_id is REQUIRED (400 missing_end_user_id) — the partition boundary, supplied
    server-side by the web proxy, never named by the browser. agent_id is an optional
    lenient filter. An empty match is a 200 with an empty list (the 0-chunks state is a
    visible answer, not a 404).
    """
    async def _memory_chunks_route(request: Request) -> JSONResponse:
        end_user_id = request.query_params.get("end_user_id")
        if not end_user_id:  # never scan against a None/empty partition
            return JSONResponse({"error_code": "missing_end_user_id"}, status_code=400)
        agent_id = request.query_params.get("agent_id") or None
        chunks = store.list_chunks(agent_id=agent_id, end_user_id=end_user_id)
        return JSONResponse(
            {"chunks": chunks, "count": len(chunks), "total": store.count_chunks()}
        )

    app.add_route("/memory/chunks", _memory_chunks_route, methods=["GET"])


def build_memory_provider_app(
    store: RatatoskrMemoryStore,
    heimdall_key: bytes,
    consumer_id: str = "ratatoskr",
):
    """Wire the JWT verifier + registration and hand the store to bifrost.

    Returns a Starlette ASGI app exposing POST /bifrost/handshake and
    POST /bifrost/memory-call. The library owns the wire; this is the thin glue.
    Additionally mounts the non-bifrost GET /memory/chunks DEBUG read route (the
    memory-viewer pane's read seam), the memory-plane analogue of the affect D2 route.
    """
    if not isinstance(store.describe_store(), dict):  # PRE-001 / INV-008
        raise ValueError("store must advertise capabilities via describe_store()")
    if not (isinstance(heimdall_key, bytes) and heimdall_key):  # PRE-002
        raise ValueError("heimdall_key must be non-empty bytes")
    verifier = JwtVerifier(algorithm="HS256", key_bytes=heimdall_key)
    registration = ConsumerRegistration(consumer_id=consumer_id)
    app = build_memory_app(store=store, verifier=verifier, registration=registration)
    add_memory_read_route(app, store)  # DEBUG read: GET /memory/chunks (memory viewer)
    return app
