"""core/evolution/evolve.py 单元测试。

覆盖验证函数、编辑预算退火、resolve_skill_file、内部辅助函数、
受保护区构建、JSON 解析、rank_and_clip、格式化工具。
"""

from __future__ import annotations

import asyncio
import json
from unittest.mock import AsyncMock, MagicMock

import pytest

from core.evolution.evolve import (
    _check_code_blocks,
    _check_length,
    _extract_section,
    _format_case_samples,
    _format_rejected_edits,
    _format_spans,
    _parse_frontmatter,
    _parse_llm_json,
    _select_top_edits,
    _skill_protected_spans,
    _strip_appendix_region,
    _strip_momentum_region,
    _strip_protected_regions,
    _system_protected_spans,
    _tool_protected_spans,
    consolidate_appendix,
    edit_budget_at,
    evolve_single_skill,
    evolve_single_tool,
    evolve_system_prompt,
    rank_and_clip,
    resolve_skill_file,
    validate_skill,
    validate_system,
    validate_tool,
)
from core.evolution.patch import (
    APPENDIX_END,
    APPENDIX_START,
    MOMENTUM_END,
    MOMENTUM_START,
)
from core.evolution.types import (
    EvolvePrompts,
    RejectedEdit,
    SkillCasePack,
    SystemCasePack,
    ToolCasePack,
)
from core.types import LLMResponse

# =========================================================================
# A. 内部辅助函数
# =========================================================================


class TestParseFrontmatter:
    """_parse_frontmatter 测试。"""

    def test_valid_frontmatter(self) -> None:
        text = "---\nname: test\ndescription: d\n---\nbody"
        result = _parse_frontmatter(text)
        assert result == {"name": "test", "description": "d"}

    def test_no_frontmatter(self) -> None:
        assert _parse_frontmatter("no frontmatter here") is None

    def test_invalid_yaml(self) -> None:
        text = "---\n: : invalid\n---\nbody"
        assert _parse_frontmatter(text) is None

    def test_empty_frontmatter(self) -> None:
        text = "---\n\n---\nbody"
        result = _parse_frontmatter(text)
        assert result is None  # yaml.safe_load("") returns None


class TestStripAppendixRegion:
    """_strip_appendix_region 测试。"""

    def test_no_markers(self) -> None:
        text = "hello world"
        assert _strip_appendix_region(text) == text

    def test_with_markers(self) -> None:
        text = f"before\n{APPENDIX_START}\nappendix stuff\n{APPENDIX_END}\nafter"
        result = _strip_appendix_region(text)
        assert "appendix stuff" not in result
        assert "before" in result
        assert "after" in result

    def test_only_start_marker(self) -> None:
        text = f"before\n{APPENDIX_START}\nno end marker"
        assert _strip_appendix_region(text) == text

    def test_only_end_marker(self) -> None:
        text = f"before\n{APPENDIX_END}\nno start marker"
        assert _strip_appendix_region(text) == text


class TestStripMomentumRegion:
    """_strip_momentum_region 测试。"""

    def test_no_markers(self) -> None:
        text = "hello world"
        assert _strip_momentum_region(text) == text

    def test_with_markers(self) -> None:
        text = f"before\n{MOMENTUM_START}\nmomentum stuff\n{MOMENTUM_END}\nafter"
        result = _strip_momentum_region(text)
        assert "momentum stuff" not in result
        assert "before" in result
        assert "after" in result

    def test_damaged_markers_raise(self) -> None:
        text = f"before\n{MOMENTUM_START}\nno end marker"
        with pytest.raises(ValueError, match="momentum marker 损坏"):
            _strip_momentum_region(text)


class TestStripProtectedRegions:
    """_strip_protected_regions 测试（先 appendix 后 momentum）。"""

    def test_both_regions(self) -> None:
        text = (
            f"body\n"
            f"{APPENDIX_START}\nappendix\n{APPENDIX_END}\n"
            f"{MOMENTUM_START}\nmomentum\n{MOMENTUM_END}\n"
            f"tail"
        )
        result = _strip_protected_regions(text)
        assert "appendix" not in result
        assert "momentum" not in result
        assert "body" in result
        assert "tail" in result


