"""CogDatabase 认知数据库测试"""

from __future__ import annotations

import json
import sqlite3
from pathlib import Path

import pytest

from index1.cognitive.db_cog import CogDatabase, truncate_payload, MAX_PAYLOAD_SIZE


@pytest.fixture()
def cog_db(tmp_path: Path) -> CogDatabase:
    """创建临时 CogDatabase 实例"""
    db = CogDatabase(tmp_path / "test_cog.db", embedding_dim=4)
    db.connect()
    yield db
    db.close()


def _make_fact(**overrides) -> dict:
    base = {
        "collection": "test-proj",
        "assertion": "Modified src/main.py",
        "category": "code",
        "session_id": "session-1",
        "confidence": 0.9,
        "source": "hook",
    }
    base.update(overrides)
    return base


# ── Connect / Schema ──


class TestConnect:
    def test_connect_creates_tables(self, cog_db: CogDatabase):
        tables = {
            r[0]
            for r in cog_db.conn.execute(
                "SELECT name FROM sqlite_master WHERE type='table'"
            ).fetchall()
        }
        assert "facts" in tables
        assert "facts_fts" in tables
        assert "mental_models" in tables
        assert "tactics" in tables
        assert "sessions" in tables
        assert "awakening_briefs" in tables
        assert "raw_events" in tables

    def test_connect_idempotent(self, cog_db: CogDatabase):
        cog_db._init_schema()
        tables = cog_db.conn.execute(
            "SELECT name FROM sqlite_master WHERE type='table'"
        ).fetchall()
        assert len(tables) >= 5

    def test_wal_mode(self, cog_db: CogDatabase):
        mode = cog_db.conn.execute("PRAGMA journal_mode").fetchone()[0]
        assert mode == "wal"

    def test_close_and_reconnect(self, tmp_path: Path):
        db = CogDatabase(tmp_path / "reopen.db")
        db.connect()
        fid = db.insert_fact(_make_fact())
        db.close()
        db.connect()
        assert db.get_fact(fid) is not None
        db.close()


# ── Facts CRUD ──


class TestFactsCRUD:
    def test_insert_and_get(self, cog_db: CogDatabase):
        fid = cog_db.insert_fact(_make_fact())
        assert fid >= 1
        fact = cog_db.get_fact(fid)
        assert fact is not None
        assert fact["assertion"] == "Modified src/main.py"
        assert fact["status"] == "active"
        assert fact["confidence"] == 0.9

    def test_get_nonexistent(self, cog_db: CogDatabase):
        assert cog_db.get_fact(99999) is None

    def test_update_fact(self, cog_db: CogDatabase):
        fid = cog_db.insert_fact(_make_fact())
        cog_db.update_fact(fid, confidence=0.5, category="architecture")
        fact = cog_db.get_fact(fid)
        assert fact["confidence"] == 0.5
        assert fact["category"] == "architecture"

    def test_update_ignores_disallowed_fields(self, cog_db: CogDatabase):
        fid = cog_db.insert_fact(_make_fact())
        cog_db.update_fact(fid, id=999, created_at="bad")
        fact = cog_db.get_fact(fid)
        assert fact["id"] == fid

    def test_update_empty(self, cog_db: CogDatabase):
        fid = cog_db.insert_fact(_make_fact())
        cog_db.update_fact(fid)  # no-op

    def test_invalidate_fact(self, cog_db: CogDatabase):
        fid = cog_db.insert_fact(_make_fact())
        cog_db.invalidate_fact(fid, "outdated")
        fact = cog_db.get_fact(fid)
        assert fact["status"] == "invalidated"
        assert fact["invalidated_reason"] == "outdated"

    def test_validate_fact_boosts_confidence(self, cog_db: CogDatabase):
        fid = cog_db.insert_fact(_make_fact(confidence=0.5))
        cog_db.validate_fact(fid)
        fact = cog_db.get_fact(fid)
        assert fact["confidence"] == pytest.approx(0.6, abs=0.01)
        assert fact["last_validated"] is not None

    def test_validate_caps_at_1(self, cog_db: CogDatabase):
        fid = cog_db.insert_fact(_make_fact(confidence=0.95))
        cog_db.validate_fact(fid)
        fact = cog_db.get_fact(fid)
        assert fact["confidence"] == 1.0

    def test_supersede_fact(self, cog_db: CogDatabase):
        fid_old = cog_db.insert_fact(_make_fact(assertion="old"))
        fid_new = cog_db.insert_fact(_make_fact(assertion="new"))
        cog_db.supersede_fact(fid_old, fid_new)
        old = cog_db.get_fact(fid_old)
        assert old["status"] == "superseded"
        assert old["superseded_by"] == fid_new

    def test_fact_exists(self, cog_db: CogDatabase):
        cog_db.insert_fact(_make_fact())
        assert cog_db.fact_exists("Modified src/main.py", "session-1", "test-proj")
        assert not cog_db.fact_exists("No such fact", "session-1", "test-proj")

    def test_touch_fact(self, cog_db: CogDatabase):
        fid = cog_db.insert_fact(_make_fact())
        assert cog_db.get_fact(fid)["access_count"] == 0
        cog_db.touch_fact(fid)
        cog_db.touch_fact(fid)
        fact = cog_db.get_fact(fid)
        assert fact["access_count"] == 2
        assert fact["last_accessed"] is not None


