"""maintenance.py 离线维护引擎测试"""

from __future__ import annotations

import asyncio
import json
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from index1.cognitive.db_cog import CogDatabase
from index1.cognitive.maintenance import MaintenanceEngine
from index1.cognitive.rules import scope_overlap
from index1.config import Config


@pytest.fixture()
def cog_db(tmp_path: Path) -> CogDatabase:
    db = CogDatabase(tmp_path / "maintenance_test.db", embedding_dim=4)
    db.connect()
    yield db
    db.close()


@pytest.fixture()
def config() -> Config:
    return Config(
        cognitive_decay_days=7,
        cognitive_gc_days=90,
        cognitive_gc_min_confidence=0.1,
        cognition_model="llama3.2:3b",
    )


@pytest.fixture()
def engine(cog_db, config) -> MaintenanceEngine:
    return MaintenanceEngine(db_cog=cog_db, config=config, embedder=None)


def _seed_facts(cog_db: CogDatabase, n: int = 5,
                collection: str = "test-proj",
                session_id: str = "s1") -> list[int]:
    ids = []
    for i in range(n):
        fid = cog_db.insert_fact({
            "collection": collection,
            "assertion": f"Test fact {i}: important detail about feature {i}",
            "confidence": 0.5 + i * 0.1,
            "session_id": session_id,
            "source": "hook",
        })
        ids.append(fid)
    return ids


class TestStepDecay:
    def test_decay_stale_facts(self, cog_db, engine):
        """陈旧 facts 应该衰减"""
        fid = cog_db.insert_fact({
            "collection": "test-proj",
            "assertion": "Old fact that should decay",
            "confidence": 0.9,
        })
        # 手动设置 created_at 为 10 天前
        cog_db.conn.execute(
            "UPDATE facts SET created_at=datetime('now', '-10 days') WHERE id=?",
            [fid],
        )
        cog_db.conn.commit()

        result = asyncio.run(engine._step_decay("test-proj", None))
        assert result >= 1

        fact = cog_db.get_fact(fid)
        assert fact["confidence"] < 0.9

    def test_decay_respects_floor(self, cog_db, engine):
        """衰减不应低于 0.05"""
        fid = cog_db.insert_fact({
            "collection": "test-proj",
            "assertion": "Very low confidence fact",
            "confidence": 0.06,
        })
        cog_db.conn.execute(
            "UPDATE facts SET created_at=datetime('now', '-10 days') WHERE id=?",
            [fid],
        )
        cog_db.conn.commit()

        asyncio.run(engine._step_decay("test-proj", None))
        fact = cog_db.get_fact(fid)
        assert fact["confidence"] >= 0.05

    def test_decay_skips_recent(self, cog_db, engine):
        """新建 facts 不应衰减"""
        fid = cog_db.insert_fact({
            "collection": "test-proj",
            "assertion": "Fresh fact",
            "confidence": 0.9,
        })

        result = asyncio.run(engine._step_decay("test-proj", None))
        fact = cog_db.get_fact(fid)
        assert fact["confidence"] == 0.9

    def test_decay_skips_validated(self, cog_db, engine):
        """最近验证的 facts 不应衰减"""
        fid = cog_db.insert_fact({
            "collection": "test-proj",
            "assertion": "Validated fact",
            "confidence": 0.9,
        })
        # 设置 created_at 很久以前，但 last_validated 是刚才
        cog_db.conn.execute(
            "UPDATE facts SET created_at=datetime('now', '-30 days'), "
            "last_validated=datetime('now') WHERE id=?",
            [fid],
        )
        cog_db.conn.commit()

        asyncio.run(engine._step_decay("test-proj", None))
        fact = cog_db.get_fact(fid)
        assert fact["confidence"] == 0.9


class TestStepConsolidate:
    def test_skips_without_embedder(self, engine):
        """无 embedder 时跳过合并"""
        result = asyncio.run(engine._step_consolidate("test-proj", None))
        assert result == 0

    def test_skips_unavailable_embedder(self, cog_db, config):
        """embedder 不可用时跳过"""
        embedder = MagicMock()
        embedder.available = False
        engine = MaintenanceEngine(cog_db, config, embedder)

        result = asyncio.run(engine._step_consolidate("test-proj", None))
        assert result == 0


