"""MCP server exposing the engram memory daemon as Claude Code tools.

Thin bridge: receives MCP tool calls, translates to daemon JSON-over-socket
protocol, formats responses as human-readable text.

Runs as subprocess with stdio transport. Registered globally by install.sh::

    "mcpServers": {
        "engram": {
            "command": "~/.local/share/claude-engram/venv/bin/python",
            "args": ["-m", "claude_engram.mcp_server"]
        }
    }

Socket path is resolved automatically via paths.resolve_socket_path():
- XDG_RUNTIME_DIR/claude-engram.sock (if set)
- /run/user/$UID/claude-engram.sock (if exists)
- /tmp/claude-engram.sock (fallback)
- CLAUDE_ENGRAM_SOCKET env var overrides all
"""

from __future__ import annotations

import asyncio
import json
import logging
import os
from typing import Any, cast
from xml.sax.saxutils import escape

from mcp.server.fastmcp import FastMCP

from claude_engram.paths import resolve_socket_path

logger = logging.getLogger("engram-mcp")

mcp = FastMCP(name="engram")


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _resolve_proj_tag(explicit: str | None = None) -> str | None:
    """Resolve project tag from explicit param or environment.

    Priority: explicit param > STAR_PROJECT_TAG env > CLAUDE_PROJECT_DIR env > None.
    """
    if explicit and explicit.strip():
        return explicit.strip()

    env_tag = os.environ.get("STAR_PROJECT_TAG", "").strip()
    if env_tag:
        return env_tag

    project_dir = os.environ.get("CLAUDE_PROJECT_DIR", "").strip()
    if project_dir:
        basename = os.path.basename(project_dir)
        if basename:
            return f"proj:{basename}"

    return None


async def _send_to_daemon(action: str, **kwargs: object) -> dict[str, Any]:
    """Send a JSON request to the engram daemon and return the response."""
    socket_path = str(resolve_socket_path())
    try:
        reader, writer = await asyncio.wait_for(
            asyncio.open_unix_connection(socket_path),
            timeout=5.0,
        )
    except TimeoutError:
        return {"status": "error", "error": "Connection to engram daemon timed out"}
    try:
        payload = json.dumps({"action": action, **kwargs})
        writer.write(payload.encode() + b"\n")
        await writer.drain()
        data = await asyncio.wait_for(reader.readline(), timeout=10.0)
        if not data:
            return {"status": "error", "error": "Empty response from daemon"}
        return cast(dict[str, Any], json.loads(data))
    except TimeoutError:
        return {"status": "error", "error": "Daemon response timed out"}
    finally:
        writer.close()
        await writer.wait_closed()


# ---------------------------------------------------------------------------
# MCP Tools
# ---------------------------------------------------------------------------


_DETAIL_LEVEL_MAP: dict[str, int] = {"index": 0, "summary": 1, "full": 2}


