"""cortex/api/routes/memory.py — Memory CRUD endpoints.

Canonical paths for remember/retrieve (issue #297):
  POST /api/v1/memories/remember  ← preferred (was /remember/v2)
  POST /api/v1/memories/retrieve  ← preferred (was /retrieve/v2)
  POST /api/v1/memories/feedback  ← preferred (was /feedback)
  POST /api/v1/memories/retrieve/explain  ← preferred (was /retrieve/explain)

Deprecated paths (sunset 2026-06-01):
  POST /api/v1/remember      → /memories/remember
  POST /api/v1/retrieve      → /memories/retrieve
  POST /api/v1/remember/v2   → /memories/remember
  POST /api/v1/retrieve/v2   → /memories/retrieve
  POST /api/v1/feedback      → /memories/feedback
  POST /api/v1/retrieve/explain → /memories/retrieve/explain
"""

from __future__ import annotations

import logging
from typing import Any, Dict, List, Literal, Optional

from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from fastapi.responses import JSONResponse

from cortex.api.deps import PREFIX, _serialise, get_plugin, plugin_holder


def _resolve_owner_id(request: Request, body_owner_id: str | None = None) -> str:
    """Return the effective owner_id: body override → auth middleware → config fallback."""
    if body_owner_id:
        return body_owner_id
    return getattr(request.state, "owner_id", None) or "default"
from cortex.api.deprecation import add_deprecation_headers
from cortex.types import VALID_CATEGORIES

# Map API-advertised aliases to engine-supported retrieval modes
_MODE_ALIASES: dict[str, str] = {"fast": "lightweight", "thorough": "agentic"}
from cortex.api.models import (
    CategoryListResponse,
    EnhancedRememberRequest,
    EnhancedRememberResponse,
    EnhancedRetrieveRequest,
    FeedbackRequest,
    ForgetResponse,
    MemoryBrowseResponse,
    MemoryDetailResponse,
    MemoryPatchRequest,
    MemoryPatchResponse,
    MemorySearchRequest,
    MemorySearchResponse,
    RememberRequest,
    RememberResponse,
    RetrieveExplainRequest,
    RetrieveExplainResponse,
    RetrieveRequest,
)

logger = logging.getLogger(__name__)

router = APIRouter()

_MAX_REMEMBER_TOTAL_CHARS = 50000
_MAX_REMEMBER_MESSAGE_CHARS = 10000
# Issue #486 — Hard cap on serialised JSON body size for /remember endpoints.
# Configurable via [cortex.api] max_remember_body_bytes in TOML; this constant
# is the fallback default used when the plugin/config is not yet available.
_MAX_REMEMBER_BYTES = 1_000_000  # 1 MB


def _normalize_and_validate_remember_conversation(conversation_input: Any) -> Any:
    """Normalize remember payload + enforce API-level length limits."""
    if isinstance(conversation_input, list):
        if len(conversation_input) == 0:
            raise HTTPException(
                status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
                detail="Conversation must not be empty",
            )

        normalized = []
        total_len = 0
        for idx, msg in enumerate(conversation_input):
            content = msg.content
            if len(content) > _MAX_REMEMBER_MESSAGE_CHARS:
                raise HTTPException(
                    status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
                    detail=(
                        f"conversation[{idx}].content too long "
                        f"(max {_MAX_REMEMBER_MESSAGE_CHARS} characters)"
                    ),
                )
            total_len += len(content)
            normalized.append({"role": msg.role, "content": content})

        if total_len > _MAX_REMEMBER_TOTAL_CHARS:
            raise HTTPException(
                status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
                detail=(
                    "Conversation too long "
                    f"(max {_MAX_REMEMBER_TOTAL_CHARS} characters total)"
                ),
            )
        return normalized

    if conversation_input == "":
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
            detail="Conversation must not be empty",
        )

    if len(conversation_input) > _MAX_REMEMBER_MESSAGE_CHARS:
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
            detail=(
                "Conversation text too long "
                f"(max {_MAX_REMEMBER_MESSAGE_CHARS} characters)"
            ),
        )

    return conversation_input



# ---------------------------------------------------------------------------
# Shadow mode helpers
# ---------------------------------------------------------------------------

async def _shadow_remember(
    plugin: Any,
    conversation: Any,
    session_id: Optional[str],
    owner_id: Optional[str],
) -> dict:
    """Run extraction pipeline in shadow mode: extract claims but do NOT persist.

    Conversation should already be validated/normalized by the caller.
    Returns a dict with extraction results suitable for the shadow response.
    """
    import time as _time

    t0 = _time.monotonic()

    extractor = getattr(plugin, "extractor", None)
    if extractor is None:
        return {
            "claims_extracted": 0,
            "would_store": [],
            "entities_resolved": 0,
            "noise_filtered": 0,
            "processing_time_ms": 0,
            "errors": ["Extraction pipeline not available"],
        }

    sid = session_id or "shadow"
    if isinstance(conversation, str):
        messages = [{"role": "user", "content": conversation}]
    else:
        messages = conversation

    result = await extractor.extract(messages=messages, session_id=sid)

    would_store: List[str] = []
    entities_resolved_set: set = set()
    for claim in result.claims:
        would_store.append(f"{claim.subject} {claim.predicate} {claim.object_value}")
        for ent in getattr(claim, "entities_mentioned", []):
            entities_resolved_set.add(getattr(ent, "name", str(ent)))

    duration_ms = int((_time.monotonic() - t0) * 1000)

    _emit_shadow_event(
        endpoint="/api/v1/remember",
        session_id=sid,
        claims_extracted=len(result.claims),
        would_store=would_store,
        entities_resolved=len(entities_resolved_set),
        noise_filtered=result.noise_filtered,
    )

    return {
        "claims_extracted": len(result.claims),
        "would_store": would_store,
        "entities_resolved": len(entities_resolved_set),
        "noise_filtered": result.noise_filtered,
        "processing_time_ms": duration_ms,
        "errors": [],
    }