class TestStepInferRelations:
    def test_skips_no_session(self, engine):
        """无 session_id 时跳过"""
        result = asyncio.run(engine._step_infer_relations("test-proj", None))
        assert result == "skipped_no_session"

    def test_skips_few_facts(self, cog_db, config):
        """facts 不足 2 条时跳过"""
        engine = MaintenanceEngine(cog_db, config, None)
        cog_db.insert_fact({
            "collection": "test-proj",
            "assertion": "Only one fact here about feature X",
            "session_id": "s1",
        })
        result = asyncio.run(engine._step_infer_relations("test-proj", "s1"))
        assert result == "skipped_few_facts"

    def test_l0_runs_without_chat(self, cog_db, config):
        """chat 不可用时 L0 规则仍然执行"""
        engine = MaintenanceEngine(cog_db, config, None)
        engine._chat_available = False
        _seed_facts(cog_db, n=3, session_id="s1")
        result = asyncio.run(engine._step_infer_relations("test-proj", "s1"))
        # L0 可能找到 0 条边，但返回的是整数不是字符串
        assert isinstance(result, int)

    def test_l2_skips_has_decisions(self, cog_db, config):
        """已有 decision 类 facts 时 L2 跳过但 L0 仍执行"""
        engine = MaintenanceEngine(cog_db, config, None)
        engine._chat_available = True
        for i in range(4):
            cog_db.insert_fact({
                "collection": "test-proj",
                "assertion": f"Fact {i}: something about testing feature {i}",
                "session_id": "s1",
                "category": "code",
            })
        cog_db.insert_fact({
            "collection": "test-proj",
            "assertion": "Chose JWT over sessions for auth reasons",
            "session_id": "s1",
            "category": "decision",
        })
        result = asyncio.run(engine._step_infer_relations("test-proj", "s1"))
        # L0 返回整数（可能 0），L2 跳过因为已有 decisions
        assert isinstance(result, int)

    def test_with_chat_inserts_facts(self, cog_db, config):
        """有 chat 模型时从 session facts 提炼 decision"""
        engine = MaintenanceEngine(cog_db, config, None)
        engine._chat_available = True
        _seed_facts(cog_db, n=5, session_id="s1")

        async def mock_chat(prompt):
            return json.dumps([{
                "assertion": "Chose SQLite over PostgreSQL for portability",
                "category": "decision",
            }])

        engine._chat = mock_chat

        result = asyncio.run(engine._step_infer_relations("test-proj", "s1"))
        # L0 edges + 1 episode fact
        assert result >= 1

        all_facts = cog_db.get_session_facts("s1", "test-proj")
        decisions = [f for f in all_facts if f["category"] == "decision"]
        assert len(decisions) == 1
        assert "SQLite" in decisions[0]["assertion"]
        assert decisions[0]["source"] == "maintenance"

    def test_chat_failed_returns_l0_count(self, cog_db, config):
        """chat 返回 None 时仍返回 L0 边数"""
        engine = MaintenanceEngine(cog_db, config, None)
        engine._chat_available = True
        _seed_facts(cog_db, n=5, session_id="s1")

        async def mock_chat(prompt):
            return None

        engine._chat = mock_chat

        result = asyncio.run(engine._step_infer_relations("test-proj", "s1"))
        # L0 edges (可能 0) + L2 episode 0 = 整数
        assert isinstance(result, int)

    def test_l0_root_cause_to_test_pass(self, cog_db, config):
        """L0 规则 1: root_cause → test pass 建立 causes 边"""
        engine = MaintenanceEngine(cog_db, config, None)
        f1 = cog_db.insert_fact({
            "collection": "test-proj",
            "assertion": "Bug caused by race condition in auth",
            "category": "root_cause",
            "session_id": "s1",
        })
        f2 = cog_db.insert_fact({
            "collection": "test-proj",
            "assertion": "All test passed after fix",
            "category": "code",
            "session_id": "s1",
        })
        facts = cog_db.get_session_facts("s1", "test-proj")
        edges = engine._infer_edges_l0(facts)
        assert edges >= 1
        # 验证边已创建
        edge_list = cog_db.get_fact_edges(f1)
        assert len(edge_list) >= 1
        assert any(e["relation"] == "causes" for e in edge_list)

    def test_l0_modified_to_committed(self, cog_db, config):
        """L0 规则 2: file modified → committed (scope overlap) 建立 leads_to 边"""
        engine = MaintenanceEngine(cog_db, config, None)
        f1 = cog_db.insert_fact({
            "collection": "test-proj",
            "assertion": "Modified src/auth.py",
            "category": "code",
            "session_id": "s1",
            "scope_files": '["src/auth.py"]',
        })
        f2 = cog_db.insert_fact({
            "collection": "test-proj",
            "assertion": "Committed changes to auth module",
            "category": "code",
            "session_id": "s1",
            "scope_files": '["src/auth.py"]',
        })
        facts = cog_db.get_session_facts("s1", "test-proj")
        edges = engine._infer_edges_l0(facts)
        assert edges >= 1
        edge_list = cog_db.get_fact_edges(f1)
        assert any(e["relation"] == "leads_to" for e in edge_list)

    def test_l0_install_to_test_pass(self, cog_db, config):
        """L0 规则 3: install → test pass 建立 supports 边"""
        engine = MaintenanceEngine(cog_db, config, None)
        f1 = cog_db.insert_fact({
            "collection": "test-proj",
            "assertion": "pip install httpx completed",
            "category": "code",
            "session_id": "s1",
        })
        f2 = cog_db.insert_fact({
            "collection": "test-proj",
            "assertion": "All tests passed successfully",
            "category": "code",
            "session_id": "s1",
        })
        facts = cog_db.get_session_facts("s1", "test-proj")
        edges = engine._infer_edges_l0(facts)
        assert edges >= 1
        edge_list = cog_db.get_fact_edges(f1)
        assert any(e["relation"] == "supports" for e in edge_list)