# ── Facts Search ──


class TestFactsSearch:
    def test_fts_search_basic(self, cog_db: CogDatabase):
        cog_db.insert_fact(_make_fact(assertion="Fixed authentication bug"))
        cog_db.insert_fact(_make_fact(assertion="Updated database schema"))
        results = cog_db.fts_search_facts("authentication", "test-proj")
        assert len(results) >= 1
        assert "authentication" in results[0]["assertion"].lower()

    def test_fts_search_empty_query(self, cog_db: CogDatabase):
        results = cog_db.fts_search_facts("", "test-proj")
        assert results == []

    def test_fts_search_respects_collection(self, cog_db: CogDatabase):
        cog_db.insert_fact(_make_fact(collection="proj-a", assertion="Fix auth"))
        cog_db.insert_fact(_make_fact(collection="proj-b", assertion="Fix auth"))
        results = cog_db.fts_search_facts("auth", "proj-a")
        assert all(r["collection"] == "proj-a" for r in results)

    def test_fts_excludes_invalidated(self, cog_db: CogDatabase):
        fid = cog_db.insert_fact(_make_fact(assertion="Old invalidated fact"))
        cog_db.invalidate_fact(fid, "outdated")
        results = cog_db.fts_search_facts("invalidated", "test-proj")
        assert len(results) == 0

    def test_fts_special_chars(self, cog_db: CogDatabase):
        cog_db.insert_fact(_make_fact(assertion="function parse_config()"))
        results = cog_db.fts_search_facts("parse_config()", "test-proj")
        assert len(results) >= 0  # should not crash

    def test_get_session_facts(self, cog_db: CogDatabase):
        cog_db.insert_fact(_make_fact(session_id="s1"))
        cog_db.insert_fact(_make_fact(session_id="s1", assertion="Another fact"))
        cog_db.insert_fact(_make_fact(session_id="s2", assertion="Other session"))
        facts = cog_db.get_session_facts("s1", "test-proj")
        assert len(facts) == 2

    def test_get_top_facts(self, cog_db: CogDatabase):
        cog_db.insert_fact(_make_fact(confidence=0.3, assertion="Low confidence"))
        cog_db.insert_fact(_make_fact(confidence=0.9, assertion="High confidence"))
        cog_db.insert_fact(_make_fact(confidence=0.8, assertion="Medium confidence"))
        top = cog_db.get_top_facts("test-proj", min_confidence=0.7)
        assert len(top) == 2
        assert top[0]["confidence"] >= top[1]["confidence"]

    def test_get_facts_missing_embedding(self, cog_db: CogDatabase):
        cog_db.insert_fact(_make_fact(assertion="No embedding"))
        missing = cog_db.get_facts_missing_embedding("test-proj")
        assert len(missing) >= 1
        assert missing[0][1] == "No embedding"

    def test_vec_search_without_vec(self, cog_db: CogDatabase):
        results = cog_db.vec_search_facts([0.1, 0.2, 0.3, 0.4], "test-proj")
        # Should return empty if vec not loaded, or some results if loaded
        assert isinstance(results, list)


# ── Mental Models ──


