"""auto_classify.py 单元测试 — 分类 / 置信度 / scope 提取 / context_json"""

import json

import pytest

from index1.cognitive.auto_classify import (
    build_context_json,
    classify_category,
    estimate_confidence,
    extract_scope,
)


# ── classify_category ──


class TestClassifyCategory:
    def test_root_cause_english(self):
        assert classify_category("the issue was caused by a race condition") == "root_cause"

    def test_root_cause_because(self):
        assert classify_category("it failed because the timeout was too short") == "root_cause"

    def test_root_cause_chinese(self):
        assert classify_category("原因是 FTS5 索引没有同步") == "root_cause"

    def test_decision_english(self):
        assert classify_category("chose SQLite instead of PostgreSQL") == "decision"

    def test_decision_chinese(self):
        assert classify_category("决定采用 ONNX 而不是 Ollama") == "decision"

    def test_decision_we_should(self):
        assert classify_category("we should use WAL mode for concurrency") == "decision"

    def test_revert(self):
        assert classify_category("reverted the migration to previous state") == "revert"

    def test_revert_chinese(self):
        assert classify_category("回滚了上次的改动") == "revert"

    def test_tradeoff(self):
        assert classify_category("tradeoff: faster indexing but more memory") == "tradeoff"

    def test_tradeoff_chinese(self):
        assert classify_category("需要权衡速度和内存占用") == "tradeoff"

    def test_architecture(self):
        assert classify_category("the auth module depends on JWT validation") == "architecture"

    def test_architecture_chinese(self):
        assert classify_category("搜索模块分层设计") == "architecture"

    def test_config(self):
        assert classify_category("port 8080 is configured in .env") == "config"

    def test_config_chinese(self):
        assert classify_category("修改了 embedding 配置") == "config"

    def test_default_code(self):
        assert classify_category("added a new helper function") == "code"

    def test_default_code_plain(self):
        assert classify_category("fixed the search result ordering") == "code"

    def test_empty_string(self):
        assert classify_category("") == "code"

    def test_priority_root_cause_over_decision(self):
        # "because" 优先于 "decided"
        assert classify_category("decided to revert because it caused bugs") == "root_cause"


# ── estimate_confidence ──


class TestEstimateConfidence:
    def test_base_confidence(self):
        assert estimate_confidence("some generic observation") == 0.80

    def test_boost_file_path(self):
        conf = estimate_confidence("issue in src/index1/db.py with locking")
        assert conf == pytest.approx(0.85)

    def test_boost_code_reference(self):
        conf = estimate_confidence("the `insert_fact()` method needs retry")
        assert conf == pytest.approx(0.85)

    def test_boost_causal(self):
        conf = estimate_confidence("failed because the timeout was 5s")
        assert conf == pytest.approx(0.85)

    def test_boost_context(self):
        conf = estimate_confidence(
            "some observation",
            context="this happened during the migration step",
        )
        assert conf == pytest.approx(0.85)

    def test_multiple_boosts(self):
        conf = estimate_confidence(
            "src/db.py `search()` failed because of lock timeout",
            context="found during stress testing the watcher",
        )
        assert conf == 0.95  # all 4 signals, capped at 0.95

    def test_cap_at_095(self):
        conf = estimate_confidence(
            "src/db.py `search()` failed because the lock caused by concurrent writes",
            context="this is very detailed context with many words",
        )
        assert conf == 0.95

    def test_empty_context_no_boost(self):
        conf = estimate_confidence("observation", context="")
        assert conf == 0.80

    def test_short_context_no_boost(self):
        conf = estimate_confidence("observation", context="short")
        assert conf == 0.80


# ── extract_scope ──