@mcp.tool()
async def search_memories(
    query: str,
    limit: int = 5,
    category: str | None = None,
    proj_tag: str | None = None,
    detail_level: str = "summary",
    valid_at: str | None = None,
    search_mode: str = "vector",
) -> str:
    """Semantic search across stored memories with progressive disclosure.

    Returns memories most relevant to your query, scoped to the current project
    by default. Searches both project-specific and global memories.
    Results are ranked by composite score (similarity + salience + recency + popularity).

    detail_level controls response verbosity (token savings):
        "index"   = IDs, category, similarity, token_count only (~50 tok/result)
        "summary" = index + first 100 chars of content (~100 tok/result) [DEFAULT]
        "full"    = complete memory text + metadata (~500 tok/result)

    Use "index" first, then expand_memory or get_memories_batch for drill-down.

    Args:
        query: What to search for.
        limit: Maximum results to return (default: 5).
        category: Filter by category (lesson, decision, interface,
            file_ownership, checkpoint, session_summary, query_cache, general).
        proj_tag: Override project scope (default: auto-detected from env).
        detail_level: One of "index", "summary", "full" (default: "summary").
        valid_at: Only return memories valid at this ISO timestamp.
            Default: returns all active memories (no temporal filter).
        search_mode: Search strategy. "vector" (default, semantic similarity),
            "hybrid" (keyword + semantic with RRF fusion),
            "keyword" (BM25 text match only).
    """
    try:
        # Map string detail_level to int, with backward compat for int values
        if isinstance(detail_level, int):
            int_level = detail_level
        else:
            int_level = _DETAIL_LEVEL_MAP.get(str(detail_level).lower(), 1)

        resolved_tag = _resolve_proj_tag(proj_tag)
        kwargs: dict[str, object] = {
            "query": query,
            "limit": limit,
            "include_global": True,
            "detail_level": int_level,
        }
        if category:
            kwargs["category"] = category
        if resolved_tag:
            kwargs["proj_tag"] = resolved_tag
        if valid_at:
            kwargs["valid_at"] = valid_at
        kwargs["search_mode"] = search_mode

        result = await _send_to_daemon("search", **kwargs)

        if "error" in result:
            return f"Search failed: {result['error']}"

        memories = result.get("results", [])
        if not memories:
            return "No memories found matching your query."

        lines: list[str] = [f'<memories count="{len(memories)}">']
        conflict_pairs: list[tuple[str, str]] = []
        for mem in memories:
            similarity = mem.get("similarity", 0)
            pct = f"{similarity * 100:.0f}%"
            cat = mem.get("category", "unknown")
            content = mem.get("content", "")
            scope = mem.get("proj_tag") or "global"
            created = mem.get("created_at", "?")
            mem_id = mem.get("id", "?")
            mem_level = mem.get("detail_level", int_level)
            token_count = mem.get("token_count", "?")
            is_active = mem.get("is_active", True)
            active_attr = ' is_active="false"' if not is_active else ""
            valid_from_val = mem.get("valid_from")
            valid_to_val = mem.get("valid_to")
            temporal_attr = ""
            if valid_from_val:
                temporal_attr += f' valid_from="{valid_from_val}"'
            if valid_to_val:
                temporal_attr += f' valid_to="{valid_to_val}"'
            conflicts = mem.get("conflicts_with", [])
            conflict_attr = ""
            if conflicts:
                conflict_attr = f' conflicts_with="{",".join(str(c) for c in conflicts)}"'
                for c_id in conflicts:
                    a, b = sorted((str(mem_id), str(c_id)))
                    pair = (a, b)
                    if pair not in conflict_pairs:
                        conflict_pairs.append(pair)
            lines.append(
                f'<memory id="{mem_id}" category="{cat}"'
                f' similarity="{pct}" scope="{scope}"'
                f' created="{created}" detail_level="{mem_level}"'
                f' token_count="{token_count}"{active_attr}{temporal_attr}{conflict_attr}>'
            )
            if mem_level == 0:
                # Index level: no content at all, just the attributes above
                pass
            elif mem_level == 1:
                summary = mem.get("summary", content[:200])
                lines.append(escape(summary))
            else:
                lines.append(escape(content))
            lines.append("</memory>")
        if conflict_pairs:
            lines.append(f'<conflicts count="{len(conflict_pairs)}">')
            for id_a, id_b in conflict_pairs:
                lines.append(f'  <pair a="{id_a}" b="{id_b}"/>')
            lines.append("</conflicts>")
        lines.append("</memories>")
        return "\n".join(lines)

    except Exception as e:
        return f"Memory search unavailable: {e}. Is the engram daemon running?"


@mcp.tool()
async def store_memory(
    content: str,
    category: str = "lesson",
    metadata: dict[str, Any] | None = None,
    proj_tag: str | None = None,
    supersedes: int | None = None,
    valid_from: str | None = None,
) -> str:
    """Store a memory for retrieval across sessions.

    Use this to persist decisions, lessons learned, interface discoveries,
    or any insight worth remembering after a context compact.

    When supersedes is provided, the old memory is marked as inactive
    (superseded by this new one). Use this when a decision replaces a
    previous one to keep search results clean.

    Args:
        content: The information to remember.
        category: Memory type — determines retention. One of: lesson (permanent),
            decision (7 days), interface (7 days), file_ownership (7 days),
            session_summary (7 days), checkpoint (4 hours), query_cache, general.
        metadata: Optional extra context (e.g. {"file": "path"}).
        proj_tag: Override project scope (default: auto-detected from env).
        supersedes: Optional ID of the memory this one replaces. The old memory
            will be marked inactive and hidden from default searches.
        valid_from: Optional ISO timestamp for when this memory became factually
            valid. Default: current time (valid from now).
    """
    try:
        resolved_tag = _resolve_proj_tag(proj_tag)
        kwargs: dict[str, object] = {
            "content": content,
            "category": category,
        }
        if metadata:
            kwargs["metadata"] = metadata
        if resolved_tag:
            kwargs["proj_tag"] = resolved_tag
        if valid_from:
            kwargs["valid_from"] = valid_from

        result = await _send_to_daemon("store", **kwargs)

        if "error" in result:
            return f"Store failed: {result['error']}"

        memory_id = result.get("id", "?")

        # If this memory supersedes an older one, mark the old one inactive
        supersede_info = ""
        if supersedes is not None and memory_id != "?":
            sup_result = await _send_to_daemon("supersede", old_id=supersedes, new_id=memory_id)
            if sup_result.get("status") == "ok" and sup_result.get("superseded"):
                supersede_info = f' supersedes="{supersedes}"'

        scope = resolved_tag or "global"
        return (
            f'<memory_stored id="{memory_id}" category="{category}"'
            f' scope="{scope}"{supersede_info}>'
            f"</memory_stored>"
        )

    except Exception as e:
        return f"Memory store unavailable: {e}. Is the engram daemon running?"