class TestCheckLength:
    """_check_length 测试。"""

    def test_normal_ratio(self) -> None:
        orig = "x" * 100
        evol = "x" * 120
        assert _check_length(orig, evol) == []

    def test_too_long(self) -> None:
        orig = "x" * 100
        evol = "x" * 300
        errors = _check_length(orig, evol)
        assert len(errors) == 1
        assert "超限" in errors[0]

    def test_too_short(self) -> None:
        orig = "x" * 100
        evol = "x" * 10
        errors = _check_length(orig, evol)
        assert len(errors) == 1
        assert "不足" in errors[0]

    def test_orig_empty(self) -> None:
        assert _check_length("", "something") == []


class TestCheckCodeBlocks:
    """_check_code_blocks 测试。"""

    def test_even_count(self) -> None:
        text = "```python\ncode\n```"
        assert _check_code_blocks(text) == []

    def test_odd_count(self) -> None:
        text = "```python\ncode"
        errors = _check_code_blocks(text)
        assert len(errors) == 1
        assert "未闭合" in errors[0]

    def test_no_blocks(self) -> None:
        assert _check_code_blocks("no code blocks") == []


class TestExtractSection:
    """_extract_section 测试。"""

    def test_found(self) -> None:
        text = "intro\n## 能力边界\nfrozen content\n## other\nmore"
        result = _extract_section(text, "能力边界")
        assert result is not None
        assert "frozen content" in result
        assert "## 能力边界" in result

    def test_not_found(self) -> None:
        text = "intro\n## other\nmore"
        assert _extract_section(text, "不存在") is None

    def test_last_section(self) -> None:
        text = "intro\n## 最后段\ncontent at end"
        result = _extract_section(text, "最后段")
        assert result is not None
        assert "content at end" in result


# =========================================================================
# B. 受保护区构建
# =========================================================================


class TestSkillProtectedSpans:
    """_skill_protected_spans 测试。"""

    def test_with_frontmatter(self) -> None:
        text = "---\nname: test\n---\nbody"
        spans = _skill_protected_spans(text)
        assert any("---" in s for s in spans)

    def test_with_appendix(self) -> None:
        text = f"body\n{APPENDIX_START}\nnotes\n{APPENDIX_END}"
        spans = _skill_protected_spans(text)
        assert any(APPENDIX_START in s for s in spans)

    def test_with_momentum(self) -> None:
        text = f"body\n{MOMENTUM_START}\nmomentum\n{MOMENTUM_END}"
        spans = _skill_protected_spans(text)
        assert any(MOMENTUM_START in s for s in spans)

    def test_empty(self) -> None:
        assert _skill_protected_spans("plain text") == []


class TestSystemProtectedSpans:
    """_system_protected_spans 测试。"""

    def test_frozen_sections(self) -> None:
        text = "intro\n## 能力边界\ncontent1\n## 输出格式\ncontent2\n## 视频树结构\ncontent3\n## other\nmore"
        spans = _system_protected_spans(text)
        assert len(spans) == 3

    def test_with_appendix(self) -> None:
        text = f"body\n{APPENDIX_START}\nnotes\n{APPENDIX_END}"
        spans = _system_protected_spans(text)
        assert len(spans) == 1


class TestToolProtectedSpans:
    """_tool_protected_spans 测试。"""

    def test_output_format_section(self) -> None:
        text = "intro\n## 输出格式\nformat\n## other\nmore"
        spans = _tool_protected_spans(text)
        assert any("输出格式" in s for s in spans)

    def test_no_output_format(self) -> None:
        text = "intro\n## other\nmore"
        spans = _tool_protected_spans(text)
        assert len(spans) == 0


# =========================================================================
# C. 验证函数
# =========================================================================