class TestMentalModels:
    def test_upsert_insert(self, cog_db: CogDatabase):
        mid = cog_db.upsert_mental_model(
            "test-proj", "src/auth/", "Auth module",
            understanding_level=0.7,
            components=json.dumps(["login", "register"]),
        )
        assert mid >= 1
        model = cog_db.get_mental_model("src/auth/", "test-proj")
        assert model is not None
        assert model["subject"] == "Auth module"
        assert model["revision"] == 1

    def test_upsert_update(self, cog_db: CogDatabase):
        mid1 = cog_db.upsert_mental_model("test-proj", "src/auth/", "Auth v1")
        mid2 = cog_db.upsert_mental_model(
            "test-proj", "src/auth/", "Auth v2",
            understanding_level=0.8,
        )
        assert mid1 == mid2
        model = cog_db.get_mental_model("src/auth/", "test-proj")
        assert model["subject"] == "Auth v2"
        assert model["revision"] == 2

    def test_get_active_models(self, cog_db: CogDatabase):
        cog_db.upsert_mental_model("test-proj", "src/a/", "Module A")
        cog_db.upsert_mental_model("test-proj", "src/b/", "Module B")
        cog_db.upsert_mental_model("other-proj", "src/c/", "Module C")
        models = cog_db.get_active_models("test-proj")
        assert len(models) == 2

    def test_get_nonexistent_model(self, cog_db: CogDatabase):
        assert cog_db.get_mental_model("nope", "test-proj") is None


# ── Tactics ──


class TestTactics:
    def test_insert_and_search(self, cog_db: CogDatabase):
        tid = cog_db.insert_tactic(
            "pytest fails with import error",
            "1. Check __init__.py\n2. Check sys.path",
            collection="test-proj",
        )
        assert tid >= 1
        results = cog_db.search_tactics("import error", "test-proj")
        assert len(results) >= 1
        assert results[0]["trigger_pattern"] == "pytest fails with import error"

    def test_update_outcome_success(self, cog_db: CogDatabase):
        tid = cog_db.insert_tactic("pattern", "steps", collection="test-proj")
        cog_db.update_tactic_outcome(tid, success=True)
        cog_db.update_tactic_outcome(tid, success=True)
        cog_db.update_tactic_outcome(tid, success=False)
        tactic = cog_db.conn.execute(
            "SELECT * FROM tactics WHERE id=?", [tid]
        ).fetchone()
        assert tactic["times_used"] == 3
        assert tactic["times_succeeded"] == 2

    def test_search_no_match(self, cog_db: CogDatabase):
        results = cog_db.search_tactics("nonexistent", "test-proj")
        assert results == []


# ── Sessions ──


class TestSessions:
    def test_create_session(self, cog_db: CogDatabase):
        session = cog_db.get_or_create_session("ext-123", "test-proj")
        assert session["external_id"] == "ext-123"
        assert session["session_count"] == 1

    def test_resume_session(self, cog_db: CogDatabase):
        s1 = cog_db.get_or_create_session("ext-123", "test-proj")
        s2 = cog_db.get_or_create_session("ext-123", "test-proj")
        assert s2["session_count"] == 2

    def test_session_without_external_id(self, cog_db: CogDatabase):
        s = cog_db.get_or_create_session(None, "test-proj")
        assert s["collection"] == "test-proj"
        assert s["session_count"] == 1


# ── Awakening Briefs ──


class TestBriefs:
    def test_save_and_get_brief(self, cog_db: CogDatabase):
        brief_data = json.dumps({"summary": "Last session: fixed auth bug"})
        cog_db.save_brief("s1", "test-proj", brief_data, "Fix remaining tests")
        brief = cog_db.get_latest_brief("test-proj")
        assert brief is not None
        assert brief["brief"]["summary"] == "Last session: fixed auth bug"
        assert brief["priority_action"] == "Fix remaining tests"

    def test_get_latest_returns_newest(self, cog_db: CogDatabase):
        cog_db.save_brief("s1", "test-proj", json.dumps({"v": 1}))
        cog_db.save_brief("s2", "test-proj", json.dumps({"v": 2}))
        brief = cog_db.get_latest_brief("test-proj")
        assert brief["brief"]["v"] == 2

    def test_get_brief_nonexistent(self, cog_db: CogDatabase):
        assert cog_db.get_latest_brief("nope") is None

    def test_brief_malformed_json(self, cog_db: CogDatabase):
        cog_db.save_brief("s1", "test-proj", "not json")
        brief = cog_db.get_latest_brief("test-proj")
        assert brief["brief"] == {}


# ── Maintenance ──


