"""MCP server for kbx — exposes knowledge base tools and resources via FastMCP."""

from __future__ import annotations

import json
import re
import sys
import traceback
from typing import TYPE_CHECKING, Any

from mcp.server.fastmcp import FastMCP
from mcp.types import ToolAnnotations

from kb.config import find_entity, find_project_root, get_db

if TYPE_CHECKING:
    from pathlib import Path

    from kb.db import Database

# ---------------------------------------------------------------------------
# Handler functions (testable without MCP transport)
# ---------------------------------------------------------------------------


def handle_kb_search(
    db: Database,
    query: str,
    fast: bool = True,
    limit: int = 5,
    from_date: str | None = None,
    to_date: str | None = None,
    tag: str | None = None,
    sort_by: str = "score",
    doc_type: str | None = None,
) -> str:
    """Search the knowledge base. Returns JSON string."""
    try:
        from kb.search import search as do_search

        results = do_search(
            db,
            None,
            query,
            limit=limit,
            fast=fast,
            from_date=from_date,
            to_date=to_date,
            tag=tag,
            sort_by=sort_by,
            doc_type=doc_type,
        )
        return json.dumps(results.model_dump(), default=str, ensure_ascii=False)
    except Exception as e:
        print(f"kb_search error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e), "results": []})


def _resolve_me(name: str) -> str:
    """Resolve 'me' to the configured user name."""
    if name.lower() != "me":
        return name
    from kb.user_config import find_config, load_config

    config_path = find_config()
    if config_path:
        cfg = load_config(config_path)
        if cfg.user.name:
            return cfg.user.name
    return name  # fall through — let find_entity handle "me" as a literal name


def handle_kb_person_find(db: Database, name: str) -> str:
    """Look up a person profile. Returns JSON string."""
    try:
        name = _resolve_me(name)
        return _build_entity_result(db, name)
    except Exception as e:
        print(f"kb_person_find error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e)})


def handle_kb_person_timeline(
    db: Database,
    name: str,
    from_date: str | None = None,
    to_date: str | None = None,
    doc_type: str | None = None,
    limit: int | None = None,
) -> str:
    """Chronological docs mentioning a person. Returns JSON string."""
    try:
        name = _resolve_me(name)
        conn = db.get_sqlite_conn()
        entity_row = find_entity(conn, name)
        if entity_row is None:
            return json.dumps({"error": f"Entity not found: {name}"})

        sql = """SELECT d.id, d.path, d.title, d.doc_date, d.doc_type, em.mention_type
                 FROM documents d
                 JOIN entity_mentions em ON d.id = em.document_id
                 WHERE em.entity_id = ?"""
        params: list[Any] = [entity_row["id"]]

        if from_date:
            sql += " AND d.doc_date >= ?"
            params.append(from_date)
        if to_date:
            sql += " AND d.doc_date <= ?"
            params.append(to_date)
        if doc_type:
            sql += " AND d.doc_type = ?"
            params.append(doc_type)

        sql += " ORDER BY d.doc_date ASC"
        if limit is not None:
            sql += " LIMIT ?"
            params.append(limit)
        docs = conn.execute(sql, params).fetchall()

        result = {
            "name": entity_row["name"],
            "entity_type": entity_row["entity_type"],
            "documents": [
                {
                    "path": d["path"],
                    "title": d["title"],
                    "date": d["doc_date"],
                    "doc_type": d["doc_type"],
                    "mention_type": d["mention_type"],
                }
                for d in docs
            ],
        }
        return json.dumps(result, default=str, ensure_ascii=False)
    except Exception as e:
        print(f"kb_person_timeline error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e)})


def handle_kb_view(db: Database, target: str) -> str:
    """View a specific document by path or #hash. Returns JSON string."""
    try:
        from kb.db import normalize_path

        conn = db.get_sqlite_conn()
        doc = None

        # Content-hash lookup: #abc123
        if target.startswith("#"):
            hash_prefix = target[1:]
            row = conn.execute(
                "SELECT * FROM documents WHERE content_hash LIKE ?",
                (hash_prefix + "%",),
            ).fetchone()
            if row:
                doc = dict(row)

        if doc is None and not target.startswith("#"):
            import unicodedata

            target_nfc = normalize_path(target)
            target_nfd = unicodedata.normalize("NFD", target)
            # Exact path match (try both NFC and NFD for compat with old data)
            row = conn.execute(
                "SELECT * FROM documents WHERE path = ? OR path = ?",
                (target_nfc, target_nfd),
            ).fetchone()
            if row:
                doc = dict(row)

            # Suffix match
            if doc is None:
                rows = conn.execute(
                    "SELECT * FROM documents WHERE path LIKE ? OR path LIKE ?",
                    ("%" + target_nfc, "%" + target_nfd),
                ).fetchall()
                if len(rows) == 1:
                    doc = dict(rows[0])

        if doc is None and not target.startswith("#"):
            # Glob / substring matching
            import fnmatch

            all_paths = [r["path"] for r in conn.execute("SELECT path FROM documents").fetchall()]

            if "*" in target or "?" in target:
                matches = [p for p in all_paths if fnmatch.fnmatch(p, target)]
            else:
                matches = [p for p in all_paths if target in p.rsplit("/", 1)[-1]]

            if len(matches) == 1:
                row = conn.execute(
                    "SELECT * FROM documents WHERE path = ?", (matches[0],)
                ).fetchone()
                if row:
                    doc = dict(row)
            elif len(matches) > 1:
                return json.dumps(
                    {"error": f"Ambiguous path: {len(matches)} matches", "matches": matches}
                )

        if doc is None:
            return json.dumps({"error": f"Document not found: {target}"})

        chunks = conn.execute(
            "SELECT heading, content FROM chunks WHERE document_id = ? ORDER BY chunk_index",
            (doc["id"],),
        ).fetchall()

        result = {
            "title": doc["title"],
            "path": doc["path"],
            "date": doc["doc_date"],
            "doc_type": doc["doc_type"],
            "content_hash": doc["content_hash"],
            "chunks": [{"heading": c["heading"], "content": c["content"]} for c in chunks],
        }
        return json.dumps(result, default=str, ensure_ascii=False)
    except Exception as e:
        print(f"kb_view error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e)})


def handle_kb_context(
    db: Database,
    project_root: Path,
    topic: str | None = None,
    fmt: str = "compact",
    mention_threshold: int = 0,
) -> str:
    """Compressed entity index. Returns JSON string."""
    try:
        from kb.context import generate_context

        result = generate_context(
            db, project_root, topic=topic, fmt=fmt, mention_threshold=mention_threshold
        )
        return json.dumps(result.model_dump(), default=str, ensure_ascii=False)
    except Exception as e:
        print(f"kb_context error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e)})


def handle_kb_memory_add(
    db: Database,
    project_root: Path,
    text: str,
    body: str | None = None,
    tags: str | None = None,
    entity: str | None = None,
    pin: bool = False,
    date: str | None = None,
) -> str:
    """Create a note or record a fact. Delegates to KnowledgeBase. Returns JSON string."""
    try:
        from kb.api import KnowledgeBase

        kb = KnowledgeBase._from_existing(db=db, project_root=project_root)
        try:
            is_note = body is not None or tags is not None or pin or entity is None

            if not is_note:
                assert entity is not None
                try:
                    result = kb.add_fact(entity, text, date=date)
                except ValueError as e:
                    return json.dumps({"error": str(e)})
                return json.dumps(result)

            # NOTE PATH
            tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else None
            result = kb.add_note(text, body=body, tags=tag_list, pin=pin, entity=entity, date=date)
            return json.dumps(result)
        finally:
            kb.close()
    except Exception as e:
        print(f"kb_memory_add error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e)})


def handle_kb_pin(db: Database, target: str) -> str:
    """Pin a document to context. Returns JSON string."""
    try:
        conn = db.get_sqlite_conn()
        doc = _find_document_by_target(conn, target)
        if doc is None:
            return json.dumps({"error": f"Document not found: {target}"})
        conn.execute("UPDATE documents SET pinned = 1 WHERE id = ?", (doc["id"],))
        conn.commit()
        return json.dumps({"status": "ok", "path": doc["path"], "pinned": True})
    except Exception as e:
        print(f"kb_pin error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e)})


def handle_kb_unpin(db: Database, target: str) -> str:
    """Unpin a document from context. Returns JSON string."""
    try:
        conn = db.get_sqlite_conn()
        doc = _find_document_by_target(conn, target)
        if doc is None:
            return json.dumps({"error": f"Document not found: {target}"})
        conn.execute("UPDATE documents SET pinned = 0 WHERE id = ?", (doc["id"],))
        conn.commit()
        return json.dumps({"status": "ok", "path": doc["path"], "pinned": False})
    except Exception as e:
        print(f"kb_unpin error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e)})


def _find_document_by_target(conn: Any, target: str) -> dict[str, Any] | None:
    """Resolve a document by path, title, glob, or content hash. For MCP handlers."""
    import fnmatch
    import unicodedata

    from kb.db import normalize_path

    # Content hash
    if target.startswith("#"):
        row = conn.execute(
            "SELECT * FROM documents WHERE content_hash LIKE ?", (target[1:] + "%",)
        ).fetchone()
        return dict(row) if row else None

    path_nfc = normalize_path(target)
    path_nfd = unicodedata.normalize("NFD", target)

    # Exact path
    row = conn.execute(
        "SELECT * FROM documents WHERE path = ? OR path = ?", (path_nfc, path_nfd)
    ).fetchone()
    if row:
        return dict(row)

    # Suffix
    rows = conn.execute(
        "SELECT * FROM documents WHERE path LIKE ? OR path LIKE ?",
        ("%" + path_nfc, "%" + path_nfd),
    ).fetchall()
    if len(rows) == 1:
        return dict(rows[0])

    # Title
    rows = conn.execute(
        "SELECT * FROM documents WHERE title = ? COLLATE NOCASE", (target,)
    ).fetchall()
    if len(rows) == 1:
        return dict(rows[0])

    # Glob / substring
    all_paths = [r["path"] for r in conn.execute("SELECT path FROM documents").fetchall()]
    if "*" in target or "?" in target:
        matches = [p for p in all_paths if fnmatch.fnmatch(p, target)]
    else:
        matches = [p for p in all_paths if target in p.rsplit("/", 1)[-1]]
    if len(matches) == 1:
        row = conn.execute("SELECT * FROM documents WHERE path = ?", (matches[0],)).fetchone()
        return dict(row) if row else None

    return None


def handle_kb_usage(db: Database) -> str:
    """Get index health stats as structured JSON."""
    try:
        conn = db.get_sqlite_conn()

        total_docs = conn.execute("SELECT COUNT(*) as c FROM documents").fetchone()["c"]
        total_entities = conn.execute("SELECT COUNT(*) as c FROM entities").fetchone()["c"]
        total_facts = conn.execute("SELECT COUNT(*) as c FROM facts").fetchone()["c"]
        pinned_docs = conn.execute(
            "SELECT COUNT(*) as c FROM documents WHERE pinned = 1"
        ).fetchone()["c"]

        date_range = conn.execute(
            "SELECT MIN(doc_date) as earliest, MAX(doc_date) as latest "
            "FROM documents WHERE doc_date IS NOT NULL"
        ).fetchone()

        tool_count = len(mcp._tool_manager._tools)

        return json.dumps(
            {
                "docs": total_docs,
                "entities": total_entities,
                "facts": total_facts,
                "pinned": pinned_docs,
                "date_range": {
                    "earliest": date_range["earliest"],
                    "latest": date_range["latest"],
                },
                "tool_count": tool_count,
            }
        )
    except Exception as e:
        print(f"kb_usage error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e)})


def handle_kb_entity_stale(
    db: Database,
    days: int = 30,
    entity_type: str | None = None,
) -> str:
    """Return entities not updated or mentioned within *days*. Returns JSON string."""
    try:
        from datetime import date, timedelta

        cutoff = (date.today() - timedelta(days=days)).isoformat()
        conn = db.get_sqlite_conn()

        query = """
            SELECT id, name, entity_type, metadata, updated_at, last_mentioned_at, pinned
            FROM entities
            WHERE (updated_at IS NULL OR updated_at < ?)
              AND (last_mentioned_at IS NULL OR last_mentioned_at < ?)
        """
        params: list[object] = [cutoff, cutoff]
        if entity_type:
            query += " AND entity_type = ?"
            params.append(entity_type)
        query += " ORDER BY COALESCE(last_mentioned_at, updated_at, '') ASC"

        rows = conn.execute(query, params).fetchall()
        results: list[dict[str, object]] = []
        for r in rows:
            meta = json.loads(r["metadata"]) if r["metadata"] else {}
            most_recent = max(r["updated_at"] or "", r["last_mentioned_at"] or "")
            age_days = (
                (date.today() - date.fromisoformat(most_recent)).days if most_recent else None
            )
            results.append(
                {
                    "name": r["name"],
                    "entity_type": r["entity_type"],
                    "role": meta.get("role"),
                    "team": meta.get("team"),
                    "updated_at": r["updated_at"],
                    "last_mentioned_at": r["last_mentioned_at"],
                    "age_days": age_days,
                    "pinned": bool(r["pinned"]),
                }
            )
        return json.dumps(
            {"results": results, "meta": {"count": len(results), "threshold_days": days}},
            default=str,
            ensure_ascii=False,
        )
    except Exception as e:
        print(f"kb_entity_stale error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e), "results": []})


def handle_kb_project_find(db: Database, name: str) -> str:
    """Look up a project profile. Returns JSON string."""
    try:
        conn = db.get_sqlite_conn()
        entity_row = find_entity(conn, name)
        if entity_row is None:
            return json.dumps({"error": f"Project not found: {name}"})
        if entity_row["entity_type"] != "project":
            return json.dumps(
                {"error": f"'{entity_row['name']}' is a {entity_row['entity_type']}, not a project"}
            )
        return _build_entity_result(db, entity_row["name"])
    except Exception as e:
        print(f"kb_project_find error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e)})


def handle_kb_project_list(db: Database, limit: int = 50, offset: int = 0) -> str:
    """List all projects. Returns JSON string."""
    try:
        return _entity_list(db, "project", limit=limit, offset=offset)
    except Exception as e:
        print(f"kb_project_list error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e), "results": []})


def handle_kb_person_list(db: Database, limit: int = 50, offset: int = 0) -> str:
    """List all people. Returns JSON string."""
    try:
        return _entity_list(db, "person", limit=limit, offset=offset)
    except Exception as e:
        print(f"kb_person_list error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e), "results": []})


def handle_kb_person_create(
    db: Database,
    project_root: Path,
    name: str,
    *,
    role: str | None = None,
    email: str | None = None,
    team: str | None = None,
    reports_to: str | None = None,
    company: str | None = None,
    aliases: str | None = None,
) -> str:
    """Create a new person entity. Returns JSON string."""
    from kb.crud import EntityExistsError, create_entity

    try:
        metadata = {
            k: v
            for k, v in {
                "role": role,
                "email": email,
                "team": team,
                "reports_to": reports_to,
                "company": company,
            }.items()
            if v is not None
        }
        alias_list = [a.strip() for a in aliases.split(",") if a.strip()] if aliases else []

        result = create_entity(
            db, project_root, "person", name, metadata=metadata, aliases=alias_list
        )

        # Index the new file so it's immediately searchable and pinnable
        from kb.indexer import index_all

        index_all(db, None, project_root, memory_only=True, skip_seed=True)

        return json.dumps(result)
    except EntityExistsError as e:
        return json.dumps({"error": str(e)})
    except Exception as e:
        print(f"kb_person_create error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e)})


def _parse_meta_string(meta: str | None) -> dict[str, str]:
    """Parse a semicolon-separated 'key=value; key2=value2' metadata string.

    Semicolons delimit pairs; commas are allowed in values.
    Falls back to comma-delimited parsing when no semicolons are present
    and only one key=value pair exists (backward compat for simple cases).
    """
    if not meta:
        return {}
    result: dict[str, str] = {}
    # Use semicolons as the primary delimiter (supports commas in values)
    if ";" in meta:
        pairs = meta.split(";")
    elif meta.count("=") == 1:
        # Single key=value with no semicolons — treat the whole string as one pair
        pairs = [meta]
    else:
        # Multiple key=value pairs with no semicolons — fall back to comma split
        pairs = meta.split(",")
    for pair in pairs:
        if "=" not in pair:
            continue
        key, _, value = pair.partition("=")
        key = key.strip()
        if key:
            result[key] = value.strip()
    return result


def handle_kb_person_edit(
    db: Database,
    project_root: Path,
    name: str,
    *,
    role: str | None = None,
    email: str | None = None,
    team: str | None = None,
    reports_to: str | None = None,
    company: str | None = None,
    aliases: str | None = None,
    meta: str | None = None,
) -> str:
    """Edit an existing person's metadata. Returns JSON string."""
    from kb.crud import EntityNotFoundError, edit_entity

    try:
        metadata: dict[str, Any] = {
            k: v
            for k, v in {
                "role": role,
                "email": email,
                "team": team,
                "reports_to": reports_to,
                "company": company,
            }.items()
            if v is not None
        }
        metadata.update(_parse_meta_string(meta))
        alias_list = [a.strip() for a in aliases.split(",") if a.strip()] if aliases else None

        result = edit_entity(db, project_root, name, metadata=metadata or None, aliases=alias_list)
        return json.dumps(result)
    except EntityNotFoundError as e:
        return json.dumps({"error": str(e)})
    except Exception as e:
        print(f"kb_person_edit error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e)})


def handle_kb_project_create(
    db: Database,
    project_root: Path,
    name: str,
    *,
    status: str | None = None,
    lead: str | None = None,
    started: str | None = None,
    aliases: str | None = None,
) -> str:
    """Create a new project entity. Returns JSON string."""
    from kb.crud import EntityExistsError, create_entity

    try:
        metadata = {
            k: v
            for k, v in {
                "status": status,
                "lead": lead,
                "started": started,
            }.items()
            if v is not None
        }
        alias_list = [a.strip() for a in aliases.split(",") if a.strip()] if aliases else []

        result = create_entity(
            db, project_root, "project", name, metadata=metadata, aliases=alias_list
        )

        # Index the new file so it's immediately searchable and pinnable
        from kb.indexer import index_all

        index_all(db, None, project_root, memory_only=True, skip_seed=True)

        return json.dumps(result)
    except EntityExistsError as e:
        return json.dumps({"error": str(e)})
    except Exception as e:
        print(f"kb_project_create error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e)})


def handle_kb_project_edit(
    db: Database,
    project_root: Path,
    name: str,
    *,
    status: str | None = None,
    lead: str | None = None,
    started: str | None = None,
    aliases: str | None = None,
    meta: str | None = None,
) -> str:
    """Edit an existing project's metadata. Returns JSON string."""
    from kb.crud import EntityNotFoundError, edit_entity

    try:
        metadata: dict[str, Any] = {
            k: v
            for k, v in {
                "status": status,
                "lead": lead,
                "started": started,
            }.items()
            if v is not None
        }
        metadata.update(_parse_meta_string(meta))
        alias_list = [a.strip() for a in aliases.split(",") if a.strip()] if aliases else None

        result = edit_entity(db, project_root, name, metadata=metadata or None, aliases=alias_list)
        return json.dumps(result)
    except EntityNotFoundError as e:
        return json.dumps({"error": str(e)})
    except Exception as e:
        print(f"kb_project_edit error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e)})


def _entity_list(db: Database, entity_type: str, limit: int = 50, offset: int = 0) -> str:
    """Shared: list entities of a given type with pagination."""
    conn = db.get_sqlite_conn()

    # Get total count first
    total = conn.execute(
        "SELECT COUNT(*) as cnt FROM entities WHERE entity_type = ?",
        (entity_type,),
    ).fetchone()["cnt"]

    rows = conn.execute(
        "SELECT id, name, entity_type, aliases, metadata, source_path FROM entities "
        "WHERE entity_type = ? ORDER BY name LIMIT ? OFFSET ?",
        (entity_type, limit, offset),
    ).fetchall()
    entities = [
        {
            "id": r["id"],
            "name": r["name"],
            "entity_type": r["entity_type"],
            "aliases": json.loads(r["aliases"]) if r["aliases"] else [],
            "metadata": json.loads(r["metadata"]) if r["metadata"] else {},
        }
        for r in rows
    ]
    return json.dumps(
        {"results": entities, "meta": {"total": total, "limit": limit, "offset": offset}},
        default=str,
        ensure_ascii=False,
    )


def _build_entity_result(db: Database, name: str) -> str:
    """Build a compact entity result via KnowledgeBase.get_entity_profile(). Returns JSON string."""
    from pathlib import Path as _Path

    from kb.api import KnowledgeBase

    kb = KnowledgeBase._from_existing(db=db, project_root=_Path("."))
    profile = kb.get_entity_profile(name)
    kb.close()
    if profile is None:
        return json.dumps({"error": f"Entity not found: {name}"})
    return json.dumps(profile, default=str, ensure_ascii=False)


def handle_kb_note_list(
    db: Database,
    tag: str | None = None,
    pinned_only: bool = False,
    limit: int = 25,
) -> str:
    """List notes with optional tag/pin filters. Returns JSON string."""
    try:
        conn = db.get_sqlite_conn()

        # Build WHERE clause with optional filters
        where = "doc_type IN ('memory_note', 'memory_doc')"
        params: list[Any] = []

        if pinned_only:
            where += " AND pinned = 1"

        # Tag filtering requires post-fetch check (JSON array in SQLite),
        # but we can still get the true total by counting all matches.
        required_tags: list[str] = []
        if tag:
            required_tags = [t.strip().lower() for t in tag.split(",") if t.strip()]

        if required_tags:
            # Tags are stored as JSON arrays — filter in Python, count all matches
            all_rows = conn.execute(
                f"SELECT id, path, title, doc_date, tags, pinned FROM documents "
                f"WHERE {where} ORDER BY doc_date DESC, id DESC",
                params,
            ).fetchall()

            matching: list[dict[str, Any]] = []
            for r in all_rows:
                doc_tags: list[str] = json.loads(r["tags"]) if r["tags"] else []
                lower_tags = [t.lower() for t in doc_tags]
                if all(rt in lower_tags for rt in required_tags):
                    matching.append(
                        {
                            "path": r["path"],
                            "title": r["title"],
                            "date": r["doc_date"],
                            "tags": doc_tags,
                            "pinned": bool(r["pinned"]),
                        }
                    )
            total = len(matching)
            results = matching[:limit]
        else:
            # No tag filter — use SQL COUNT + LIMIT
            total = conn.execute(
                f"SELECT COUNT(*) as cnt FROM documents WHERE {where}",
                params,
            ).fetchone()["cnt"]

            rows = conn.execute(
                f"SELECT id, path, title, doc_date, tags, pinned FROM documents "
                f"WHERE {where} ORDER BY doc_date DESC, id DESC LIMIT ?",
                [*params, limit],
            ).fetchall()

            results = [
                {
                    "path": r["path"],
                    "title": r["title"],
                    "date": r["doc_date"],
                    "tags": json.loads(r["tags"]) if r["tags"] else [],
                    "pinned": bool(r["pinned"]),
                }
                for r in rows
            ]

        return json.dumps(
            {"results": results, "meta": {"total": total, "limit": limit}},
            default=str,
            ensure_ascii=False,
        )
    except Exception as e:
        print(f"kb_note_list error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e), "results": []})


def handle_kb_note_edit(
    db: Database,
    project_root: Path,
    target: str,
    body: str | None = None,
    append: str | None = None,
    tags: str | None = None,
    pin: bool | None = None,
) -> str:
    """Edit a note's body, tags, or pin status. Delegates to KnowledgeBase. Returns JSON string."""
    try:
        from kb.api import KnowledgeBase

        kb = KnowledgeBase._from_existing(db=db, project_root=project_root)
        try:
            tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else None
            result = kb.edit_note(target, body=body, append=append, tags=tag_list, pin=pin)
            return json.dumps(result, default=str, ensure_ascii=False)
        except ValueError as e:
            return json.dumps({"error": str(e)})
        finally:
            kb.close()
    except Exception as e:
        print(f"kb_note_edit error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e)})


def handle_kb_note_delete(db: Database, project_root: Path, target: str) -> str:
    """Delete a memory note. Delegates to KnowledgeBase. Returns JSON string."""
    try:
        from kb.api import KnowledgeBase

        # Resolve target to path first (KnowledgeBase.delete_note takes a path)
        conn = db.get_sqlite_conn()
        doc = _find_document_by_target(conn, target)
        if doc is None:
            return json.dumps({"error": f"Note not found: {target}"})

        kb = KnowledgeBase._from_existing(db=db, project_root=project_root)
        try:
            result = kb.delete_note(doc["path"])
            return json.dumps(
                {"status": "ok", "path": result["path"], "title": result["title"]},
                default=str,
                ensure_ascii=False,
            )
        except ValueError as e:
            return json.dumps({"error": str(e)})
        finally:
            kb.close()
    except Exception as e:
        print(f"kb_note_delete error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e)})


def handle_kb_memory_list(
    db: Database, project_root: Path, since_days: int | None = None, entity: str | None = None
) -> str:
    """List recorded facts. Delegates to KnowledgeBase. Returns JSON string."""
    try:
        from kb.api import KnowledgeBase

        kb = KnowledgeBase._from_existing(db=db, project_root=project_root)
        try:
            facts = kb.list_facts(since_days=since_days, entity=entity)
            return json.dumps(
                {"results": facts, "meta": {"total": len(facts)}},
                default=str,
                ensure_ascii=False,
            )
        finally:
            kb.close()
    except Exception as e:
        print(f"kb_memory_list error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e), "results": []})


def handle_kb_memory_delete_fact(db: Database, project_root: Path, fact_id: int) -> str:
    """Delete a fact by ID. Delegates to KnowledgeBase. Returns JSON string."""
    try:
        from kb.api import KnowledgeBase

        kb = KnowledgeBase._from_existing(db=db, project_root=project_root)
        try:
            result = kb.delete_fact(fact_id)
            return json.dumps(result, default=str, ensure_ascii=False)
        finally:
            kb.close()
    except ValueError as e:
        return json.dumps({"error": str(e)})
    except Exception as e:
        print(f"kb_memory_delete_fact error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e)})


def handle_kb_memory_edit_fact(
    db: Database,
    project_root: Path,
    fact_id: int,
    text: str | None = None,
    date: str | None = None,
) -> str:
    """Edit a fact's text or date. Delegates to KnowledgeBase. Returns JSON string."""
    try:
        if text is None and date is None:
            return json.dumps({"error": "Specify text and/or date to edit."})

        from kb.api import KnowledgeBase

        kb = KnowledgeBase._from_existing(db=db, project_root=project_root)
        try:
            result = kb.edit_fact(fact_id, text=text, date=date)
            return json.dumps(result, default=str, ensure_ascii=False)
        finally:
            kb.close()
    except ValueError as e:
        return json.dumps({"error": str(e)})
    except Exception as e:
        print(f"kb_memory_edit_fact error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e)})


def handle_kb_glossary_list(project_root: Path) -> str:
    """List all glossary terms. Returns JSON string."""
    try:
        from kb.glossary import list_terms

        terms = list_terms(project_root)
        return json.dumps(
            {
                "results": [t.model_dump() for t in terms],
                "meta": {"total": len(terms)},
            },
            default=str,
            ensure_ascii=False,
        )
    except Exception as e:
        print(f"kb_glossary_list error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e), "results": []})


def handle_kb_glossary_add(
    project_root: Path, term: str, expansion: str, section: str = "Acronyms"
) -> str:
    """Add a glossary term. Returns JSON string."""
    try:
        from kb.glossary import add_term

        result = add_term(project_root, term, expansion, section=section)
        return json.dumps(result, default=str, ensure_ascii=False)
    except Exception as e:
        print(f"kb_glossary_add error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e)})


def handle_kb_glossary_edit(project_root: Path, term: str, expansion: str) -> str:
    """Edit a glossary term. Returns JSON string."""
    try:
        from kb.glossary import edit_term

        result = edit_term(project_root, term, expansion)
        return json.dumps(result, default=str, ensure_ascii=False)
    except ValueError as e:
        return json.dumps({"error": str(e)})
    except Exception as e:
        print(f"kb_glossary_edit error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e)})


def handle_kb_granola_view(calendar_uid: str, mode: str | None = None) -> str:
    """View meeting notes/transcript/summary by calendar UID. Returns JSON string."""
    try:
        from kb.sync.granola import (
            GranolaClient,
            extract_panel_markdown,
            transcript_to_markdown,
        )

        client = GranolaClient()
        doc = client.find_document(calendar_uid=calendar_uid)
        if not doc:
            return json.dumps({"error": f"No Granola document found for UID '{calendar_uid}'"})

        notes_md = doc.get("notes_markdown") or ""
        summary_md = extract_panel_markdown(doc)

        transcript_md = ""
        if mode in ("transcript", "all"):
            segments = client.get_transcript(doc["id"])
            transcript_md = transcript_to_markdown(segments)

        gcal = doc.get("google_calendar_event") or {}
        result: dict[str, Any] = {
            "title": doc.get("title") or "Untitled",
            "date": (doc.get("created_at") or "")[:10],
            "granola_id": doc.get("id", ""),
            "calendar_uid": gcal.get("iCalUID") or gcal.get("id") or calendar_uid,
            "has_notes": bool(notes_md.strip()),
            "has_summary": bool(summary_md.strip()),
            "has_transcript": bool(transcript_md.strip()),
        }

        if mode == "summary":
            result["content"] = summary_md or "(no AI summary)"
        elif mode == "transcript":
            result["content"] = transcript_md or "(no transcript)"
        elif mode == "all":
            result["notes"] = notes_md or "(no notes)"
            result["summary"] = summary_md or "(no AI summary)"
            result["transcript"] = transcript_md or "(no transcript)"
        else:
            result["content"] = notes_md or "(no notes)"

        return json.dumps(result, default=str, ensure_ascii=False)
    except Exception as e:
        print(f"kb_granola_view error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e)})


def handle_kb_granola_edit(
    calendar_uid: str,
    body: str | None = None,
    append: str | None = None,
) -> str:
    """Edit meeting notes in Granola (API only). Returns JSON string."""
    try:
        from kb.sync.granola import GranolaClient

        if body is None and append is None:
            return json.dumps({"error": "Provide body or append."})
        if body is not None and append is not None:
            return json.dumps({"error": "Provide only one of body or append."})

        client = GranolaClient()
        doc = client.find_document(calendar_uid=calendar_uid)
        if not doc:
            return json.dumps({"error": f"No Granola document found for UID '{calendar_uid}'"})

        if append is not None:
            existing_md = doc.get("notes_markdown") or ""
            if existing_md.strip():
                markdown = existing_md.rstrip() + "\n\n" + append
            else:
                markdown = append
        else:
            assert body is not None
            markdown = body

        client.update_document_notes(doc["id"], markdown)

        return json.dumps(
            {
                "status": "ok",
                "calendar_uid": calendar_uid,
                "action": "append" if append else "replace",
            },
            default=str,
            ensure_ascii=False,
        )
    except Exception as e:
        print(f"kb_granola_edit error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e)})


def handle_kb_list(
    db: Database,
    doc_type: str | None = None,
    from_date: str | None = None,
    to_date: str | None = None,
    limit: int = 25,
    since_hours: int | None = None,
) -> str:
    """Browse documents by date/type. Returns JSON string."""
    try:
        conn = db.get_sqlite_conn()
        sql = "SELECT id, path, title, doc_date, doc_type, content_hash, chunk_count FROM documents WHERE 1=1"
        params: list[Any] = []

        if doc_type:
            sql += " AND doc_type = ?"
            params.append(doc_type)
        if from_date:
            sql += " AND doc_date >= ?"
            params.append(from_date)
        if to_date:
            sql += " AND doc_date <= ?"
            params.append(to_date)
        if since_hours is not None:
            sql += " AND indexed_at >= datetime('now', ?)"
            params.append(f"-{since_hours} hours")

        sql += " ORDER BY doc_date DESC NULLS LAST LIMIT ?"
        params.append(limit)

        rows = conn.execute(sql, params).fetchall()
        docs = [
            {
                "id": r["id"],
                "path": r["path"],
                "title": r["title"],
                "date": r["doc_date"],
                "doc_type": r["doc_type"],
                "content_hash": r["content_hash"][:6] if r["content_hash"] else None,
                "chunks": r["chunk_count"],
            }
            for r in rows
        ]
        return json.dumps(
            {"results": docs, "meta": {"total": len(docs), "limit": limit}},
            default=str,
            ensure_ascii=False,
        )
    except Exception as e:
        print(f"kb_list error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e), "results": []})


def handle_kb_index_status(db: Database) -> str:
    """Database health: doc counts, entity counts, freshness. Returns JSON string."""
    try:
        conn = db.get_sqlite_conn()

        type_rows = conn.execute(
            "SELECT doc_type, COUNT(*) as count FROM documents GROUP BY doc_type"
        ).fetchall()
        doc_counts = {r["doc_type"]: r["count"] for r in type_rows}
        total_docs = sum(doc_counts.values())

        chunk_count = conn.execute("SELECT COUNT(*) as count FROM chunks").fetchone()["count"]
        entity_count = conn.execute("SELECT COUNT(*) as count FROM entities").fetchone()["count"]
        fact_count = conn.execute("SELECT COUNT(*) as count FROM facts").fetchone()["count"]
        last_indexed = conn.execute("SELECT MAX(indexed_at) as ts FROM documents").fetchone()["ts"]

        date_range = conn.execute(
            "SELECT MIN(doc_date) as earliest, MAX(doc_date) as latest "
            "FROM documents WHERE doc_date IS NOT NULL"
        ).fetchone()

        status = {
            "documents": total_docs,
            "documents_by_type": doc_counts,
            "chunks": chunk_count,
            "entities": entity_count,
            "facts": fact_count,
            "last_indexed": last_indexed,
            "date_range": {
                "earliest": date_range["earliest"],
                "latest": date_range["latest"],
            },
        }
        return json.dumps(status, default=str, ensure_ascii=False)
    except Exception as e:
        print(f"kb_index_status error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e)})


def handle_kb_correct(
    project_root: Path,
    term: str,
    replacement: str | None = None,
    apply: bool = False,
    scope: str | None = None,
    file_type: str | None = None,
    word_boundary: bool = False,
    ignore_case: bool = False,
) -> str:
    """Find (and optionally replace) a term across memory files. Returns JSON string."""
    try:
        from kb.correct import apply_corrections, enrich_matches, scan

        memory_root = project_root / "memory"
        if not memory_root.is_dir():
            return json.dumps({"error": f"Memory directory not found at {memory_root}"})

        matches = scan(
            memory_root,
            term,
            ignore_case=ignore_case,
            word_boundary=word_boundary,
            scope=scope,
            file_type=file_type,
        )

        if not matches:
            return json.dumps({"results": [], "meta": {"term": term, "total": 0, "action": "scan"}})

        if replacement is None:
            enriched = enrich_matches(memory_root, matches)
            total = sum(m.count for m in matches)
            return json.dumps(
                {
                    "results": enriched,
                    "meta": {
                        "term": term,
                        "total_occurrences": total,
                        "files": len(matches),
                        "action": "scan",
                    },
                },
                default=str,
                ensure_ascii=False,
            )

        if not apply:
            enriched = enrich_matches(memory_root, matches)
            total = sum(m.count for m in matches)
            return json.dumps(
                {
                    "results": enriched,
                    "meta": {
                        "term": term,
                        "replacement": replacement,
                        "total_occurrences": total,
                        "files": len(matches),
                        "action": "dry_run",
                    },
                },
                default=str,
                ensure_ascii=False,
            )

        from kb.config import get_data_dir

        log_path = get_data_dir() / "corrections.log"
        result = apply_corrections(
            memory_root,
            matches,
            replacement,
            ignore_case=ignore_case,
            word_boundary=word_boundary,
            log_path=log_path,
        )
        return json.dumps(
            {
                "results": [{"path": p} for p in result.changed_paths],
                "meta": {
                    "term": term,
                    "replacement": replacement,
                    "action": "applied",
                    "files_changed": result.files_changed,
                    "occurrences_replaced": result.occurrences_replaced,
                },
            },
            default=str,
            ensure_ascii=False,
        )
    except Exception as e:
        print(f"kb_correct error: {e}", file=sys.stderr)
        traceback.print_exc(file=sys.stderr)
        return json.dumps({"error": str(e)})


# ---------------------------------------------------------------------------
# FastMCP server setup
# ---------------------------------------------------------------------------

mcp = FastMCP("kbx")

# Tool annotation presets
_READ_ONLY = ToolAnnotations(readOnlyHint=True, destructiveHint=False, idempotentHint=True)
_MUTATING = ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=False)
_MUTATING_IDEMPOTENT = ToolAnnotations(
    readOnlyHint=False, destructiveHint=False, idempotentHint=True
)
_DESTRUCTIVE = ToolAnnotations(readOnlyHint=False, destructiveHint=True, idempotentHint=False)

_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")


def _validate_date(value: str | None) -> str | None:
    """Validate YYYY-MM-DD format. Returns the value or None if invalid."""
    if value is None:
        return None
    if not _DATE_RE.match(value):
        return None
    return value


@mcp.tool(annotations=_READ_ONLY)
def kb_search(
    query: str,
    fast: bool = True,
    limit: int = 5,
    from_date: str | None = None,
    to_date: str | None = None,
    tag: str | None = None,
    sort_by: str = "score",
    doc_type: str | None = None,
) -> str:
    """Search the knowledge base. Returns JSON with matching documents, ranked by relevance.
    Use fast=True (default) for instant FTS-only search.
    Optionally filter by date range (YYYY-MM-DD).
    Optionally filter by tag (comma-separated for AND, e.g. 'decision,infra').
    Note: tags only work on memory notes, not meeting docs.
    doc_type: filter by document type (e.g. 'notes', 'transcript', 'memory_note').
    sort_by: 'score' (default) or 'date' (newest first)."""
    limit = max(1, min(limit, 100))
    from_date = _validate_date(from_date)
    to_date = _validate_date(to_date)
    if sort_by not in ("score", "date"):
        sort_by = "score"
    db = get_db()
    return handle_kb_search(
        db,
        query,
        fast=fast,
        limit=limit,
        from_date=from_date,
        to_date=to_date,
        tag=tag,
        sort_by=sort_by,
        doc_type=doc_type,
    )


@mcp.tool(annotations=_READ_ONLY)
def kb_person_find(name: str) -> str:
    """Look up a person profile — compact output with facts, metadata, and breadcrumbs.
    Supports exact name, alias, or partial match."""
    db = get_db()
    return handle_kb_person_find(db, name)


@mcp.tool(annotations=_READ_ONLY)
def kb_person_timeline(
    name: str,
    from_date: str | None = None,
    to_date: str | None = None,
    doc_type: str | None = None,
    limit: int | None = None,
) -> str:
    """Get chronological list of documents mentioning a person.
    Optionally filter by date range (YYYY-MM-DD).
    doc_type: filter by document type (e.g. 'notes', 'transcript', 'debrief').
    limit: max results (default: no limit)."""
    from_date = _validate_date(from_date)
    to_date = _validate_date(to_date)
    db = get_db()
    return handle_kb_person_timeline(
        db, name, from_date=from_date, to_date=to_date, doc_type=doc_type, limit=limit
    )


@mcp.tool(annotations=_READ_ONLY)
def kb_view(target: str) -> str:
    """View a specific document by path or content-hash prefix (#abc123).
    Returns document metadata and all chunks."""
    db = get_db()
    return handle_kb_view(db, target)


@mcp.tool(annotations=_READ_ONLY)
def kb_context(topic: str | None = None, fmt: str = "compact", mention_threshold: int = 0) -> str:
    """Get compressed entity index — overview of all people, projects, teams, and terms.
    Optionally filter to entities relevant to a specific topic.
    fmt: 'compact' (default, pipe-delimited) or 'human' (markdown with headings).
    mention_threshold: minimum mention count for non-pinned entities (0 = no filter)."""
    if fmt not in ("compact", "human"):
        fmt = "compact"
    db = get_db()
    project_root = find_project_root()
    return handle_kb_context(
        db, project_root, topic=topic, fmt=fmt, mention_threshold=mention_threshold
    )


@mcp.tool(annotations=_READ_ONLY)
def kb_usage() -> str:
    """Get index health stats: document/entity/fact/pinned counts, date range, and tool count.
    Returns structured JSON — use individual tool docstrings for usage reference."""
    db = get_db()
    return handle_kb_usage(db)


@mcp.tool(annotations=_MUTATING)
def kb_memory_add(
    text: str,
    body: str | None = None,
    tags: str | None = None,
    entity: str | None = None,
    pin: bool = False,
    date: str | None = None,
) -> str:
    """Create a searchable note or record a fact about an entity.
    If entity is given without body/tags/pin, records a fact.
    Otherwise creates a note file in memory/notes/ and indexes it immediately.
    Use pin=True to make the note appear in kb_context output."""
    db = get_db()
    project_root = find_project_root()
    return handle_kb_memory_add(
        db, project_root, text, body=body, tags=tags, entity=entity, pin=pin, date=date
    )


@mcp.tool(annotations=_MUTATING_IDEMPOTENT)
def kb_pin(target: str) -> str:
    """Pin a document to context so it appears in kb_context output.
    Accepts path, title, or glob pattern."""
    db = get_db()
    return handle_kb_pin(db, target)


@mcp.tool(annotations=_MUTATING_IDEMPOTENT)
def kb_unpin(target: str) -> str:
    """Unpin a document from context.
    Accepts path, title, or glob pattern."""
    db = get_db()
    return handle_kb_unpin(db, target)


@mcp.tool(annotations=_READ_ONLY)
def kb_entity_stale(days: int = 30, entity_type: str | None = None) -> str:
    """List entities not updated or mentioned within a threshold.
    Returns stale entities sorted by stalest first.
    days: threshold in days (default 30).
    entity_type: optional filter ('person', 'project')."""
    days = max(1, days)
    db = get_db()
    return handle_kb_entity_stale(db, days=days, entity_type=entity_type)


@mcp.tool(annotations=_READ_ONLY)
def kb_project_find(name: str) -> str:
    """Look up a project profile — compact output with facts, metadata, and breadcrumbs.
    Supports exact name, alias, or partial match."""
    db = get_db()
    return handle_kb_project_find(db, name)


@mcp.tool(annotations=_READ_ONLY)
def kb_project_list(limit: int = 50, offset: int = 0) -> str:
    """List all known projects with their metadata.
    limit: max results (default 50).
    offset: skip first N results for pagination."""
    limit = max(1, min(limit, 500))
    db = get_db()
    return handle_kb_project_list(db, limit=limit, offset=offset)


@mcp.tool(annotations=_READ_ONLY)
def kb_person_list(limit: int = 50, offset: int = 0) -> str:
    """List all known people with their metadata.
    limit: max results (default 50).
    offset: skip first N results for pagination."""
    limit = max(1, min(limit, 500))
    db = get_db()
    return handle_kb_person_list(db, limit=limit, offset=offset)


@mcp.tool(annotations=_MUTATING)
def kb_person_create(
    name: str,
    role: str | None = None,
    email: str | None = None,
    team: str | None = None,
    reports_to: str | None = None,
    company: str | None = None,
    aliases: str | None = None,
) -> str:
    """Create a new person entity with a markdown file in memory/people/.
    name: full name (e.g. 'Jane Doe').
    role, email, team, reports_to, company: optional metadata fields.
    aliases: comma-separated alternative names (e.g. 'Jane,JD')."""
    db = get_db()
    project_root = find_project_root()
    return handle_kb_person_create(
        db,
        project_root,
        name,
        role=role,
        email=email,
        team=team,
        reports_to=reports_to,
        company=company,
        aliases=aliases,
    )


@mcp.tool(annotations=_MUTATING_IDEMPOTENT)
def kb_person_edit(
    name: str,
    role: str | None = None,
    email: str | None = None,
    team: str | None = None,
    reports_to: str | None = None,
    company: str | None = None,
    aliases: str | None = None,
    meta: str | None = None,
) -> str:
    """Edit an existing person's metadata. Preserves freeform content.
    name: person to edit (exact name, alias, or partial match).
    role, email, team, reports_to, company: standard fields (set to update).
    aliases: comma-separated names to add (e.g. 'Jane,JD').
    meta: comma-separated key=value pairs for arbitrary metadata (e.g. 'timezone=CET,lang=FR').
    Set a value to empty string to remove a key (e.g. 'timezone=')."""
    db = get_db()
    project_root = find_project_root()
    return handle_kb_person_edit(
        db,
        project_root,
        name,
        role=role,
        email=email,
        team=team,
        reports_to=reports_to,
        company=company,
        aliases=aliases,
        meta=meta,
    )


@mcp.tool(annotations=_MUTATING)
def kb_project_create(
    name: str,
    status: str | None = None,
    lead: str | None = None,
    started: str | None = None,
    aliases: str | None = None,
) -> str:
    """Create a new project entity with a markdown file in memory/projects/.
    name: project name (e.g. 'API Redesign').
    status, lead, started: optional metadata fields.
    aliases: comma-separated alternative names (e.g. 'api-v2,redesign')."""
    db = get_db()
    project_root = find_project_root()
    return handle_kb_project_create(
        db,
        project_root,
        name,
        status=status,
        lead=lead,
        started=started,
        aliases=aliases,
    )


@mcp.tool(annotations=_MUTATING_IDEMPOTENT)
def kb_project_edit(
    name: str,
    status: str | None = None,
    lead: str | None = None,
    started: str | None = None,
    aliases: str | None = None,
    meta: str | None = None,
) -> str:
    """Edit an existing project's metadata. Preserves freeform content.
    name: project to edit (exact name, alias, or partial match).
    status, lead, started: standard fields (set to update).
    aliases: comma-separated names to add (e.g. 'api-v2,redesign').
    meta: comma-separated key=value pairs for arbitrary metadata (e.g. 'priority=High').
    Set a value to empty string to remove a key (e.g. 'priority=')."""
    db = get_db()
    project_root = find_project_root()
    return handle_kb_project_edit(
        db,
        project_root,
        name,
        status=status,
        lead=lead,
        started=started,
        aliases=aliases,
        meta=meta,
    )


@mcp.tool(annotations=_READ_ONLY)
def kb_note_list(tag: str | None = None, pinned_only: bool = False, limit: int = 25) -> str:
    """Browse memory notes with optional filtering.
    tag: comma-separated tags (AND filter).
    pinned_only: only return pinned notes.
    limit: max results (default 25)."""
    limit = max(1, min(limit, 100))
    db = get_db()
    return handle_kb_note_list(db, tag=tag, pinned_only=pinned_only, limit=limit)


@mcp.tool(annotations=_MUTATING_IDEMPOTENT)
def kb_note_edit(
    target: str,
    body: str | None = None,
    append: str | None = None,
    tags: str | None = None,
    pin: bool | None = None,
) -> str:
    """Edit a memory note's body, tags, or pin status.
    target: note path, title, or glob.
    body: replace note body entirely.
    append: append to existing body.
    tags: comma-separated tags (replaces existing).
    pin: True to pin, False to unpin."""
    db = get_db()
    project_root = find_project_root()
    return handle_kb_note_edit(
        db, project_root, target, body=body, append=append, tags=tags, pin=pin
    )


@mcp.tool(annotations=_DESTRUCTIVE)
def kb_note_delete(target: str) -> str:
    """Delete a memory note (file + index entry).
    target: note path, title, or glob."""
    db = get_db()
    project_root = find_project_root()
    return handle_kb_note_delete(db, project_root, target)


@mcp.tool(annotations=_READ_ONLY)
def kb_memory_list(since_days: int | None = None, entity: str | None = None) -> str:
    """List recorded facts, newest first.
    since_days: optional filter to only show facts from last N days.
    entity: optional entity name to filter facts for a specific person/project."""
    db = get_db()
    project_root = find_project_root()
    return handle_kb_memory_list(db, project_root, since_days=since_days, entity=entity)


@mcp.tool(annotations=_DESTRUCTIVE)
def kb_memory_delete_fact(fact_id: int) -> str:
    """Delete a fact by its ID."""
    db = get_db()
    project_root = find_project_root()
    return handle_kb_memory_delete_fact(db, project_root, fact_id)


@mcp.tool(annotations=_MUTATING_IDEMPOTENT)
def kb_memory_edit_fact(fact_id: int, text: str | None = None, date: str | None = None) -> str:
    """Edit a fact's text or date.
    fact_id: the fact ID to edit.
    text: new fact text (optional).
    date: new date in YYYY-MM-DD (optional)."""
    db = get_db()
    project_root = find_project_root()
    return handle_kb_memory_edit_fact(db, project_root, fact_id, text=text, date=date)


@mcp.tool(annotations=_READ_ONLY)
def kb_glossary_list() -> str:
    """List all glossary terms (acronyms and jargon)."""
    project_root = find_project_root()
    return handle_kb_glossary_list(project_root)


@mcp.tool(annotations=_MUTATING)
def kb_glossary_add(term: str, expansion: str, section: str = "Acronyms") -> str:
    """Add a term to the glossary.
    term: the abbreviation or term.
    expansion: what it stands for.
    section: glossary section heading (default 'Acronyms')."""
    project_root = find_project_root()
    return handle_kb_glossary_add(project_root, term, expansion, section=section)


@mcp.tool(annotations=_MUTATING_IDEMPOTENT)
def kb_glossary_edit(term: str, expansion: str) -> str:
    """Update an existing glossary term's expansion.
    term: the term to edit.
    expansion: the new expansion text."""
    project_root = find_project_root()
    return handle_kb_glossary_edit(project_root, term, expansion)


@mcp.tool(annotations=_READ_ONLY)
def kb_granola_view(calendar_uid: str, mode: str | None = None) -> str:
    """View meeting notes, AI summary, or transcript from Granola.
    calendar_uid: Google Calendar event ID or iCalUID.
    mode: 'summary', 'transcript', 'all', or None (notes only, default)."""
    if mode and mode not in ("summary", "transcript", "all"):
        mode = None
    return handle_kb_granola_view(calendar_uid, mode=mode)


@mcp.tool(annotations=_MUTATING_IDEMPOTENT)
def kb_granola_edit(
    calendar_uid: str,
    body: str | None = None,
    append: str | None = None,
) -> str:
    """Edit meeting notes in Granola (writes to Granola API).
    calendar_uid: Google Calendar event ID or iCalUID.
    body: replace notes entirely.
    append: append to existing notes."""
    return handle_kb_granola_edit(calendar_uid, body=body, append=append)


@mcp.tool(annotations=_READ_ONLY)
def kb_list(
    doc_type: str | None = None,
    from_date: str | None = None,
    to_date: str | None = None,
    limit: int = 25,
    since_hours: int | None = None,
) -> str:
    """Browse documents by date and type.
    doc_type: filter (e.g. 'notes', 'transcript', 'memory_person').
    from_date/to_date: YYYY-MM-DD date range.
    since_hours: only return documents indexed within the last N hours.
    limit: max results (default 25)."""
    limit = max(1, min(limit, 100))
    from_date = _validate_date(from_date)
    to_date = _validate_date(to_date)
    db = get_db()
    return handle_kb_list(
        db,
        doc_type=doc_type,
        from_date=from_date,
        to_date=to_date,
        limit=limit,
        since_hours=since_hours,
    )


@mcp.tool(annotations=_READ_ONLY)
def kb_index_status() -> str:
    """Get database health: document counts by type, entity/fact counts, date range, last indexed timestamp."""
    db = get_db()
    return handle_kb_index_status(db)


@mcp.tool(annotations=_MUTATING)
def kb_correct(
    term: str,
    replacement: str | None = None,
    apply: bool = False,
    scope: str | None = None,
    file_type: str | None = None,
    word_boundary: bool = False,
    ignore_case: bool = False,
) -> str:
    """Find (and optionally replace) a term across all memory files.
    Scan mode (no replacement): lists all occurrences with context.
    Dry-run mode (replacement, apply=False): previews changes.
    Apply mode (replacement, apply=True): executes replacements with audit log.
    scope: glob pattern to limit search (e.g. '**/meetings/*').
    word_boundary: only match whole words.
    ignore_case: case-insensitive matching."""
    project_root = find_project_root()
    return handle_kb_correct(
        project_root,
        term,
        replacement=replacement,
        apply=apply,
        scope=scope,
        file_type=file_type,
        word_boundary=word_boundary,
        ignore_case=ignore_case,
    )


# ---------------------------------------------------------------------------
# MCP resources
# ---------------------------------------------------------------------------


@mcp.resource("kb://context")
def get_context() -> str:
    """Compressed entity index — use at session start for overview of all entities."""
    db = get_db()
    project_root = find_project_root()
    return handle_kb_context(db, project_root)


@mcp.resource("kb://person/{name}")
def get_person(name: str) -> str:
    """Person profile — compact output with facts, metadata, and breadcrumbs."""
    db = get_db()
    return handle_kb_person_find(db, name)


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


def main() -> None:
    """Run the MCP server on stdio transport."""
    # Redirect any accidental stdout prints to stderr
    mcp.run(transport="stdio")


if __name__ == "__main__":
    main()