class TestValidateSkill:
    """validate_skill 测试。"""

    def test_identical_passes(self) -> None:
        content = "---\nname: test\ndescription: d\ntask_type: t\n---\nbody"
        assert validate_skill(content, content).passed

    def test_changed_frontmatter_fails(self) -> None:
        orig = "---\nname: a\ndescription: d\ntask_type: t\n---\nbody"
        evol = "---\nname: b\ndescription: d\ntask_type: t\n---\nbody"
        result = validate_skill(orig, evol)
        assert not result.passed
        assert any("name" in e for e in result.errors)

    def test_length_ratio_too_short_fails(self) -> None:
        orig = "---\nname: a\ndescription: d\ntask_type: t\n---\n" + "x" * 1000
        evol = "---\nname: a\ndescription: d\ntask_type: t\n---\nshort"
        result = validate_skill(orig, evol)
        assert not result.passed
        assert any("不足" in e for e in result.errors)

    def test_length_ratio_too_long_fails(self) -> None:
        orig = "---\nname: a\ndescription: d\ntask_type: t\n---\nshort"
        evol = "---\nname: a\ndescription: d\ntask_type: t\n---\n" + "x" * 1000
        result = validate_skill(orig, evol)
        assert not result.passed
        assert any("超限" in e for e in result.errors)

    def test_unclosed_code_block_fails(self) -> None:
        orig = "---\nname: a\ndescription: d\ntask_type: t\n---\nbody"
        evol = "---\nname: a\ndescription: d\ntask_type: t\n---\nbody\n```"
        result = validate_skill(orig, evol)
        assert not result.passed
        assert any("未闭合" in e for e in result.errors)

    def test_missing_original_frontmatter(self) -> None:
        result = validate_skill("no frontmatter", "no frontmatter")
        assert not result.passed
        assert any("原文缺少" in e for e in result.errors)

    def test_missing_evolved_frontmatter(self) -> None:
        orig = "---\nname: a\ndescription: d\ntask_type: t\n---\nbody"
        result = validate_skill(orig, "no frontmatter body")
        assert not result.passed


class TestValidateSystem:
    """validate_system 测试。"""

    def test_identical_passes(self) -> None:
        content = "intro\n## 能力边界\nfrozen\n## 输出格式\nfrozen2\n## other\nbody"
        assert validate_system(content, content).passed

    def test_changed_frozen_section_fails(self) -> None:
        orig = "intro\n## 能力边界\noriginal\n## other\nbody"
        evol = "intro\n## 能力边界\nchanged\n## other\nbody"
        result = validate_system(orig, evol)
        assert not result.passed
        assert any("能力边界" in e for e in result.errors)

    def test_missing_frozen_section_fails(self) -> None:
        orig = "intro\n## 能力边界\nfrozen\n## other\nbody"
        evol = "intro\n## other\nbody that is similar length content padding"
        result = validate_system(orig, evol)
        assert not result.passed
        assert any("缺失" in e for e in result.errors)

    def test_no_frozen_sections_passes(self) -> None:
        content = "intro\n## other section\nbody content"
        assert validate_system(content, content).passed

    def test_code_block_check(self) -> None:
        content = "## 能力边界\nfrozen\n## other\nbody\n```unclosed"
        result = validate_system(content, content)
        assert not result.passed


class TestValidateTool:
    """validate_tool 测试。"""

    def test_identical_passes(self) -> None:
        extract = "## 输出格式\nfixed\n## other\nbody"
        verify = "## 输出格式\nfixed2\n## other\nbody2"
        assert validate_tool(extract, extract, verify, verify).passed

    def test_no_code_block_check(self) -> None:
        """validate_tool 不检查代码块闭合（与 skill/system 不同）。"""
        extract = "## 输出格式\nfixed\n```\nunclosed"
        assert validate_tool(extract, extract, "v", "v").passed

    def test_changed_output_format_fails(self) -> None:
        orig = "## 输出格式\noriginal format\n## other\nbody"
        evol = "## 输出格式\nchanged format\n## other\nbody"
        result = validate_tool(orig, evol, "v", "v")
        assert not result.passed
        assert any("输出格式" in e for e in result.errors)

    def test_verify_output_format_checked_too(self) -> None:
        extract = "## 输出格式\nfixed\n## other\nbody"
        orig_verify = "## 输出格式\nfixed_v\n## other\nbody_v"
        evol_verify = "## 输出格式\nchanged_v\n## other\nbody_v"
        result = validate_tool(extract, extract, orig_verify, evol_verify)
        assert not result.passed


# =========================================================================
# D. 纯数学
# =========================================================================