class TestStepUpdateModels:
    def test_skips_no_session(self, engine):
        """无 session_id 时跳过"""
        result = asyncio.run(engine._step_update_models("test-proj", None))
        assert result == 0

    def test_skips_no_chat_model(self, cog_db, config):
        """chat 模型不可用时跳过"""
        engine = MaintenanceEngine(cog_db, config, None)
        engine._chat_available = False
        _seed_facts(cog_db, session_id="s1")

        result = asyncio.run(engine._step_update_models("test-proj", "s1"))
        assert result == 0

    def test_skips_no_models(self, cog_db, config):
        """无 mental models 时跳过"""
        engine = MaintenanceEngine(cog_db, config, None)
        engine._chat_available = True
        _seed_facts(cog_db, session_id="s1")

        result = asyncio.run(engine._step_update_models("test-proj", "s1"))
        assert result == 0

    def test_updates_matching_model(self, cog_db, config):
        """有匹配 model 时更新"""
        engine = MaintenanceEngine(cog_db, config, None)
        engine._chat_available = True

        # 创建 model
        cog_db.upsert_mental_model("test-proj", "src/auth/", "Auth module")

        # 插入与 scope 匹配的 fact
        cog_db.insert_fact({
            "collection": "test-proj",
            "assertion": "src/auth/ uses JWT for authentication",
            "session_id": "s1",
        })

        # Mock chat 返回
        async def mock_chat(prompt):
            return "Auth module now uses JWT tokens for session management"

        engine._chat = mock_chat

        result = asyncio.run(engine._step_update_models("test-proj", "s1"))
        assert result == 1


class TestStepGenerateBrief:
    def test_template_fallback(self, cog_db, config):
        """无 chat 模型时使用模板"""
        engine = MaintenanceEngine(cog_db, config, None)
        engine._chat_available = False
        _seed_facts(cog_db, session_id="s1")

        result = asyncio.run(engine._step_generate_brief("test-proj", "s1"))
        assert result == "template"

        brief = cog_db.get_latest_brief("test-proj")
        assert brief is not None
        assert "test-proj" in brief["brief_json"]

    def test_template_with_no_facts(self, cog_db, config):
        """空数据库也能生成模板 brief"""
        engine = MaintenanceEngine(cog_db, config, None)
        engine._chat_available = False

        result = asyncio.run(engine._step_generate_brief("test-proj", None))
        assert result == "template"

        brief = cog_db.get_latest_brief("test-proj")
        assert brief is not None

    def test_chat_brief(self, cog_db, config):
        """chat 模型可用时生成 brief"""
        engine = MaintenanceEngine(cog_db, config, None)
        engine._chat_available = True
        _seed_facts(cog_db, session_id="s1")

        async def mock_chat(prompt):
            return "Important fix applied to auth module"

        engine._chat = mock_chat

        result = asyncio.run(engine._step_generate_brief("test-proj", "s1"))
        assert result == "chat"

        brief = cog_db.get_latest_brief("test-proj")
        assert brief is not None
        assert "Important" in brief["brief_json"]

    def test_chat_failure_fallback(self, cog_db, config):
        """chat 失败时回退到模板"""
        engine = MaintenanceEngine(cog_db, config, None)
        engine._chat_available = True
        _seed_facts(cog_db, session_id="s1")

        async def mock_chat(prompt):
            return None  # 模拟 chat 失败

        engine._chat = mock_chat

        result = asyncio.run(engine._step_generate_brief("test-proj", "s1"))
        assert result == "template"


class TestStepGC:
    def test_gc_low_confidence(self, cog_db, engine):
        """低置信度 + 从未访问的 facts 应被回收"""
        fid = cog_db.insert_fact({
            "collection": "test-proj",
            "assertion": "Low confidence fact to gc",
            "confidence": 0.05,
        })

        result = asyncio.run(engine._step_gc("test-proj", None))
        assert result >= 1

        fact = cog_db.get_fact(fid)
        assert fact["status"] == "invalidated"
        assert fact["invalidated_reason"] == "low_confidence_gc"

    def test_gc_stale(self, cog_db, engine):
        """超过 90 天未访问的 facts 应被回收"""
        fid = cog_db.insert_fact({
            "collection": "test-proj",
            "assertion": "Stale fact never accessed",
            "confidence": 0.5,
        })
        # 手动设置 created_at 为 100 天前
        cog_db.conn.execute(
            "UPDATE facts SET created_at=datetime('now', '-100 days') WHERE id=?",
            [fid],
        )
        cog_db.conn.commit()

        result = asyncio.run(engine._step_gc("test-proj", None))
        assert result >= 1

        fact = cog_db.get_fact(fid)
        assert fact["status"] == "invalidated"

    def test_gc_preserves_accessed(self, cog_db, engine):
        """有访问记录的 facts 不应被 stale gc 回收"""
        fid = cog_db.insert_fact({
            "collection": "test-proj",
            "assertion": "Accessed fact should survive",
            "confidence": 0.5,
        })
        cog_db.conn.execute(
            "UPDATE facts SET created_at=datetime('now', '-100 days'), "
            "last_accessed=datetime('now') WHERE id=?",
            [fid],
        )
        cog_db.conn.commit()

        asyncio.run(engine._step_gc("test-proj", None))
        fact = cog_db.get_fact(fid)
        assert fact["status"] == "active"


