#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Data Modules 单元测试
"""

import pytest
import asyncio
import json
import tempfile
import sys
from pathlib import Path

from data_modules import (
    DataModulesConfig,
    EntityLinker,
    StateManager,
    IndexManager,
    RAGAdapter,
    StyleSampler,
    EntityState,
    ChapterMeta,
    SceneMeta,
    StyleSample,
)
import data_modules.index_manager as index_manager_module
from data_modules.index_manager import (
    EntityMeta,
    StateChangeMeta,
    RelationshipMeta,
    OverrideContractMeta,
    ChaseDebtMeta,
    ChapterReadingPowerMeta,
    ReviewMetrics,
    WritingChecklistScoreMeta,
)
from project_locator import write_current_project_pointer


@pytest.fixture
def temp_project():
    """创建临时项目目录"""
    with tempfile.TemporaryDirectory() as tmpdir:
        config = DataModulesConfig.from_project_root(tmpdir)
        config.ensure_dirs()
        _ensure_project_state(config)
        yield config




def _ensure_project_state(config):
    config.ensure_dirs()
    state_path = config.webnovel_dir / "state.json"
    if not state_path.exists():
        state_path.write_text(json.dumps({}, ensure_ascii=False), encoding="utf-8")


class TestEntityLinker:
    """实体链接器测试"""

    def test_register_and_lookup_alias(self, temp_project):
        linker = EntityLinker(temp_project)
        # 先注册实体，否则 aliases JOIN 不会返回
        IndexManager(temp_project).upsert_entity(
            EntityMeta(
                id="xiaoyan",
                type="角色",
                canonical_name="萧炎",
                current={},
                first_appearance=1,
                last_appearance=1,
            )
        )

        # canonical_name 会在实体写入时自动注册为别名
        assert linker.lookup_alias("萧炎") == "xiaoyan"
        assert linker.register_alias("xiaoyan", "小炎子")

        # 查找
        assert linker.lookup_alias("萧炎") == "xiaoyan"
        assert linker.lookup_alias("小炎子") == "xiaoyan"
        assert linker.lookup_alias("不存在") is None

    def test_alias_one_to_many(self, temp_project):
        """v5.0: 同一别名可映射多个实体（一对多）"""
        linker = EntityLinker(temp_project)

        idx = IndexManager(temp_project)
        idx.upsert_entity(
            EntityMeta(
                id="xiaoyan",
                type="角色",
                canonical_name="萧炎",
                current={},
                first_appearance=1,
                last_appearance=1,
            )
        )
        idx.upsert_entity(
            EntityMeta(
                id="other_person",
                type="角色",
                canonical_name="萧炎",
                current={},
                first_appearance=1,
                last_appearance=1,
            )
        )

        # canonical_name 会自动作为别名；同一别名可绑定不同实体（一对多）

        # 查找所有匹配
        entries = linker.lookup_alias_all("萧炎")
        assert len(entries) == 2

    def test_get_all_aliases(self, temp_project):
        linker = EntityLinker(temp_project)
        IndexManager(temp_project).upsert_entity(
            EntityMeta(
                id="xiaoyan",
                type="角色",
                canonical_name="萧炎",
                current={},
                first_appearance=1,
                last_appearance=1,
            )
        )

        linker.register_alias("xiaoyan", "萧炎")
        linker.register_alias("xiaoyan", "小炎子")
        linker.register_alias("xiaoyan", "炎哥")

        aliases = linker.get_all_aliases("xiaoyan")
        assert len(aliases) == 3
        assert "萧炎" in aliases

    def test_confidence_evaluation(self, temp_project):
        linker = EntityLinker(temp_project)

        # 高置信度
        action, adopt, warning = linker.evaluate_confidence(0.9)
        assert action == "auto"
        assert adopt is True
        assert warning is None

        # 中置信度
        action, adopt, warning = linker.evaluate_confidence(0.6)
        assert action == "warn"
        assert adopt is True
        assert warning is not None

        # 低置信度
        action, adopt, warning = linker.evaluate_confidence(0.3)
        assert action == "manual"
        assert adopt is False

    def test_process_uncertain(self, temp_project):
        linker = EntityLinker(temp_project)

        result = linker.process_uncertain(
            mention="那位前辈",
            candidates=["yaolao", "elder_zhang"],
            suggested="yaolao",
            confidence=0.7
        )

        assert result.mention == "那位前辈"
        assert result.entity_id == "yaolao"
        assert result.adopted is True
        assert result.warning is not None


class TestStateManager:
    """状态管理器测试"""

    def test_add_and_get_entity(self, temp_project):
        manager = StateManager(temp_project)

        entity = EntityState(
            id="xiaoyan",
            name="萧炎",
            type="角色",
            tier="核心"
        )
        assert manager.add_entity(entity)

        # 获取实体
        result = manager.get_entity("xiaoyan")
        assert result is not None
        assert result["canonical_name"] == "萧炎"

    def test_update_entity(self, temp_project):
        manager = StateManager(temp_project)

        entity = EntityState(id="xiaoyan", name="萧炎", type="角色")
        manager.add_entity(entity)

        # 更新属性 (v5.0: attributes 存在 current 字段)
        manager.update_entity("xiaoyan", {"current": {"realm": "斗师"}})

        result = manager.get_entity("xiaoyan")
        assert result["current"]["realm"] == "斗师"

    def test_record_state_change(self, temp_project):
        manager = StateManager(temp_project)

        entity = EntityState(id="xiaoyan", name="萧炎", type="角色")
        manager.add_entity(entity)

        manager.record_state_change(
            entity_id="xiaoyan",
            field="realm",
            old_value="斗者",
            new_value="斗师",
            reason="突破",
            chapter=100
        )

        changes = manager.get_state_changes("xiaoyan")
        assert len(changes) == 1
        assert changes[0]["new_value"] == "斗师"

    def test_add_relationship(self, temp_project):
        manager = StateManager(temp_project)

        manager.add_relationship(
            from_entity="xiaoyan",
            to_entity="yaolao",
            rel_type="师徒",
            description="药老收萧炎为徒",
            chapter=10
        )

        rels = manager.get_relationships("xiaoyan")
        assert len(rels) == 1
        assert rels[0]["type"] == "师徒"

    def test_process_chapter_result(self, temp_project):
        manager = StateManager(temp_project)

        result = {
            "entities_appeared": [
                {"id": "xiaoyan", "mentions": ["萧炎", "他"]}
            ],
            "entities_new": [
                {"suggested_id": "hongyi_girl", "name": "红衣女子", "type": "角色", "tier": "装饰"}
            ],
            "state_changes": [
                {"entity_id": "xiaoyan", "field": "realm", "old": "斗者", "new": "斗师", "reason": "突破"}
            ],
            "relationships_new": [
                {"from": "xiaoyan", "to": "hongyi_girl", "type": "相识", "description": "初次见面"}
            ]
        }

        # 先添加萧炎
        manager.add_entity(EntityState(id="xiaoyan", name="萧炎", type="角色"))

        warnings = manager.process_chapter_result(100, result)

        # 验证新实体被添加
        assert manager.get_entity("hongyi_girl") is not None

        # 验证状态变化
        changes = manager.get_state_changes("xiaoyan")
        assert len(changes) == 1

        # 验证进度更新
        assert manager.get_current_chapter() == 100

    def test_save_state_with_init_project_schema(self, temp_project):
        """回归：init_project 生成的 state.json，StateManager 仍应可写入。(v5.1 SQLite-only)"""
        # v5.1: state.json 不再包含 entities_v3/alias_index，实体数据在 SQLite
        init_state = {
            "project_info": {"title": "测试书名", "genre": "修仙/玄幻", "created_at": "2026-01-01"},
            "progress": {"current_chapter": 0, "total_words": 0, "last_updated": "2026-01-01 00:00:00"},
            "protagonist_state": {"name": "测试主角"},
            "relationships": {},
            "world_settings": {"power_system": [], "factions": [], "locations": []},
            "plot_threads": {"active_threads": [], "foreshadowing": []},
            "review_checkpoints": [],
            "strand_tracker": {"current_dominant": "quest", "history": []},
        }
        temp_project.state_file.write_text(json.dumps(init_state, ensure_ascii=False, indent=2), encoding="utf-8")

        manager = StateManager(temp_project)
        manager.update_progress(5, words=100)
        manager.save_state()

        saved = json.loads(temp_project.state_file.read_text(encoding="utf-8"))
        assert "meta" not in saved
        assert saved["progress"]["current_chapter"] == 5
        assert saved["progress"]["total_words"] == 100
        # v5.1: entities_v3/alias_index 不再在 state.json 中

    def test_save_state_preserves_unrelated_fields(self, temp_project):
        """回归：仅写入增量，不应覆盖/丢失其他模块维护的字段。(v5.1 SQLite-only)"""
        init_state = {
            "project_info": {"title": "测试书名", "genre": "修仙/玄幻", "created_at": "2026-01-01"},
            "progress": {"current_chapter": 10, "total_words": 1000, "last_updated": "2026-01-01 00:00:00"},
            "protagonist_state": {"name": "测试主角"},
            "relationships": {"allies": ["药老"], "enemies": []},
            "world_settings": {"power_system": [], "factions": [], "locations": []},
            "plot_threads": {"active_threads": [{"id": "t1", "title": "主线"}], "foreshadowing": []},
            "review_checkpoints": [],
            "strand_tracker": {"current_dominant": "quest", "history": []},
            "custom_field": {"keep": True},
        }
        temp_project.state_file.write_text(json.dumps(init_state, ensure_ascii=False, indent=2), encoding="utf-8")

        manager = StateManager(temp_project)
        manager.add_entity(EntityState(id="xiaoyan", name="萧炎", type="角色", tier="核心"))
        manager.save_state()

        saved = json.loads(temp_project.state_file.read_text(encoding="utf-8"))
        assert saved.get("custom_field", {}).get("keep") is True
        assert saved.get("plot_threads", {}).get("active_threads", [])[0].get("id") == "t1"
        assert isinstance(saved.get("relationships"), dict)

    def test_disambiguation_feedback_persisted(self, temp_project):
        """回归：中/低置信度消歧必须对 Writer 可见（写入 state.json）。"""
        manager = StateManager(temp_project)

        result = {
            "entities_appeared": [],
            "entities_new": [],
            "state_changes": [],
            "relationships_new": [],
            "uncertain": [
                {
                    "mention": "那位前辈",
                    "context": "那位前辈看了他一眼",
                    "candidates": [{"type": "角色", "id": "yaolao"}, {"type": "角色", "id": "elder_zhang"}],
                    "suggested": "yaolao",
                    "confidence": 0.6,
                },
                {
                    "mention": "宗主",
                    "context": "宗主出现在血煞秘境",
                    "candidates": ["xueshazonzhu", "lintian"],
                    "suggested": "xueshazonzhu",
                    "confidence": 0.4,
                },
            ],
        }

        warnings = manager.process_chapter_result(100, result)
        manager.save_state()

        state = json.loads(temp_project.state_file.read_text(encoding="utf-8"))
        assert isinstance(state.get("disambiguation_warnings"), list)
        assert isinstance(state.get("disambiguation_pending"), list)

        assert len(state["disambiguation_warnings"]) == 1
        assert len(state["disambiguation_pending"]) == 1

        warn = state["disambiguation_warnings"][0]
        assert warn.get("chapter") == 100
        assert warn.get("mention") == "那位前辈"
        assert warn.get("chosen_id") == "yaolao"

        pending = state["disambiguation_pending"][0]
        assert pending.get("chapter") == 100
        assert pending.get("mention") == "宗主"

        # 返回值也应包含可见警告，便于 CLI/日志透出
        assert any("消歧警告" in w for w in warnings)
        assert any("需人工确认" in w for w in warnings)


class TestIndexManager:
    """索引管理器测试"""

    def test_add_and_get_chapter(self, temp_project):
        manager = IndexManager(temp_project)

        meta = ChapterMeta(
            chapter=100,
            title="突破",
            location="天云宗",
            word_count=3500,
            characters=["xiaoyan", "yaolao"]
        )
        manager.add_chapter(meta)

        result = manager.get_chapter(100)
        assert result is not None
        assert result["title"] == "突破"
        assert "xiaoyan" in result["characters"]

    def test_add_scenes(self, temp_project):
        manager = IndexManager(temp_project)

        scenes = [
            SceneMeta(chapter=100, scene_index=1, start_line=1, end_line=50,
                     location="天云宗·闭关室", summary="萧炎闭关突破", characters=["xiaoyan"]),
            SceneMeta(chapter=100, scene_index=2, start_line=51, end_line=100,
                     location="天云宗·演武场", summary="展示实力", characters=["xiaoyan", "lintian"])
        ]
        manager.add_scenes(100, scenes)

        result = manager.get_scenes(100)
        assert len(result) == 2
        assert result[0]["location"] == "天云宗·闭关室"

    def test_record_appearance(self, temp_project):
        manager = IndexManager(temp_project)

        manager.record_appearance("xiaoyan", 100, ["萧炎", "他"], 0.95)
        manager.record_appearance("yaolao", 100, ["药老"], 0.92)

        appearances = manager.get_chapter_appearances(100)
        assert len(appearances) == 2

        entity_history = manager.get_entity_appearances("xiaoyan")
        assert len(entity_history) == 1

    def test_search_scenes_by_location(self, temp_project):
        manager = IndexManager(temp_project)

        scenes = [
            SceneMeta(chapter=100, scene_index=1, start_line=1, end_line=50,
                     location="天云宗·闭关室", summary="闭关", characters=[]),
            SceneMeta(chapter=101, scene_index=1, start_line=1, end_line=50,
                     location="天云宗·大殿", summary="议事", characters=[])
        ]
        manager.add_scenes(100, scenes[:1])
        manager.add_scenes(101, scenes[1:])

        results = manager.search_scenes_by_location("天云宗")
        assert len(results) == 2

    def test_get_stats(self, temp_project):
        manager = IndexManager(temp_project)

        manager.upsert_entity(
            EntityMeta(
                id="xiaoyan",
                type="角色",
                canonical_name="萧炎",
                current={},
                first_appearance=1,
                last_appearance=1,
            )
        )
        manager.add_chapter(ChapterMeta(chapter=1, title="", location="", word_count=1000, characters=[]))
        manager.add_scenes(1, [SceneMeta(chapter=1, scene_index=1, start_line=1, end_line=50,
                                        location="", summary="", characters=[])])
        manager.record_appearance("xiaoyan", 1, [], 1.0)

        stats = manager.get_stats()
        assert stats["chapters"] == 1
        assert stats["scenes"] == 1
        assert stats["entities"] == 1

    def test_entity_canonical_name_alias_fallback(self, temp_project):
        manager = IndexManager(temp_project)

        manager.upsert_entity(
            EntityMeta(
                id="chenfeng",
                type="角色",
                canonical_name="陈锋",
                current={},
                first_appearance=1,
                last_appearance=1,
            )
        )

        aliases = manager.get_entity_aliases("chenfeng")
        assert "陈锋" in aliases
        assert manager.get_entities_by_alias("陈锋")[0]["id"] == "chenfeng"
        assert manager.get_entity("陈锋")["id"] == "chenfeng"

    def test_entity_alias_and_relationships(self, temp_project):
        manager = IndexManager(temp_project)

        entity_main = EntityMeta(
            id="xiaoyan",
            type="角色",
            canonical_name="萧炎",
            tier="核心",
            desc="主角",
            current={"realm": "斗者"},
            first_appearance=1,
            last_appearance=1,
            is_protagonist=True,
        )
        entity_other = EntityMeta(
            id="yaolao",
            type="角色",
            canonical_name="药老",
            tier="重要",
            current={},
            first_appearance=1,
            last_appearance=2,
        )

        assert manager.upsert_entity(entity_main) is True
        assert manager.upsert_entity(entity_other) is True

        # 更新 current
        assert manager.update_entity_current("xiaoyan", {"realm": "斗师"}) is True
        entity = manager.get_entity("xiaoyan")
        assert entity["current_json"]["realm"] == "斗师"

        # 元数据更新
        entity_main.desc = "主角（更新）"
        entity_main.last_appearance = 3
        assert manager.upsert_entity(entity_main, update_metadata=True) is False

        # 别名管理
        assert manager.register_alias("炎帝", "xiaoyan", "角色")
        assert "炎帝" in manager.get_entity_aliases("xiaoyan")
        assert manager.get_entities_by_alias("炎帝")[0]["id"] == "xiaoyan"
        assert manager.remove_alias("炎帝", "xiaoyan")
        assert manager.get_entities_by_alias("炎帝") == []

        # 类型/层级/核心/主角查询
        assert len(manager.get_entities_by_type("角色")) == 2
        assert any(e["id"] == "xiaoyan" for e in manager.get_entities_by_tier("核心"))
        assert any(e["id"] == "xiaoyan" for e in manager.get_core_entities())
        assert manager.get_protagonist()["id"] == "xiaoyan"

        # 归档实体
        assert manager.archive_entity("yaolao") is True
        assert all(e["id"] != "yaolao" for e in manager.get_entities_by_type("角色"))
        assert any(
            e["id"] == "yaolao"
            for e in manager.get_entities_by_type("角色", include_archived=True)
        )

        # 关系管理（新建 + 更新）
        rel = RelationshipMeta(
            from_entity="xiaoyan",
            to_entity="yaolao",
            type="师徒",
            description="收徒",
            chapter=1,
        )
        assert manager.upsert_relationship(rel) is True
        rel.description = "收徒（更新）"
        rel.chapter = 2
        assert manager.upsert_relationship(rel) is False

        assert len(manager.get_entity_relationships("xiaoyan", "from")) == 1
        assert len(manager.get_entity_relationships("yaolao", "to")) == 1
        assert len(manager.get_entity_relationships("xiaoyan", "both")) >= 1
        assert len(manager.get_relationship_between("xiaoyan", "yaolao")) == 1
        assert len(manager.get_recent_relationships(limit=5)) >= 1

    def test_state_changes_and_appearances(self, temp_project):
        manager = IndexManager(temp_project)

        entity = EntityMeta(
            id="xiaoyan",
            type="角色",
            canonical_name="萧炎",
            current={},
            first_appearance=1,
            last_appearance=1,
        )
        manager.upsert_entity(entity)

        change = StateChangeMeta(
            entity_id="xiaoyan",
            field="realm",
            old_value="斗者",
            new_value="斗师",
            reason="突破",
            chapter=2,
        )
        change_id = manager.record_state_change(change)
        assert change_id > 0

        assert len(manager.get_entity_state_changes("xiaoyan")) == 1
        assert len(manager.get_recent_state_changes(limit=5)) == 1
        assert len(manager.get_chapter_state_changes(2)) == 1

        # 出场记录（含 skip_if_exists 分支）
        manager.record_appearance("xiaoyan", 2, ["萧炎"], 1.0)
        manager.record_appearance("xiaoyan", 2, ["萧炎"], 1.0, skip_if_exists=True)
        manager.record_appearance("xiaoyan", 3, ["萧炎"], 1.0)

        assert len(manager.get_entity_appearances("xiaoyan")) == 2
        assert len(manager.get_recent_appearances(limit=5)) >= 1
        assert len(manager.get_chapter_appearances(2)) == 1

    def test_chapter_queries_and_bulk(self, temp_project):
        manager = IndexManager(temp_project)

        manager.add_chapter(
            ChapterMeta(
                chapter=1,
                title="起点",
                location="天云宗",
                word_count=1000,
                characters=["xiaoyan"],
            )
        )
        manager.add_chapter(
            ChapterMeta(
                chapter=2,
                title="突破",
                location="天云宗",
                word_count=1200,
                characters=["xiaoyan", "yaolao"],
            )
        )

        recent = manager.get_recent_chapters()
        assert recent[0]["chapter"] == 2

        scenes = [
            SceneMeta(
                chapter=1,
                scene_index=1,
                start_line=1,
                end_line=50,
                location="天云宗·闭关室",
                summary="闭关",
                characters=["xiaoyan"],
            ),
            SceneMeta(
                chapter=1,
                scene_index=2,
                start_line=51,
                end_line=80,
                location="天云宗·演武场",
                summary="练习",
                characters=["xiaoyan"],
            ),
        ]
        manager.add_scenes(1, scenes)
        assert len(manager.get_scenes(1)) == 2

        results = manager.search_scenes_by_location("天云宗")
        assert len(results) >= 2

        stats = manager.process_chapter_data(
            chapter=10,
            title="试炼",
            location="秘境",
            word_count=1500,
            entities=[{"id": "xiaoyan", "type": "角色", "mentions": ["萧炎"]}],
            scenes=[{"index": 1, "start_line": 1, "end_line": 20, "location": "秘境", "summary": "开场", "characters": ["xiaoyan"]}],
        )
        assert stats["chapters"] == 1
        assert stats["scenes"] == 1
        assert stats["appearances"] == 1

    def test_debt_and_override_flow(self, temp_project):
        manager = IndexManager(temp_project)

        contract = OverrideContractMeta(
            chapter=1,
            constraint_type="SOFT_MICROPAYOFF",
            constraint_id="micropayoff_count",
            rationale_type="TRANSITIONAL_SETUP",
            rationale_text="铺垫需要",
            payback_plan="下章补偿",
            due_chapter=3,
            status="pending",
        )
        contract_id = manager.create_override_contract(contract)
        assert contract_id > 0

        # pending 状态允许更新
        contract.rationale_text = "调整理由"
        contract.due_chapter = 4
        assert manager.create_override_contract(contract) == contract_id
        updated = manager.get_chapter_overrides(1)[0]
        assert updated["rationale_text"] == "调整理由"
        assert updated["due_chapter"] == 4

        # 终态冻结
        contract.status = "fulfilled"
        contract.rationale_text = "终态理由"
        contract.due_chapter = 5
        manager.create_override_contract(contract)
        frozen = manager.get_chapter_overrides(1)[0]
        assert frozen["status"] == "fulfilled"
        assert frozen["rationale_text"] == "终态理由"

        # 试图回写 pending，不应改动终态字段
        contract.status = "pending"
        contract.rationale_text = "不应生效"
        contract.due_chapter = 99
        manager.create_override_contract(contract)
        frozen_again = manager.get_chapter_overrides(1)[0]
        assert frozen_again["status"] == "fulfilled"
        assert frozen_again["rationale_text"] == "终态理由"
        assert frozen_again["due_chapter"] == 5

        debt_contract_id = manager.create_override_contract(
            OverrideContractMeta(
                chapter=2,
                constraint_type="SOFT_HOOK_STRENGTH",
                constraint_id="hook_strength",
                rationale_type="ARC_TIMING",
                rationale_text="节奏安排",
                payback_plan="后续补强",
                due_chapter=4,
                status="pending",
            )
        )

        debt1 = ChaseDebtMeta(
            debt_type="hook_strength",
            original_amount=1.0,
            current_amount=1.0,
            interest_rate=0.1,
            source_chapter=1,
            due_chapter=2,
            override_contract_id=debt_contract_id,
            status="active",
        )
        debt2 = ChaseDebtMeta(
            debt_type="micropayoff",
            original_amount=2.0,
            current_amount=2.0,
            interest_rate=0.2,
            source_chapter=1,
            due_chapter=2,
            override_contract_id=debt_contract_id,
            status="active",
        )
        debt_id_1 = manager.create_debt(debt1)
        debt_id_2 = manager.create_debt(debt2)
        assert len(manager.get_active_debts()) == 2
        assert manager.get_total_debt_balance() > 0

        # 计息与幂等保护
        result = manager.accrue_interest(current_chapter=2)
        assert result["debts_processed"] == 2
        result_again = manager.accrue_interest(current_chapter=2)
        assert result_again["skipped_already_processed"] == 2

        # 逾期标记
        result_overdue = manager.accrue_interest(current_chapter=3)
        assert result_overdue["new_overdues"] >= 1
        overdue = manager.get_overdue_debts(current_chapter=3)
        assert any(d["status"] == "overdue" for d in overdue)
        history = manager.get_debt_history(debt_id_1)
        assert any(h["event_type"] == "interest_accrued" for h in history)

        # 金额校验
        error = manager.pay_debt(debt_id_1, 0, chapter=3)
        assert "error" in error

        # 部分偿还
        partial = manager.pay_debt(debt_id_1, 0.5, chapter=3)
        assert partial["fully_paid"] is False

        # 完全偿还（仍有另一笔债务时不应 fulfilled）
        full = manager.pay_debt(debt_id_1, 100, chapter=3)
        assert full["fully_paid"] is True
        assert full["override_fulfilled"] is False

        # 清空最后一笔债务 -> fulfilled
        full2 = manager.pay_debt(debt_id_2, 100, chapter=3)
        assert full2["fully_paid"] is True
        assert full2["override_fulfilled"] is True

    def test_reading_power_and_debt_summary(self, temp_project):
        manager = IndexManager(temp_project)

        # 追读力元数据
        manager.save_chapter_reading_power(
            ChapterReadingPowerMeta(
                chapter=1,
                hook_type="渴望钩",
                hook_strength="strong",
                coolpoint_patterns=["打脸权威", "身份掉马"],
                micropayoffs=["能力兑现"],
                hard_violations=[],
                soft_suggestions=["SOFT_HOOK_STRENGTH"],
                is_transition=False,
                override_count=1,
                debt_balance=1.5,
            )
        )
        manager.save_chapter_reading_power(
            ChapterReadingPowerMeta(
                chapter=2,
                hook_type="悬念钩",
                hook_strength="medium",
                coolpoint_patterns=["身份掉马"],
                micropayoffs=["信息兑现"],
                hard_violations=["HARD-004"],
                soft_suggestions=[],
                is_transition=True,
                override_count=0,
                debt_balance=0.0,
            )
        )

        record = manager.get_chapter_reading_power(1)
        assert record["hook_type"] == "渴望钩"
        assert "身份掉马" in record["coolpoint_patterns"]
        assert record["is_transition"] == 0  # SQLite 存储为 0/1
        assert manager.get_chapter_reading_power(999) is None

        recent = manager.get_recent_reading_power(limit=2)
        assert len(recent) == 2

        pattern_stats = manager.get_pattern_usage_stats(last_n_chapters=5)
        assert pattern_stats.get("身份掉马") == 2

        hook_stats = manager.get_hook_type_stats(last_n_chapters=5)
        assert hook_stats.get("渴望钩") == 1

        # 债务汇总
        contract_id = manager.create_override_contract(
            OverrideContractMeta(
                chapter=3,
                constraint_type="SOFT_HOOK_STRENGTH",
                constraint_id="hook_strength",
                rationale_type="ARC_TIMING",
                rationale_text="节奏安排",
                payback_plan="后续补强",
                due_chapter=5,
                status="pending",
            )
        )
        manager.create_debt(
            ChaseDebtMeta(
                debt_type="hook_strength",
                original_amount=1.0,
                current_amount=1.0,
                interest_rate=0.1,
                source_chapter=3,
                due_chapter=4,
                override_contract_id=contract_id,
                status="active",
            )
        )
        manager.create_debt(
            ChaseDebtMeta(
                debt_type="micropayoff",
                original_amount=2.0,
                current_amount=2.0,
                interest_rate=0.1,
                source_chapter=3,
                due_chapter=4,
                override_contract_id=0,
                status="overdue",
            )
        )

        summary = manager.get_debt_summary()
        assert summary["active_debts"] == 1
        assert summary["overdue_debts"] == 1
        assert summary["pending_overrides"] >= 1
        assert summary["total_balance"] == summary["active_total"] + summary["overdue_total"]

        pending = manager.get_pending_overrides()
        assert any(o["id"] == contract_id for o in pending)
        pending_before = manager.get_pending_overrides(before_chapter=10)
        assert any(o["id"] == contract_id for o in pending_before)
        overdue_overrides = manager.get_overdue_overrides(current_chapter=6)
        assert any(o["id"] == contract_id for o in overdue_overrides)

        other_id = manager.create_override_contract(
            OverrideContractMeta(
                chapter=4,
                constraint_type="SOFT_EXPECTATION_OVERLOAD",
                constraint_id="expectation_count",
                rationale_type="EDITORIAL_INTENT",
                rationale_text="作者意图",
                payback_plan="后续补足",
                due_chapter=6,
                status="pending",
            )
        )
        assert manager.fulfill_override(other_id) is True
        assert manager.get_chapter_overrides(4)[0]["status"] == "fulfilled"

    def test_review_metrics_and_trends(self, temp_project):
        manager = IndexManager(temp_project)

        manager.save_review_metrics(
            ReviewMetrics(
                start_chapter=1,
                end_chapter=1,
                overall_score=48,
                dimension_scores={
                    "爽点密度": 8,
                    "设定一致性": 7,
                    "节奏控制": 7,
                    "人物塑造": 8,
                    "连贯性": 9,
                    "追读力": 9,
                },
                severity_counts={"critical": 0, "high": 1, "medium": 2, "low": 0},
                critical_issues=[],
                report_file="审查报告/第1-1章审查报告.md",
            )
        )
        manager.save_review_metrics(
            ReviewMetrics(
                start_chapter=2,
                end_chapter=2,
                overall_score=42,
                dimension_scores={
                    "爽点密度": 6,
                    "设定一致性": 8,
                    "节奏控制": 7,
                    "人物塑造": 7,
                    "连贯性": 7,
                    "追读力": 7,
                },
                severity_counts={"critical": 1, "high": 0, "medium": 1, "low": 2},
                critical_issues=["设定自相矛盾"],
                report_file="审查报告/第2-2章审查报告.md",
            )
        )

        recent = manager.get_recent_review_metrics(limit=2)
        assert len(recent) == 2

        trends = manager.get_review_trend_stats(last_n=5)
        assert trends["count"] == 2
        assert trends["overall_avg"] > 0
        assert "爽点密度" in trends["dimension_avg"]

    def test_writing_checklist_score_persistence_and_trend(self, temp_project):
        manager = IndexManager(temp_project)

        manager.save_writing_checklist_score(
            WritingChecklistScoreMeta(
                chapter=10,
                template="plot",
                total_items=6,
                required_items=4,
                completed_items=4,
                completed_required=3,
                total_weight=6.2,
                completed_weight=4.1,
                completion_rate=0.6667,
                score=78.5,
                score_breakdown={"weighted_completion_rate": 0.66},
                pending_items=["段末留钩"],
            )
        )
        manager.save_writing_checklist_score(
            WritingChecklistScoreMeta(
                chapter=11,
                template="plot",
                total_items=6,
                required_items=4,
                completed_items=5,
                completed_required=4,
                total_weight=6.2,
                completed_weight=5.4,
                completion_rate=0.8333,
                score=86.0,
                score_breakdown={"weighted_completion_rate": 0.87},
                pending_items=[],
            )
        )

        one = manager.get_writing_checklist_score(10)
        assert one is not None
        assert one["chapter"] == 10
        assert one["score"] == 78.5

        recent = manager.get_recent_writing_checklist_scores(limit=2)
        assert len(recent) == 2
        assert recent[0]["chapter"] == 11

        trend = manager.get_writing_checklist_score_trend(last_n=5)
        assert trend["count"] == 2
        assert trend["score_avg"] > 0
        assert trend["completion_avg"] > 0

    def test_index_manager_cli(self, temp_project, monkeypatch, capsys):
        root = str(temp_project.project_root)
        manager = IndexManager(temp_project)

        # 基础数据
        manager.upsert_entity(
            EntityMeta(
                id="xiaoyan",
                type="角色",
                canonical_name="萧炎",
                tier="核心",
                current={"realm": "斗者"},
                first_appearance=1,
                last_appearance=1,
                is_protagonist=True,
            )
        )
        manager.upsert_entity(
            EntityMeta(
                id="yaolao",
                type="角色",
                canonical_name="药老",
                tier="重要",
                current={},
                first_appearance=1,
                last_appearance=2,
            )
        )

        manager.register_alias("炎帝", "xiaoyan", "角色")
        manager.add_chapter(
            ChapterMeta(
                chapter=1,
                title="起点",
                location="天云宗",
                word_count=1000,
                characters=["xiaoyan"],
            )
        )
        manager.add_scenes(
            1,
            [
                SceneMeta(
                    chapter=1,
                    scene_index=1,
                    start_line=1,
                    end_line=20,
                    location="天云宗·闭关室",
                    summary="闭关",
                    characters=["xiaoyan"],
                )
            ],
        )
        manager.record_appearance("xiaoyan", 1, ["萧炎"], 1.0)
        manager.record_state_change(
            StateChangeMeta(
                entity_id="xiaoyan",
                field="realm",
                old_value="斗者",
                new_value="斗师",
                reason="突破",
                chapter=1,
            )
        )
        manager.upsert_relationship(
            RelationshipMeta(
                from_entity="xiaoyan",
                to_entity="yaolao",
                type="师徒",
                description="收徒",
                chapter=1,
            )
        )

        # 追读力与债务
        manager.save_chapter_reading_power(
            ChapterReadingPowerMeta(
                chapter=1,
                hook_type="渴望钩",
                hook_strength="medium",
                coolpoint_patterns=["身份掉马"],
                micropayoffs=["能力兑现"],
                hard_violations=[],
                soft_suggestions=[],
            )
        )
        contract_id = manager.create_override_contract(
            OverrideContractMeta(
                chapter=1,
                constraint_type="SOFT_HOOK_STRENGTH",
                constraint_id="hook_strength",
                rationale_type="ARC_TIMING",
                rationale_text="节奏安排",
                payback_plan="后续补强",
                due_chapter=2,
                status="pending",
            )
        )
        debt_id = manager.create_debt(
            ChaseDebtMeta(
                debt_type="hook_strength",
                original_amount=1.0,
                current_amount=1.0,
                interest_rate=0.1,
                source_chapter=1,
                due_chapter=2,
                override_contract_id=contract_id,
                status="active",
            )
        )

        def run_cli(args):
            monkeypatch.setattr(sys, "argv", ["index_manager"] + args)
            index_manager_module.main()

        # 基础命令
        run_cli(["--project-root", root, "stats"])
        run_cli(["--project-root", root, "get-chapter", "--chapter", "1"])
        run_cli(["--project-root", root, "get-chapter", "--chapter", "99"])
        run_cli(["--project-root", root, "recent-appearances", "--limit", "5"])
        run_cli(["--project-root", root, "entity-appearances", "--entity", "xiaoyan", "--limit", "5"])
        run_cli(["--project-root", root, "search-scenes", "--location", "天云宗", "--limit", "5"])

        # 处理章节
        run_cli(
            [
                "--project-root",
                root,
                "process-chapter",
                "--chapter",
                "2",
                "--title",
                "试炼",
                "--location",
                "秘境",
                "--word-count",
                "1200",
                "--entities",
                json.dumps([{"id": "xiaoyan", "mentions": ["萧炎"]}], ensure_ascii=False),
                "--scenes",
                json.dumps(
                    [
                        {
                            "index": 1,
                            "start_line": 1,
                            "end_line": 10,
                            "location": "秘境",
                            "summary": "开场",
                            "characters": ["xiaoyan"],
                        }
                    ],
                    ensure_ascii=False,
                ),
            ]
        )

        # v5.1 命令
        run_cli(["--project-root", root, "get-entity", "--id", "xiaoyan"])
        run_cli(["--project-root", root, "get-entity", "--id", "missing"])
        run_cli(["--project-root", root, "get-core-entities"])
        run_cli(["--project-root", root, "get-protagonist"])
        run_cli(
            ["--project-root", root, "get-entities-by-type", "--type", "角色", "--include-archived"]
        )
        run_cli(["--project-root", root, "get-by-alias", "--alias", "炎帝"])
        run_cli(["--project-root", root, "get-by-alias", "--alias", "不存在"])
        run_cli(["--project-root", root, "get-aliases", "--entity", "xiaoyan"])
        run_cli(["--project-root", root, "register-alias", "--alias", "炎哥", "--entity", "xiaoyan", "--type", "角色"])
        run_cli(["--project-root", root, "get-relationships", "--entity", "xiaoyan", "--direction", "from"])
        run_cli(["--project-root", root, "get-state-changes", "--entity", "xiaoyan", "--limit", "20"])
        run_cli(
            [
                "--project-root",
                root,
                "upsert-entity",
                "--data",
                json.dumps(
                    {
                        "id": "lintian",
                        "type": "角色",
                        "canonical_name": "林天",
                        "tier": "装饰",
                        "current": {"realm": "斗者"},
                    },
                    ensure_ascii=False,
                ),
            ]
        )
        run_cli(
            [
                "--project-root",
                root,
                "upsert-relationship",
                "--data",
                json.dumps(
                    {
                        "from_entity": "xiaoyan",
                        "to_entity": "lintian",
                        "type": "相识",
                        "description": "初见",
                        "chapter": 2,
                    },
                    ensure_ascii=False,
                ),
            ]
        )
        run_cli(
            [
                "--project-root",
                root,
                "record-state-change",
                "--data",
                json.dumps(
                    {
                        "entity_id": "xiaoyan",
                        "field": "realm",
                        "old_value": "斗者",
                        "new_value": "斗师",
                        "reason": "突破",
                        "chapter": 2,
                    },
                    ensure_ascii=False,
                ),
            ]
        )

        # v5.3 命令
        run_cli(["--project-root", root, "get-debt-summary"])
        run_cli(["--project-root", root, "get-recent-reading-power", "--limit", "5"])
        run_cli(["--project-root", root, "get-chapter-reading-power", "--chapter", "1"])
        run_cli(["--project-root", root, "get-chapter-reading-power", "--chapter", "99"])
        run_cli(["--project-root", root, "get-pattern-usage-stats", "--last-n", "5"])
        run_cli(["--project-root", root, "get-hook-type-stats", "--last-n", "5"])
        run_cli(["--project-root", root, "get-pending-overrides"])
        run_cli(["--project-root", root, "get-overdue-overrides", "--current-chapter", "3"])
        run_cli(["--project-root", root, "get-active-debts"])
        run_cli(["--project-root", root, "get-overdue-debts", "--current-chapter", "3"])
        run_cli(["--project-root", root, "accrue-interest", "--current-chapter", "3"])
        run_cli(["--project-root", root, "pay-debt", "--debt-id", str(debt_id), "--amount", "0", "--chapter", "3"])
        run_cli(["--project-root", root, "pay-debt", "--debt-id", str(debt_id), "--amount", "5", "--chapter", "3"])
        run_cli(
            [
                "--project-root",
                root,
                "create-override-contract",
                "--data",
                json.dumps(
                    {
                        "chapter": 3,
                        "constraint_type": "SOFT_MICROPAYOFF",
                        "constraint_id": "micropayoff_count",
                        "rationale_type": "TRANSITIONAL_SETUP",
                        "rationale_text": "铺垫",
                        "payback_plan": "后续补偿",
                        "due_chapter": 4,
                    },
                    ensure_ascii=False,
                ),
            ]
        )
        run_cli(
            [
                "--project-root",
                root,
                "create-debt",
                "--data",
                json.dumps(
                    {
                        "debt_type": "micropayoff",
                        "original_amount": 1.0,
                        "current_amount": 1.0,
                        "interest_rate": 0.1,
                        "source_chapter": 3,
                        "due_chapter": 4,
                        "override_contract_id": contract_id,
                    },
                    ensure_ascii=False,
                ),
            ]
        )
        run_cli(["--project-root", root, "fulfill-override", "--contract-id", str(contract_id)])
        run_cli(
            [
                "--project-root",
                root,
                "save-chapter-reading-power",
                "--data",
                json.dumps(
                    {
                        "chapter": 3,
                        "hook_type": "悬念钩",
                        "hook_strength": "medium",
                        "coolpoint_patterns": ["打脸权威"],
                        "micropayoffs": ["信息兑现"],
                        "hard_violations": [],
                        "soft_suggestions": [],
                        "is_transition": False,
                        "override_count": 0,
                        "debt_balance": 0.0,
                    },
                    ensure_ascii=False,
                ),
            ]
        )

        review_payload = {
            "start_chapter": 1,
            "end_chapter": 1,
            "overall_score": 50,
            "dimension_scores": {
                "爽点密度": 8,
                "设定一致性": 7,
                "节奏控制": 8,
                "人物塑造": 8,
                "连贯性": 9,
                "追读力": 10,
            },
            "severity_counts": {"critical": 0, "high": 1, "medium": 2, "low": 0},
            "critical_issues": [],
            "report_file": "审查报告/第1-1章审查报告.md",
        }
        run_cli(
            [
                "--project-root",
                root,
                "save-review-metrics",
                "--data",
                json.dumps(review_payload, ensure_ascii=False),
            ]
        )
        run_cli(["--project-root", root, "get-recent-review-metrics", "--limit", "5"])
        run_cli(["--project-root", root, "get-review-trend-stats", "--last-n", "5"])

        checklist_payload = {
            "chapter": 5,
            "template": "plot",
            "total_items": 6,
            "required_items": 4,
            "completed_items": 4,
            "completed_required": 3,
            "total_weight": 6.5,
            "completed_weight": 4.8,
            "completion_rate": 0.6667,
            "score": 79.2,
            "score_breakdown": {"weighted_completion_rate": 0.73},
            "pending_items": ["钩子差异化"],
            "source": "context_manager",
        }
        run_cli(
            [
                "--project-root",
                root,
                "save-writing-checklist-score",
                "--data",
                json.dumps(checklist_payload, ensure_ascii=False),
            ]
        )
        run_cli(["--project-root", root, "get-writing-checklist-score", "--chapter", "5"])
        run_cli(["--project-root", root, "get-writing-checklist-score", "--chapter", "99"])
        run_cli(["--project-root", root, "get-recent-writing-checklist-scores", "--limit", "5"])
        run_cli(["--project-root", root, "get-writing-checklist-score-trend", "--last-n", "5"])

        capsys.readouterr()

    def test_index_manager_cli_rejects_json_file_outside_resolved_book_root(
        self, tmp_path, monkeypatch
    ):
        workspace = tmp_path / "workspace"
        workspace.mkdir()
        (workspace / ".git").mkdir()
        (workspace / ".claude").mkdir()
        project_root = workspace / "book"
        config = DataModulesConfig.from_project_root(project_root)
        config.ensure_dirs()
        _ensure_project_state(config)
        write_current_project_pointer(project_root, workspace_root=workspace)

        entities = project_root / "entities.json"
        outside_scenes = workspace / "scenes.json"
        entities.write_text("[]", encoding="utf-8")
        outside_scenes.write_text("[]", encoding="utf-8")

        monkeypatch.setattr(
            sys,
            "argv",
            [
                "index_manager",
                "--project-root",
                str(workspace),
                "process-chapter",
                "--chapter",
                "2",
                "--title",
                "试炼",
                "--location",
                "秘境",
                "--word-count",
                "1200",
                "--entities",
                f"@{entities}",
                "--scenes",
                f"@{outside_scenes}",
            ],
        )

        with pytest.raises(ValueError, match="outside allowed directory"):
            index_manager_module.main()


class TestStyleSampler:
    """风格样本测试"""

    def test_add_and_get_sample(self, temp_project):
        sampler = StyleSampler(temp_project)

        sample = StyleSample(
            id="ch100_s1",
            chapter=100,
            scene_type="战斗",
            content="萧炎一拳轰出...",
            score=0.85,
            tags=["战斗", "激烈"]
        )
        assert sampler.add_sample(sample)

        results = sampler.get_samples_by_type("战斗")
        assert len(results) == 1
        assert results[0].id == "ch100_s1"

    def test_extract_candidates(self, temp_project):
        sampler = StyleSampler(temp_project)

        scenes = [
            {"index": 1, "summary": "战斗场景", "content": "萧炎一拳轰出，斗气如虹，直接将对手击退三丈，周围的空气都被震得嗡嗡作响..." + "a" * 200}
        ]

        # 低分不提取
        candidates = sampler.extract_candidates(100, "", 70, scenes)
        assert len(candidates) == 0

        # 高分提取
        candidates = sampler.extract_candidates(100, "", 85, scenes)
        assert len(candidates) == 1
        assert candidates[0].scene_type == "战斗"

    def test_select_samples_for_chapter(self, temp_project):
        sampler = StyleSampler(temp_project)

        # 添加一些样本
        for i in range(3):
            sampler.add_sample(StyleSample(
                id=f"battle_{i}",
                chapter=i,
                scene_type="战斗",
                content=f"战斗内容 {i}",
                score=0.9,
                tags=[]
            ))

        samples = sampler.select_samples_for_chapter("本章有一场激烈的战斗")
        assert len(samples) <= 3
        assert all(s.scene_type == "战斗" for s in samples)


class TestRAGAdapter:
    """RAG 适配器测试（不包含 API 调用）"""

    def test_bm25_search(self, temp_project):
        adapter = RAGAdapter(temp_project)

        # 手动插入一些测试数据
        with adapter._get_conn() as conn:
            cursor = conn.cursor()

            # 插入向量记录（空向量，只测试 BM25）
            cursor.execute("""
                INSERT INTO vectors (chunk_id, chapter, scene_index, content, embedding)
                VALUES (?, ?, ?, ?, ?)
            """, ("ch1_s1", 1, 1, "萧炎在天云宗修炼斗气", b""))

            cursor.execute("""
                INSERT INTO vectors (chunk_id, chapter, scene_index, content, embedding)
                VALUES (?, ?, ?, ?, ?)
            """, ("ch1_s2", 1, 2, "药老传授炼药技巧", b""))

            conn.commit()

            # 更新 BM25 索引
            adapter._update_bm25_index(cursor, "ch1_s1", "萧炎在天云宗修炼斗气")
            adapter._update_bm25_index(cursor, "ch1_s2", "药老传授炼药技巧")
            conn.commit()

        # BM25 搜索
        results = adapter.bm25_search("萧炎修炼", top_k=5)
        assert len(results) >= 1
        assert results[0].chunk_id == "ch1_s1"

    def test_tokenize(self, temp_project):
        adapter = RAGAdapter(temp_project)

        tokens = adapter._tokenize("萧炎hello世界world")
        assert "萧" in tokens
        assert "炎" in tokens
        assert "hello" in tokens
        assert "world" in tokens


if __name__ == "__main__":
    pytest.main([__file__, "-v"])