class TestEditBudget:
    """edit_budget_at 测试。"""

    def test_start_at_zero(self) -> None:
        assert edit_budget_at(0, 100, 5, 2) == 5

    def test_end_at_total(self) -> None:
        assert edit_budget_at(100, 100, 5, 2) == 2

    def test_total_steps_one(self) -> None:
        assert edit_budget_at(0, 1, 5, 2) == 5

    def test_start_less_than_end_asserts(self) -> None:
        with pytest.raises(AssertionError):
            edit_budget_at(0, 100, 2, 5)

    def test_mid_step(self) -> None:
        result = edit_budget_at(50, 100, 5, 2)
        assert 2 <= result <= 5

    def test_beyond_total_clamped(self) -> None:
        """global_step 超过 total_steps 时被钳住在 end。"""
        assert edit_budget_at(200, 100, 5, 2) == 2

    def test_equal_start_end(self) -> None:
        assert edit_budget_at(50, 100, 3, 3) == 3

    def test_total_steps_zero(self) -> None:
        """total_steps <= 1 直接返回 start。"""
        assert edit_budget_at(0, 0, 5, 2) == 5


# =========================================================================
# E. JSON 解析
# =========================================================================


class TestParseLlmJson:
    """_parse_llm_json 测试。"""

    def test_plain_json(self) -> None:
        raw = '{"key": "value"}'
        assert _parse_llm_json(raw) == {"key": "value"}

    def test_fenced_json(self) -> None:
        raw = 'text before\n```json\n{"key": "value"}\n```\ntext after'
        assert _parse_llm_json(raw) == {"key": "value"}

    def test_non_dict_returns_none(self) -> None:
        raw = "[1, 2, 3]"
        assert _parse_llm_json(raw) is None

    def test_invalid_json_returns_none(self) -> None:
        raw = "not json at all"
        assert _parse_llm_json(raw) is None

    def test_empty_string(self) -> None:
        assert _parse_llm_json("") is None


# =========================================================================
# F. rank_and_clip
# =========================================================================


class TestSelectTopEdits:
    """_select_top_edits 测试。"""

    def test_valid_indices(self) -> None:
        edits = [{"op": "a"}, {"op": "b"}, {"op": "c"}]
        result = _select_top_edits([2, 0], edits, 2)
        assert result == [{"op": "c"}, {"op": "a"}]

    def test_dedup(self) -> None:
        edits = [{"op": "a"}, {"op": "b"}]
        result = _select_top_edits([0, 0, 1], edits, 3)
        assert len(result) == 2

    def test_out_of_bounds_skipped(self) -> None:
        edits = [{"op": "a"}, {"op": "b"}]
        result = _select_top_edits([5, 0, -1], edits, 3)
        assert result == [{"op": "a"}]

    def test_bool_excluded(self) -> None:
        """type(True) is int 返回 True 但 type(idx) is int 排除 bool。"""
        edits = [{"op": "a"}, {"op": "b"}]
        result = _select_top_edits([True, False, 0], edits, 3)
        assert result == [{"op": "a"}]

    def test_max_edits_limit(self) -> None:
        edits = [{"op": "a"}, {"op": "b"}, {"op": "c"}]
        result = _select_top_edits([0, 1, 2], edits, 2)
        assert len(result) == 2


class TestRankAndClip:
    """rank_and_clip 测试。"""

    @pytest.mark.asyncio
    async def test_within_budget_passthrough(self) -> None:
        """edits <= max_edits 时原样返回。"""
        llm = AsyncMock()
        edits = [{"op": "a"}, {"op": "b"}]
        result, info = await rank_and_clip(llm, "content", edits, 5, "test")
        assert result == edits
        assert info["triggered"] is False
        llm.chat.assert_not_called()

    @pytest.mark.asyncio
    async def test_llm_rank_success(self) -> None:
        """LLM 成功返回索引时按优先级裁剪。"""
        llm = AsyncMock()
        response = AsyncMock()
        response.content = json.dumps({"selected_indices": [2, 0]})
        llm.chat.return_value = response

        edits = [{"op": "a"}, {"op": "b"}, {"op": "c"}]
        result, info = await rank_and_clip(llm, "content", edits, 2, "test")
        assert len(result) == 2
        assert result[0] == {"op": "c"}
        assert info["triggered"] is True

    @pytest.mark.asyncio
    async def test_llm_failure_fallback(self) -> None:
        """LLM 失败时退化为按原序取前 max_edits 条。"""
        llm = AsyncMock()
        llm.chat.side_effect = Exception("LLM down")

        edits = [{"op": "a"}, {"op": "b"}, {"op": "c"}]
        result, info = await rank_and_clip(llm, "content", edits, 2, "test")
        assert len(result) == 2
        assert result == [{"op": "a"}, {"op": "b"}]
        assert info["triggered"] is True