class TestFullRun:
    def test_run_all_steps(self, cog_db, config):
        """完整维护流程应完成所有步骤"""
        engine = MaintenanceEngine(cog_db, config, None)
        engine._chat_available = False
        _seed_facts(cog_db, session_id="s1")

        results = asyncio.run(engine.run("test-proj", "s1"))

        assert "decay" in results
        assert "consolidate" in results
        assert isinstance(results["infer_relations"], int)
        assert "update_models" in results
        # #260: generate_brief 已废弃
        assert "generate_brief" not in results
        assert "gc" in results
        assert "total_ms" in results

    def test_step_failure_doesnt_block(self, cog_db, config):
        """单步失败不应阻塞其他步骤"""
        engine = MaintenanceEngine(cog_db, config, None)
        engine._chat_available = False

        # 让 decay 崩溃
        original_decay = engine._step_decay
        async def broken_decay(*args):
            raise RuntimeError("Simulated decay failure")
        engine._step_decay = broken_decay

        results = asyncio.run(engine.run("test-proj", None))

        # decay 失败但其他步骤正常
        assert "error" in str(results["decay"])
        assert results["infer_relations"] == "skipped_no_session"
        # #260: generate_brief 已废弃
        assert "generate_brief" not in results
        assert "gc" in results


class TestScopeOverlap:
    def test_overlap_found(self):
        """有重叠文件时返回 True"""
        a = {"scope_files": '["src/auth.py", "src/db.py"]'}
        b = {"scope_files": '["src/auth.py", "tests/test_auth.py"]'}
        assert scope_overlap(a, b) is True

    def test_no_overlap(self):
        """无重叠文件时返回 False"""
        a = {"scope_files": '["src/auth.py"]'}
        b = {"scope_files": '["src/db.py"]'}
        assert scope_overlap(a, b) is False

    def test_empty_scope(self):
        """空 scope_files 返回 False"""
        a = {"scope_files": "[]"}
        b = {"scope_files": '["src/auth.py"]'}
        assert scope_overlap(a, b) is False

    def test_missing_scope(self):
        """缺少 scope_files 字段返回 False"""
        a = {}
        b = {"scope_files": '["src/auth.py"]'}
        assert scope_overlap(a, b) is False

    def test_malformed_json(self):
        """非法 JSON 返回 False"""
        a = {"scope_files": "not json"}
        b = {"scope_files": '["src/auth.py"]'}
        assert scope_overlap(a, b) is False