@mcp.tool()
async def recent_memories(
    limit: int = 10,
    category: str | None = None,
    proj_tag: str | None = None,
) -> str:
    """List recent memories in chronological order.

    Useful for reviewing what happened in the last session or recent activity.
    No semantic search — just the most recent entries.

    Args:
        limit: Maximum results to return (default: 10).
        category: Filter by category (lesson, decision, interface,
            file_ownership, checkpoint, session_summary, query_cache, general).
        proj_tag: Override project scope (default: auto-detected from env).
    """
    try:
        resolved_tag = _resolve_proj_tag(proj_tag)
        kwargs: dict[str, object] = {
            "limit": limit,
            "include_global": True,
        }
        if category:
            kwargs["category"] = category
        if resolved_tag:
            kwargs["proj_tag"] = resolved_tag

        result = await _send_to_daemon("recent", **kwargs)

        if "error" in result:
            return f"Recent memories failed: {result['error']}"

        memories = result.get("results", [])
        if not memories:
            return "No recent memories found."

        lines: list[str] = [f'<memories count="{len(memories)}">']
        for mem in memories:
            cat = mem.get("category", "unknown")
            content = mem.get("content", "")
            scope = mem.get("proj_tag") or "global"
            created = mem.get("created_at", "?")
            mem_id = mem.get("id", "?")
            lines.append(
                f'<memory id="{mem_id}" category="{cat}" scope="{scope}" created="{created}">'
            )
            lines.append(escape(content))
            lines.append("</memory>")
        lines.append("</memories>")
        return "\n".join(lines)

    except Exception as e:
        return f"Recent memories unavailable: {e}. Is the engram daemon running?"


@mcp.tool()
async def delete_memory(memory_id: int, proj_tag: str | None = None) -> str:
    """Delete a memory by its ID.

    Use this to clean up obsolete, incorrect, or outdated memories.
    Memory IDs can be found in search_memories or recent_memories results.

    The daemon enforces project ownership: a memory can only be deleted if its
    proj_tag matches the resolved tag. Pass proj_tag explicitly or let it
    auto-detect from environment variables.

    Args:
        memory_id: The numeric ID of the memory to delete.
        proj_tag: Override project scope (default: auto-detected from env).
    """
    try:
        resolved_tag = _resolve_proj_tag(proj_tag)
        kwargs: dict[str, object] = {"id": memory_id}
        if resolved_tag:
            kwargs["proj_tag"] = resolved_tag

        result = await _send_to_daemon("delete", **kwargs)

        if "error" in result:
            return f"Delete failed: {result['error']}"

        status = "success" if result.get("deleted") else "not_found"
        return f'<memory_deleted id="{memory_id}" status="{status}"></memory_deleted>'

    except Exception as e:
        return f"Memory delete unavailable: {e}. Is the engram daemon running?"


@mcp.tool()
async def expand_memory(
    memory_id: int,
    proj_tag: str | None = None,
) -> str:
    """Expand a memory from Tier 1 summary to full content (Tier 2/3).

    Use this after search_memories with tier=1 to get the full content of
    a specific memory. This is the "drill-down" step in progressive disclosure.

    Args:
        memory_id: The numeric ID of the memory to expand.
        proj_tag: Override project scope (default: auto-detected from env).
    """
    try:
        resolved_tag = _resolve_proj_tag(proj_tag)
        kwargs: dict[str, object] = {"id": memory_id}
        if resolved_tag:
            kwargs["proj_tag"] = resolved_tag

        result = await _send_to_daemon("get_by_id", **kwargs)

        if "error" in result:
            return f"Expand failed: {result['error']}"

        mem = result.get("memory")
        if not mem:
            return f"Memory {memory_id} not found."

        cat = mem.get("category", "unknown")
        content = mem.get("content", "")
        scope = mem.get("proj_tag") or "global"
        created = mem.get("created_at", "?")
        metadata = mem.get("metadata", {})

        token_count = mem.get("token_count", "?")
        lines: list[str] = [
            f'<memory id="{memory_id}" category="{cat}"'
            f' scope="{scope}" created="{created}"'
            f' detail_level="2" token_count="{token_count}">',
            escape(content),
        ]
        if metadata:
            lines.append(f"<metadata>{escape(str(metadata))}</metadata>")
        lines.append("</memory>")
        return "\n".join(lines)

    except Exception as e:
        return f"Memory expand unavailable: {e}. Is the engram daemon running?"