class TestMaintenance:
    def test_decay_affects_stale_facts(self, cog_db: CogDatabase):
        fid = cog_db.insert_fact(_make_fact(confidence=0.9))
        # Make the fact appear stale by backdating
        cog_db.conn.execute(
            "UPDATE facts SET created_at=datetime('now', '-30 days') WHERE id=?",
            [fid],
        )
        affected = cog_db.decay_facts("test-proj", stale_days=7)
        assert affected >= 1
        fact = cog_db.get_fact(fid)
        assert fact["confidence"] < 0.9

    def test_decay_skips_recently_validated(self, cog_db: CogDatabase):
        fid = cog_db.insert_fact(_make_fact(confidence=0.9))
        cog_db.validate_fact(fid)  # sets last_validated = now
        affected = cog_db.decay_facts("test-proj", stale_days=7)
        assert affected == 0

    def test_decay_floor(self, cog_db: CogDatabase):
        fid = cog_db.insert_fact(_make_fact(confidence=0.06))
        cog_db.conn.execute(
            "UPDATE facts SET created_at=datetime('now', '-30 days') WHERE id=?",
            [fid],
        )
        cog_db.decay_facts("test-proj", stale_days=7)
        fact = cog_db.get_fact(fid)
        assert fact["confidence"] >= 0.05

    def test_gc_low_confidence_no_access(self, cog_db: CogDatabase):
        fid = cog_db.insert_fact(_make_fact(confidence=0.05))
        affected = cog_db.garbage_collect("test-proj", min_confidence=0.1)
        assert affected >= 1
        fact = cog_db.get_fact(fid)
        assert fact["status"] == "invalidated"
        assert fact["invalidated_reason"] == "low_confidence_gc"

    def test_gc_spares_accessed_facts(self, cog_db: CogDatabase):
        fid = cog_db.insert_fact(_make_fact(confidence=0.05))
        cog_db.touch_fact(fid)  # access_count > 0
        affected = cog_db.garbage_collect("test-proj", min_confidence=0.1)
        fact = cog_db.get_fact(fid)
        # Low confidence but accessed → not gc'd by low_confidence rule
        # Might still be gc'd by stale rule if old enough
        assert fact["status"] == "active" or fact["invalidated_reason"] == "stale_gc"

    def test_gc_stale_facts(self, cog_db: CogDatabase):
        fid = cog_db.insert_fact(_make_fact(confidence=0.9))
        cog_db.conn.execute(
            "UPDATE facts SET created_at=datetime('now', '-100 days') WHERE id=?",
            [fid],
        )
        affected = cog_db.garbage_collect("test-proj", stale_days=90)
        assert affected >= 1


# ── Stats ──


class TestStats:
    def test_empty_stats(self, cog_db: CogDatabase):
        stats = cog_db.get_stats("test-proj")
        assert stats["facts_total"] == 0
        assert stats["facts_active"] == 0
        assert stats["mental_models"] == 0

    def test_stats_counts(self, cog_db: CogDatabase):
        cog_db.insert_fact(_make_fact(source="hook"))
        cog_db.insert_fact(_make_fact(source="claude", assertion="Another"))
        fid = cog_db.insert_fact(_make_fact(source="hook", assertion="To invalidate"))
        cog_db.invalidate_fact(fid, "test")
        cog_db.upsert_mental_model("test-proj", "scope", "subject")

        stats = cog_db.get_stats("test-proj")
        assert stats["facts_total"] == 3
        assert stats["facts_active"] == 2
        assert stats["facts_by_source"]["hook"] == 2
        assert stats["facts_by_source"]["claude"] == 1
        assert stats["mental_models"] == 1

    def test_stats_all_collections(self, cog_db: CogDatabase):
        cog_db.insert_fact(_make_fact(collection="a"))
        cog_db.insert_fact(_make_fact(collection="b"))
        stats = cog_db.get_stats()
        assert stats["facts_total"] == 2


# ── Session Context ──