class TestExtractScope:
    def test_extract_file_paths(self):
        result = extract_scope("modified src/index1/db.py and tests/test_db.py")
        assert "src/index1/db.py" in result["scope_files"]
        assert "tests/test_db.py" in result["scope_files"]

    def test_extract_backtick_entities(self):
        result = extract_scope("the `Database` class and `insert_fact` method")
        assert "Database" in result["scope_entities"]
        assert "insert_fact" in result["scope_entities"]

    def test_extract_pascal_case(self):
        result = extract_scope("issue with CogDatabase and ServiceCooldown")
        assert "CogDatabase" in result["scope_entities"]
        assert "ServiceCooldown" in result["scope_entities"]

    def test_extract_modules(self):
        result = extract_scope("bug in src/index1/cognitive/hooks.py")
        assert "cognitive" in result["scope_modules"]

    def test_no_src_in_modules(self):
        result = extract_scope("modified src/index1/db.py")
        assert "src" not in result["scope_modules"]

    def test_empty_text(self):
        result = extract_scope("nothing special here")
        assert result["scope_files"] == []
        assert result["scope_entities"] == []
        assert result["scope_modules"] == []

    def test_dedup_files(self):
        result = extract_scope("read src/db.py then edited src/db.py again")
        assert result["scope_files"].count("src/db.py") == 1

    def test_backtick_filter_short(self):
        # 单字符 backtick 被过滤
        result = extract_scope("variable `x` is unused")
        assert "x" not in result["scope_entities"]


# ── build_context_json ──


class TestBuildContextJson:
    def test_empty_context(self):
        result = json.loads(build_context_json("insight", ""))
        assert result == {"source": "learn_tool"}

    def test_plain_text_context(self):
        result = json.loads(build_context_json("insight", "some extra info"))
        assert result["source"] == "learn_tool"
        assert result["user_context"] == "some extra info"

    def test_valid_json_context(self):
        ctx = json.dumps({"commit": "abc123", "branch": "dev"})
        result = json.loads(build_context_json("insight", ctx))
        assert result["source"] == "learn_tool"
        assert result["commit"] == "abc123"
        assert result["branch"] == "dev"

    def test_json_array_context(self):
        # JSON array 不是 dict，存为 user_context
        ctx = json.dumps(["a", "b"])
        result = json.loads(build_context_json("insight", ctx))
        assert result["source"] == "learn_tool"
        assert result["user_context"] == ctx

    def test_source_always_present(self):
        # 即使用户 JSON 里有 source，也被覆盖... 不，update 先于 source 设置
        # 实际上 result 先设置 source，再 update 用户数据
        # 如果用户传了 source，会被用户的覆盖 — 这是预期行为
        ctx = json.dumps({"source": "user_custom"})
        result = json.loads(build_context_json("insight", ctx))
        # 用户的 source 会覆盖默认的 learn_tool（因为 update 在后）
        assert "source" in result


# ── Worldview 分类覆盖率 + 假阳性评估 ──
# Issue #106 评估标准：覆盖率 > 80%，假阳性 < 20%