@mcp.tool()
async def get_memories_batch(
    memory_ids: list[int],
    proj_tag: str | None = None,
) -> str:
    """Fetch multiple memories by ID in a single call.

    Efficient batch retrieval for expanding multiple Tier 1 summaries
    to full content. Returns all requested memories in one response.

    Args:
        memory_ids: List of memory IDs to fetch (max 20).
        proj_tag: Override project scope (default: auto-detected from env).
    """
    try:
        if len(memory_ids) > 20:
            return "Error: Maximum 20 memory IDs per batch request."

        resolved_tag = _resolve_proj_tag(proj_tag)
        kwargs: dict[str, object] = {"ids": memory_ids}
        if resolved_tag:
            kwargs["proj_tag"] = resolved_tag

        result = await _send_to_daemon("get_by_ids", **kwargs)

        if "error" in result:
            return f"Batch fetch failed: {result['error']}"

        memories = result.get("memories", [])
        if not memories:
            return "No memories found for the given IDs."

        lines: list[str] = [f'<memories count="{len(memories)}">']
        for mem in memories:
            cat = mem.get("category", "unknown")
            content = mem.get("content", "")
            scope = mem.get("proj_tag") or "global"
            created = mem.get("created_at", "?")
            mem_id = mem.get("id", "?")
            token_count = mem.get("token_count", "?")
            lines.append(
                f'<memory id="{mem_id}" category="{cat}"'
                f' scope="{scope}" created="{created}"'
                f' detail_level="2" token_count="{token_count}">'
            )
            lines.append(escape(content))
            metadata = mem.get("metadata", {})
            if metadata:
                lines.append(f"<metadata>{escape(str(metadata))}</metadata>")
            lines.append("</memory>")
        lines.append("</memories>")
        return "\n".join(lines)

    except Exception as e:
        return f"Batch fetch unavailable: {e}. Is the engram daemon running?"


@mcp.tool()
async def health_check() -> str:
    """Check the health status of the engram daemon.

    Returns diagnostic information about the daemon's operational state,
    including database size, memory counts, cache status, and system resources.

    Useful for verifying the daemon is running correctly or diagnosing issues.
    """
    try:
        result = await _send_to_daemon("health_check")

        if "error" in result:
            return f"Health check failed: {result['error']}"

        health = result.get("health", {})

        # Format sizes
        db_size_mb = health.get("db_size_bytes", 0) / (1024 * 1024)
        wal_size_kb = health.get("wal_size_bytes", 0) / 1024

        # Build XML report
        socket_status = "active" if health.get("socket_exists") else "inactive"
        embedding_status = "loaded" if health.get("embedding_model_loaded") else "not loaded"
        pid_file_status = "yes" if health.get("pid_file_exists") else "no"

        lines: list[str] = ["<health_status>"]
        lines.append(
            f'  <database size_mb="{db_size_mb:.1f}" memories="{health.get("memory_count", 0)}"'
            f' wal_kb="{wal_size_kb:.1f}"/>'
        )
        if health.get("oldest_memory"):
            lines.append(f"  <oldest_memory>{health['oldest_memory']}</oldest_memory>")
        if health.get("newest_memory"):
            lines.append(f"  <newest_memory>{health['newest_memory']}</newest_memory>")
        lines.append(
            f'  <socket path="{health.get("socket_path", "?")}" status="{socket_status}"/>'
        )
        lines.append(f'  <process pid="{health.get("pid", "?")}" pid_file="{pid_file_status}"/>')
        lines.append(f'  <embedding_model status="{embedding_status}"/>')
        lines.append(f'  <cache entries="{health.get("cache_size", 0)}"/>')
        lines.append(f'  <checkpoint_sessions count="{health.get("checkpoint_sessions", 0)}"/>')
        lines.append("</health_status>")

        return "\n".join(lines)

    except Exception as e:
        return f"Health check unavailable: {e}. Is the engram daemon running?"


@mcp.tool()
async def update_counter(
    key: str,
    delta: int = 1,
    proj_tag: str | None = None,
) -> str:
    """Increment or decrement a named counter.

    Counters are lightweight key-value integers scoped by project. They're
    separate from memory storage — no embeddings or TTL. Use them for tracking
    tool call counts, MCP usage metrics, or any structured numeric data.

    The counter is created automatically on first use.

    Args:
        key: Counter name (e.g. "serena_calls", "mcp_tool_use").
        delta: Amount to add (default: 1). Use negative to decrement.
        proj_tag: Override project scope (default: auto-detected from env).
    """
    try:
        resolved_tag = _resolve_proj_tag(proj_tag)
        kwargs: dict[str, object] = {"key": key, "delta": delta}
        if resolved_tag:
            kwargs["proj_tag"] = resolved_tag

        result = await _send_to_daemon("update_counter", **kwargs)

        if result.get("status") == "error":
            return f"Counter update failed: {result.get('error', 'unknown error')}"

        value = result.get("value", "?")
        scope = resolved_tag or "global"
        return f'<counter key="{key}" value="{value}" scope="{scope}"></counter>'

    except Exception as e:
        return f"Counter update unavailable: {e}. Is the engram daemon running?"


@mcp.tool()
async def get_counters(
    key: str | None = None,
    proj_tag: str | None = None,
) -> str:
    """Get counter values.

    If a key is provided, returns that single counter's value.
    If no key is provided, returns all counters for the project scope.

    Args:
        key: Optional specific counter name. Omit to list all.
        proj_tag: Override project scope (default: auto-detected from env).
    """
    try:
        resolved_tag = _resolve_proj_tag(proj_tag)
        kwargs: dict[str, object] = {}
        if key:
            kwargs["key"] = key
        if resolved_tag:
            kwargs["proj_tag"] = resolved_tag

        result = await _send_to_daemon("get_counters", **kwargs)

        if result.get("status") == "error":
            return f"Counter lookup failed: {result.get('error', 'unknown error')}"

        scope = resolved_tag or "global"

        if key:
            value = result.get("value", 0)
            return f'<counter key="{key}" value="{value}" scope="{scope}"></counter>'

        counters = result.get("counters", {})
        if not counters:
            return f'<counters scope="{scope}" count="0"></counters>'

        lines: list[str] = [f'<counters scope="{scope}" count="{len(counters)}">']
        for k, v in sorted(counters.items()):
            lines.append(f'  <counter key="{k}" value="{v}"/>')
        lines.append("</counters>")
        return "\n".join(lines)

    except Exception as e:
        return f"Counter lookup unavailable: {e}. Is the engram daemon running?"