class TestSessionContext:
    def test_update_and_get_context(self, cog_db: CogDatabase):
        """update + get session context 完整流程"""
        cog_db.get_or_create_session("ext-ctx-1", "test-proj")
        cog_db.update_session_context(
            "ext-ctx-1",
            current_task="fix #80",
            active_files=json.dumps(["/src/auth.py"]),
            pending_actions=json.dumps({"/src/auth.py": "modified, needs testing"}),
        )
        ctx = cog_db.get_session_context("ext-ctx-1")
        assert ctx is not None
        assert ctx["current_task"] == "fix #80"
        assert ctx["active_files"] == ["/src/auth.py"]
        assert ctx["pending_actions"] == {"/src/auth.py": "modified, needs testing"}

    def test_get_context_nonexistent(self, cog_db: CogDatabase):
        """不存在的 session 返回 None"""
        assert cog_db.get_session_context("no-such-session") is None

    def test_get_context_empty_id(self, cog_db: CogDatabase):
        """空 external_id 返回 None"""
        assert cog_db.get_session_context("") is None

    def test_update_partial(self, cog_db: CogDatabase):
        """只更新部分字段"""
        cog_db.get_or_create_session("ext-partial", "test-proj")
        cog_db.update_session_context("ext-partial", current_task="Issue #80")
        ctx = cog_db.get_session_context("ext-partial")
        assert ctx["current_task"] == "Issue #80"
        assert ctx["active_files"] == []  # default parsed
        assert ctx["pending_actions"] == {}  # default parsed

    def test_columns_exist(self, cog_db: CogDatabase):
        """sessions 表应包含新列"""
        cols = {
            r[1]
            for r in cog_db.conn.execute("PRAGMA table_info(sessions)").fetchall()
        }
        assert "current_task" in cols
        assert "active_files" in cols
        assert "pending_actions" in cols

    def test_update_ignores_unknown_fields(self, cog_db: CogDatabase):
        """不允许的字段被忽略"""
        cog_db.get_or_create_session("ext-ignore", "test-proj")
        cog_db.update_session_context("ext-ignore", bad_field="hack")
        # 不报错即可

    def test_context_survives_reconnect(self, tmp_path: Path):
        """断开重连后 context 仍在"""
        db = CogDatabase(tmp_path / "ctx_reopen.db")
        db.connect()
        db.get_or_create_session("ext-persist", "proj")
        db.update_session_context("ext-persist", current_task="Task A")
        db.close()

        db2 = CogDatabase(tmp_path / "ctx_reopen.db")
        db2.connect()
        ctx = db2.get_session_context("ext-persist")
        assert ctx["current_task"] == "Task A"
        db2.close()


# ── context_json 列 ──


class TestContextJson:
    def test_column_exists(self, cog_db: CogDatabase):
        """context_json 列应存在"""
        cols = {
            r[1]
            for r in cog_db.conn.execute("PRAGMA table_info(facts)").fetchall()
        }
        assert "context_json" in cols

    def test_insert_with_context_json(self, cog_db: CogDatabase):
        """带 context_json 的 fact 可插入和读取"""
        ctx = json.dumps({"commit_type": "fix", "scope": "auth"})
        fid = cog_db.insert_fact(_make_fact(
            assertion="Fix auth timeout",
            context_json=ctx,
        ))
        fact = cog_db.get_fact(fid)
        assert fact["context_json"] == ctx
        parsed = json.loads(fact["context_json"])
        assert parsed["commit_type"] == "fix"

    def test_default_context_json(self, cog_db: CogDatabase):
        """不提供 context_json 时默认为 '{}'"""
        fid = cog_db.insert_fact(_make_fact())
        fact = cog_db.get_fact(fid)
        assert fact["context_json"] == "{}"

    def test_update_context_json(self, cog_db: CogDatabase):
        """可通过 update_fact 更新 context_json"""
        fid = cog_db.insert_fact(_make_fact())
        new_ctx = json.dumps({"reason": "performance"})
        cog_db.update_fact(fid, context_json=new_ctx)
        fact = cog_db.get_fact(fid)
        assert fact["context_json"] == new_ctx


# ── category 感知衰减 ──


class TestCategoryAwareDecay:
    def test_decision_decays_slower(self, cog_db: CogDatabase):
        """decision 类 facts 衰减更慢"""
        fid_code = cog_db.insert_fact(_make_fact(
            assertion="Regular code fact about testing",
            category="code",
            confidence=0.9,
        ))
        fid_decision = cog_db.insert_fact(_make_fact(
            assertion="Chose JWT over sessions for auth",
            category="decision",
            confidence=0.9,
        ))
        # 设置为 30 天前
        cog_db.conn.execute(
            "UPDATE facts SET created_at=datetime('now', '-30 days') WHERE id IN (?, ?)",
            [fid_code, fid_decision],
        )
        cog_db.conn.commit()

        cog_db.decay_facts("test-proj", stale_days=7)

        code_fact = cog_db.get_fact(fid_code)
        decision_fact = cog_db.get_fact(fid_decision)
        # decision 衰减更慢（0.2x），置信度应更高
        assert decision_fact["confidence"] > code_fact["confidence"]

    def test_root_cause_decays_slower(self, cog_db: CogDatabase):
        """root_cause 类 facts 也享受慢衰减"""
        fid_code = cog_db.insert_fact(_make_fact(
            assertion="Normal code modification record",
            category="code",
            confidence=0.8,
        ))
        fid_root = cog_db.insert_fact(_make_fact(
            assertion="Bug caused by race condition",
            category="root_cause",
            confidence=0.8,
        ))
        cog_db.conn.execute(
            "UPDATE facts SET created_at=datetime('now', '-30 days') WHERE id IN (?, ?)",
            [fid_code, fid_root],
        )
        cog_db.conn.commit()

        cog_db.decay_facts("test-proj", stale_days=7)

        code_fact = cog_db.get_fact(fid_code)
        root_fact = cog_db.get_fact(fid_root)
        assert root_fact["confidence"] > code_fact["confidence"]


