"""Tests for kb MCP server — Phase 8: tools, config, resources."""

from __future__ import annotations

import json
import os
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from kb.db import Database

# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def mcp_db():
    """Create a test database with known data for MCP tests."""
    with tempfile.TemporaryDirectory() as tmpdir:
        db = Database(Path(tmpdir))
        conn = db.get_sqlite_conn()

        docs = [
            (
                "meetings/2026/01/27/mfa_review.notes.md",
                "MFA Implementation Review",
                "2026-01-27",
                "notes",
                "granola",
                "id1",
                "[]",
                "aaa111222333",
                2,
            ),
            (
                "meetings/2026/01/20/rust_status.notes.md",
                "Cloud Migration Status",
                "2026-01-20",
                "notes",
                "granola",
                "id2",
                "[]",
                "bbb444555666",
                2,
            ),
            (
                "meetings/2026/02/01/corelogic.notes.md",
                "Datastore Migration Plan",
                "2026-02-01",
                "notes",
                "granola",
                "id3",
                "[]",
                "ccc777888999",
                1,
            ),
            (
                "memory/people/eve.md",
                "Eve Perrin",
                None,
                "memory_person",
                "memory",
                None,
                "[]",
                "ddd000111222",
                1,
            ),
        ]
        for path, title, date, dtype, src, sid, tags, chash, cc in docs:
            conn.execute(
                """INSERT INTO documents (path, title, doc_date, doc_type, source_system, source_id, tags, content_hash, chunk_count)
                   VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
                (path, title, date, dtype, src, sid, tags, chash, cc),
            )

        chunks = [
            (
                1,
                0,
                "Overview",
                "[Meeting: MFA Implementation Review | Date: 2026-01-27]\nMFA implementation using TOTP with Okta integration.",
            ),
            (
                1,
                1,
                "Rollout",
                "[Meeting: MFA Implementation Review | Date: 2026-01-27]\nRollout to all employees by end of February.",
            ),
            (
                2,
                0,
                "Status",
                "[Meeting: Cloud Migration Status | Date: 2026-01-20]\nRust migration at 45% completion. 180 of 400 modules converted.",
            ),
            (
                2,
                1,
                "Performance",
                "[Meeting: Cloud Migration Status | Date: 2026-01-20]\nRust engine is 5x faster than Python.",
            ),
            (
                3,
                0,
                "Plan",
                "[Meeting: Datastore Migration Plan | Date: 2026-02-01]\nDatastore SSO integration and Grafana dashboard migration.",
            ),
            (4, 0, None, "Eve Perrin is the Infrastructure/Platform lead."),
        ]
        for doc_id, idx, heading, content in chunks:
            conn.execute(
                "INSERT INTO chunks (document_id, chunk_index, heading, content) VALUES (?, ?, ?, ?)",
                (doc_id, idx, heading, content),
            )

        entities = [
            (
                "Eve Perrin",
                "person",
                '["Eve"]',
                '{"role": "Engineering Leader", "team": "Platform"}',
                "memory/people/eve.md",
            ),
            (
                "Cloud Migration",
                "project",
                '["cloud-migration"]',
                '{"status": "In Progress"}',
                None,
            ),
        ]
        for name, etype, aliases, meta, src in entities:
            conn.execute(
                "INSERT INTO entities (name, entity_type, aliases, metadata, source_path) VALUES (?, ?, ?, ?, ?)",
                (name, etype, aliases, meta, src),
            )

        conn.execute(
            "INSERT INTO entity_mentions (entity_id, document_id, mention_type) VALUES (1, 1, 'discussed')"
        )
        conn.execute(
            "INSERT INTO entity_mentions (entity_id, document_id, mention_type) VALUES (2, 2, 'discussed')"
        )
        conn.commit()
        yield db, Path(tmpdir)
        db.close()


# ---------------------------------------------------------------------------
# config.py tests
# ---------------------------------------------------------------------------


class TestConfig:
    def test_find_project_root_returns_path(self):
        """find_project_root should return a Path."""
        from kb.config import find_project_root

        result = find_project_root()
        assert isinstance(result, Path)

    def test_get_data_dir_from_env(self, tmp_path):
        """get_data_dir should use KB_DATA_DIR env var when set."""
        from kb.config import get_data_dir

        old = os.environ.get("KB_DATA_DIR")
        try:
            os.environ["KB_DATA_DIR"] = str(tmp_path)
            result = get_data_dir()
            assert result == tmp_path
        finally:
            if old is not None:
                os.environ["KB_DATA_DIR"] = old
            else:
                os.environ.pop("KB_DATA_DIR", None)

    def test_get_db_returns_database(self, mcp_db):
        """get_db should return a Database instance."""
        from kb.config import get_db

        _, db_path = mcp_db
        old = os.environ.get("KB_DATA_DIR")
        try:
            os.environ["KB_DATA_DIR"] = str(db_path)
            db = get_db()
            assert isinstance(db, Database)
        finally:
            if old is not None:
                os.environ["KB_DATA_DIR"] = old
            else:
                os.environ.pop("KB_DATA_DIR", None)

    def test_find_entity_exact_match(self, mcp_db):
        """find_entity should find by exact name."""
        from kb.config import find_entity

        db, _ = mcp_db
        conn = db.get_sqlite_conn()
        result = find_entity(conn, "Eve Perrin")
        assert result is not None
        assert result["name"] == "Eve Perrin"

    def test_find_entity_alias_match(self, mcp_db):
        """find_entity should find by alias."""
        from kb.config import find_entity

        db, _ = mcp_db
        conn = db.get_sqlite_conn()
        result = find_entity(conn, "Eve")
        assert result is not None
        assert result["name"] == "Eve Perrin"

    def test_find_entity_not_found(self, mcp_db):
        """find_entity should return None for unknown names."""
        from kb.config import find_entity

        db, _ = mcp_db
        conn = db.get_sqlite_conn()
        result = find_entity(conn, "NonexistentPerson")
        assert result is None


# ---------------------------------------------------------------------------
# MCP tool handler tests (test functions directly, not MCP transport)
# ---------------------------------------------------------------------------


class TestMcpSearch:
    def test_search_returns_json(self, mcp_db):
        """kb_search should return valid JSON with results."""
        from kb.mcp_server import handle_kb_search

        db, _ = mcp_db
        result = handle_kb_search(db, "MFA", fast=True, limit=5)
        data = json.loads(result)
        assert "results" in data
        assert len(data["results"]) > 0

    def test_search_finds_correct_doc(self, mcp_db):
        """kb_search should find the right document."""
        from kb.mcp_server import handle_kb_search

        db, _ = mcp_db
        result = handle_kb_search(db, "Cloud migration", fast=True, limit=5)
        data = json.loads(result)
        titles = [r["title"] for r in data["results"]]
        assert any("Cloud" in t for t in titles)

    def test_search_no_results(self, mcp_db):
        """kb_search should return empty results for no matches."""
        from kb.mcp_server import handle_kb_search

        db, _ = mcp_db
        result = handle_kb_search(db, "zzzznonexistentquery", fast=True, limit=5)
        data = json.loads(result)
        assert data["results"] == []


class TestMcpPersonFind:
    def test_person_find_returns_json(self, mcp_db):
        """kb_person_find should return valid JSON with person data."""
        from kb.mcp_server import handle_kb_person_find

        db, _ = mcp_db
        result = handle_kb_person_find(db, "Eve")
        data = json.loads(result)
        assert data["name"] == "Eve Perrin"

    def test_person_find_compact_output(self, mcp_db):
        """kb_person_find should return compact output with facts, doc count, breadcrumbs."""
        from kb.mcp_server import handle_kb_person_find

        db, _ = mcp_db
        result = handle_kb_person_find(db, "Eve")
        data = json.loads(result)
        assert "document_count" in data
        assert "breadcrumbs" in data
        assert "facts" in data
        assert isinstance(data["document_count"], int)
        assert isinstance(data["breadcrumbs"], dict)
        assert isinstance(data["facts"], list)
        # Should NOT have the old full documents list
        assert "documents" not in data

    def test_person_find_not_found(self, mcp_db):
        """kb_person_find should return error for unknown person."""
        from kb.mcp_server import handle_kb_person_find

        db, _ = mcp_db
        result = handle_kb_person_find(db, "NonexistentPerson")
        data = json.loads(result)
        assert "error" in data


class TestMcpPersonTimeline:
    def test_timeline_returns_json(self, mcp_db):
        """kb_person_timeline should return valid JSON."""
        from kb.mcp_server import handle_kb_person_timeline

        db, _ = mcp_db
        result = handle_kb_person_timeline(db, "Eve")
        data = json.loads(result)
        assert "documents" in data

    def test_timeline_not_found(self, mcp_db):
        """kb_person_timeline should return error for unknown person."""
        from kb.mcp_server import handle_kb_person_timeline

        db, _ = mcp_db
        result = handle_kb_person_timeline(db, "NonexistentPerson")
        data = json.loads(result)
        assert "error" in data


class TestMcpView:
    def test_view_by_path(self, mcp_db):
        """kb_view should return document by path."""
        from kb.mcp_server import handle_kb_view

        db, _ = mcp_db
        result = handle_kb_view(db, "meetings/2026/01/27/mfa_review.notes.md")
        data = json.loads(result)
        assert data["title"] == "MFA Implementation Review"
        assert "chunks" in data

    def test_view_by_hash(self, mcp_db):
        """kb_view should return document by #hash prefix."""
        from kb.mcp_server import handle_kb_view

        db, _ = mcp_db
        result = handle_kb_view(db, "#aaa111")
        data = json.loads(result)
        assert data["title"] == "MFA Implementation Review"

    def test_view_not_found(self, mcp_db):
        """kb_view should return error for unknown document."""
        from kb.mcp_server import handle_kb_view

        db, _ = mcp_db
        result = handle_kb_view(db, "nonexistent/path.md")
        data = json.loads(result)
        assert "error" in data


class TestMcpViewGlob:
    def test_view_glob_finds_document(self, mcp_db):
        """MCP kb_view with glob pattern should find matching document."""
        from kb.mcp_server import handle_kb_view

        db, _ = mcp_db
        result = handle_kb_view(db, "*mfa_review*")
        data = json.loads(result)
        assert "error" not in data
        assert data["title"] == "MFA Implementation Review"

    def test_view_filename_substring(self, mcp_db):
        """MCP kb_view with filename substring should find matching document."""
        from kb.mcp_server import handle_kb_view

        db, _ = mcp_db
        result = handle_kb_view(db, "mfa_review")
        data = json.loads(result)
        assert "error" not in data
        assert data["title"] == "MFA Implementation Review"


class TestMcpViewUnicode:
    def test_view_nfc_finds_nfd_path(self, mcp_db):
        """MCP kb_view with NFC input should find document stored with NFD path."""
        import unicodedata

        from kb.mcp_server import handle_kb_view

        db, _ = mcp_db
        conn = db.get_sqlite_conn()
        nfd_path = unicodedata.normalize("NFD", "meetings/2026/01/23/Camille_test.notes.md")
        conn.execute(
            """INSERT INTO documents (path, title, doc_date, doc_type, source_system, tags, content_hash, chunk_count)
               VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
            (nfd_path, "Camille Test", "2026-01-23", "notes", "granola", "[]", "uuu999888777", 1),
        )
        conn.execute(
            "INSERT INTO chunks (document_id, chunk_index, heading, content) VALUES (?, ?, ?, ?)",
            (5, 0, None, "Meeting with Camille."),
        )
        conn.commit()

        nfc_input = unicodedata.normalize("NFC", "meetings/2026/01/23/Camille_test.notes.md")
        result = handle_kb_view(db, nfc_input)
        data = json.loads(result)
        assert "error" not in data
        assert data["title"] == "Camille Test"


class TestMcpContext:
    def test_context_returns_json(self, mcp_db):
        """kb_context should return valid JSON."""
        from kb.mcp_server import handle_kb_context

        db, _ = mcp_db
        result = handle_kb_context(db, project_root=Path("/tmp"))
        data = json.loads(result)
        assert "text" in data
        assert "stats" in data

    def test_context_with_topic(self, mcp_db):
        """kb_context with topic should filter."""
        from kb.mcp_server import handle_kb_context

        db, _ = mcp_db
        result = handle_kb_context(db, project_root=Path("/tmp"), topic="Cloud")
        data = json.loads(result)
        assert "text" in data


class TestMcpContextFmt:
    def test_context_default_compact(self, mcp_db):
        """handle_kb_context with default fmt should return compact format."""
        from kb.mcp_server import handle_kb_context

        db, _ = mcp_db
        result = handle_kb_context(db, project_root=Path("/tmp"))
        data = json.loads(result)
        assert "text" in data
        # Compact format uses [People:key] style
        assert "[People:key]" in data["text"]

    def test_context_human_fmt(self, mcp_db):
        """handle_kb_context with fmt='human' should return markdown format."""
        from kb.mcp_server import handle_kb_context

        db, _ = mcp_db
        result = handle_kb_context(db, project_root=Path("/tmp"), fmt="human")
        data = json.loads(result)
        assert "text" in data
        # Human format uses ## headings
        assert "## Key People" in data["text"]

    def test_context_mcp_tool_accepts_fmt(self, mcp_db):
        """kb_context MCP tool should accept fmt parameter."""
        import inspect

        from kb.mcp_server import kb_context

        sig = inspect.signature(kb_context)
        assert "fmt" in sig.parameters


class TestMcpUsage:
    def test_usage_returns_string(self, mcp_db):
        """kb_usage should return usage text with stats."""
        from kb.mcp_server import handle_kb_usage

        db, _ = mcp_db
        result = handle_kb_usage(db)
        assert "kb_search" in result
        assert "documents" in result.lower() or "entities" in result.lower()


# ---------------------------------------------------------------------------
# CLI still works after refactor
# ---------------------------------------------------------------------------


class TestCliAfterRefactor:
    def test_cli_search_still_works(self, mcp_db):
        """CLI search should still work after config.py refactor."""
        from click.testing import CliRunner

        from kb.cli import cli

        _, db_path = mcp_db
        runner = CliRunner()
        result = runner.invoke(
            cli,
            ["search", "MFA", "--fast", "--json"],
            env={"KB_DATA_DIR": str(db_path)},
            catch_exceptions=False,
        )
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["results"]

    def test_cli_person_find_still_works(self, mcp_db):
        """CLI person find should still work after config.py refactor."""
        from click.testing import CliRunner

        from kb.cli import cli

        _, db_path = mcp_db
        runner = CliRunner()
        result = runner.invoke(
            cli,
            ["person", "find", "Eve", "--json"],
            env={"KB_DATA_DIR": str(db_path)},
            catch_exceptions=False,
        )
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data["name"] == "Eve Perrin"

    def test_kb_mcp_command_exists(self):
        """kb mcp command should exist."""
        from click.testing import CliRunner

        from kb.cli import cli

        runner = CliRunner()
        result = runner.invoke(cli, ["mcp", "--help"])
        assert result.exit_code == 0
        assert "MCP" in result.output or "mcp" in result.output.lower()


# ---------------------------------------------------------------------------
# _validate_date tests
# ---------------------------------------------------------------------------


class TestValidateDate:
    def test_valid_date(self):
        """Valid YYYY-MM-DD date string returned as-is."""
        from kb.mcp_server import _validate_date

        assert _validate_date("2026-01-15") == "2026-01-15"

    def test_invalid_date_format(self):
        """Non-date string returns None."""
        from kb.mcp_server import _validate_date

        assert _validate_date("not-a-date") is None

    def test_none_input(self):
        """None input returns None."""
        from kb.mcp_server import _validate_date

        assert _validate_date(None) is None

    def test_partial_date_returns_none(self):
        """Incomplete date like YYYY-MM returns None."""
        from kb.mcp_server import _validate_date

        assert _validate_date("2026-01") is None

    def test_date_with_time_returns_none(self):
        """Date with time suffix returns None."""
        from kb.mcp_server import _validate_date

        assert _validate_date("2026-01-15T12:00:00") is None


# ---------------------------------------------------------------------------
# handle_kb_pin / handle_kb_unpin tests
# ---------------------------------------------------------------------------


class TestMcpPinUnpin:
    def test_pin_by_path(self, mcp_db):
        """Pinning a known document by path sets pinned flag."""
        from kb.mcp_server import handle_kb_pin

        db, _ = mcp_db
        result = json.loads(handle_kb_pin(db, "meetings/2026/01/27/mfa_review.notes.md"))
        assert result["pinned"] is True
        assert result["status"] == "ok"

        # Verify in DB
        conn = db.get_sqlite_conn()
        row = conn.execute(
            "SELECT pinned FROM documents WHERE path = ?",
            ("meetings/2026/01/27/mfa_review.notes.md",),
        ).fetchone()
        assert row["pinned"] == 1

    def test_unpin_by_path(self, mcp_db):
        """Unpinning a previously pinned document clears pinned flag."""
        from kb.mcp_server import handle_kb_pin, handle_kb_unpin

        db, _ = mcp_db
        handle_kb_pin(db, "meetings/2026/01/27/mfa_review.notes.md")
        result = json.loads(handle_kb_unpin(db, "meetings/2026/01/27/mfa_review.notes.md"))
        assert result["pinned"] is False
        assert result["status"] == "ok"

        # Verify in DB
        conn = db.get_sqlite_conn()
        row = conn.execute(
            "SELECT pinned FROM documents WHERE path = ?",
            ("meetings/2026/01/27/mfa_review.notes.md",),
        ).fetchone()
        assert row["pinned"] == 0

    def test_pin_not_found(self, mcp_db):
        """Pinning nonexistent document returns error."""
        from kb.mcp_server import handle_kb_pin

        db, _ = mcp_db
        result = json.loads(handle_kb_pin(db, "nonexistent.md"))
        assert "error" in result

    def test_unpin_not_found(self, mcp_db):
        """Unpinning nonexistent document returns error."""
        from kb.mcp_server import handle_kb_unpin

        db, _ = mcp_db
        result = json.loads(handle_kb_unpin(db, "nonexistent.md"))
        assert "error" in result


# ---------------------------------------------------------------------------
# _find_document_by_target tests
# ---------------------------------------------------------------------------


class TestFindDocumentByTarget:
    def test_find_by_exact_path(self, mcp_db):
        """Finds document by exact path."""
        from kb.mcp_server import _find_document_by_target

        db, _ = mcp_db
        conn = db.get_sqlite_conn()
        doc = _find_document_by_target(conn, "meetings/2026/01/27/mfa_review.notes.md")
        assert doc is not None
        assert doc["title"] == "MFA Implementation Review"

    def test_find_by_hash_prefix(self, mcp_db):
        """Finds document by content hash prefix."""
        from kb.mcp_server import _find_document_by_target

        db, _ = mcp_db
        conn = db.get_sqlite_conn()
        doc = _find_document_by_target(conn, "#aaa111")
        assert doc is not None
        assert doc["title"] == "MFA Implementation Review"

    def test_find_by_title(self, mcp_db):
        """Finds document by exact title (case-insensitive)."""
        from kb.mcp_server import _find_document_by_target

        db, _ = mcp_db
        conn = db.get_sqlite_conn()
        doc = _find_document_by_target(conn, "MFA Implementation Review")
        assert doc is not None
        assert doc["title"] == "MFA Implementation Review"

    def test_find_by_title_case_insensitive(self, mcp_db):
        """Title lookup is case-insensitive."""
        from kb.mcp_server import _find_document_by_target

        db, _ = mcp_db
        conn = db.get_sqlite_conn()
        doc = _find_document_by_target(conn, "mfa implementation review")
        assert doc is not None
        assert doc["title"] == "MFA Implementation Review"

    def test_find_returns_none_for_ambiguous_glob(self, mcp_db):
        """Ambiguous glob matching multiple docs returns None."""
        from kb.mcp_server import _find_document_by_target

        db, _ = mcp_db
        conn = db.get_sqlite_conn()
        # *.notes.md matches multiple documents
        doc = _find_document_by_target(conn, "*.notes.md")
        assert doc is None

    def test_find_returns_none_for_nonexistent(self, mcp_db):
        """Completely unknown target returns None."""
        from kb.mcp_server import _find_document_by_target

        db, _ = mcp_db
        conn = db.get_sqlite_conn()
        doc = _find_document_by_target(conn, "totally-nonexistent-doc.md")
        assert doc is None

    def test_find_by_suffix(self, mcp_db):
        """Finds document by path suffix."""
        from kb.mcp_server import _find_document_by_target

        db, _ = mcp_db
        conn = db.get_sqlite_conn()
        doc = _find_document_by_target(conn, "corelogic.notes.md")
        assert doc is not None
        assert doc["title"] == "Datastore Migration Plan"


# ---------------------------------------------------------------------------
# handle_kb_memory_add tests
# ---------------------------------------------------------------------------


class TestMcpMemoryAdd:
    def test_add_fact_to_known_entity(self, mcp_db):
        """Adding a fact to a known entity inserts into facts table."""
        from kb.mcp_server import handle_kb_memory_add

        db, db_path = mcp_db
        result = json.loads(
            handle_kb_memory_add(db, db_path, "Eve is great at debugging", entity="Eve")
        )
        assert result["type"] == "fact"
        assert result["status"] == "ok"
        assert result["entity"] == "Eve Perrin"

        # Verify in DB
        conn = db.get_sqlite_conn()
        facts = conn.execute("SELECT * FROM facts").fetchall()
        assert len(facts) >= 1
        assert any("great at debugging" in f["fact_text"] for f in facts)

    def test_add_fact_unknown_entity_returns_error(self, mcp_db):
        """Adding a fact for unknown entity returns error."""
        from kb.mcp_server import handle_kb_memory_add

        db, db_path = mcp_db
        result = json.loads(
            handle_kb_memory_add(db, db_path, "Some fact about unknown", entity="ZzzNobody")
        )
        assert "error" in result
        assert "not found" in result["error"].lower()

    def test_add_note_creates_file(self, mcp_db):
        """Adding a note creates a markdown file in memory/notes/."""
        from kb.mcp_server import handle_kb_memory_add

        db, db_path = mcp_db
        with patch("kb.indexer.index_all"):
            result = json.loads(
                handle_kb_memory_add(db, db_path, "Test note title", body="Some body content")
            )
        assert result["type"] == "note"
        assert result["status"] == "ok"
        assert "path" in result

        # Check file was created
        notes_dir = db_path / "memory" / "notes"
        assert notes_dir.exists()
        md_files = list(notes_dir.glob("*.md"))
        assert len(md_files) >= 1

        # Check content
        content = md_files[0].read_text()
        assert "title: Test note title" in content
        assert "Some body content" in content

    def test_add_note_with_tags(self, mcp_db):
        """Note with tags includes them in frontmatter."""
        from kb.mcp_server import handle_kb_memory_add

        db, db_path = mcp_db
        with patch("kb.indexer.index_all"):
            result = json.loads(
                handle_kb_memory_add(
                    db, db_path, "Tagged note", body="Content", tags="infra,urgent"
                )
            )
        assert result["type"] == "note"
        assert result["status"] == "ok"

        notes_dir = db_path / "memory" / "notes"
        md_files = list(notes_dir.glob("*.md"))
        content = md_files[0].read_text()
        assert "infra" in content
        assert "urgent" in content

    def test_add_note_with_pin(self, mcp_db):
        """Note with pin=True sets pinned on the document."""
        from kb.mcp_server import handle_kb_memory_add

        db, db_path = mcp_db
        with patch("kb.indexer.index_all"):
            result = json.loads(
                handle_kb_memory_add(db, db_path, "Pinned note", body="Content", pin=True)
            )
        assert result["type"] == "note"
        assert result["pinned"] is True

    def test_add_note_with_entity_linking(self, mcp_db):
        """Note with entity creates the file and links entity."""
        from kb.mcp_server import handle_kb_memory_add

        db, db_path = mcp_db
        with patch("kb.indexer.index_all"):
            result = json.loads(
                handle_kb_memory_add(
                    db,
                    db_path,
                    "Note about Eve",
                    body="Eve helped with the migration",
                    entity="Eve",
                )
            )
        # entity + body -> note path (not fact path)
        assert result["type"] == "note"
        assert result["status"] == "ok"

    def test_add_note_duplicate_filename(self, mcp_db):
        """Duplicate filename gets counter suffix."""
        from kb.mcp_server import handle_kb_memory_add

        db, db_path = mcp_db
        with patch("kb.indexer.index_all"):
            # Create first note
            result1 = json.loads(
                handle_kb_memory_add(db, db_path, "Same title", body="First", date="2026-01-15")
            )
            # Create second note with same title and date
            result2 = json.loads(
                handle_kb_memory_add(db, db_path, "Same title", body="Second", date="2026-01-15")
            )

        assert result1["status"] == "ok"
        assert result2["status"] == "ok"
        # Paths should differ
        assert result1["path"] != result2["path"]

        # Verify both files exist
        notes_dir = db_path / "memory" / "notes"
        md_files = list(notes_dir.glob("*.md"))
        assert len(md_files) >= 2

    def test_add_note_without_body_or_entity(self, mcp_db):
        """One-liner note (no body, no entity) takes note path."""
        from kb.mcp_server import handle_kb_memory_add

        db, db_path = mcp_db
        with patch("kb.indexer.index_all"):
            result = json.loads(handle_kb_memory_add(db, db_path, "Quick note"))
        # entity=None -> is_note=True
        assert result["type"] == "note"
        assert result["status"] == "ok"

    def test_add_note_custom_date(self, mcp_db):
        """Custom date parameter used in filename."""
        from kb.mcp_server import handle_kb_memory_add

        db, db_path = mcp_db
        with patch("kb.indexer.index_all"):
            result = json.loads(
                handle_kb_memory_add(db, db_path, "Dated note", body="content", date="2025-12-25")
            )
        assert result["status"] == "ok"
        assert "2025-12-25" in result["path"]


# ---------------------------------------------------------------------------
# handle_kb_usage — facts count coverage
# ---------------------------------------------------------------------------


class TestMcpUsageFacts:
    def test_usage_includes_facts_count(self, mcp_db):
        """kb_usage output includes facts count."""
        from kb.mcp_server import handle_kb_memory_add, handle_kb_usage

        db, db_path = mcp_db
        # Add a fact first
        handle_kb_memory_add(db, db_path, "Eve knows Rust", entity="Eve")

        result = handle_kb_usage(db)
        # Should include "1 facts" in the output
        assert "1 facts" in result


# ---------------------------------------------------------------------------
# handle_kb_entity_stale tests
# ---------------------------------------------------------------------------


class TestMcpEntityStale:
    def test_entity_stale_returns_stale(self, mcp_db):
        """handle_kb_entity_stale returns entities with old timestamps."""
        from kb.mcp_server import handle_kb_entity_stale

        db, _ = mcp_db
        conn = db.get_sqlite_conn()
        # Set old timestamps on both test entities
        conn.execute(
            "UPDATE entities SET updated_at = '2025-01-01', last_mentioned_at = '2025-01-15'"
        )
        conn.commit()

        result = json.loads(handle_kb_entity_stale(db, days=30))
        assert "results" in result
        assert len(result["results"]) == 2
        names = {r["name"] for r in result["results"]}
        assert "Eve Perrin" in names

    def test_entity_stale_excludes_fresh(self, mcp_db):
        """handle_kb_entity_stale excludes recently updated entities."""
        from datetime import date, timedelta

        from kb.mcp_server import handle_kb_entity_stale

        db, _ = mcp_db
        conn = db.get_sqlite_conn()
        yesterday = (date.today() - timedelta(days=1)).isoformat()
        conn.execute(
            f"UPDATE entities SET updated_at = '{yesterday}', last_mentioned_at = '{yesterday}'"
        )
        conn.commit()

        result = json.loads(handle_kb_entity_stale(db, days=30))
        assert len(result["results"]) == 0

    def test_entity_stale_type_filter(self, mcp_db):
        """handle_kb_entity_stale respects entity_type filter."""
        from kb.mcp_server import handle_kb_entity_stale

        db, _ = mcp_db
        conn = db.get_sqlite_conn()
        conn.execute(
            "UPDATE entities SET updated_at = '2025-01-01', last_mentioned_at = '2025-01-15'"
        )
        conn.commit()

        result = json.loads(handle_kb_entity_stale(db, days=30, entity_type="person"))
        assert len(result["results"]) == 1
        assert result["results"][0]["name"] == "Eve Perrin"

    def test_entity_stale_meta_includes_threshold(self, mcp_db):
        """Response meta includes threshold_days and count."""
        from kb.mcp_server import handle_kb_entity_stale

        db, _ = mcp_db
        result = json.loads(handle_kb_entity_stale(db, days=60))
        assert result["meta"]["threshold_days"] == 60
        assert "count" in result["meta"]


# ---------------------------------------------------------------------------
# person_find freshness fields
# ---------------------------------------------------------------------------


class TestMcpPersonFindFreshness:
    def test_person_find_includes_freshness_fields(self, mcp_db):
        """kb_person_find response includes updated_at and last_mentioned_at."""
        from kb.mcp_server import handle_kb_person_find

        db, _ = mcp_db
        conn = db.get_sqlite_conn()
        conn.execute(
            "UPDATE entities SET updated_at = '2026-02-01', last_mentioned_at = '2026-02-15' WHERE name = 'Eve Perrin'"
        )
        conn.commit()

        result = json.loads(handle_kb_person_find(db, "Eve"))
        assert result["updated_at"] == "2026-02-01"
        assert result["last_mentioned_at"] == "2026-02-15"

    def test_person_find_null_freshness_fields(self, mcp_db):
        """kb_person_find returns None for missing freshness timestamps."""
        from kb.mcp_server import handle_kb_person_find

        db, _ = mcp_db
        result = json.loads(handle_kb_person_find(db, "Eve"))
        assert "updated_at" in result
        assert "last_mentioned_at" in result


# ---------------------------------------------------------------------------
# handle_kb_project_find tests
# ---------------------------------------------------------------------------


class TestMcpProjectFind:
    def test_project_find_returns_json(self, mcp_db):
        """kb_project_find should return project data."""
        from kb.mcp_server import handle_kb_project_find

        db, _ = mcp_db
        result = json.loads(handle_kb_project_find(db, "Cloud Migration"))
        assert result["name"] == "Cloud Migration"
        assert result["entity_type"] == "project"
        assert "facts" in result
        assert "breadcrumbs" in result

    def test_project_find_by_alias(self, mcp_db):
        """kb_project_find should work with aliases."""
        from kb.mcp_server import handle_kb_project_find

        db, _ = mcp_db
        result = json.loads(handle_kb_project_find(db, "cloud-migration"))
        assert result["name"] == "Cloud Migration"

    def test_project_find_not_found(self, mcp_db):
        """kb_project_find should return error for unknown project."""
        from kb.mcp_server import handle_kb_project_find

        db, _ = mcp_db
        result = json.loads(handle_kb_project_find(db, "NonexistentProject"))
        assert "error" in result

    def test_project_find_wrong_type(self, mcp_db):
        """kb_project_find should reject person entities."""
        from kb.mcp_server import handle_kb_project_find

        db, _ = mcp_db
        result = json.loads(handle_kb_project_find(db, "Eve Perrin"))
        assert "error" in result
        assert "person" in result["error"]


# ---------------------------------------------------------------------------
# handle_kb_project_list / handle_kb_person_list tests
# ---------------------------------------------------------------------------


class TestMcpEntityLists:
    def test_project_list(self, mcp_db):
        """kb_project_list returns all projects."""
        from kb.mcp_server import handle_kb_project_list

        db, _ = mcp_db
        result = json.loads(handle_kb_project_list(db))
        assert "results" in result
        assert "meta" in result
        assert result["meta"]["total"] == 1
        assert result["results"][0]["name"] == "Cloud Migration"

    def test_person_list(self, mcp_db):
        """kb_person_list returns all people."""
        from kb.mcp_server import handle_kb_person_list

        db, _ = mcp_db
        result = json.loads(handle_kb_person_list(db))
        assert "results" in result
        assert result["meta"]["total"] == 1
        assert result["results"][0]["name"] == "Eve Perrin"


# ---------------------------------------------------------------------------
# handle_kb_note_list tests
# ---------------------------------------------------------------------------


class TestMcpNoteList:
    def _insert_note(self, conn, path, title, date, tags, pinned=False):
        conn.execute(
            """INSERT INTO documents (path, title, doc_date, doc_type, source_system, tags, content_hash, chunk_count, pinned)
               VALUES (?, ?, ?, 'memory_note', 'memory', ?, ?, 1, ?)""",
            (path, title, date, json.dumps(tags), f"hash_{title[:6]}", 1 if pinned else 0),
        )

    def test_note_list_empty(self, mcp_db):
        """kb_note_list returns empty when no notes exist."""
        from kb.mcp_server import handle_kb_note_list

        db, _ = mcp_db
        result = json.loads(handle_kb_note_list(db))
        assert result["results"] == []

    def test_note_list_returns_notes(self, mcp_db):
        """kb_note_list returns inserted memory notes."""
        from kb.mcp_server import handle_kb_note_list

        db, _ = mcp_db
        conn = db.get_sqlite_conn()
        self._insert_note(conn, "memory/notes/test.md", "Test Note", "2026-01-01", ["infra"])
        conn.commit()

        result = json.loads(handle_kb_note_list(db))
        assert len(result["results"]) == 1
        assert result["results"][0]["title"] == "Test Note"
        assert result["results"][0]["tags"] == ["infra"]

    def test_note_list_tag_filter(self, mcp_db):
        """kb_note_list filters by tag."""
        from kb.mcp_server import handle_kb_note_list

        db, _ = mcp_db
        conn = db.get_sqlite_conn()
        self._insert_note(conn, "memory/notes/a.md", "Note A", "2026-01-01", ["infra", "urgent"])
        self._insert_note(conn, "memory/notes/b.md", "Note B", "2026-01-02", ["decision"])
        conn.commit()

        result = json.loads(handle_kb_note_list(db, tag="infra"))
        assert len(result["results"]) == 1
        assert result["results"][0]["title"] == "Note A"

    def test_note_list_pinned_only(self, mcp_db):
        """kb_note_list pinned_only returns only pinned notes."""
        from kb.mcp_server import handle_kb_note_list

        db, _ = mcp_db
        conn = db.get_sqlite_conn()
        self._insert_note(conn, "memory/notes/a.md", "Pinned", "2026-01-01", [], pinned=True)
        self._insert_note(conn, "memory/notes/b.md", "Not Pinned", "2026-01-02", [])
        conn.commit()

        result = json.loads(handle_kb_note_list(db, pinned_only=True))
        assert len(result["results"]) == 1
        assert result["results"][0]["title"] == "Pinned"

    def test_note_list_limit(self, mcp_db):
        """kb_note_list respects limit."""
        from kb.mcp_server import handle_kb_note_list

        db, _ = mcp_db
        conn = db.get_sqlite_conn()
        for i in range(5):
            self._insert_note(conn, f"memory/notes/{i}.md", f"Note {i}", f"2026-01-0{i+1}", [])
        conn.commit()

        result = json.loads(handle_kb_note_list(db, limit=2))
        assert len(result["results"]) == 2


# ---------------------------------------------------------------------------
# handle_kb_note_edit tests
# ---------------------------------------------------------------------------


class TestMcpNoteEdit:
    def _create_note_file(self, project_root, rel_path, content):
        full = project_root / rel_path
        full.parent.mkdir(parents=True, exist_ok=True)
        full.write_text(content, encoding="utf-8")

    def test_note_edit_body(self, mcp_db):
        """kb_note_edit replaces note body."""
        from kb.mcp_server import handle_kb_note_edit

        db, root = mcp_db
        conn = db.get_sqlite_conn()
        rel = "memory/notes/edit_test.md"
        self._create_note_file(root, rel, "---\ntitle: Edit Test\n---\nOld body\n")
        conn.execute(
            """INSERT INTO documents (path, title, doc_date, doc_type, source_system, tags, content_hash, chunk_count)
               VALUES (?, 'Edit Test', '2026-01-01', 'memory_note', 'memory', '[]', 'eee111', 1)""",
            (rel,),
        )
        conn.commit()

        with patch("kb.indexer.index_all"):
            result = json.loads(handle_kb_note_edit(db, root, rel, body="New body"))
        assert result["status"] == "ok"
        content = (root / rel).read_text()
        assert "New body" in content
        assert "Old body" not in content

    def test_note_edit_append(self, mcp_db):
        """kb_note_edit appends to note body."""
        from kb.mcp_server import handle_kb_note_edit

        db, root = mcp_db
        conn = db.get_sqlite_conn()
        rel = "memory/notes/append_test.md"
        self._create_note_file(root, rel, "---\ntitle: Append Test\n---\nExisting.\n")
        conn.execute(
            """INSERT INTO documents (path, title, doc_date, doc_type, source_system, tags, content_hash, chunk_count)
               VALUES (?, 'Append Test', '2026-01-01', 'memory_note', 'memory', '[]', 'fff222', 1)""",
            (rel,),
        )
        conn.commit()

        with patch("kb.indexer.index_all"):
            result = json.loads(handle_kb_note_edit(db, root, rel, append="\nExtra line."))
        assert result["status"] == "ok"
        content = (root / rel).read_text()
        assert "Existing." in content
        assert "Extra line." in content

    def test_note_edit_not_found(self, mcp_db):
        """kb_note_edit returns error for unknown note."""
        from kb.mcp_server import handle_kb_note_edit

        db, root = mcp_db
        result = json.loads(handle_kb_note_edit(db, root, "nonexistent.md", body="x"))
        assert "error" in result

    def test_note_edit_rejects_non_note(self, mcp_db):
        """kb_note_edit rejects non-memory_note doc types."""
        from kb.mcp_server import handle_kb_note_edit

        db, root = mcp_db
        result = json.loads(
            handle_kb_note_edit(db, root, "meetings/2026/01/27/mfa_review.notes.md", body="x")
        )
        assert "error" in result
        assert "Not a memory note" in result["error"]

    def test_note_edit_no_changes(self, mcp_db):
        """kb_note_edit returns error when no edit options given."""
        from kb.mcp_server import handle_kb_note_edit

        db, root = mcp_db
        conn = db.get_sqlite_conn()
        rel = "memory/notes/noop.md"
        self._create_note_file(root, rel, "---\ntitle: Noop\n---\nBody\n")
        conn.execute(
            """INSERT INTO documents (path, title, doc_date, doc_type, source_system, tags, content_hash, chunk_count)
               VALUES (?, 'Noop', '2026-01-01', 'memory_note', 'memory', '[]', 'ggg333', 1)""",
            (rel,),
        )
        conn.commit()

        result = json.loads(handle_kb_note_edit(db, root, rel))
        assert "error" in result
        assert "No edit options" in result["error"]

    def test_note_edit_both_body_and_append(self, mcp_db):
        """kb_note_edit rejects body + append together."""
        from kb.mcp_server import handle_kb_note_edit

        db, root = mcp_db
        conn = db.get_sqlite_conn()
        rel = "memory/notes/both.md"
        self._create_note_file(root, rel, "---\ntitle: Both\n---\nBody\n")
        conn.execute(
            """INSERT INTO documents (path, title, doc_date, doc_type, source_system, tags, content_hash, chunk_count)
               VALUES (?, 'Both', '2026-01-01', 'memory_note', 'memory', '[]', 'hhh444', 1)""",
            (rel,),
        )
        conn.commit()

        result = json.loads(handle_kb_note_edit(db, root, rel, body="new", append="extra"))
        assert "error" in result


# ---------------------------------------------------------------------------
# handle_kb_note_delete tests
# ---------------------------------------------------------------------------


class TestMcpNoteDelete:
    def test_note_delete(self, mcp_db):
        """kb_note_delete removes file and DB rows."""
        from kb.mcp_server import handle_kb_note_delete

        db, root = mcp_db
        conn = db.get_sqlite_conn()
        rel = "memory/notes/delete_me.md"
        note_path = root / rel
        note_path.parent.mkdir(parents=True, exist_ok=True)
        note_path.write_text("---\ntitle: Delete Me\n---\nContent\n")
        conn.execute(
            """INSERT INTO documents (path, title, doc_date, doc_type, source_system, tags, content_hash, chunk_count)
               VALUES (?, 'Delete Me', '2026-01-01', 'memory_note', 'memory', '[]', 'del111', 1)""",
            (rel,),
        )
        conn.commit()

        result = json.loads(handle_kb_note_delete(db, root, rel))
        assert result["status"] == "ok"
        assert not note_path.exists()
        row = conn.execute("SELECT id FROM documents WHERE path = ?", (rel,)).fetchone()
        assert row is None

    def test_note_delete_not_found(self, mcp_db):
        """kb_note_delete returns error for unknown note."""
        from kb.mcp_server import handle_kb_note_delete

        db, root = mcp_db
        result = json.loads(handle_kb_note_delete(db, root, "nonexistent.md"))
        assert "error" in result

    def test_note_delete_rejects_non_note(self, mcp_db):
        """kb_note_delete rejects non-memory_note doc types."""
        from kb.mcp_server import handle_kb_note_delete

        db, root = mcp_db
        result = json.loads(
            handle_kb_note_delete(db, root, "meetings/2026/01/27/mfa_review.notes.md")
        )
        assert "error" in result


# ---------------------------------------------------------------------------
# handle_kb_memory_list tests
# ---------------------------------------------------------------------------


class TestMcpMemoryList:
    def test_memory_list_empty(self, mcp_db):
        """kb_memory_list returns empty when no facts exist."""
        from kb.mcp_server import handle_kb_memory_list

        db, _ = mcp_db
        result = json.loads(handle_kb_memory_list(db))
        assert result["results"] == []
        assert result["meta"]["total"] == 0

    def test_memory_list_with_facts(self, mcp_db):
        """kb_memory_list returns inserted facts."""
        from kb.mcp_server import handle_kb_memory_add, handle_kb_memory_list

        db, root = mcp_db
        handle_kb_memory_add(db, root, "Eve speaks French", entity="Eve")
        handle_kb_memory_add(db, root, "Eve likes Rust", entity="Eve")

        result = json.loads(handle_kb_memory_list(db))
        assert result["meta"]["total"] == 2
        texts = [f["fact_text"] for f in result["results"]]
        assert "Eve speaks French" in texts


# ---------------------------------------------------------------------------
# handle_kb_memory_delete_fact / handle_kb_memory_edit_fact tests
# ---------------------------------------------------------------------------


class TestMcpMemoryFactOps:
    def test_memory_delete_fact(self, mcp_db):
        """kb_memory_delete_fact removes a fact by ID."""
        from kb.mcp_server import handle_kb_memory_add, handle_kb_memory_delete_fact

        db, root = mcp_db
        # Add a fact first
        add_result = json.loads(handle_kb_memory_add(db, root, "Eve is great", entity="Eve"))
        assert add_result["status"] == "ok"

        # Get fact ID
        conn = db.get_sqlite_conn()
        fact_row = conn.execute("SELECT id FROM facts WHERE fact_text = 'Eve is great'").fetchone()
        fact_id = fact_row["id"]

        with patch("kb.config.get_data_dir", return_value=root):
            result = json.loads(handle_kb_memory_delete_fact(root, fact_id))
        assert result.get("status") == "ok" or "deleted" in str(result).lower()

    def test_memory_delete_fact_not_found(self, mcp_db):
        """kb_memory_delete_fact returns error for unknown fact ID."""
        from kb.mcp_server import handle_kb_memory_delete_fact

        _, root = mcp_db
        with patch("kb.config.get_data_dir", return_value=root):
            result = json.loads(handle_kb_memory_delete_fact(root, 99999))
        assert "error" in result

    def test_memory_edit_fact_no_changes(self):
        """kb_memory_edit_fact returns error when no text or date given."""
        from kb.mcp_server import handle_kb_memory_edit_fact

        result = json.loads(handle_kb_memory_edit_fact(Path("/tmp"), 1))
        assert "error" in result


# ---------------------------------------------------------------------------
# handle_kb_glossary_* tests
# ---------------------------------------------------------------------------


class TestMcpGlossary:
    def _create_glossary(self, project_root):
        glossary_path = project_root / "memory" / "glossary.md"
        glossary_path.parent.mkdir(parents=True, exist_ok=True)
        glossary_path.write_text(
            "# Glossary\n\n## Acronyms\n\n| Term | Expansion |\n|------|----------|\n| GG | GitGuardian |\n\n"
            "## Jargon\n\n| Term | Expansion |\n|------|----------|\n| Wards | Ward patrol runs |\n",
            encoding="utf-8",
        )

    def test_glossary_list(self, mcp_db):
        """kb_glossary_list returns all terms."""
        from kb.mcp_server import handle_kb_glossary_list

        _, root = mcp_db
        self._create_glossary(root)

        result = json.loads(handle_kb_glossary_list(root))
        assert "results" in result
        assert result["meta"]["total"] >= 2
        terms = [r["term"] for r in result["results"]]
        assert "GG" in terms

    def test_glossary_add(self, mcp_db):
        """kb_glossary_add inserts a new term."""
        from kb.mcp_server import handle_kb_glossary_add

        _, root = mcp_db
        self._create_glossary(root)

        result = json.loads(handle_kb_glossary_add(root, "MFA", "Multi-Factor Authentication"))
        assert result.get("status") == "ok" or "term" in result

        # Verify it's in the file
        content = (root / "memory" / "glossary.md").read_text()
        assert "MFA" in content

    def test_glossary_edit(self, mcp_db):
        """kb_glossary_edit updates an existing term."""
        from kb.mcp_server import handle_kb_glossary_edit

        _, root = mcp_db
        self._create_glossary(root)

        result = json.loads(handle_kb_glossary_edit(root, "GG", "GitGuardian Inc."))
        assert result.get("status") == "ok" or "term" in result

        content = (root / "memory" / "glossary.md").read_text()
        assert "GitGuardian Inc." in content

    def test_glossary_edit_not_found(self, mcp_db):
        """kb_glossary_edit returns error for unknown term."""
        from kb.mcp_server import handle_kb_glossary_edit

        _, root = mcp_db
        self._create_glossary(root)

        result = json.loads(handle_kb_glossary_edit(root, "ZZZNOPE", "Nothing"))
        assert "error" in result


# ---------------------------------------------------------------------------
# handle_kb_granola_view / handle_kb_granola_edit tests
# ---------------------------------------------------------------------------


class TestMcpGranolaView:
    def test_granola_view_not_found(self):
        """kb_granola_view returns error when no doc found."""
        from kb.mcp_server import handle_kb_granola_view

        with patch("kb.sync.granola.GranolaClient") as mock_cls:
            mock_client = MagicMock()
            mock_client.find_document.return_value = None
            mock_cls.return_value = mock_client

            result = json.loads(handle_kb_granola_view("fake-uid-123"))
        assert "error" in result

    def test_granola_view_notes_mode(self):
        """kb_granola_view returns notes in default mode."""
        from kb.mcp_server import handle_kb_granola_view

        fake_doc = {
            "id": "doc1",
            "title": "Test Meeting",
            "created_at": "2026-01-15T10:00:00Z",
            "notes_markdown": "# Notes\nSome notes here.",
            "google_calendar_event": {"iCalUID": "uid-123"},
        }

        with patch("kb.sync.granola.GranolaClient") as mock_cls, patch(
            "kb.sync.granola.extract_panel_markdown", return_value=""
        ):
            mock_client = MagicMock()
            mock_client.find_document.return_value = fake_doc
            mock_cls.return_value = mock_client

            result = json.loads(handle_kb_granola_view("uid-123"))
        assert result["title"] == "Test Meeting"
        assert "Notes" in result["content"]


class TestMcpGranolaEdit:
    def test_granola_edit_no_args(self):
        """kb_granola_edit returns error when neither body nor append given."""
        from kb.mcp_server import handle_kb_granola_edit

        result = json.loads(handle_kb_granola_edit("uid-123"))
        assert "error" in result

    def test_granola_edit_both_args(self):
        """kb_granola_edit returns error when both body and append given."""
        from kb.mcp_server import handle_kb_granola_edit

        result = json.loads(handle_kb_granola_edit("uid-123", body="new", append="extra"))
        assert "error" in result

    def test_granola_edit_body(self):
        """kb_granola_edit calls API to replace body."""
        from kb.mcp_server import handle_kb_granola_edit

        fake_doc = {"id": "doc1", "notes_markdown": "old notes"}

        with patch("kb.sync.granola.GranolaClient") as mock_cls:
            mock_client = MagicMock()
            mock_client.find_document.return_value = fake_doc
            mock_cls.return_value = mock_client

            result = json.loads(handle_kb_granola_edit("uid-123", body="new notes"))
        assert result["status"] == "ok"
        assert result["action"] == "replace"
        mock_client.update_document_notes.assert_called_once_with("doc1", "new notes")

    def test_granola_edit_append(self):
        """kb_granola_edit appends to existing notes."""
        from kb.mcp_server import handle_kb_granola_edit

        fake_doc = {"id": "doc1", "notes_markdown": "existing notes"}

        with patch("kb.sync.granola.GranolaClient") as mock_cls:
            mock_client = MagicMock()
            mock_client.find_document.return_value = fake_doc
            mock_cls.return_value = mock_client

            result = json.loads(handle_kb_granola_edit("uid-123", append="appended text"))
        assert result["status"] == "ok"
        assert result["action"] == "append"
        call_args = mock_client.update_document_notes.call_args[0]
        assert "existing notes" in call_args[1]
        assert "appended text" in call_args[1]


# ---------------------------------------------------------------------------
# handle_kb_list tests
# ---------------------------------------------------------------------------


class TestMcpList:
    def test_list_all(self, mcp_db):
        """kb_list returns all documents."""
        from kb.mcp_server import handle_kb_list

        db, _ = mcp_db
        result = json.loads(handle_kb_list(db))
        assert "results" in result
        assert result["meta"]["total"] == 4  # 3 meetings + 1 memory_person

    def test_list_by_type(self, mcp_db):
        """kb_list filters by doc_type."""
        from kb.mcp_server import handle_kb_list

        db, _ = mcp_db
        result = json.loads(handle_kb_list(db, doc_type="notes"))
        titles = [r["title"] for r in result["results"]]
        assert len(titles) == 3  # all 3 meeting notes

    def test_list_by_date_range(self, mcp_db):
        """kb_list filters by date range."""
        from kb.mcp_server import handle_kb_list

        db, _ = mcp_db
        result = json.loads(handle_kb_list(db, from_date="2026-01-25", to_date="2026-02-05"))
        titles = [r["title"] for r in result["results"]]
        assert "MFA Implementation Review" in titles
        assert "Datastore Migration Plan" in titles
        # Cloud Migration Status is 2026-01-20, outside range
        assert "Cloud Migration Status" not in titles

    def test_list_limit(self, mcp_db):
        """kb_list respects limit."""
        from kb.mcp_server import handle_kb_list

        db, _ = mcp_db
        result = json.loads(handle_kb_list(db, limit=2))
        assert len(result["results"]) == 2

    def test_list_content_hash_truncated(self, mcp_db):
        """kb_list truncates content_hash to 6 chars."""
        from kb.mcp_server import handle_kb_list

        db, _ = mcp_db
        result = json.loads(handle_kb_list(db, limit=1))
        hash_val = result["results"][0]["content_hash"]
        assert hash_val is None or len(hash_val) == 6


# ---------------------------------------------------------------------------
# handle_kb_index_status tests
# ---------------------------------------------------------------------------


class TestMcpIndexStatus:
    def test_index_status(self, mcp_db):
        """kb_index_status returns document/entity/fact counts."""
        from kb.mcp_server import handle_kb_index_status

        db, _ = mcp_db
        result = json.loads(handle_kb_index_status(db))
        assert result["documents"] == 4
        assert result["chunks"] == 6
        assert result["entities"] == 2
        assert "documents_by_type" in result
        assert "date_range" in result

    def test_index_status_doc_types(self, mcp_db):
        """kb_index_status breaks down by doc_type."""
        from kb.mcp_server import handle_kb_index_status

        db, _ = mcp_db
        result = json.loads(handle_kb_index_status(db))
        by_type = result["documents_by_type"]
        assert by_type.get("notes") == 3
        assert by_type.get("memory_person") == 1


# ---------------------------------------------------------------------------
# handle_kb_correct tests
# ---------------------------------------------------------------------------


class TestMcpCorrect:
    def _create_memory_files(self, project_root):
        mem = project_root / "memory"
        mem.mkdir(parents=True, exist_ok=True)
        notes = mem / "notes"
        notes.mkdir(exist_ok=True)
        (notes / "test.md").write_text("---\ntitle: Test\n---\nCoreLogix is great.\nCoreLogix rules.\n")

    def test_correct_scan(self, mcp_db):
        """kb_correct scan mode finds occurrences."""
        from kb.mcp_server import handle_kb_correct

        _, root = mcp_db
        self._create_memory_files(root)

        result = json.loads(handle_kb_correct(root, "CoreLogix"))
        assert result["meta"]["action"] == "scan"
        assert result["meta"]["total_occurrences"] == 2

    def test_correct_dry_run(self, mcp_db):
        """kb_correct dry-run mode previews without changing."""
        from kb.mcp_server import handle_kb_correct

        _, root = mcp_db
        self._create_memory_files(root)

        result = json.loads(handle_kb_correct(root, "CoreLogix", replacement="Coralogix"))
        assert result["meta"]["action"] == "dry_run"
        assert result["meta"]["total_occurrences"] == 2

        # File unchanged
        content = (root / "memory" / "notes" / "test.md").read_text()
        assert "CoreLogix" in content

    def test_correct_apply(self, mcp_db):
        """kb_correct apply mode replaces occurrences."""
        from kb.mcp_server import handle_kb_correct

        _, root = mcp_db
        self._create_memory_files(root)

        with patch("kb.config.get_data_dir", return_value=root):
            result = json.loads(
                handle_kb_correct(root, "CoreLogix", replacement="Coralogix", apply=True)
            )
        assert result["meta"]["action"] == "applied"
        assert result["meta"]["occurrences_replaced"] == 2

        content = (root / "memory" / "notes" / "test.md").read_text()
        assert "Coralogix" in content
        assert "CoreLogix" not in content

    def test_correct_no_matches(self, mcp_db):
        """kb_correct returns empty when no matches found."""
        from kb.mcp_server import handle_kb_correct

        _, root = mcp_db
        self._create_memory_files(root)

        result = json.loads(handle_kb_correct(root, "ZZZnonexistent"))
        assert result["meta"]["total"] == 0
        assert result["results"] == []


# ---------------------------------------------------------------------------
# Audit fixes: A1 — kb_search doc_type filter
# ---------------------------------------------------------------------------


class TestMcpSearchDocType:
    def test_search_with_doc_type_filter(self, mcp_db):
        """kb_search should filter by doc_type when specified."""
        from kb.mcp_server import handle_kb_search

        db, _ = mcp_db
        # Search for "migration" which appears in both notes and memory_person chunks
        result = json.loads(handle_kb_search(db, "migration", fast=True, limit=10, doc_type="notes"))
        # All results should be doc_type "notes"
        for r in result["results"]:
            assert r["doc_type"] == "notes"

    def test_search_without_doc_type_returns_all(self, mcp_db):
        """kb_search without doc_type should return all matching types."""
        from kb.mcp_server import handle_kb_search

        db, _ = mcp_db
        result = json.loads(handle_kb_search(db, "MFA", fast=True, limit=10))
        assert len(result["results"]) > 0


# ---------------------------------------------------------------------------
# Audit fixes: A2 — entity list pagination
# ---------------------------------------------------------------------------


class TestMcpEntityListPagination:
    def _insert_people(self, db, count):
        conn = db.get_sqlite_conn()
        for i in range(count):
            conn.execute(
                "INSERT INTO entities (name, entity_type, aliases, metadata) VALUES (?, 'person', '[]', '{}')",
                (f"Person {i:03d}",),
            )
        conn.commit()

    def test_person_list_default_limit(self, mcp_db):
        """kb_person_list should default to limit 50."""
        from kb.mcp_server import handle_kb_person_list

        db, _ = mcp_db
        self._insert_people(db, 60)

        result = json.loads(handle_kb_person_list(db))
        # 60 new + 1 existing = 61, but limit 50 should cap it
        assert len(result["results"]) == 50
        assert result["meta"]["total"] == 61

    def test_person_list_with_limit(self, mcp_db):
        """kb_person_list should accept custom limit."""
        from kb.mcp_server import handle_kb_person_list

        db, _ = mcp_db
        self._insert_people(db, 10)

        result = json.loads(handle_kb_person_list(db, limit=5))
        assert len(result["results"]) == 5

    def test_person_list_with_offset(self, mcp_db):
        """kb_person_list should accept offset for pagination."""
        from kb.mcp_server import handle_kb_person_list

        db, _ = mcp_db
        self._insert_people(db, 10)

        page1 = json.loads(handle_kb_person_list(db, limit=5, offset=0))
        page2 = json.loads(handle_kb_person_list(db, limit=5, offset=5))
        # No overlap between pages
        names1 = {r["name"] for r in page1["results"]}
        names2 = {r["name"] for r in page2["results"]}
        assert names1.isdisjoint(names2)

    def test_project_list_with_limit(self, mcp_db):
        """kb_project_list should accept limit param."""
        from kb.mcp_server import handle_kb_project_list

        db, _ = mcp_db
        result = json.loads(handle_kb_project_list(db, limit=1))
        assert len(result["results"]) == 1


# ---------------------------------------------------------------------------
# Audit fixes: A3 — person_timeline doc_type + limit
# ---------------------------------------------------------------------------


class TestMcpTimelineFilters:
    def _setup_multi_type_docs(self, db):
        """Add transcript + debrief docs mentioning Eve."""
        conn = db.get_sqlite_conn()
        conn.execute(
            """INSERT INTO documents (path, title, doc_date, doc_type, source_system, tags, content_hash, chunk_count)
               VALUES ('meetings/2026/01/27/mfa.transcript.md', 'MFA Transcript', '2026-01-27', 'transcript', 'granola', '[]', 'ttt111', 1)""",
        )
        conn.execute(
            """INSERT INTO documents (path, title, doc_date, doc_type, source_system, tags, content_hash, chunk_count)
               VALUES ('meetings/2026/01/27/mfa.debrief.md', 'MFA Debrief', '2026-01-27', 'debrief', 'memory', '[]', 'ddd111', 1)""",
        )
        # Link both to Eve (entity_id=1)
        # doc IDs 5 and 6 (after the 4 existing docs)
        transcript_id = conn.execute("SELECT id FROM documents WHERE path = 'meetings/2026/01/27/mfa.transcript.md'").fetchone()["id"]
        debrief_id = conn.execute("SELECT id FROM documents WHERE path = 'meetings/2026/01/27/mfa.debrief.md'").fetchone()["id"]
        conn.execute("INSERT INTO entity_mentions (entity_id, document_id, mention_type) VALUES (1, ?, 'discussed')", (transcript_id,))
        conn.execute("INSERT INTO entity_mentions (entity_id, document_id, mention_type) VALUES (1, ?, 'discussed')", (debrief_id,))
        conn.commit()

    def test_timeline_with_doc_type_filter(self, mcp_db):
        """kb_person_timeline should filter by doc_type."""
        from kb.mcp_server import handle_kb_person_timeline

        db, _ = mcp_db
        self._setup_multi_type_docs(db)

        result = json.loads(handle_kb_person_timeline(db, "Eve", doc_type="transcript"))
        assert len(result["documents"]) == 1
        assert result["documents"][0]["doc_type"] == "transcript"

    def test_timeline_with_limit(self, mcp_db):
        """kb_person_timeline should respect limit param."""
        from kb.mcp_server import handle_kb_person_timeline

        db, _ = mcp_db
        self._setup_multi_type_docs(db)

        # Eve has 3 docs: original mention + transcript + debrief
        result = json.loads(handle_kb_person_timeline(db, "Eve", limit=2))
        assert len(result["documents"]) == 2

    def test_timeline_default_has_all(self, mcp_db):
        """kb_person_timeline without filters returns all doc types."""
        from kb.mcp_server import handle_kb_person_timeline

        db, _ = mcp_db
        self._setup_multi_type_docs(db)

        result = json.loads(handle_kb_person_timeline(db, "Eve"))
        types = {d["doc_type"] for d in result["documents"]}
        assert "notes" in types
        assert "transcript" in types
        assert "debrief" in types


# ---------------------------------------------------------------------------
# Audit fixes: A5 — kb_correct MCP path resolution
# ---------------------------------------------------------------------------


class TestMcpCorrectPathResolution:
    def test_correct_with_global_config_project_root(self, mcp_db):
        """kb_correct should work when project_root has memory/ subdir."""
        from kb.mcp_server import handle_kb_correct

        _, root = mcp_db
        mem = root / "memory"
        mem.mkdir(parents=True, exist_ok=True)
        (mem / "notes").mkdir(exist_ok=True)
        (mem / "notes" / "test.md").write_text("Some text with Typo here.\n")

        # Should not error — memory dir exists at project_root/memory
        result = json.loads(handle_kb_correct(root, "Typo"))
        assert "error" not in result
        assert result["meta"]["total_occurrences"] == 1


# ---------------------------------------------------------------------------
# Audit fixes: A7 — kb_list since_hours param
# ---------------------------------------------------------------------------


class TestMcpListSinceHours:
    def test_list_since_hours(self, mcp_db):
        """kb_list with since_hours returns recently indexed docs."""
        from kb.mcp_server import handle_kb_list

        db, _ = mcp_db
        # All test docs were just indexed (indexed_at is NOW), so since_hours=1 should return them
        result = json.loads(handle_kb_list(db, since_hours=1))
        assert len(result["results"]) > 0

    def test_list_since_hours_excludes_old(self, mcp_db):
        """kb_list with since_hours=0 should return nothing (0 hours ago = now)."""
        from kb.mcp_server import handle_kb_list

        db, _ = mcp_db
        # Set indexed_at to yesterday for all docs
        conn = db.get_sqlite_conn()
        conn.execute("UPDATE documents SET indexed_at = datetime('now', '-2 days')")
        conn.commit()

        result = json.loads(handle_kb_list(db, since_hours=1))
        assert len(result["results"]) == 0


# ---------------------------------------------------------------------------
# Audit fixes: A8 — glossary path resolution for MCP
# ---------------------------------------------------------------------------


class TestMcpGlossaryPathResolution:
    def test_glossary_list_with_correct_project_root(self, mcp_db):
        """kb_glossary_list should find glossary at project_root/memory/glossary.md."""
        from kb.mcp_server import handle_kb_glossary_list

        _, root = mcp_db
        glossary_dir = root / "memory"
        glossary_dir.mkdir(parents=True, exist_ok=True)
        (glossary_dir / "glossary.md").write_text(
            "# Glossary\n\n## Acronyms\n\n| Term | Expansion |\n|------|----------|\n| API | Application Programming Interface |\n",
            encoding="utf-8",
        )

        result = json.loads(handle_kb_glossary_list(root))
        assert result["meta"]["total"] >= 1
        terms = [r["term"] for r in result["results"]]
        assert "API" in terms

    def test_glossary_list_empty_when_no_file(self, tmp_path):
        """kb_glossary_list returns empty when glossary.md doesn't exist."""
        from kb.mcp_server import handle_kb_glossary_list

        result = json.loads(handle_kb_glossary_list(tmp_path))
        assert result["results"] == []
        assert result["meta"]["total"] == 0


# ---------------------------------------------------------------------------
# Audit fix A8: find_project_root with global XDG config
# ---------------------------------------------------------------------------


class TestFindProjectRootGlobalConfig:
    def test_global_config_absolute_memory_path(self, tmp_path):
        """find_project_root derives root from absolute memory path in global config."""
        from kb.config import find_project_root

        # Set up a fake project structure
        project_dir = tmp_path / "project"
        memory_dir = project_dir / "memory"
        memory_dir.mkdir(parents=True)

        # Set up a fake global config with absolute memory path
        xdg_dir = tmp_path / "xdg" / "kbx"
        xdg_dir.mkdir(parents=True)
        config_file = xdg_dir / "config.toml"
        config_file.write_text(
            f'[sources]\nmemory = "{memory_dir}"\n\n[data]\ndir = "{tmp_path / "data"}"\n'
        )

        with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(tmp_path / "xdg"), "KBX_CONFIG": ""}):
            # Clear KBX_CONFIG so find_config walks up CWD then falls to XDG
            os.environ.pop("KBX_CONFIG", None)
            # Ensure CWD doesn't contain kbx.toml
            old_cwd = os.getcwd()
            try:
                os.chdir(str(tmp_path / "xdg"))
                root = find_project_root()
            finally:
                os.chdir(old_cwd)

        # Should resolve to project_dir (parent of absolute memory path)
        assert root == project_dir

    def test_local_config_unchanged(self, tmp_path):
        """find_project_root still uses config parent for local kbx.toml."""
        from kb.config import find_project_root

        # Set up local kbx.toml
        config_file = tmp_path / "kbx.toml"
        config_file.write_text('[sources]\nmemory = "memory"\n')
        (tmp_path / "memory").mkdir()

        with patch.dict(os.environ, {"KBX_CONFIG": str(config_file)}):
            root = find_project_root()

        assert root == tmp_path