class TestWorldviewCoverage:
    """评估 constraint/preference 分类的覆盖率（真实风格测试数据）

    覆盖率 = 正确分类为 constraint/preference 的数量 / 应分类为 constraint/preference 的总数
    """

    # 应被分类为 constraint 的真实表述（30 条）
    TRUE_CONSTRAINTS = [
        # ── 英文显式关键词 ──
        "Must always use parameterized queries for SQL",
        "Never commit .env files to the repository",
        "must not use global mutable state in hooks",
        "Always validate input at system boundaries",
        "Never expose internal stack traces to API consumers",
        "Must use HTTPS for all external API calls",
        "Always close database connections in finally blocks",
        "Must not store passwords in plaintext",
        "Never use eval() on untrusted input",
        "Always run migrations inside a transaction",
        # ── 中文显式关键词 ──
        "必须使用离线优先设计",
        "禁止在 orient hook 中调用网络",
        "不允许使用 eval() 执行用户输入",
        "强制使用 WAL 模式",
        "绝不在热路径中做 embedding",
        "一定要用 safe_db_write 包裹写操作",
        "禁止跨 index.db 和 cognitive.db 的事务",
        "不可在阻塞 Hook 中做网络请求",
        "必须使用参数化查询，禁止拼接 SQL",
        "不准许在生产环境使用 debug 模式",
        # ── 中英混合 / 边界语气 ──
        "Must never skip pre-commit hooks without explicit approval",
        "绝不允许 force push 到 main 分支",
        "Always ensure backward compatibility when changing APIs",
        "必须在 error boundary 内处理 MCP tool 异常",
        "Never cache degraded or empty search results",
        "不能在 observe hook 做 embedding 调用",
        "Must always have L0 fallback for every feature",
        "禁止使用裸 except 捕获异常",
        "Always use context managers for file I/O",
        "不允许在热路径中引入超过 50ms 的阻塞操作",
    ]

    # 隐含约束 — 没有 must/never 关键词但实质是约束（20 条）
    IMPLICIT_CONSTRAINTS = [
        "We use SQLite, not ORM — this is a hard rule",
        "Hook 进程崩溃时只能 exit 0",
        "所有 DB 写操作必须经过 safe_db_write",  # 有"必须"
        "observe 不做 embedding，留给 orient backfill",
        "maintenance 每步独立 try-except，单步失败不阻断",
        "awaken 注入不超过 150 tokens",
        "orient 延迟预算 210ms 以内",
        "双数据库隔离，index.db 和 cognitive.db 互不影响",
        "MCP tool 异常返回 error dict，不抛异常",
        "降级结果和空结果不缓存",
        # 新增
        "数据采集只信任 Hook 自动采集和 L0 规则推断",
        "每层降级独立工作，上层挂了不影响下层",
        "watcher 补偿扫描不阻塞正常文件监听",
        "reflect 只启动子进程，立即返回",
        "索引事务原子化，扫描到入库一个事务",
        "FTS5 和 sqlite-vec 索引同步更新",
        "搜索缓存 key 区分 L1 response 和 L2 embedding",
        "所有 MCP tool 返回 dict 格式不抛异常",
        "Hooks exit 0 on any failure, never propagate to Claude Code",
        "Circuit breaker buffer replays automatically on recovery",
    ]

    # 应被分类为 preference 的真实表述（20 条）
    TRUE_PREFERENCES = [
        "Prefer pytest over unittest",
        "推荐使用 asyncio 而非 threading",
        "Recommended to use WAL mode for concurrency",
        "优先使用 ONNX embedding 而非 Ollama",
        "偏好用 Click 做 CLI 而非 argparse",
        "Prefer BM25 + vector hybrid over pure vector search",
        "建议使用 FastMCP 而非原始 MCP SDK",
        "Preference for incremental indexing over full rebuild",
        # 新增
        "Prefer small focused commits over large monolithic ones",
        "推荐使用 context manager 管理数据库连接",
        "Preferred approach is to use RRF fusion for search ranking",
        "优先选择 tree-sitter 而非 Pygments 做符号提取",
        "建议使用 subprocess.Popen 而非 daemon thread",
        "Recommended to keep hook injection under 100 tokens",
        "Prefer explicit error types over bare Exception",
        "倾向于用 SQLite FTS5 而非外部搜索引擎",
        "优先考虑零配置方案，用户不装 Ollama 也能用",
        "Prefer L0 rule-based extraction over LLM-dependent approaches",
        "推荐使用 safe_db_transaction 做批量写入",
        "Preference for schema evolution via ALTER TABLE over migration tools",
    ]

    def test_explicit_constraint_coverage(self):
        """显式约束（有 must/never/禁止 等关键词）应 100% 覆盖"""
        hits = sum(
            1 for t in self.TRUE_CONSTRAINTS
            if classify_category(t) == "constraint"
        )
        coverage = hits / len(self.TRUE_CONSTRAINTS)
        assert coverage >= 0.9, (
            f"显式 constraint 覆盖率 {coverage:.0%} < 90%，"
            f"漏检: {[t for t in self.TRUE_CONSTRAINTS if classify_category(t) != 'constraint']}"
        )

    def test_implicit_constraint_coverage(self):
        """隐含约束（无显式关键词）的覆盖率统计

        这些是正则难以捕获的，记录实际覆盖率作为基线。
        当前预期：部分命中（有"必须"的会命中）。
        """
        hits = sum(
            1 for t in self.IMPLICIT_CONSTRAINTS
            if classify_category(t) in ("constraint", "preference")
        )
        total = len(self.IMPLICIT_CONSTRAINTS)
        coverage = hits / total
        # 记录基线，不设硬性阈值（隐含约束本来就难）
        # 但至少有"必须"关键词的应该命中
        must_hit = sum(
            1 for t in self.IMPLICIT_CONSTRAINTS
            if "必须" in t and classify_category(t) == "constraint"
        )
        assert must_hit > 0, "含'必须'的隐含约束应被分类为 constraint"

    def test_preference_coverage(self):
        """preference 表述覆盖率应 >= 80%"""
        hits = sum(
            1 for t in self.TRUE_PREFERENCES
            if classify_category(t) == "preference"
        )
        coverage = hits / len(self.TRUE_PREFERENCES)
        assert coverage >= 0.8, (
            f"preference 覆盖率 {coverage:.0%} < 80%，"
            f"漏检: {[t for t in self.TRUE_PREFERENCES if classify_category(t) != 'preference']}"
        )

    def test_overall_worldview_coverage(self):
        """总体覆盖率（显式 constraint + preference）>= 80%"""
        all_worldview = self.TRUE_CONSTRAINTS + self.TRUE_PREFERENCES
        hits = sum(
            1 for t in all_worldview
            if classify_category(t) in ("constraint", "preference")
        )
        coverage = hits / len(all_worldview)
        assert coverage >= 0.8, f"总体 worldview 覆盖率 {coverage:.0%} < 80%"