# ── FTS5 Update ──


# ── Fact Edges ──


class TestFactEdges:
    def test_table_exists(self, cog_db: CogDatabase):
        """fact_edges 表应存在"""
        tables = {
            r[0]
            for r in cog_db.conn.execute(
                "SELECT name FROM sqlite_master WHERE type='table'"
            ).fetchall()
        }
        assert "fact_edges" in tables

    def test_insert_edge(self, cog_db: CogDatabase):
        """插入因果边"""
        f1 = cog_db.insert_fact(_make_fact(assertion="Root cause: race condition"))
        f2 = cog_db.insert_fact(_make_fact(assertion="Test passed after fix"))
        ok = cog_db.insert_edge(f1, f2, "causes", 0.8, "rule")
        assert ok is True

    def test_insert_edge_idempotent(self, cog_db: CogDatabase):
        """重复插入同一边应忽略"""
        f1 = cog_db.insert_fact(_make_fact(assertion="Fact A"))
        f2 = cog_db.insert_fact(_make_fact(assertion="Fact B"))
        ok1 = cog_db.insert_edge(f1, f2, "causes")
        ok2 = cog_db.insert_edge(f1, f2, "causes")
        assert ok1 is True
        assert ok2 is True  # INSERT OR IGNORE returns non-None
        # 只有一条边
        edges = cog_db.get_fact_edges(f1)
        causes = [e for e in edges if e["relation"] == "causes"]
        assert len(causes) == 1

    def test_multiple_relations(self, cog_db: CogDatabase):
        """同一对 facts 可有多种关系"""
        f1 = cog_db.insert_fact(_make_fact(assertion="Fact X"))
        f2 = cog_db.insert_fact(_make_fact(assertion="Fact Y"))
        cog_db.insert_edge(f1, f2, "causes")
        cog_db.insert_edge(f1, f2, "supports")
        edges = cog_db.get_fact_edges(f1)
        relations = {e["relation"] for e in edges}
        assert "causes" in relations
        assert "supports" in relations

    def test_get_fact_edges_bidirectional(self, cog_db: CogDatabase):
        """get_fact_edges 应返回双向边"""
        f1 = cog_db.insert_fact(_make_fact(assertion="Fact 1"))
        f2 = cog_db.insert_fact(_make_fact(assertion="Fact 2"))
        f3 = cog_db.insert_fact(_make_fact(assertion="Fact 3"))
        cog_db.insert_edge(f1, f2, "causes")
        cog_db.insert_edge(f3, f1, "supports")
        edges = cog_db.get_fact_edges(f1)
        assert len(edges) == 2

    def test_trace_facts_basic(self, cog_db: CogDatabase):
        """BFS 追踪因果链"""
        f1 = cog_db.insert_fact(_make_fact(assertion="Root cause"))
        f2 = cog_db.insert_fact(_make_fact(assertion="Intermediate fix"))
        f3 = cog_db.insert_fact(_make_fact(assertion="Test passed"))
        cog_db.insert_edge(f1, f2, "causes")
        cog_db.insert_edge(f2, f3, "leads_to")
        trace = cog_db.trace_facts(f1, max_depth=5)
        assert len(trace) == 2
        fact_ids = {t["fact_id"] for t in trace}
        assert f2 in fact_ids
        assert f3 in fact_ids

    def test_trace_facts_depth_limit(self, cog_db: CogDatabase):
        """追踪深度限制"""
        ids = []
        for i in range(6):
            fid = cog_db.insert_fact(_make_fact(assertion=f"Chain node {i}"))
            ids.append(fid)
        for i in range(5):
            cog_db.insert_edge(ids[i], ids[i + 1], "causes")
        # depth=2 应只到第 2 层
        trace = cog_db.trace_facts(ids[0], max_depth=2)
        assert len(trace) == 2

    def test_trace_facts_no_cycle(self, cog_db: CogDatabase):
        """追踪不会陷入循环"""
        f1 = cog_db.insert_fact(_make_fact(assertion="Node A"))
        f2 = cog_db.insert_fact(_make_fact(assertion="Node B"))
        cog_db.insert_edge(f1, f2, "causes")
        cog_db.insert_edge(f2, f1, "leads_to")
        trace = cog_db.trace_facts(f1, max_depth=10)
        # 只应包含 f2（visited 集合防止回环）
        assert len(trace) == 1

    def test_trace_direction(self, cog_db: CogDatabase):
        """追踪应标记 upstream/downstream 方向"""
        f1 = cog_db.insert_fact(_make_fact(assertion="Upstream cause"))
        f2 = cog_db.insert_fact(_make_fact(assertion="Center node"))
        f3 = cog_db.insert_fact(_make_fact(assertion="Downstream effect"))
        cog_db.insert_edge(f1, f2, "causes")
        cog_db.insert_edge(f2, f3, "leads_to")
        trace = cog_db.trace_facts(f2, max_depth=5)
        directions = {t["fact_id"]: t["direction"] for t in trace}
        assert directions[f1] == "upstream"
        assert directions[f3] == "downstream"

    def test_trace_empty(self, cog_db: CogDatabase):
        """无边的 fact 返回空 trace"""
        fid = cog_db.insert_fact(_make_fact(assertion="Isolated fact"))
        trace = cog_db.trace_facts(fid)
        assert trace == []

    def test_delete_edges_by_fact(self, cog_db: CogDatabase):
        """删除 fact 关联的所有边"""
        f1 = cog_db.insert_fact(_make_fact(assertion="Fact 1"))
        f2 = cog_db.insert_fact(_make_fact(assertion="Fact 2"))
        f3 = cog_db.insert_fact(_make_fact(assertion="Fact 3"))
        cog_db.insert_edge(f1, f2, "causes")
        cog_db.insert_edge(f3, f1, "supports")
        deleted = cog_db.delete_edges_by_fact(f1)
        assert deleted == 2
        assert cog_db.get_fact_edges(f1) == []

    def test_delete_edges_nonexistent(self, cog_db: CogDatabase):
        """删除不存在的 fact 边返回 0"""
        deleted = cog_db.delete_edges_by_fact(99999)
        assert deleted == 0