# ---------------------------------------------------------------------------
# A4 — kb_context mention_threshold
# ---------------------------------------------------------------------------


class TestMcpContextMentionThreshold:
    """A4: mention_threshold filters low-mention entities from context."""

    @pytest.fixture
    def threshold_db(self):
        """DB with entities at varying mention counts + one pinned low-mention entity."""
        with tempfile.TemporaryDirectory() as tmpdir:
            db = Database(Path(tmpdir))
            conn = db.get_sqlite_conn()

            # Create enough documents for mention variety
            for i in range(1, 12):
                conn.execute(
                    "INSERT INTO documents (path, title, doc_type, doc_date, content_hash) "
                    f"VALUES ('meetings/test{i}.md', 'Test Meeting {i}', 'notes', '2026-01-{i:02d}', 'hash-thr-{i}')"
                )
            # Insert a chunk so FTS table exists
            conn.execute(
                "INSERT INTO chunks (document_id, chunk_index, content) VALUES (1, 0, 'content')"
            )

            # Person with 10 mentions (high)
            conn.execute(
                "INSERT INTO entities (name, entity_type, aliases, metadata, pinned) "
                "VALUES ('Alice High', 'person', '[]', "
                "'{\"role\": \"Engineer\", \"team\": \"Platform\"}', 0)"
            )
            # Person with 2 mentions (low)
            conn.execute(
                "INSERT INTO entities (name, entity_type, aliases, metadata, pinned) "
                "VALUES ('Bob Low', 'person', '[]', "
                "'{\"role\": \"Intern\", \"team\": \"Platform\"}', 0)"
            )
            # Person with 1 mention, PINNED (should always appear)
            conn.execute(
                "INSERT INTO entities (name, entity_type, aliases, metadata, pinned) "
                "VALUES ('Carol Pinned', 'person', '[]', "
                "'{\"role\": \"CTO\", \"team\": \"Exec\"}', 1)"
            )
            # Project with 1 mention (low)
            conn.execute(
                "INSERT INTO entities (name, entity_type, aliases, metadata, pinned) "
                "VALUES ('Tiny Project', 'project', '[]', "
                "'{\"status\": \"Active\"}', 0)"
            )

            # Add mentions: Alice=10, Bob=2, Carol=1, Tiny Project=1
            # Each mention uses a different document (unique constraint)
            for i in range(1, 11):
                conn.execute(
                    "INSERT INTO entity_mentions (entity_id, document_id, mention_type) "
                    f"VALUES (1, {i}, 'discussed')"
                )
            for i in range(1, 3):
                conn.execute(
                    "INSERT INTO entity_mentions (entity_id, document_id, mention_type) "
                    f"VALUES (2, {i}, 'discussed')"
                )
            conn.execute(
                "INSERT INTO entity_mentions (entity_id, document_id, mention_type) "
                "VALUES (3, 1, 'discussed')"
            )
            conn.execute(
                "INSERT INTO entity_mentions (entity_id, document_id, mention_type) "
                "VALUES (4, 1, 'discussed')"
            )

            conn.commit()
            yield db, Path(tmpdir)
            db.close()

    def test_threshold_zero_returns_all(self, threshold_db):
        """mention_threshold=0 (default) should include all entities."""
        from kb.mcp_server import handle_kb_context

        db, root = threshold_db
        result = handle_kb_context(db, project_root=root, mention_threshold=0)
        data = json.loads(result)
        text = data["text"]
        assert "Alice" in text
        assert "Bob" in text
        assert "Carol" in text

    def test_threshold_filters_low_mention_entities(self, threshold_db):
        """mention_threshold=5 should drop Bob (2 mentions) but keep Alice (10)."""
        from kb.mcp_server import handle_kb_context

        db, root = threshold_db
        result = handle_kb_context(db, project_root=root, mention_threshold=5)
        data = json.loads(result)
        text = data["text"]
        assert "Alice" in text
        assert "Bob" not in text

    def test_threshold_preserves_pinned(self, threshold_db):
        """Pinned entities should appear regardless of mention_threshold."""
        from kb.mcp_server import handle_kb_context

        db, root = threshold_db
        result = handle_kb_context(db, project_root=root, mention_threshold=5)
        data = json.loads(result)
        text = data["text"]
        # Carol is pinned with only 1 mention — must still appear
        assert "Carol" in text

    def test_threshold_filters_projects(self, threshold_db):
        """mention_threshold should also filter projects below threshold."""
        from kb.mcp_server import handle_kb_context

        db, root = threshold_db
        result = handle_kb_context(db, project_root=root, mention_threshold=5)
        data = json.loads(result)
        text = data["text"]
        assert "Tiny Project" not in text

    def test_mcp_tool_accepts_mention_threshold(self, threshold_db):
        """kb_context MCP tool should accept mention_threshold parameter."""
        import inspect

        from kb.mcp_server import kb_context

        sig = inspect.signature(kb_context)
        assert "mention_threshold" in sig.parameters
        assert sig.parameters["mention_threshold"].default == 0