class TestWorldviewFalsePositives:
    """评估 constraint/preference 分类的假阳性率

    假阳性 = 被错误分类为 constraint/preference 的数量 / 被分类为 constraint/preference 的总数
    """

    # 明确不是 constraint/preference 的表述（50 条）
    NOT_WORLDVIEW = [
        # ── root_cause (10) ──
        "The bug was caused by a race condition in the lock",
        "原因是 FTS5 索引没有同步更新",
        "Failed because the timeout was set too short",
        "Issue caused by missing null check in update_fact",
        "问题出在 WAL checkpoint 没有执行",
        "Root cause: the watcher thread was killed by OOM",
        "由于 SQLite busy timeout 太短导致写入失败",
        "The crash happened because daemon threads don't join on exit",
        "之所以失败是因为 embedding 维度不匹配",
        "Since the connection pool was exhausted, queries timed out",
        # ── decision (8) ──
        "Chose SQLite instead of PostgreSQL for simplicity",
        "决定采用 ONNX 而不是 Ollama 作为默认后端",
        "We should use WAL mode for better concurrency",
        "Chose to use FastMCP over raw SDK for simpler API",
        "决定用 BM25 做主搜索引擎",
        "We should refactor the search module next sprint",
        "采用增量索引方案而非全量重建",
        "选择 Click 而非 argparse 作为 CLI 框架",
        # ── code — 普通代码描述 (10) ──
        "Added a retry mechanism to the indexer",
        "Fixed the search result ordering bug",
        "Implemented chunker for markdown files",
        "重构了搜索模块的缓存逻辑",
        "Added logging to the maintenance pipeline",
        "修复了 watcher 的崩溃恢复逻辑",
        "Implemented circuit breaker for DB writes",
        "重写了 chunker 的 markdown 解析器",
        "Updated the test fixtures for cog_db",
        "Moved evolve_neighbors from hot path to maintenance",
        # ── architecture (4) ──
        "The auth module depends on JWT validation",
        "搜索模块分层设计，BM25 和向量分开",
        "corpus and cognition use separate SQLite databases",
        "Hook 系统四阶段架构：awaken/orient/observe/reflect",
        # ── config (4) ──
        "Port 8080 is configured in the .env file",
        "修改了 embedding model 的配置参数",
        "Set busy_timeout to 15000ms in SQLite configuration",
        "环境变量 OLLAMA_URL 默认值为 http://localhost:11434",
        # ── tradeoff (4) ──
        "Tradeoff: faster indexing but more memory usage",
        "需要权衡搜索速度和内存占用",
        "Pro: simpler code. Con: slightly slower performance",
        "优点是零依赖，缺点是精度稍低",
        # ── revert (3) ──
        "Reverted the migration to previous state",
        "回滚了上次的 schema 改动",
        "Undo the experimental caching layer",
        # ── pattern（L3a，不是 L3b worldview）(3) ──
        "Every time we skip tests, bugs appear in production",
        "Recurring issue with database connection timeouts",
        "总是在大文件索引时出现内存不足",
        # ── 含关键词但不是约束的边界case (4) ──
        "I've never seen this error before in production",
        "This has always been a tricky area of the codebase",
        "The user preferred the old UI layout over the new one",
        "We have always used this pattern for error handling",
    ]

    def test_false_positive_rate(self):
        """非 worldview 表述被误分类为 constraint/preference 的比率 < 20%"""
        false_positives = [
            t for t in self.NOT_WORLDVIEW
            if classify_category(t) in ("constraint", "preference")
        ]
        fp_rate = len(false_positives) / len(self.NOT_WORLDVIEW)
        assert fp_rate < 0.2, (
            f"假阳性率 {fp_rate:.0%} >= 20%，"
            f"误分类: {[(t, classify_category(t)) for t in false_positives]}"
        )

    def test_root_cause_not_misclassified(self):
        """root_cause 表述不应被分为 constraint/preference"""
        root_causes = [
            "The bug was caused by a race condition",
            "原因是 FTS5 索引没有同步",
            "Issue caused by missing null check",
            "问题出在 WAL checkpoint 没有执行",
            "Root cause: connection pool exhaustion under load",
            "由于缓存中毒导致搜索结果不准确",
        ]
        for text in root_causes:
            cat = classify_category(text)
            assert cat != "constraint", f"'{text}' 误分类为 constraint（实际是 {cat}）"
            assert cat != "preference", f"'{text}' 误分类为 preference（实际是 {cat}）"

    def test_decision_not_misclassified(self):
        """decision 表述不应被分为 constraint/preference"""
        decisions = [
            "Chose to use FastMCP over raw SDK",
            "决定用 BM25 做主搜索",
            "We should refactor the search module",
            "采用增量索引方案",
            "选择了 ONNX 作为默认 embedding 后端",
            "决定把 evolve_neighbors 从热路径移到维护进程",
        ]
        for text in decisions:
            cat = classify_category(text)
            assert cat != "constraint", f"'{text}' 误分类为 constraint（实际是 {cat}）"
            assert cat != "preference", f"'{text}' 误分类为 preference（实际是 {cat}）"

    def test_plain_code_not_misclassified(self):
        """普通代码描述不应被分为 constraint/preference"""
        code_facts = [
            "Added logging to the maintenance pipeline",
            "修复了 watcher 的崩溃恢复逻辑",
            "Implemented circuit breaker for DB writes",
            "重写了 chunker 的 markdown 解析器",
            "Updated the test fixtures for cog_db",
            "Extracted shared rules into rules.py module",
            "增加了 10 个 maintenance step 7/8 的测试",
            "Moved worldview query to get_worldview_facts method",
        ]
        for text in code_facts:
            cat = classify_category(text)
            assert cat not in ("constraint", "preference"), (
                f"'{text}' 误分类为 {cat}"
            )

    def test_boundary_cases_with_keywords(self):
        """含 always/never/prefer 关键词但不是约束/偏好的边界 case"""
        boundary = [
            ("I've never seen this error before in production", False),
            ("This has always been a tricky area of the codebase", False),
            ("The user preferred the old UI layout", False),
            ("We have always used this pattern for error handling", False),
            ("It never occurred to me to check the logs", False),
            ("She preferred working on backend tasks", False),
        ]
        false_positives = []
        for text, should_be_worldview in boundary:
            cat = classify_category(text)
            is_worldview = cat in ("constraint", "preference")
            if is_worldview and not should_be_worldview:
                false_positives.append((text, cat))
        # 边界 case 允许部分误判（正则本身有局限），但不应超过 50%
        fp_rate = len(false_positives) / len(boundary)
        assert fp_rate <= 0.5, (
            f"边界 case 假阳性率 {fp_rate:.0%} > 50%，"
            f"误分类: {false_positives}"
        )