class TestHelpers:
    def test_scope_matches_assertion(self, engine):
        """scope 出现在 assertion 中应匹配"""
        fact = {"assertion": "src/auth/ uses JWT", "scope_files": "[]"}
        model = {"scope": "src/auth/"}
        assert engine._scope_matches(fact, model)

    def test_scope_matches_scope_files(self, engine):
        """scope 出现在 scope_files 中应匹配"""
        fact = {"assertion": "uses JWT", "scope_files": '["src/auth/login.py"]'}
        model = {"scope": "src/auth/"}
        assert engine._scope_matches(fact, model)

    def test_scope_no_match(self, engine):
        """无关 scope 不应匹配"""
        fact = {"assertion": "database uses SQLite", "scope_files": "[]"}
        model = {"scope": "src/auth/"}
        assert not engine._scope_matches(fact, model)

    def test_scope_empty(self, engine):
        """空 scope 不匹配"""
        fact = {"assertion": "anything", "scope_files": "[]"}
        model = {"scope": ""}
        assert not engine._scope_matches(fact, model)

    def test_template_brief_format(self, engine):
        """模板 brief 应为合法 JSON"""
        result = engine._template_brief(
            "test-proj",
            [{"assertion": "test fact", "confidence": 0.9}],
            [],
        )
        parsed = json.loads(result)
        assert "summary" in parsed
        assert "test-proj" in parsed["summary"]

    def test_extract_priority_found(self, engine):
        """包含关键词时提取优先事项"""
        result = engine._extract_priority(
            "The critical issue in auth needs fixing. Other things are ok."
        )
        assert result is not None
        assert "critical" in result.lower()

    def test_extract_priority_none(self, engine):
        """无关键词时返回 None"""
        result = engine._extract_priority("Everything looks good today")
        assert result is None

    def test_build_model_update_prompt(self, engine):
        """prompt 应包含 scope 和 facts"""
        model = {"scope": "src/db/", "subject": "Database layer"}
        facts = [{"assertion": "Uses SQLite WAL mode"}]
        prompt = engine._build_model_update_prompt(model, facts)
        assert "src/db/" in prompt
        assert "SQLite WAL" in prompt

    def test_build_episode_prompt(self, engine):
        """Episode prompt 应包含 facts"""
        facts = [
            {"category": "code", "assertion": "Modified src/auth.py"},
            {"category": "code", "assertion": "Tests passed: pytest tests/"},
        ]
        prompt = engine._build_episode_prompt(facts)
        assert "auth.py" in prompt
        assert "Tests passed" in prompt
        assert "JSON array" in prompt

    def test_parse_episode_response_valid(self, engine):
        """解析有效 JSON 数组"""
        response = '[{"assertion": "Chose JWT for cross-origin support", "category": "decision"}]'
        facts = engine._parse_episode_response(response, "proj", "s1")
        assert len(facts) == 1
        assert facts[0]["category"] == "decision"
        assert facts[0]["source"] == "maintenance"
        assert facts[0]["confidence"] == 0.7

    def test_parse_episode_response_in_code_block(self, engine):
        """解析被 markdown code block 包裹的 JSON"""
        response = '```json\n[{"assertion": "Bug caused by race condition in auth", "category": "root_cause"}]\n```'
        facts = engine._parse_episode_response(response, "proj", "s1")
        assert len(facts) == 1
        assert facts[0]["category"] == "root_cause"

    def test_parse_episode_response_empty(self, engine):
        """空数组返回空列表"""
        facts = engine._parse_episode_response("[]", "proj", "s1")
        assert facts == []

    def test_parse_episode_response_invalid(self, engine):
        """非法 JSON 返回空列表"""
        facts = engine._parse_episode_response("not json at all", "proj", "s1")
        assert facts == []

    def test_parse_episode_response_caps_at_5(self, engine):
        """最多解析 5 条 facts"""
        items = [{"assertion": f"Decision {i} about component {i}", "category": "decision"} for i in range(10)]
        response = json.dumps(items)
        facts = engine._parse_episode_response(response, "proj", "s1")
        assert len(facts) == 5


# ── Step 7: 模式提取测试 ──


class TestStepExtractPatterns:
    """_step_extract_patterns: 3+ 同 scope+category facts → pattern fact (L3a)"""

    def test_extracts_pattern_from_cluster(self, cog_db, engine):
        """同 scope_files + category ≥3 个 facts → 产出 pattern fact"""
        scope = '["src/auth/login.py"]'
        for i in range(4):
            cog_db.insert_fact({
                "collection": "test-proj",
                "assertion": f"Auth login issue {i}: token validation fails on edge case {i}",
                "category": "root_cause",
                "scope_files": scope,
                "confidence": 0.8,
            })

        result = asyncio.run(engine._step_extract_patterns("test-proj", None))
        assert result >= 1

        # 验证 pattern fact 已创建
        rows = cog_db.conn.execute("""
            SELECT * FROM facts
            WHERE collection='test-proj' AND category='pattern' AND status='active'
        """).fetchall()
        assert len(rows) >= 1
        pattern = dict(rows[0])
        assert pattern["layer"] == "L3a"
        assert "[Pattern" in pattern["assertion"]
        # context_json 应含 evidence 信息
        ctx = json.loads(pattern["context_json"])
        assert ctx["evidence_count"] >= 3
        assert ctx["source_category"] == "root_cause"
        assert len(ctx["evidence_fact_ids"]) >= 3

    def test_skips_existing_pattern(self, cog_db, engine):
        """已有同 scope 的 pattern 时不重复创建"""
        scope = '["src/db.py"]'
        for i in range(3):
            cog_db.insert_fact({
                "collection": "test-proj",
                "assertion": f"DB issue {i}: connection timeout in query {i}",
                "category": "code",
                "scope_files": scope,
                "confidence": 0.8,
            })
        # 先跑一次
        asyncio.run(engine._step_extract_patterns("test-proj", None))
        count1 = cog_db.conn.execute(
            "SELECT COUNT(*) FROM facts WHERE category='pattern' AND collection='test-proj'"
        ).fetchone()[0]

        # 再跑一次 — 不应增加
        asyncio.run(engine._step_extract_patterns("test-proj", None))
        count2 = cog_db.conn.execute(
            "SELECT COUNT(*) FROM facts WHERE category='pattern' AND collection='test-proj'"
        ).fetchone()[0]
        assert count2 == count1

    def test_skips_with_fewer_than_3(self, cog_db, engine):
        """不足 3 个 facts 时不提取 pattern"""
        scope = '["src/utils.py"]'
        for i in range(2):
            cog_db.insert_fact({
                "collection": "test-proj",
                "assertion": f"Utils issue {i}: formatting error in output {i}",
                "category": "code",
                "scope_files": scope,
            })

        result = asyncio.run(engine._step_extract_patterns("test-proj", None))
        assert result == 0

    def test_excludes_constraint_preference_pattern(self, cog_db, engine):
        """constraint/preference/pattern 类 facts 不参与聚合"""
        scope = '["src/config.py"]'
        for i in range(5):
            cog_db.insert_fact({
                "collection": "test-proj",
                "assertion": f"Constraint {i}: must use SQLite for config storage {i}",
                "category": "constraint",
                "scope_files": scope,
            })

        result = asyncio.run(engine._step_extract_patterns("test-proj", None))
        assert result == 0

    def test_empty_scope_not_extracted(self, cog_db, engine):
        """空 scope_files 的 facts 不参与聚合"""
        for i in range(5):
            cog_db.insert_fact({
                "collection": "test-proj",
                "assertion": f"Fact {i} without scope files about random topic {i}",
                "category": "code",
                "scope_files": "[]",
            })

        result = asyncio.run(engine._step_extract_patterns("test-proj", None))
        assert result == 0