@mcp.tool()
async def set_counter(
    key: str,
    value: int,
    proj_tag: str | None = None,
) -> str:
    """Set a counter to an absolute value.

    Unlike update_counter (which increments/decrements), this sets the counter
    to exactly the given value. Creates the counter if it doesn't exist.

    Args:
        key: Counter name (e.g. "serena_calls", "mcp_tool_use").
        value: The absolute value to set.
        proj_tag: Override project scope (default: auto-detected from env).
    """
    try:
        resolved_tag = _resolve_proj_tag(proj_tag)
        kwargs: dict[str, object] = {"key": key, "value": value}
        if resolved_tag:
            kwargs["proj_tag"] = resolved_tag

        result = await _send_to_daemon("set_counter", **kwargs)

        if result.get("status") == "error":
            return f"Counter set failed: {result.get('error', 'unknown error')}"

        value_result = result.get("value", "?")
        scope = resolved_tag or "global"
        return f'<counter key="{key}" value="{value_result}" scope="{scope}"></counter>'

    except Exception as e:
        return f"Counter set unavailable: {e}. Is the engram daemon running?"


@mcp.tool()
async def delete_counter(
    key: str,
    proj_tag: str | None = None,
) -> str:
    """Delete a counter by key.

    Permanently removes the counter. If it doesn't exist, returns a
    "not found" message (not an error).

    Args:
        key: Counter name to delete.
        proj_tag: Override project scope (default: auto-detected from env).
    """
    try:
        resolved_tag = _resolve_proj_tag(proj_tag)
        kwargs: dict[str, object] = {"key": key}
        if resolved_tag:
            kwargs["proj_tag"] = resolved_tag

        result = await _send_to_daemon("delete_counter", **kwargs)

        if result.get("status") == "error":
            return f"Counter delete failed: {result.get('error', 'unknown error')}"

        deleted = result.get("deleted", False)
        scope = resolved_tag or "global"
        status = "deleted" if deleted else "not_found"
        return f'<counter_deleted key="{key}" status="{status}" scope="{scope}"/>'

    except Exception as e:
        return f"Counter delete unavailable: {e}. Is the engram daemon running?"


@mcp.tool()
async def consolidate_memories(
    dry_run: bool = True,
    min_cluster_size: int = 3,
) -> str:
    """Consolidate duplicate lesson memories using HDBSCAN clustering.

    Clusters lesson memories by embedding similarity. For each cluster of
    near-duplicates, the longest/most detailed memory is kept as representative;
    shorter duplicates are marked as superseded (hidden from default search).

    SAFE BY DEFAULT: dry_run=True shows what WOULD happen without changing data.
    Set dry_run=False to actually consolidate.

    Args:
        dry_run: If True (default), report clusters without modifying data.
            Set to False to actually supersede duplicate memories.
        min_cluster_size: Minimum lessons to form a cluster (default: 3).
            Lower values find smaller groups; higher values are more conservative.
    """
    try:
        result = await _send_to_daemon(
            "consolidate",
            dry_run=dry_run,
            min_cluster_size=min_cluster_size,
        )

        if "error" in result and result.get("status") == "error":
            return f"Consolidation failed: {result['error']}"

        mode = "DRY RUN" if result.get("dry_run", True) else "EXECUTED"
        clusters = result.get("clusters", [])

        lines: list[str] = [
            f'<consolidation_report mode="{mode}"'
            f' total_lessons="{result.get("total_lessons", 0)}"'
            f' clusters_found="{result.get("clusters_found", 0)}"'
            f' memories_consolidated="{result.get("memories_consolidated", 0)}"'
            f' tokens_saved="{result.get("tokens_saved", 0)}"'
            f' noise_count="{result.get("noise_count", 0)}">'
        ]

        for cluster in clusters:
            rep_id = cluster.get("representative_id", "?")
            rep_preview = escape(cluster.get("representative_preview", ""))
            superseded = cluster.get("superseded_ids", [])
            savings = cluster.get("token_savings", 0)
            members = cluster.get("member_ids", [])

            lines.append(
                f'  <cluster id="{cluster.get("cluster_id", "?")}"'
                f' members="{len(members)}" representative="{rep_id}"'
                f' superseded="{len(superseded)}" token_savings="{savings}">'
            )
            lines.append(f"    <representative>{rep_preview}</representative>")
            for sid, preview in zip(
                superseded, cluster.get("member_previews", [])[1:], strict=False
            ):
                lines.append(f'    <superseded id="{sid}">{escape(preview)}</superseded>')
            lines.append("  </cluster>")

        lines.append("</consolidation_report>")
        return "\n".join(lines)

    except Exception as e:
        return f"Consolidation unavailable: {e}. Is the engram daemon running?"