# =========================================================================
# G. resolve_skill_file
# =========================================================================


class TestResolveSkillFile:
    """resolve_skill_file 测试。"""

    def test_direct_match(self) -> None:
        class FakeStore:
            def list_skill_files(self) -> list[str]:
                return ["action-reasoning.md", "default-strategy.md"]

            def read_skill(self, f: str) -> str:
                return ""

        assert resolve_skill_file(FakeStore(), "Action Reasoning") == "action-reasoning.md"

    def test_fallback_to_default(self) -> None:
        class FakeStore:
            def list_skill_files(self) -> list[str]:
                return ["default-strategy.md"]

            def read_skill(self, f: str) -> str:
                return ""

        assert resolve_skill_file(FakeStore(), "Unknown Type") == "default-strategy.md"

    def test_case_insensitive(self) -> None:
        class FakeStore:
            def list_skill_files(self) -> list[str]:
                return ["temporal-reasoning.md"]

            def read_skill(self, f: str) -> str:
                return ""

        assert resolve_skill_file(FakeStore(), "Temporal Reasoning") == "temporal-reasoning.md"


# =========================================================================
# H. 格式化辅助
# =========================================================================


class TestFormatCaseSamples:
    """_format_case_samples 测试。"""

    def test_basic_format(self) -> None:
        cases = [
            {
                "question_id": "q1",
                "question": "What?",
                "options": ["A", "B"],
                "answer": "A",
                "prediction": "B",
                "error_type": "wrong",
                "selection_reason": "test",
                "trace": [],
            }
        ]
        result = _format_case_samples(cases)
        assert "q1" in result
        assert "What?" in result

    def test_trace_truncation(self) -> None:
        cases = [
            {
                "question_id": "q1",
                "question": "Q",
                "options": [],
                "answer": "A",
                "prediction": "B",
                "error_type": "e",
                "selection_reason": "s",
                "trace": [
                    {
                        "step": 1,
                        "tool_name": "t",
                        "tool_args": {},
                        "tool_output": "x" * 600,
                    }
                ],
            }
        ]
        result = _format_case_samples(cases)
        assert "..." in result


class TestFormatSpans:
    """_format_spans 测试。"""

    def test_basic_format(self) -> None:
        spans = [
            {
                "step": 1,
                "tool_name": "extract",
                "tool_args": {"query": "test"},
                "tool_output": "result",
                "extraction_completeness": 0.9,
                "hallucination_rate": 0.1,
            }
        ]
        result = _format_spans(spans)
        assert "extract" in result
        assert "0.9" in result

    def test_output_truncation(self) -> None:
        spans = [
            {
                "step": 1,
                "tool_name": "t",
                "tool_args": {},
                "tool_output": "x" * 600,
                "extraction_completeness": 0.5,
                "hallucination_rate": 0.0,
            }
        ]
        result = _format_spans(spans)
        assert "..." in result


class TestFormatRejectedEdits:
    """_format_rejected_edits 测试。"""

    def test_with_gate_evidence(self) -> None:
        edits = [
            RejectedEdit(
                target_file="skill.md",
                target_type="skill",
                change_summary="changed X",
                delta=-0.05,
                source_version="v2",
                epoch=3,
                gate_w=5,
                gate_l=8,
                gate_e_value=0.42,
                gate_delta_shrunk=-0.123,
            )
        ]
        result = _format_rejected_edits(edits)
        assert "W=5" in result
        assert "L=8" in result
        assert "E=0.42" in result
        assert "δ̂=-0.123" in result

    def test_without_gate_evidence(self) -> None:
        edits = [
            RejectedEdit(
                target_file="skill.md",
                target_type="skill",
                change_summary="changed X",
                delta=-0.05,
                source_version="v2",
                epoch=3,
            )
        ]
        result = _format_rejected_edits(edits)
        assert "skill.md" in result
        assert "changed X" in result
        assert "W=" not in result