# ── Step 8: 邻居演化测试 ──


class TestStepEvolveNeighbors:
    """_step_evolve_neighbors: BM25 搜索关联 facts → context_json.related_facts"""

    def test_evolves_high_value_facts(self, cog_db, engine):
        """高价值 facts 应通过 BM25 找到邻居并写入 related_facts"""
        # 插入多个主题相关的 facts
        target_id = cog_db.insert_fact({
            "collection": "test-proj",
            "assertion": "SQLite WAL mode improves concurrent read performance",
            "category": "architecture",
            "confidence": 0.9,
        })
        # 设置 access_count ≥ 2（候选条件）
        cog_db.conn.execute(
            "UPDATE facts SET access_count=3 WHERE id=?", [target_id]
        )

        # 插入相关 facts（BM25 应匹配到）
        related1 = cog_db.insert_fact({
            "collection": "test-proj",
            "assertion": "SQLite WAL mode requires checkpoint for disk sync",
            "category": "code",
            "confidence": 0.8,
        })
        related2 = cog_db.insert_fact({
            "collection": "test-proj",
            "assertion": "SQLite concurrent read uses shared lock in WAL mode",
            "category": "code",
            "confidence": 0.7,
        })
        cog_db.conn.commit()

        result = asyncio.run(engine._step_evolve_neighbors("test-proj", None))
        assert result >= 1

        # 验证 context_json 已更新
        fact = cog_db.get_fact(target_id)
        ctx = json.loads(fact["context_json"])
        assert "related_facts" in ctx
        assert isinstance(ctx["related_facts"], list)
        assert len(ctx["related_facts"]) >= 1
        # 不应包含自身
        assert target_id not in ctx["related_facts"]

    def test_skips_low_value_facts(self, cog_db, engine):
        """低价值 facts（confidence < 0.7 或 access < 2）不被演化"""
        fid = cog_db.insert_fact({
            "collection": "test-proj",
            "assertion": "Low value fact about database optimization",
            "category": "code",
            "confidence": 0.5,
        })
        # access_count = 0（默认）

        result = asyncio.run(engine._step_evolve_neighbors("test-proj", None))
        assert result == 0

        fact = cog_db.get_fact(fid)
        ctx = json.loads(fact.get("context_json") or "{}")
        assert "related_facts" not in ctx

    def test_skips_already_evolved(self, cog_db, engine):
        """已有 related_facts 的 facts 不重复演化"""
        fid = cog_db.insert_fact({
            "collection": "test-proj",
            "assertion": "Already evolved fact about testing patterns",
            "category": "code",
            "confidence": 0.9,
            "context_json": json.dumps({"related_facts": [99]}),
        })
        cog_db.conn.execute(
            "UPDATE facts SET access_count=5 WHERE id=?", [fid]
        )
        cog_db.conn.commit()

        result = asyncio.run(engine._step_evolve_neighbors("test-proj", None))
        assert result == 0

    def test_no_neighbors_found(self, cog_db, engine):
        """BM25 找不到邻居时不写入"""
        fid = cog_db.insert_fact({
            "collection": "test-proj",
            "assertion": "xyzzy unique isolated fact with no related content",
            "category": "code",
            "confidence": 0.9,
        })
        cog_db.conn.execute(
            "UPDATE facts SET access_count=3 WHERE id=?", [fid]
        )
        cog_db.conn.commit()

        result = asyncio.run(engine._step_evolve_neighbors("test-proj", None))
        # 可能 0（无邻居）或 BM25 自匹配被排除
        fact = cog_db.get_fact(fid)
        ctx = json.loads(fact.get("context_json") or "{}")
        # 如果写入了 related_facts，列表应不含自身
        if "related_facts" in ctx:
            assert fid not in ctx["related_facts"]

    def test_preserves_existing_context(self, cog_db, engine):
        """演化时保留 context_json 中已有的其他字段"""
        fid = cog_db.insert_fact({
            "collection": "test-proj",
            "assertion": "SQLite busy timeout should be set to 15 seconds",
            "category": "config",
            "confidence": 0.9,
            "context_json": json.dumps({"tool_sequence": "Read → Edit"}),
        })
        cog_db.conn.execute(
            "UPDATE facts SET access_count=3 WHERE id=?", [fid]
        )
        # 插入相关 fact
        cog_db.insert_fact({
            "collection": "test-proj",
            "assertion": "SQLite busy timeout prevents lock contention errors",
            "category": "code",
            "confidence": 0.8,
        })
        cog_db.conn.commit()

        asyncio.run(engine._step_evolve_neighbors("test-proj", None))

        fact = cog_db.get_fact(fid)
        ctx = json.loads(fact["context_json"])
        # 原有字段保留
        assert ctx.get("tool_sequence") == "Read → Edit"
        # 新字段已写入
        if "related_facts" in ctx:
            assert isinstance(ctx["related_facts"], list)