@mcp.tool()
async def add_relation(
    source_id: int,
    target_id: int,
    relation_type: str,
    metadata: dict[str, Any] | None = None,
) -> str:
    """Create a directed edge between two memories in the knowledge graph.

    Relation types: updates, supersedes, supports, contradicts, resulted_in, related_to.

    Idempotent: calling with the same (source_id, target_id, relation_type) is a no-op.

    Args:
        source_id: Origin memory ID (the newer or asserting memory).
        target_id: Destination memory ID (the older or referenced memory).
        relation_type: Edge type. Use 'supersedes' when one replaces another,
            'supports' for corroborating evidence, 'contradicts' for conflicts,
            'resulted_in' for decision→outcome tracking.
        metadata: Optional context (e.g. {"confidence": 0.9, "reason": "explicit"}).
    """
    try:
        result = await _send_to_daemon(
            "add_relation",
            source_id=source_id,
            target_id=target_id,
            relation_type=relation_type,
            metadata=metadata,
        )
        if "error" in result:
            return f"Add relation failed: {result['error']}"

        created = result.get("created", False)
        rel_id = result.get("relation_id", "?")
        status = "created" if created else "already_exists"
        return (
            f'<relation id="{rel_id}" source="{source_id}" target="{target_id}"'
            f' type="{relation_type}" status="{status}"/>'
        )
    except Exception as e:
        return f"Add relation unavailable: {e}. Is the engram daemon running?"


@mcp.tool()
async def get_relations(
    memory_id: int,
    direction: str = "both",
    relation_types: list[str] | None = None,
    detail_level: str = "summary",
) -> str:
    """Get all graph edges connected to a memory.

    Use this to understand how a memory relates to others — what it updates,
    what supports it, what contradicts it, and what outcomes resulted from it.

    Args:
        memory_id: The memory to query edges for.
        direction: "outgoing" (edges FROM this memory), "incoming" (edges TO this
            memory), or "both" (default).
        relation_types: Optional filter to specific types (e.g. ["supersedes", "supports"]).
        detail_level: "summary" (default) includes connected memory snippet,
            "minimal" returns edge data only.
    """
    try:
        include_summary = detail_level != "minimal"
        result = await _send_to_daemon(
            "get_relations",
            memory_id=memory_id,
            direction=direction,
            relation_types=relation_types,
            include_memory_summary=include_summary,
        )
        if "error" in result:
            return f"Get relations failed: {result['error']}"

        rels = result.get("relations", [])
        if not rels:
            return f'<relations memory_id="{memory_id}" count="0"/>'

        lines: list[str] = [f'<relations memory_id="{memory_id}" count="{len(rels)}">']
        for rel in rels:
            attrs = (
                f'id="{rel["id"]}" source="{rel["source_id"]}" target="{rel["target_id"]}"'
                f' type="{rel["relation_type"]}" created="{rel["created_at"]}"'
            )
            if include_summary and "connected_summary" in rel:
                lines.append(f"  <relation {attrs}>")
                summary = rel.get("connected_summary", "")
                cat = rel.get("connected_category", "")
                lines.append(
                    f'    <connected id="{rel.get("connected_id", "?")}" category="{cat}">'
                )
                lines.append(f"      {escape(summary or '')}")
                lines.append("    </connected>")
                lines.append("  </relation>")
            else:
                lines.append(f"  <relation {attrs}/>")
        lines.append("</relations>")
        return "\n".join(lines)

    except Exception as e:
        return f"Get relations unavailable: {e}. Is the engram daemon running?"


@mcp.tool()
async def traverse_graph(
    start_id: int,
    depth: int = 3,
    direction: str = "outgoing",
    relation_types: list[str] | None = None,
) -> str:
    """Traverse the memory knowledge graph from a starting point.

    Follows edges recursively up to the given depth, with automatic cycle
    prevention. Returns all reachable memories with their relationship path.

    Args:
        start_id: Starting memory ID.
        depth: Maximum hops to traverse (default: 3, max: 5).
        direction: "outgoing" (follow source→target), "incoming" (follow
            target→source), or "both".
        relation_types: Optional filter to traverse only specific edge types.
    """
    try:
        result = await _send_to_daemon(
            "traverse_graph",
            start_id=start_id,
            depth=depth,
            direction=direction,
            relation_types=relation_types,
        )
        if "error" in result:
            return f"Traverse failed: {result['error']}"

        nodes = result.get("nodes", [])
        if not nodes:
            return f'<graph start="{start_id}" nodes="0">No connected memories found.</graph>'

        lines: list[str] = [f'<graph start="{start_id}" nodes="{len(nodes)}">']
        for node in nodes:
            via = f' via="{node["via_id"]}"' if node.get("via_id") else ""
            rtype = f' relation="{node["relation_type"]}"' if node.get("relation_type") else ""
            cat = node.get("category", "")
            lines.append(
                f'  <node id="{node["memory_id"]}" depth="{node["depth"]}"'
                f'{rtype}{via} category="{cat}">'
            )
            lines.append(f"    {escape(node.get('summary', '') or '')}")
            lines.append("  </node>")
        lines.append("</graph>")
        return "\n".join(lines)

    except Exception as e:
        return f"Graph traversal unavailable: {e}. Is the engram daemon running?"