class TestFTS5Update:
    def test_update_assertion_syncs_fts(self, cog_db: CogDatabase):
        fid = cog_db.insert_fact(_make_fact(assertion="Zebra migration completed"))
        cog_db.update_fact(fid, assertion="Alpaca deployment finished")
        # Should find by new text
        results = cog_db.fts_search_facts("alpaca deployment", "test-proj")
        assert len(results) >= 1
        assert any(r["id"] == fid for r in results)
        # Should not find by old unique keyword
        old_results = cog_db.fts_search_facts("zebra", "test-proj")
        assert all(r["id"] != fid for r in old_results)


# ── truncate_payload (#124) ──


class TestTruncatePayload:
    def test_small_payload_unchanged(self):
        payload = {"tool_name": "Edit", "tool_response": "ok"}
        result = json.loads(truncate_payload(payload))
        assert result["tool_response"] == "ok"
        assert "_truncated" not in result

    def test_large_payload_truncated(self):
        big = "x" * 8000
        payload = {"tool_name": "Bash", "tool_response": big}
        result = json.loads(truncate_payload(payload))
        assert result["_truncated"] is True
        assert result["_original_size"] == 8000
        assert len(result["tool_response"]) == MAX_PAYLOAD_SIZE

    def test_non_string_tool_response(self):
        # Non-string response: always converted to str (type consistency)
        payload = {"tool_name": "Bash", "tool_response": 12345}
        result = json.loads(truncate_payload(payload))
        assert "_truncated" not in result
        assert result["tool_response"] == "12345"  # int → str
        # Large non-string response: converted to str then truncated
        big_list = list(range(2000))
        payload2 = {"tool_name": "Bash", "tool_response": big_list}
        result2 = json.loads(truncate_payload(payload2))
        assert result2["_truncated"] is True
        assert isinstance(result2["tool_response"], str)

    def test_preserves_other_fields(self):
        big = "y" * 10000
        payload = {"tool_name": "Bash", "tool_input": {"cmd": "ls"}, "tool_response": big}
        result = json.loads(truncate_payload(payload))
        assert result["tool_name"] == "Bash"
        assert result["tool_input"] == {"cmd": "ls"}
        assert result["_truncated"] is True