# ── Realtime Marker 协调测试 ──


class TestRealtimeMarkerCoordination:
    """验证 maintenance 对 realtime marker 的响应"""

    def test_skip_l0_when_realtime_active(self, cog_db, config, tmp_path):
        """marker 存在时跳过 L0 步骤（decay/extract_tactics/gc）"""
        from unittest.mock import patch as mock_patch
        from index1.cognitive.realtime import RealtimeUpdater

        engine = MaintenanceEngine(cog_db, config)

        # 模拟 marker 存在
        with mock_patch.object(RealtimeUpdater, "check_marker", return_value=True), \
             mock_patch.object(RealtimeUpdater, "clear_marker"):
            results = asyncio.run(engine.run("test-proj", None))

        # L0 步骤不应出现在结果中
        assert "decay" not in results
        assert "extract_tactics" not in results
        assert "gc" not in results
        # L1/L2 步骤应存在
        assert "consolidate" in results
        assert "infer_relations" in results
        # #260: generate_brief 已废弃
        assert "generate_brief" not in results
        assert results.get("realtime_active") is True

    def test_full_pipeline_no_marker(self, cog_db, config):
        """marker 不存在时执行全部步骤"""
        from unittest.mock import patch as mock_patch
        from index1.cognitive.realtime import RealtimeUpdater

        engine = MaintenanceEngine(cog_db, config)

        with mock_patch.object(RealtimeUpdater, "check_marker", return_value=False), \
             mock_patch.object(RealtimeUpdater, "clear_marker"):
            results = asyncio.run(engine.run("test-proj", None))

        # 全部步骤应存在
        assert "decay" in results
        assert "consolidate" in results
        assert "infer_relations" in results
        assert "extract_tactics" in results
        assert "gc" in results
        assert "realtime_active" not in results

    def test_infer_relations_skips_l0_when_realtime(self, cog_db, config):
        """realtime_active 时 infer_relations 只做 L2，跳过 L0 edges"""
        from unittest.mock import patch as mock_patch
        from index1.cognitive.realtime import RealtimeUpdater

        engine = MaintenanceEngine(cog_db, config)

        # 插入 session facts
        session_id = "test-rt-session"
        for i in range(3):
            cog_db.insert_fact({
                "assertion": f"Test fact {i}",
                "category": "code",
                "collection": "test-proj",
                "session_id": session_id,
                "confidence": 0.8,
                "source": "test",
                "status": "active",
                "scope_files": "[]",
                "scope_modules": "[]",
                "scope_entities": "[]",
                "context_json": "{}",
            })

        with mock_patch.object(RealtimeUpdater, "check_marker", return_value=True), \
             mock_patch.object(RealtimeUpdater, "clear_marker"):
            results = asyncio.run(engine.run("test-proj", session_id))

        # infer_relations 应执行但跳过 L0 部分
        assert "infer_relations" in results


# ── Step 10: backfill_file_refs 测试 ──