@mcp.tool()
async def timeline(
    anchor_id: int,
    depth_before: int = 5,
    depth_after: int = 5,
    follow_edges: bool = False,
    detail_level: str = "summary",
) -> str:
    """Navigate memories chronologically around an anchor point.

    This is Layer 2 of the three-layer progressive disclosure pattern:
        1. search_memories → find relevant memories (compact results)
        2. timeline → explore chronological context around a memory
        3. expand_memory → get full content of specific memories

    Use this after search_memories to understand the temporal context:
    what happened before and after a specific memory.

    Args:
        anchor_id: The memory ID to center the timeline on.
        depth_before: Number of memories to show before the anchor (default: 5).
        depth_after: Number of memories to show after the anchor (default: 5).
        follow_edges: If True, also include graph-connected memories
            (shows both temporal and relational context).
        detail_level: "summary" (default, ~50-100 tokens each) or "full".
    """
    try:
        result = await _send_to_daemon(
            "timeline",
            anchor_id=anchor_id,
            depth_before=depth_before,
            depth_after=depth_after,
            follow_edges=follow_edges,
            detail_level=detail_level,
        )
        if "error" in result:
            return f"Timeline failed: {result['error']}"

        anchor = result.get("anchor", {})
        before = result.get("before", [])
        after = result.get("after", [])
        edges = result.get("edges", [])

        lines: list[str] = [
            f'<timeline anchor="{anchor.get("id", "?")}"'
            f' before="{len(before)}" after="{len(after)}">'
        ]

        def _format_mem(mem: dict[str, Any], tag: str = "memory") -> list[str]:
            mem_lines: list[str] = []
            attrs = f'id="{mem.get("id", "?")}" category="{mem.get("category", "")}"'
            attrs += f' created="{mem.get("created_at", "?")}"'
            if mem.get("valid_from"):
                attrs += f' valid_from="{mem["valid_from"]}"'
            if mem.get("valid_to"):
                attrs += f' valid_to="{mem["valid_to"]}"'
            content = mem.get("summary") or mem.get("content", "")
            mem_lines.append(f"    <{tag} {attrs}>")
            mem_lines.append(f"      {escape(content)}")
            mem_lines.append(f"    </{tag}>")
            return mem_lines

        # Anchor
        lines.append("  <anchor>")
        lines.extend(_format_mem(anchor))
        lines.append("  </anchor>")

        # Before (chronologically earlier)
        if before:
            lines.append("  <before>")
            for mem in before:
                lines.extend(_format_mem(mem))
            lines.append("  </before>")

        # After (chronologically later)
        if after:
            lines.append("  <after>")
            for mem in after:
                lines.extend(_format_mem(mem))
            lines.append("  </after>")

        # Graph edges (if follow_edges=True)
        if edges:
            lines.append(f'  <edges count="{len(edges)}">')
            for edge in edges:
                lines.append(
                    f'    <edge source="{edge["source_id"]}" target="{edge["target_id"]}"'
                    f' type="{edge["relation_type"]}"/>'
                )
            lines.append("  </edges>")

        lines.append("</timeline>")
        return "\n".join(lines)

    except Exception as e:
        return f"Timeline unavailable: {e}. Is the engram daemon running?"


@mcp.tool()
async def record_outcome(
    decision_id: int,
    outcome_id: int,
    confidence: float = 1.0,
    measurement_method: str | None = None,
    impact_score: float | None = None,
    measured_at: str | None = None,
    causation_id: int | None = None,
    correlation_id: str | None = None,
) -> str:
    """Link a decision to its observed outcome for tracking decision quality.

    Creates a 'resulted_in' edge with structured confidence metadata.
    Use this after observing the effects of a previous decision to build
    a decision quality history.

    Valid measurement methods: user_confirmed (0.90-0.95), direct_observation
    (0.70-0.80), statistical_inference (0.45-0.55), temporal_proximity (0.25-0.35),
    combined_evidence (0.30-0.95).

    Args:
        decision_id: Memory ID of the decision.
        outcome_id: Memory ID of the observed result.
        confidence: Certainty of causal link (0.0-1.0, default: 1.0).
        measurement_method: How the outcome was measured.
        impact_score: Quantified impact (0.0-1.0, optional).
        measured_at: ISO 8601 timestamp when outcome was observed.
        causation_id: Optional pointer to root decision memory ID.
        correlation_id: Optional group ID for related experiments.
    """
    try:
        kwargs: dict[str, Any] = {
            "decision_id": decision_id,
            "outcome_id": outcome_id,
            "confidence": confidence,
        }
        if measurement_method is not None:
            kwargs["measurement_method"] = measurement_method
        if impact_score is not None:
            kwargs["impact_score"] = impact_score
        if measured_at is not None:
            kwargs["measured_at"] = measured_at
        if causation_id is not None:
            kwargs["causation_id"] = causation_id
        if correlation_id is not None:
            kwargs["correlation_id"] = correlation_id

        result = await _send_to_daemon("record_outcome", **kwargs)

        if "error" in result:
            return f"Record outcome failed: {result['error']}"

        created = result.get("created", False)
        rel_id = result.get("relation_id", "?")
        status = "created" if created else "already_exists"
        return (
            f'<outcome_recorded decision="{decision_id}" outcome="{outcome_id}"'
            f' relation_id="{rel_id}" confidence="{confidence}" status="{status}"/>'
        )
    except Exception as e:
        return f"Record outcome unavailable: {e}. Is the engram daemon running?"