# =========================================================================
# I. 进化入口函数测试（Task 8）
# =========================================================================


_PROMPTS = EvolvePrompts(
    evolve_skill="sk",
    evolve_system="sys",
    evolve_tool="tool",
    evolve_rank="rank",
    consolidate_system="cons",
)


def _make_fake_llm(response_content: str) -> AsyncMock:
    """构造返回固定 LLMResponse 的模拟 LLM。"""
    mock = AsyncMock()
    mock.chat.return_value = LLMResponse(
        content=response_content,
        thinking="",
        model="test",
        provider="test",
        prompt_tokens=0,
        completion_tokens=0,
        latency_ms=0,
        ttft_ms=None,
        max_inter_token_ms=None,
        cache_hit=False,
        call_id="test-id",
    )
    return mock


class TestEvolveSingleSkill:
    """evolve_single_skill 测试。"""

    def test_empty_pack_skipped(self) -> None:
        """空案例包（无失败、无 lapse）导致无 applied edits → rejected。"""
        pack = SkillCasePack(
            task_type="test",
            target_file="test.md",
            stats={},
            failure_cases=[],
            success_cases=[],
            lapse_notes=[],
        )
        store = MagicMock()
        store.read_skill.return_value = "---\nname: t\ndescription: d\ntask_type: t\n---\nbody"
        store.list_skill_files.return_value = ["test.md"]
        llm = _make_fake_llm('{"suggestions":[],"edits":[]}')
        record = asyncio.run(evolve_single_skill(llm, pack, store, _PROMPTS, "v1", 5, 6))
        assert record.status in ("rejected", "skipped")


class TestEvolveSystemPrompt:
    """evolve_system_prompt 测试。"""

    def test_no_failures_returns_skipped(self) -> None:
        """空 failure_cases + 空 edits → 无 applied → rejected。"""
        pack = SystemCasePack(stats={}, failure_cases=[], success_cases=[])
        store = MagicMock()
        store.read_prompt.return_value = (
            "## 能力边界\nfixed\n## 输出格式\nfixed\n## 视频树结构\nfixed\nbody"
        )
        llm = _make_fake_llm('{"suggestions":[],"edits":[]}')
        record = asyncio.run(evolve_system_prompt(llm, pack, store, _PROMPTS, "v1", 5))
        assert record.status in ("rejected", "skipped")


class TestEvolveSingleTool:
    """evolve_single_tool 测试。"""

    def test_evolved_content_is_json(self) -> None:
        """即使 rejected，evolved_content 仍是合法 JSON 含 extract/verify。"""
        pack = ToolCasePack(
            tool_name="view_node",
            target_files=["view_node_extract.md", "view_node_verify.md"],
            stats={},
            failure_spans=[],
            success_spans=[],
        )
        store = MagicMock()
        store.read_prompt.return_value = "## 输出格式\nfixed\nbody"
        llm = _make_fake_llm('{"suggestions":[],"edits":[]}')
        record = asyncio.run(evolve_single_tool(llm, pack, store, _PROMPTS, "v1", 5))
        parsed = json.loads(record.evolved_content)
        assert "extract" in parsed and "verify" in parsed


class TestConsolidateAppendix:
    """consolidate_appendix 测试。"""

    def test_single_note_passthrough(self) -> None:
        """G1 守卫：单条 note 直接返回，不调 LLM。"""
        llm = _make_fake_llm("")
        result = asyncio.run(consolidate_appendix(llm, ["note1"]))
        assert result == ["note1"]

    def test_exception_returns_original(self) -> None:
        """G3 守卫：LLM 异常时降级返回原 notes。"""
        llm = AsyncMock()
        llm.chat.side_effect = RuntimeError("boom")
        result = asyncio.run(consolidate_appendix(llm, ["a", "b", "c"]))
        assert result == ["a", "b", "c"]