def _emit_shadow_event(
    endpoint: str,
    session_id: str,
    claims_extracted: int = 0,
    would_store: Optional[List[str]] = None,
    entities_resolved: int = 0,
    noise_filtered: int = 0,
) -> None:
    """Emit a structured shadow-mode debug event.

    If cortex.debug exists (ring buffer from #452), use it.
    Otherwise fall back to structured Python logging.
    """
    import datetime

    event = {
        "event_type": "shadow",
        "endpoint": endpoint,
        "session_id": session_id,
        "timestamp": datetime.datetime.now(datetime.UTC).isoformat(),
        "claims_extracted": claims_extracted,
        "would_store": would_store or [],
        "entities_resolved": entities_resolved,
        "noise_filtered": noise_filtered,
    }

    try:
        from cortex.debug import emit_event
        emit_event(event)
    except (ImportError, AttributeError):
        logger.info("shadow_event: %s", event)


def _make_routes(verify_api_key):
    """Register all memory routes with the given auth dependency."""

    # DEPRECATED: v1 remember — use POST {PREFIX}/remember/v2 (canonical)
    @router.post(
        f"{PREFIX}/remember",
        response_model=RememberResponse,
        status_code=status.HTTP_200_OK,
        summary="Extract and store memories (DEPRECATED — use /memories/remember)",
        tags=["memory"],
        operation_id="remember",
        deprecated=True,
    )
    async def remember(
        request: Request,
        body: RememberRequest,
        response: Response,
        shadow: bool = Query(False, description="Shadow mode — extract but do not persist"),
        _: None = Depends(verify_api_key),
        plugin: Any = Depends(get_plugin),
    ) -> JSONResponse:
        """
        Extract semantic claims from a conversation and persist them to memory.

        Accepts either a list of ``{role, content}`` messages or a raw text string.
        Pass ``?shadow=true`` to run extraction without persisting (dry run).

        .. deprecated::
            Use ``POST /memories/remember`` instead, which returns enhanced response details.
        """
        add_deprecation_headers(response, f"{PREFIX}/memories/remember")

        # Issue #486 — body size guard (before any serialisation work)
        import json as _json
        try:
            _cfg_limit = getattr(getattr(plugin, "config", None), "max_remember_body_bytes", None)
            _body_limit = int(_cfg_limit) if isinstance(_cfg_limit, int) else _MAX_REMEMBER_BYTES
        except (TypeError, ValueError):
            _body_limit = _MAX_REMEMBER_BYTES
        if _body_limit > 0:
            _body_bytes = len(_json.dumps(
                body.conversation if not hasattr(body.conversation, "__iter__")
                or isinstance(body.conversation, str)
                else [m.model_dump() if hasattr(m, "model_dump") else (m.dict() if hasattr(m, "dict") else m) for m in body.conversation]
            ).encode("utf-8"))
            if _body_bytes > _body_limit:
                raise HTTPException(
                    status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
                    detail=f"Request body too large ({_body_bytes} bytes > {_body_limit} limit)",
                )

        conversation = _normalize_and_validate_remember_conversation(body.conversation)

        effective_owner = _resolve_owner_id(request, body.owner_id)

        if shadow:
            shadow_result = await _shadow_remember(
                plugin, conversation, body.session_id, effective_owner,
            )
            return JSONResponse(content={
                "session_id": body.session_id or "shadow",
                "shadow": True,
                "claims_extracted": shadow_result["claims_extracted"],
                "claims_stored": 0,
                "would_store": shadow_result["would_store"],
                "entities_resolved": shadow_result["entities_resolved"],
                "noise_filtered": shadow_result["noise_filtered"],
                "errors": shadow_result["errors"],
                "warnings": [],
                "zero_claim_warning": False,
                "ok": True,
            })

        try:
            report = await plugin.remember(
                conversation=conversation,
                session_id=body.session_id,
                owner_id=effective_owner,
            )
        except (ValueError, TypeError) as exc:
            raise HTTPException(
                status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
                detail=f"Invalid conversation payload: {exc}",
            ) from exc
        return JSONResponse(content={
            "session_id": report.session_id,
            "claims_extracted": report.claims_extracted,
            "claims_stored": report.claims_stored,
            "entities_resolved": report.entities_resolved,
            "noise_filtered": report.noise_filtered,
            "errors": report.errors,
            "warnings": getattr(report, "warnings", []),
            "zero_claim_warning": getattr(report, "zero_claim_warning", False),
            "ok": report.ok,
        })

    # DEPRECATED: v1 retrieve — use POST {PREFIX}/retrieve/v2 (canonical)
    @router.post(
        f"{PREFIX}/retrieve",
        summary="Retrieve context for a query (DEPRECATED — use /memories/retrieve)",
        tags=["memory"],
        operation_id="retrieve",
        deprecated=True,
    )
    async def retrieve(
        request: Request,
        body: RetrieveRequest,
        response: Response,
        shadow: bool = Query(False, description="Shadow mode — flag response as shadow"),
        _: None = Depends(verify_api_key),
        plugin: Any = Depends(get_plugin),
    ) -> JSONResponse:
        """
        Retrieve relevant memories for a query, respecting an optional token budget.

        Returns the raw RetrievalResult serialised as JSON.
        Pass ``?shadow=true`` to flag the response as a shadow-mode retrieval.

        .. deprecated::
            Use ``POST /memories/retrieve`` instead, which supports explain mode.
        """
        add_deprecation_headers(response, f"{PREFIX}/memories/retrieve")
        effective_mode = body.strategy or body.mode
        effective_mode = _MODE_ALIASES.get(effective_mode, effective_mode)
        effective_owner = _resolve_owner_id(request, body.owner_id)
        result = await plugin.retrieve(
            query=body.query,
            token_budget=body.token_budget,
            mode=effective_mode,
            owner_id=effective_owner,
        )
        if result is None:
            raise HTTPException(
                status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
                detail="Retrieval pipeline not available",
            )
        if hasattr(result, "__dict__"):
            data = _serialise(result.__dict__)
        else:
            data = _serialise(result)
        if shadow:
            data["shadow"] = True
            _emit_shadow_event(endpoint="/api/v1/retrieve", session_id="shadow")
        return JSONResponse(content=data)

    # NOTE: /memories/categories MUST come before /memories/{memory_id}
    # so FastAPI doesn't interpret "categories" as a path param.
    @router.get(
        f"{PREFIX}/memories/categories",
        response_model=CategoryListResponse,
        summary="List memory categories with counts",
        tags=["memories"],
        operation_id="list_memory_categories",
    )
    async def list_memory_categories(
        _: None = Depends(verify_api_key),
        plugin: Any = Depends(get_plugin),
    ) -> CategoryListResponse:
        """Return all memory categories with per-category claim counts."""
        errors: List[str] = []
        storage = getattr(plugin, "storage", None)
        if storage is None:
            errors.append("Storage not available")
            return CategoryListResponse(errors=errors)

        owner_id = getattr(plugin.config, "owner_id", "default")

        _CATEGORY_META: Dict[str, Dict[str, str]] = {
            "identity":    {"color": "#6366f1", "description": "Who the user is"},
            "personal":    {"color": "#ec4899", "description": "Personal facts and relationships"},
            "preferences": {"color": "#f59e0b", "description": "Preferences and likes/dislikes"},
            "engineering": {"color": "#10b981", "description": "Technical knowledge and projects"},
            "goals":       {"color": "#3b82f6", "description": "Goals and aspirations"},
            "misc":        {"color": "#6b7280", "description": "Miscellaneous memories"},
        }

        try:
            db_counts = await storage.get_category_counts(owner_id=owner_id)
        except Exception as exc:
            errors.append(f"Category fetch failed: {exc}")
            db_counts = []

        counts_by_name = {r["name"]: r["count"] for r in db_counts}
        all_names = set(_CATEGORY_META.keys()) | set(counts_by_name.keys())
        categories = []
        for name in sorted(all_names):
            meta = _CATEGORY_META.get(name, {"color": "#6b7280", "description": name})
            categories.append({
                "name": name,
                "count": counts_by_name.get(name, 0),
                "color": meta["color"],
                "description": meta["description"],
            })

        return CategoryListResponse(categories=categories, errors=errors)

    @router.get(
        f"{PREFIX}/memories",
        response_model=MemoryBrowseResponse,
        summary="Browse and filter memories",
        tags=["memories"],
        operation_id="browse_memories",
    )
    async def browse_memories(
        page: int = 1,
        per_page: int = 50,
        time_range: str = "all",
        status: Optional[str] = None,
        category: Optional[str] = None,
        entity_name: Optional[str] = None,
        q: Optional[str] = None,
        sort_by: Literal["created_at", "updated_at", "subject"] = "updated_at",
        sort_order: Literal["asc", "desc"] = "desc",
        owner_id: Optional[str] = None,
        _: None = Depends(verify_api_key),
        plugin: Any = Depends(get_plugin),
    ) -> MemoryBrowseResponse:
        """Browse semantic memories with filtering, pagination, and keyword search."""
        errors: List[str] = []
        storage = getattr(plugin, "storage", None)
        if storage is None:
            errors.append("Storage not available")
            return MemoryBrowseResponse(errors=errors)

        effective_owner = owner_id or getattr(plugin.config, "owner_id", "default")
        per_page = max(1, min(per_page, 200))
        page = max(1, page)

        try:
            result = await storage.browse_memories(
                owner_id=effective_owner,
                page=page,
                per_page=per_page,
                time_range=time_range,
                status=status,
                category=category,
                entity_name=entity_name,
                q=q,
                sort_by=sort_by,
                sort_order=sort_order,
            )
        except Exception as exc:
            err = f"Memory browse failed: {exc}"
            logger.error(err, exc_info=True)
            errors.append(err)
            return MemoryBrowseResponse(errors=errors)

        return MemoryBrowseResponse(
            items=result["items"],
            total=result["total"],
            page=result["page"],
            per_page=result["per_page"],
            pages=result["pages"],
            category_counts=result["category_counts"],
            errors=errors,
        )

    @router.get(
        f"{PREFIX}/memories/{{memory_id}}",
        response_model=MemoryDetailResponse,
        summary="Get full memory detail",
        tags=["memories"],
        operation_id="get_memory",
    )
    async def get_memory_detail(
        memory_id: str,
        owner_id: Optional[str] = None,
        _: None = Depends(verify_api_key),
        plugin: Any = Depends(get_plugin),
    ) -> MemoryDetailResponse:
        """Return full claim data, changelog, and related memories (same subject entity)."""
        errors: List[str] = []
        storage = getattr(plugin, "storage", None)
        if storage is None:
            errors.append("Storage not available")
            return MemoryDetailResponse(errors=errors)

        effective_owner = owner_id or getattr(plugin.config, "owner_id", "default")
        claim = await storage.get_claim(memory_id, owner_id=effective_owner)
        if claim is None:
            raise HTTPException(
                status_code=status.HTTP_404_NOT_FOUND,
                detail=f"Memory '{memory_id}' not found",
            )

        memory_dict = _serialise(claim.__dict__) if hasattr(claim, "__dict__") else {"id": memory_id}

        try:
            changelog = await storage.get_changelog(memory_id)
        except Exception as exc:
            errors.append(f"Changelog fetch failed: {exc}")
            changelog = []

        provenance: List[Dict[str, Any]] = []
        try:
            prov_records = await storage.get_provenance(memory_id)
            for p in prov_records:
                provenance.append(_serialise(p.__dict__) if hasattr(p, "__dict__") else dict(p))
        except Exception as exc:
            errors.append(f"Provenance fetch failed: {exc}")

        related: List[Dict[str, Any]] = []
        try:
            entity_id = getattr(claim, "subject_entity_id", None)
            if entity_id:
                siblings = await storage.get_claims_by_entity(
                    entity_id,
                    owner_id=effective_owner,
                )
                for s in siblings:
                    if s.id != memory_id:
                        related.append({
                            "id": s.id,
                            "subject": s.subject,
                            "predicate": s.predicate,
                            "object_value": (s.object_value[:200] if s.object_value else ""),
                            "category": getattr(s, "category", "misc"),
                            "confidence": s.confidence,
                            "status": s.status,
                        })
                        if len(related) >= 5:
                            break
            else:
                siblings = await storage.get_claims_by_subject(
                    claim.subject,
                    owner_id=effective_owner,
                )
                for s in siblings:
                    if s.id != memory_id:
                        related.append({
                            "id": s.id,
                            "subject": s.subject,
                            "predicate": s.predicate,
                            "object_value": (s.object_value[:200] if s.object_value else ""),
                            "category": getattr(s, "category", "misc"),
                            "confidence": s.confidence,
                            "status": s.status,
                        })
                        if len(related) >= 5:
                            break
        except Exception as exc:
            errors.append(f"Related memories fetch failed: {exc}")

        return MemoryDetailResponse(
            memory=memory_dict,
            changelog=changelog,
            related=related,
            provenance=provenance,
            errors=errors,
        )

    @router.delete(
        f"{PREFIX}/memories/{{memory_id}}",
        response_model=ForgetResponse,
        summary="Delete a memory (soft or hard)",
        tags=["memories"],
        operation_id="delete_memory",
    )
    async def delete_memory(
        request: Request,
        memory_id: str,
        hard: bool = False,
        owner_id: Optional[str] = None,
        _: None = Depends(verify_api_key),
        plugin: Any = Depends(get_plugin),
    ) -> ForgetResponse:
        """
        Delete a memory.

        - Soft delete (default): marks status as 'archived'.
        - Hard delete (``?hard=true``): removes row from DB.
        - Logs action='deleted' to changelog regardless.
        """
        errors: List[str] = []
        storage = getattr(plugin, "storage", None)
        if storage is None:
            raise HTTPException(
                status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
                detail="Storage not available",
            )

        effective_owner = _resolve_owner_id(request, owner_id)
        claim = await storage.get_claim(memory_id, owner_id=effective_owner)
        if claim is None:
            raise HTTPException(
                status_code=status.HTTP_404_NOT_FOUND,
                detail=f"Memory '{memory_id}' not found",
            )

        try:
            # Log changelog BEFORE hard delete (row will be gone after)
            existing_log = await storage.get_changelog(memory_id)
            next_version = (max((e.get("version", 0) for e in existing_log), default=0) + 1
                            if existing_log else 1)
            snapshot = f"{claim.subject} {claim.predicate} {claim.object_value}"
            action = "hard_deleted" if hard else "deleted"
            await storage.log_changelog(
                claim_id=memory_id,
                version=next_version,
                content_snapshot=snapshot,
                action=action,
                reason=f"Operator {'hard' if hard else 'soft'} delete via API",
                changed_by="operator",
            )
        except Exception as exc:
            errors.append(f"Changelog write failed: {exc}")

        try:
            await storage.delete_claim(memory_id, owner_id=effective_owner, hard=hard)
        except Exception as exc:
            raise HTTPException(
                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
                detail="Internal server error",
            ) from exc

        return ForgetResponse(
            memory_id=memory_id,
            deleted=True,
            reason=f"Memory {'hard' if hard else 'soft'} deleted",
            errors=errors,
        )

    @router.patch(
        f"{PREFIX}/memories/{{memory_id}}",
        response_model=MemoryPatchResponse,
        summary="Update memory metadata",
        tags=["memories"],
        operation_id="patch_memory",
    )
    async def patch_memory(
        request: Request,
        memory_id: str,
        body: MemoryPatchRequest,
        owner_id: Optional[str] = None,
        _: None = Depends(verify_api_key),
        plugin: Any = Depends(get_plugin),
    ) -> MemoryPatchResponse:
        """Update metadata fields on a memory: category, sensitivity, status, confidence."""
        errors: List[str] = []
        storage = getattr(plugin, "storage", None)
        if storage is None:
            raise HTTPException(
                status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
                detail="Storage not available",
            )

        effective_owner = _resolve_owner_id(request, owner_id)
        claim = await storage.get_claim(memory_id, owner_id=effective_owner)
        if claim is None:
            raise HTTPException(
                status_code=status.HTTP_404_NOT_FOUND,
                detail=f"Memory '{memory_id}' not found",
            )

        _valid_sensitivity = {"normal", "private", "secret"}
        _valid_status = {"active", "cooling", "deprecated", "archived", "superseded"}
        _valid_categories = VALID_CATEGORIES

        updates: Dict[str, Any] = {}

        if body.category is not None:
            if body.category not in _valid_categories:
                raise HTTPException(
                    status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
                    detail=f"Invalid category '{body.category}'. Valid: {sorted(_valid_categories)}",
                )
            updates["category"] = body.category

        if body.sensitivity is not None:
            if body.sensitivity not in _valid_sensitivity:
                raise HTTPException(
                    status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
                    detail=f"Invalid sensitivity '{body.sensitivity}'. Valid: {sorted(_valid_sensitivity)}",
                )
            updates["sensitivity"] = body.sensitivity

        if body.status is not None:
            if body.status not in _valid_status:
                raise HTTPException(
                    status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
                    detail=f"Invalid status '{body.status}'. Valid: {sorted(_valid_status)}",
                )
            updates["status"] = body.status

        if body.confidence is not None:
            if not (0.0 <= body.confidence <= 1.0):
                raise HTTPException(
                    status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
                    detail="confidence must be between 0.0 and 1.0",
                )
            updates["confidence"] = body.confidence

        if not updates:
            raise HTTPException(
                status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
                detail="No valid fields provided for update",
            )

        try:
            updated_claim = await storage.update_claim(
                memory_id,
                owner_id=effective_owner,
                **updates,
            )
        except Exception as exc:
            raise HTTPException(
                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
                detail="Internal server error",
            ) from exc

        # Log to changelog
        try:
            existing_log = await storage.get_changelog(memory_id)
            next_version = (max((e.get("version", 0) for e in existing_log), default=0) + 1
                            if existing_log else 1)
            snapshot = f"{updated_claim.subject} {updated_claim.predicate} {updated_claim.object_value}"
            change_summary = ", ".join(f"{k}={v}" for k, v in updates.items())
            await storage.log_changelog(
                claim_id=memory_id,
                version=next_version,
                content_snapshot=snapshot,
                action="metadata_updated",
                reason=f"Operator patch: {change_summary}",
                changed_by="operator",
            )
        except Exception as exc:
            errors.append(f"Changelog write failed: {exc}")

        memory_dict = _serialise(updated_claim.__dict__) if hasattr(updated_claim, "__dict__") else {"id": memory_id}
        return MemoryPatchResponse(memory=memory_dict, errors=errors)

    # ---- Canonical: /memories/feedback  (issue #297) ----
    @router.post(
        f"{PREFIX}/memories/feedback",
        summary="Submit memory feedback",
        tags=["memory"],
        operation_id="memories_submit_feedback",
    )
    async def memories_submit_feedback(
        body: FeedbackRequest,
        _: None = Depends(verify_api_key),
        plugin: Any = Depends(get_plugin),
    ) -> JSONResponse:
        """Submit positive or negative feedback for a retrieved memory.

        Canonical path: ``POST /api/v1/memories/feedback``.
        """
        return await _do_submit_feedback(body, plugin)

    # ---- DEPRECATED: /feedback  (issue #297) ----
    @router.post(
        f"{PREFIX}/feedback",
        summary="Submit memory feedback — DEPRECATED, use /memories/feedback",
        tags=["memory (deprecated)"],
        operation_id="submit_feedback",
        deprecated=True,
    )
    async def submit_feedback(
        body: FeedbackRequest,
        response: Response,
        _: None = Depends(verify_api_key),
        plugin: Any = Depends(get_plugin),
    ) -> JSONResponse:
        """Submit positive or negative feedback for a retrieved memory.

        .. deprecated::
            Use ``POST /api/v1/memories/feedback``.
            Sunset: 2026-06-01  (issue #297)
        """
        add_deprecation_headers(response, f"{PREFIX}/memories/feedback")
        return await _do_submit_feedback(body, plugin)

    async def _do_submit_feedback(body: FeedbackRequest, plugin: Any) -> JSONResponse:
        """Shared implementation for feedback endpoints."""
        result = await plugin.feedback(
            memory_id=body.memory_id,
            helpful=body.helpful,
            context=body.context,
            target_type=body.target_type,
        )
        if result is None:
            raise HTTPException(
                status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
                detail="FeedbackSystem not available",
            )
        data = _serialise(result.__dict__) if hasattr(result, "__dict__") else str(result)
        return JSONResponse(content={"feedback": data})

    # ---- Canonical: /memories/retrieve/explain  (issue #297) ----
    @router.post(
        f"{PREFIX}/memories/retrieve/explain",
        response_model=RetrieveExplainResponse,
        status_code=status.HTTP_200_OK,
        summary="Retrieval with explanation",
        tags=["memory"],
        operation_id="memories_retrieve_explain",
    )
    async def memories_retrieve_explain(
        body: RetrieveExplainRequest,
        _: None = Depends(verify_api_key),
        plugin: Any = Depends(get_plugin),
    ) -> RetrieveExplainResponse:
        """Retrieve memories for a query and return explanation of retrieval process.

        Canonical path: ``POST /api/v1/memories/retrieve/explain``.
        """
        return await _do_retrieve_explain(body, plugin)

    # ---- DEPRECATED: /retrieve/explain  (issue #297) ----
    @router.post(
        f"{PREFIX}/retrieve/explain",
        response_model=RetrieveExplainResponse,
        status_code=status.HTTP_200_OK,
        summary="Retrieval with explanation — DEPRECATED, use /memories/retrieve/explain",
        tags=["memory (deprecated)"],
        operation_id="retrieve_explain",
        deprecated=True,
    )
    async def retrieve_explain(
        body: RetrieveExplainRequest,
        response: Response,
        _: None = Depends(verify_api_key),
        plugin: Any = Depends(get_plugin),
    ) -> RetrieveExplainResponse:
        """DEPRECATED — use ``POST /api/v1/memories/retrieve/explain``.

        .. deprecated:: sunset 2026-06-01  (issue #297)
        """
        add_deprecation_headers(response, f"{PREFIX}/memories/retrieve/explain")
        return await _do_retrieve_explain(body, plugin)

    async def _do_retrieve_explain(
        body: RetrieveExplainRequest, plugin: Any
    ) -> RetrieveExplainResponse:
        """Retrieve memories for a query and return explanation of retrieval process."""
        errors: List[str] = []
        results: List[Dict[str, Any]] = []
        explanation: Dict[str, Any] = {}

        storage = getattr(plugin, "storage", None)

        try:
            from cortex.core.retrieval import RetrievalConstraints, RetrievalPipeline

            pipeline = RetrievalPipeline(
                storage=storage,
                config=plugin.config,
                token_budget=getattr(plugin, "token_budget_manager", None),
                cornerstone_guardian=getattr(plugin, "cornerstone_guardian", None),
                llm=getattr(plugin, "llm", None),
            )

            constraints = RetrievalConstraints(top_k=body.limit)
            owner_id = getattr(plugin.config, "owner_id", "default")

            import asyncio
            import time

            t0 = time.monotonic()

            candidate_k = body.limit * pipeline.candidate_multiplier

            bm25_task = asyncio.create_task(
                pipeline._bm25_search(body.query, top_k=candidate_k, owner_id=owner_id)
            )
            vector_task = asyncio.create_task(
                pipeline._vector_search(body.query, top_k=candidate_k, owner_id=owner_id)
            )

            from cortex.core.retrieval import _normalise_bm25, rrf_fusion
            bm25_results, vector_results = await asyncio.gather(bm25_task, vector_task)

            fts_matches = len(bm25_results)
            vector_matches = len(vector_results)

            bm25_ranked = _normalise_bm25(bm25_results)
            vector_ranked = [(r.item_id, r.score) for r in vector_results]
            fused = rrf_fusion([bm25_ranked, vector_ranked], k=pipeline.rrf_k)
            rrf_scores = [
                {"item_id": item_id, "score": round(score, 6)}
                for item_id, score in fused[:body.limit]
            ]

            cornerstone_injected = False
            cornerstone_tokens = 0
            guardian = getattr(pipeline, "cornerstone_guardian", None)
            if guardian is not None:
                try:
                    cs_list = await guardian.get_cornerstones(owner_id=owner_id)
                    cornerstone_injected = len(cs_list) > 0
                    for cs in cs_list:
                        cs_text = getattr(cs, "content", "") or ""
                        cornerstone_tokens += pipeline._count_tokens(cs_text)
                except Exception as e:
                    logger.warning("http_server: unhandled exception: %s", e)

            total_budget = getattr(plugin.config, "retrieval_token_budget", 3000)
            remaining = total_budget

            full_result = await pipeline.retrieve(
                query=body.query,
                mode="auto",
                constraints=constraints,
                owner_id=owner_id,
            )

            strategy = getattr(full_result, "mode", "lightweight")
            tokens_used = getattr(full_result, "tokens_used", 0)
            remaining = max(0, total_budget - tokens_used - cornerstone_tokens)

            results = [
                _serialise(item.__dict__) if hasattr(item, "__dict__") else _serialise(item)
                for item in (getattr(full_result, "items", []) or [])
            ]

            explanation = {
                "strategy": strategy,
                "fts_matches": fts_matches,
                "vector_matches": vector_matches,
                "rrf_scores": rrf_scores,
                "cornerstone_injected": cornerstone_injected,
                "token_budget": {
                    "total": total_budget,
                    "used": tokens_used,
                    "remaining": remaining,
                },
            }

        except Exception as exc:
            err = f"Retrieve/explain failed: {exc}"
            logger.error(err, exc_info=True)
            errors.append(err)

        return RetrieveExplainResponse(
            results=results,
            explanation=explanation,
            errors=errors,
        )

    @router.post(
        f"{PREFIX}/memories/search",
        response_model=MemorySearchResponse,
        status_code=status.HTTP_200_OK,
        summary="Semantic memory search",
        tags=["memory"],
        operation_id="search_memories",
    )
    async def search_memories(
        request: Request,
        body: MemorySearchRequest,
        _: None = Depends(verify_api_key),
        plugin: Any = Depends(get_plugin),
    ) -> MemorySearchResponse:
        """Semantic search over stored memories."""
        errors: List[str] = []
        items: List[Dict[str, Any]] = []

        owner_id = _resolve_owner_id(request, body.owner_id) if body.owner_id else (
            getattr(request.state, "owner_id", None) or getattr(plugin.config, "owner_id", "default")
        )
        filters = body.filters or {}
        requested_limit = body.top_k if body.top_k is not None else body.limit

        try:
            result = await plugin.retrieve(
                query=body.query,
                token_budget=None,
                mode="auto",
                owner_id=owner_id,
            )

            if result is None:
                return MemorySearchResponse(
                    errors=["Retrieval pipeline not available"], total=0
                )

            raw_items = getattr(result, "items", []) or []

            category_filter = filters.get("category")
            status_filter = filters.get("status")
            entity_filter = filters.get("entity_name")

            out: List[Dict[str, Any]] = []
            for item in raw_items:
                d = _serialise(item.__dict__) if hasattr(item, "__dict__") else _serialise(item)

                if category_filter and d.get("category") != category_filter:
                    continue
                if status_filter and d.get("status") != status_filter:
                    continue
                if entity_filter:
                    subject = (d.get("subject") or "").lower()
                    obj_val = (d.get("object_value") or "").lower()
                    if entity_filter.lower() not in subject and entity_filter.lower() not in obj_val:
                        continue

                score = d.pop("score", None) or d.pop("relevance_score", None) or 0.0
                out.append({
                    "id":       d.get("item_id") or d.get("id", ""),
                    "content":  d.get("content") or d.get("object_value") or "",
                    "score":    score,
                    "category": d.get("category", ""),
                    "entity":   d.get("subject") or d.get("entity_name", ""),
                    **{k: v for k, v in d.items() if k not in
                       ("item_id", "id", "content", "object_value", "score",
                        "relevance_score", "category", "entity_name", "subject")},
                })

                if len(out) >= requested_limit:
                    break

            items = out[:requested_limit]

        except Exception as exc:
            err = f"Memory search failed: {exc}"
            logger.error(err, exc_info=True)
            errors.append(err)

        return MemorySearchResponse(items=items, total=len(items), errors=errors)

    # ---- Canonical: /memories/retrieve  (issue #297) ----
    @router.post(
        f"{PREFIX}/memories/retrieve",
        summary="Enhanced agent retrieval with optional explain",
        tags=["memory"],
        operation_id="memories_retrieve",
    )
    async def memories_retrieve(
        request: Request,
        body: EnhancedRetrieveRequest,
        shadow: bool = Query(False, description="Shadow mode — flag response as shadow"),
        _: None = Depends(verify_api_key),
        plugin: Any = Depends(get_plugin),
    ) -> JSONResponse:
        """Retrieve relevant memories for a query. With ``explain=true``, includes scoring details.

        Canonical path: ``POST /api/v1/memories/retrieve``.
        """
        body.owner_id = _resolve_owner_id(request, body.owner_id)
        return await _do_retrieve_v2(body, shadow, plugin)

    # ---- DEPRECATED: /retrieve/v2  (issue #297) ----
    @router.post(
        f"{PREFIX}/retrieve/v2",
        summary="Enhanced agent retrieval — DEPRECATED, use /memories/retrieve",
        tags=["memory (deprecated)"],
        operation_id="retrieve_v2",
        deprecated=True,
    )
    async def retrieve_v2(
        body: EnhancedRetrieveRequest,
        response: Response,
        shadow: bool = Query(False, description="Shadow mode — flag response as shadow"),
        _: None = Depends(verify_api_key),
        plugin: Any = Depends(get_plugin),
    ) -> JSONResponse:
        """DEPRECATED — use ``POST /api/v1/memories/retrieve``.

        .. deprecated:: sunset 2026-06-01  (issue #297)
        """
        add_deprecation_headers(response, f"{PREFIX}/memories/retrieve")
        return await _do_retrieve_v2(body, shadow, plugin)

    async def _do_retrieve_v2(
        body: EnhancedRetrieveRequest,
        shadow: bool,
        plugin: Any,
    ) -> JSONResponse:
        """Shared implementation for retrieve v2 / memories/retrieve endpoints."""
        import time as _time

        effective_mode = body.strategy or body.mode
        effective_mode = _MODE_ALIASES.get(effective_mode, effective_mode)

        # Build temporal constraints when before_date/after_date are supplied (#513)
        _constraints = None
        _before = getattr(body, "before_date", None)
        _after = getattr(body, "after_date", None)
        if _before or _after:
            try:
                from cortex.core.retrieval import RetrievalConstraints
                _constraints = RetrievalConstraints(
                    token_budget=body.token_budget,
                    before_date=_before,
                    after_date=_after,
                )
            except Exception:
                pass  # fall through; constraints stay None

        result = await plugin.retrieve(
            query=body.query,
            token_budget=body.token_budget,
            mode=effective_mode,
            owner_id=body.owner_id,
            constraints=_constraints,
        )
        if result is None:
            raise HTTPException(
                status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
                detail="Retrieval pipeline not available",
            )

        data = _serialise(result.__dict__) if hasattr(result, "__dict__") else _serialise(result)

        if shadow:
            data["shadow"] = True
            _emit_shadow_event(endpoint="/api/v1/memories/retrieve", session_id="shadow")

        if body.explain:
            data["_explain"] = {
                "mode":        getattr(result, "mode", body.mode),
                "tokens_used": getattr(result, "tokens_used", None),
                "item_count":  len(getattr(result, "items", []) or []),
                "scores": [
                    {
                        "id":    getattr(i, "item_id", None) or getattr(i, "id", ""),
                        "score": getattr(i, "score", None) or getattr(i, "relevance_score", None),
                    }
                    for i in (getattr(result, "items", []) or [])
                ],
                "query": body.query,
            }

        return JSONResponse(content=data)

    # ---- Canonical: /memories/remember  (issue #297) ----
    @router.post(
        f"{PREFIX}/memories/remember",
        response_model=EnhancedRememberResponse,
        status_code=status.HTTP_200_OK,
        summary="Enhanced memory write with detailed response",
        tags=["memory"],
        operation_id="memories_remember",
    )
    async def memories_remember(
        request: Request,
        body: EnhancedRememberRequest,
        shadow: bool = Query(False, description="Shadow mode — extract but do not persist"),
        _: None = Depends(verify_api_key),
        plugin: Any = Depends(get_plugin),
    ) -> JSONResponse:
        """Extract and store memories with enhanced response details.

        Canonical path: ``POST /api/v1/memories/remember``.
        Pass ``?shadow=true`` to run extraction without persisting (dry run).
        """
        body.owner_id = _resolve_owner_id(request, body.owner_id)
        return await _do_remember_v2(body, shadow, plugin)

    # ---- DEPRECATED: /remember/v2  (issue #297) ----
    @router.post(
        f"{PREFIX}/remember/v2",
        response_model=EnhancedRememberResponse,
        status_code=status.HTTP_200_OK,
        summary="Enhanced memory write — DEPRECATED, use /memories/remember",
        tags=["memory (deprecated)"],
        operation_id="remember_v2",
        deprecated=True,
    )
    async def remember_v2(
        body: EnhancedRememberRequest,
        response: Response,
        shadow: bool = Query(False, description="Shadow mode — extract but do not persist"),
        _: None = Depends(verify_api_key),
        plugin: Any = Depends(get_plugin),
    ) -> JSONResponse:
        """DEPRECATED — use ``POST /api/v1/memories/remember``.

        .. deprecated:: sunset 2026-06-01  (issue #297)
        """
        add_deprecation_headers(response, f"{PREFIX}/memories/remember")
        return await _do_remember_v2(body, shadow, plugin)

    async def _do_remember_v2(
        body: EnhancedRememberRequest, shadow: bool, plugin: Any
    ) -> JSONResponse:
        """Shared implementation for remember v2 / memories/remember endpoints."""
        import json as _json_v2
        import time as _time

        # Issue #486 — body size guard
        try:
            _cfg_limit_v2 = getattr(getattr(plugin, "config", None), "max_remember_body_bytes", None)
            _body_limit_v2 = int(_cfg_limit_v2) if isinstance(_cfg_limit_v2, int) else _MAX_REMEMBER_BYTES
        except (TypeError, ValueError):
            _body_limit_v2 = _MAX_REMEMBER_BYTES
        if _body_limit_v2 > 0:
            _body_bytes_v2 = len(_json_v2.dumps(
                body.conversation if not hasattr(body.conversation, "__iter__")
                or isinstance(body.conversation, str)
                else [m.model_dump() if hasattr(m, "model_dump") else (m.dict() if hasattr(m, "dict") else m) for m in body.conversation]
            ).encode("utf-8"))
            if _body_bytes_v2 > _body_limit_v2:
                raise HTTPException(
                    status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
                    detail=f"Request body too large ({_body_bytes_v2} bytes > {_body_limit_v2} limit)",
                )

        conversation = _normalize_and_validate_remember_conversation(body.conversation)

        if shadow:
            shadow_result = await _shadow_remember(
                plugin, conversation, body.session_id, body.owner_id,
            )
            return JSONResponse(content={
                "session_id": body.session_id or "shadow",
                "shadow": True,
                "claims_extracted": shadow_result["claims_extracted"],
                "claims_added": 0,
                "claims_updated": 0,
                "claims_stored": 0,
                "would_store": shadow_result["would_store"],
                "entities_resolved": shadow_result["entities_resolved"],
                "noise_filtered": shadow_result["noise_filtered"],
                "events_logged": 0,
                "processing_time_ms": shadow_result["processing_time_ms"],
                "errors": shadow_result["errors"],
                "warnings": [],
                "zero_claim_warning": False,
                "ok": True,
            })

        t0 = _time.monotonic()

        try:
            report = await plugin.remember(
                conversation=conversation,
                session_id=body.session_id,
                owner_id=body.owner_id,
            )
        except (ValueError, TypeError) as exc:
            raise HTTPException(
                status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
                detail=f"Invalid conversation payload: {exc}",
            ) from exc

        duration_ms = int((_time.monotonic() - t0) * 1000)

        claims_added = getattr(report, "claims_added", 0) or report.claims_stored
        claims_updated = getattr(report, "claims_updated", 0) or 0
        events_logged = getattr(report, "events_logged", 0) or 0

        return JSONResponse(content={
            "session_id": report.session_id,
            "claims_extracted": report.claims_extracted,
            "claims_added": claims_added,
            "claims_updated": claims_updated,
            "claims_stored": report.claims_stored,
            "entities_resolved": report.entities_resolved,
            "noise_filtered": report.noise_filtered,
            "events_logged": events_logged,
            "processing_time_ms": duration_ms,
            "errors": report.errors,
            "warnings": getattr(report, "warnings", []),
            "zero_claim_warning": getattr(report, "zero_claim_warning", False),
            "ok": report.ok,
        })