# ── Raw Events (#124) ──


class TestRawEvents:
    def test_insert_raw_event(self, cog_db: CogDatabase):
        eid = cog_db.insert_raw_event(
            session_id="s1",
            hook_event="PostToolUse",
            collection="test-proj",
            payload={"tool_name": "Edit", "tool_input": {}, "tool_response": "ok"},
            tool_name="Edit",
        )
        assert eid is not None

        row = cog_db.conn.execute(
            "SELECT * FROM raw_events WHERE event_id = ?", [eid]
        ).fetchone()
        assert row is not None
        assert row["session_id"] == "s1"
        assert row["hook_event"] == "PostToolUse"
        assert row["tool_name"] == "Edit"
        assert row["processed"] == 0

        # payload JSON 可以反序列化
        payload = json.loads(row["payload"])
        assert payload["tool_name"] == "Edit"
        assert payload["tool_response"] == "ok"

    def test_insert_truncates_large_payload(self, cog_db: CogDatabase):
        big_output = "x" * 10000  # 10KB > 4KB limit
        eid = cog_db.insert_raw_event(
            session_id="s1",
            hook_event="PostToolUse",
            collection="test-proj",
            payload={"tool_name": "Bash", "tool_input": {}, "tool_response": big_output},
            tool_name="Bash",
        )
        assert eid is not None

        row = cog_db.conn.execute(
            "SELECT payload FROM raw_events WHERE event_id = ?", [eid]
        ).fetchone()
        payload = json.loads(row["payload"])
        assert payload["_truncated"] is True
        assert payload["_original_size"] == 10000
        assert len(payload["tool_response"]) == 4096

    def test_insert_preserves_small_payload(self, cog_db: CogDatabase):
        small_output = "hello world"
        eid = cog_db.insert_raw_event(
            session_id="s1",
            hook_event="PostToolUse",
            collection="test-proj",
            payload={"tool_name": "Edit", "tool_input": {}, "tool_response": small_output},
        )
        row = cog_db.conn.execute(
            "SELECT payload FROM raw_events WHERE event_id = ?", [eid]
        ).fetchone()
        payload = json.loads(row["payload"])
        assert "_truncated" not in payload
        assert payload["tool_response"] == "hello world"

    def test_gc_raw_events(self, cog_db: CogDatabase):
        # Insert an event
        eid = cog_db.insert_raw_event(
            session_id="s1",
            hook_event="PostToolUse",
            collection="test-proj",
            payload={"tool_name": "Edit"},
        )
        assert eid is not None

        # GC with 7 days retention — fresh event should survive
        deleted = cog_db.gc_raw_events(retention_days=7)
        assert deleted == 0
        assert cog_db.conn.execute(
            "SELECT 1 FROM raw_events WHERE event_id = ?", [eid]
        ).fetchone() is not None

        # Backdate the event to 8 days ago
        cog_db.conn.execute(
            "UPDATE raw_events SET timestamp = datetime('now', '-8 days') WHERE event_id = ?",
            [eid],
        )
        cog_db.conn.commit()

        # Now GC should delete it
        deleted = cog_db.gc_raw_events(retention_days=7)
        assert deleted == 1
        assert cog_db.conn.execute(
            "SELECT 1 FROM raw_events WHERE event_id = ?", [eid]
        ).fetchone() is None

    def test_indexes_exist(self, cog_db: CogDatabase):
        indexes = {
            r[0]
            for r in cog_db.conn.execute(
                "SELECT name FROM sqlite_master WHERE type='index'"
            ).fetchall()
        }
        assert "idx_raw_unprocessed" in indexes
        assert "idx_raw_session" in indexes
        assert "idx_raw_collection" in indexes

    def test_processed_state_default(self, cog_db: CogDatabase):
        eid = cog_db.insert_raw_event(
            session_id="s1",
            hook_event="PostToolUse",
            collection="test-proj",
            payload={"tool_name": "Bash"},
        )
        row = cog_db.conn.execute(
            "SELECT processed, processed_at, batch_id FROM raw_events WHERE event_id = ?",
            [eid],
        ).fetchone()
        assert row["processed"] == 0
        assert row["processed_at"] is None
        assert row["batch_id"] is None