@mcp.tool()
async def decision_chain(
    start_id: int,
    depth: int = 3,
    direction: str = "outgoing",
) -> str:
    """Reconstruct a decision-to-outcome chain showing how decisions led to results.

    Follows 'resulted_in' edges from a decision to find all downstream outcomes
    (or upstream decisions if direction='incoming'). Returns confidence and
    impact scores for each link in the chain.

    Use this to evaluate decision quality by tracing a decision to its outcomes.

    Args:
        start_id: Memory ID to start from (decision or outcome).
        depth: Maximum chain hops (default: 3, max: 5).
        direction: "outgoing" (decision→outcomes) or "incoming" (outcome→decisions).
    """
    try:
        result = await _send_to_daemon(
            "decision_chain",
            start_id=start_id,
            depth=depth,
            direction=direction,
        )
        if "error" in result:
            return f"Decision chain failed: {result['error']}"

        outcomes = result.get("outcomes", [])
        max_depth = result.get("max_depth", 0)

        if not outcomes:
            return (
                f'<decision_chain start="{start_id}" outcomes="0" max_depth="0">'
                f"No outcomes found.</decision_chain>"
            )

        lines: list[str] = [
            f'<decision_chain start="{start_id}" outcomes="{len(outcomes)}"'
            f' max_depth="{max_depth}">'
        ]
        for o in outcomes:
            via = f' via="{o["via_id"]}"' if o.get("via_id") else ""
            conf = f' confidence="{o["confidence"]}"' if o.get("confidence") is not None else ""
            impact = f' impact="{o["impact_score"]}"' if o.get("impact_score") is not None else ""
            cat = o.get("category", "")
            lines.append(
                f'  <outcome id="{o["memory_id"]}" depth="{o["depth"]}"'
                f'{via}{conf}{impact} category="{cat}">'
            )
            lines.append(f"    {escape(o.get('summary', '') or '')}")
            lines.append("  </outcome>")
        lines.append("</decision_chain>")
        return "\n".join(lines)

    except Exception as e:
        return f"Decision chain unavailable: {e}. Is the engram daemon running?"


@mcp.tool()
async def cache_stats() -> str:
    """Get semantic search cache performance metrics.

    Returns statistics about the SQLite-vec semantic cache layer that
    accelerates repeated similar searches. Includes hit/miss counts,
    hit rate, entry count, and average entry age.
    """
    try:
        result = await _send_to_daemon("semantic_cache_stats")

        if result.get("status") == "error":
            return f"Cache stats failed: {result.get('error', 'unknown error')}"

        cache = result.get("cache", {})
        if not cache.get("enabled", False):
            return '<semantic_cache enabled="false"/>'

        return (
            f'<semantic_cache enabled="true"'
            f' hits="{cache.get("hits", 0)}"'
            f' misses="{cache.get("misses", 0)}"'
            f' hit_rate="{cache.get("hit_rate", 0):.1%}"'
            f' entries="{cache.get("entries", 0)}"'
            f' avg_age_seconds="{cache.get("avg_age_seconds", 0)}"/>'
        )

    except Exception as e:
        return f"Cache stats unavailable: {e}. Is the engram daemon running?"


@mcp.tool()
async def cache_clear() -> str:
    """Clear the semantic search cache.

    Manually invalidates all cached search results. Use this after bulk
    data changes or when you suspect stale cache entries.
    """
    try:
        result = await _send_to_daemon("semantic_cache_clear")

        if result.get("status") == "error":
            return f"Cache clear failed: {result.get('error', 'unknown error')}"

        cleared = result.get("cleared", 0)
        return f'<semantic_cache_cleared entries="{cleared}"/>'

    except Exception as e:
        return f"Cache clear unavailable: {e}. Is the engram daemon running?"


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------


def main() -> None:
    """Run the engram MCP server (stdio transport)."""
    mcp.run(transport="stdio")


if __name__ == "__main__":
    main()