class TestBackfillFileRefs:
    """_step_backfill_file_refs: L0 纯正则从 assertion 提取文件路径"""

    def test_extract_full_path(self, engine):
        """全路径中提取 src/ 开头的相对路径"""
        ref = engine._extract_file_ref(
            "Changed function 'orient' in /home/user/project/src/index1/cognitive/hooks.py"
        )
        assert ref == "src/index1/cognitive/hooks.py"

    def test_extract_modified_pattern(self, engine):
        """Modified 模式提取"""
        ref = engine._extract_file_ref(
            "Modified /home/user/project/src/index1/config.py"
        )
        assert ref == "src/index1/config.py"

    def test_extract_changed_class(self, engine):
        """Changed class 模式"""
        ref = engine._extract_file_ref(
            "Changed class 'HookHandler' in /home/user/project/src/index1/cognitive/hooks.py"
        )
        assert ref == "src/index1/cognitive/hooks.py"

    def test_extract_short_module(self, engine):
        """短模块名 fallback"""
        ref = engine._extract_file_ref("Tests passed: capture.py validated")
        assert ref == "capture.py"

    def test_extract_tests_path(self, engine):
        """tests/ 路径提取"""
        ref = engine._extract_file_ref(
            "Created /home/user/project/tests/test_hooks.py"
        )
        assert ref == "tests/test_hooks.py"

    def test_no_file_ref(self, engine):
        """无文件引用返回 None"""
        ref = engine._extract_file_ref(
            "Architecture decision: use BM25 for offline search"
        )
        assert ref is None

    def test_long_assertion_skips_short_module(self, engine):
        """长 assertion 中不匹配短模块名（避免误匹配）"""
        long_text = "A" * 300 + " hooks.py mentioned somewhere"
        ref = engine._extract_file_ref(long_text)
        assert ref is None

    def test_backfill_step_fills_context_json(self, cog_db, engine):
        """完整流程：assertion 中有文件路径 → context_json.file 被回填"""
        # 插入一条有文件路径的 fact
        cog_db.insert_fact({
            "assertion": "Modified /home/user/project/src/index1/hooks.py",
            "category": "code",
            "confidence": 0.8,
            "source": "test",
            "collection": "test-col",
        })
        result = asyncio.run(engine._step_backfill_file_refs("test-col", None))
        assert result == 1

        # 验证 context_json 被更新
        facts = cog_db.conn.execute(
            "SELECT context_json FROM facts WHERE collection='test-col'"
        ).fetchall()
        ctx = json.loads(facts[0][0])
        assert ctx["file"] == "src/index1/hooks.py"

    def test_backfill_skips_already_filled(self, cog_db, engine):
        """已有 file 字段的 fact 不重复处理"""
        cog_db.insert_fact({
            "assertion": "Modified /home/user/project/src/index1/hooks.py",
            "category": "code",
            "confidence": 0.8,
            "source": "test",
            "collection": "test-col",
        })
        # 手动设置 context_json.file
        cog_db.update_fact(1, context_json='{"file": "src/index1/hooks.py"}')

        result = asyncio.run(engine._step_backfill_file_refs("test-col", None))
        assert result == 0

    def test_backfill_preserves_existing_context(self, cog_db, engine):
        """回填时保留 context_json 中已有的其他字段"""
        cog_db.insert_fact({
            "assertion": "Modified /home/user/project/src/index1/hooks.py",
            "category": "code",
            "confidence": 0.8,
            "source": "test",
            "collection": "test-col",
        })
        cog_db.update_fact(1, context_json='{"source": "learn_tool"}')

        result = asyncio.run(engine._step_backfill_file_refs("test-col", None))
        assert result == 1

        ctx = json.loads(
            cog_db.conn.execute("SELECT context_json FROM facts WHERE id=1").fetchone()[0]
        )
        assert ctx["file"] == "src/index1/hooks.py"
        assert ctx["source"] == "learn_tool"


# ── Step 11: detect_stale 测试 ──


class TestDetectStale:
    """_step_detect_stale: L0 文件系统检查，降低已删除文件的 fact confidence"""

    def test_stale_file_lowers_confidence(self, cog_db, engine):
        """引用不存在的文件 → confidence 减半"""
        cog_db.insert_fact({
            "assertion": "something about a file",
            "category": "code",
            "confidence": 0.8,
            "source": "test",
            "collection": "test-col",
        })
        cog_db.update_fact(
            1, context_json='{"file": "src/nonexistent/deleted_module.py"}'
        )

        result = asyncio.run(engine._step_detect_stale("test-col", None))
        assert result == 1

        fact = cog_db.get_fact(1)
        assert fact["confidence"] == 0.4

    def test_existing_file_no_change(self, cog_db, engine, tmp_path):
        """引用存在的文件 → confidence 不变"""
        # 创建一个真实文件
        real_file = tmp_path / "src" / "real.py"
        real_file.parent.mkdir(parents=True)
        real_file.write_text("# real")

        cog_db.insert_fact({
            "assertion": "something",
            "category": "code",
            "confidence": 0.8,
            "source": "test",
            "collection": "test-col",
        })
        cog_db.update_fact(
            1, context_json=json.dumps({"file": str(real_file)})
        )

        result = asyncio.run(engine._step_detect_stale("test-col", None))
        assert result == 0

        fact = cog_db.get_fact(1)
        assert fact["confidence"] == 0.8

    def test_short_module_name_skipped(self, cog_db, engine):
        """短模块名（无 /）不检测 — 无法判断磁盘位置"""
        cog_db.insert_fact({
            "assertion": "about hooks.py",
            "category": "code",
            "confidence": 0.8,
            "source": "test",
            "collection": "test-col",
        })
        cog_db.update_fact(1, context_json='{"file": "hooks.py"}')

        result = asyncio.run(engine._step_detect_stale("test-col", None))
        assert result == 0

    def test_minimum_confidence_floor(self, cog_db, engine):
        """confidence 不会低于 0.05"""
        cog_db.insert_fact({
            "assertion": "stale ref",
            "category": "code",
            "confidence": 0.08,
            "source": "test",
            "collection": "test-col",
        })
        cog_db.update_fact(
            1, context_json='{"file": "src/deleted/gone.py"}'
        )

        result = asyncio.run(engine._step_detect_stale("test-col", None))
        assert result == 1

        fact = cog_db.get_fact(1)
        assert fact["confidence"] == 0.05
