"""session_review.py 专项测试 (#145 Phase C)

覆盖：分桶、多样性选择、未完成工作检测、L0 review、
prompt 构建、JSON 解析、fact 生成。
"""

from __future__ import annotations

import json

import pytest

from index1.cognitive.session_review import (
    MAX_PER_BUCKET,
    MIN_EVENTS_FOR_REVIEW,
    TOP_K_EVENTS,
    _classify_event_bucket,
    _detect_patterns,
    _extract_outcome,
    _parse_signals,
    build_review_prompt,
    detect_incomplete_work,
    make_session_record_fact,
    parse_review_output,
    select_diverse_events,
    session_review_l0,
)


# ── helpers ──────────────────────────────────────────────────


def _ev(
    event_id: int = 1,
    tool_name: str = "Edit",
    summary: str = "",
    importance: float = 0.5,
    signals_json: str = "{}",
    payload: str = "{}",
) -> dict:
    """快速构造 raw_event dict"""
    return {
        "event_id": event_id,
        "tool_name": tool_name,
        "summary": summary,
        "importance": importance,
        "signals_json": signals_json,
        "payload": payload,
    }


# ── _classify_event_bucket ───────────────────────────────────


class TestClassifyEventBucket:
    def test_error_by_importance(self):
        assert _classify_event_bucket(_ev(importance=0.9)) == "error"
        assert _classify_event_bucket(_ev(importance=0.95)) == "error"

    def test_error_by_summary_keywords(self):
        for kw in ("error", "traceback", "failed", "panic", "fatal"):
            assert _classify_event_bucket(_ev(summary=kw, importance=0.1)) == "error"

    def test_test_by_importance(self):
        assert _classify_event_bucket(_ev(importance=0.8)) == "test"

    def test_test_by_summary_keywords(self):
        for kw in ("passed", "pytest", "test result"):
            assert _classify_event_bucket(_ev(summary=kw, importance=0.1)) == "test"

    def test_create_bucket(self):
        assert _classify_event_bucket(_ev(tool_name="Write", importance=0.3)) == "create"

    def test_edit_bucket(self):
        assert _classify_event_bucket(_ev(tool_name="Edit", importance=0.3)) == "edit"
        assert _classify_event_bucket(_ev(tool_name="MultiEdit", importance=0.3)) == "edit"

    def test_explore_default(self):
        assert _classify_event_bucket(_ev(tool_name="Read", importance=0.3)) == "explore"
        assert _classify_event_bucket(_ev(tool_name="Grep", importance=0.3)) == "explore"

    def test_error_priority_over_test(self):
        """importance 0.9 → error，不是 test"""
        assert _classify_event_bucket(_ev(importance=0.9, summary="test passed")) == "error"

    def test_none_summary_safe(self):
        ev = {"tool_name": "Read", "summary": None, "importance": 0.3}
        assert _classify_event_bucket(ev) == "explore"

    def test_none_importance_safe(self):
        ev = {"tool_name": "Read", "summary": "", "importance": None}
        assert _classify_event_bucket(ev) == "explore"


# ── select_diverse_events ────────────────────────────────────


class TestSelectDiverseEvents:
    def test_empty_returns_empty(self):
        assert select_diverse_events([]) == []

    def test_fewer_than_top_k(self):
        events = [_ev(event_id=i, importance=0.5) for i in range(3)]
        result = select_diverse_events(events, top_k=5)
        assert len(result) == 3

    def test_max_per_bucket_enforced(self):
        """同一个桶最多 MAX_PER_BUCKET 条，剩余由填充逻辑补充"""
        # 全是 error 桶（importance >= 0.9）
        events = [_ev(event_id=i, importance=0.9 + i * 0.001) for i in range(5)]
        result = select_diverse_events(events, top_k=5)
        # error 桶选 top-2，填充阶段从剩余池按 importance 选 3 条
        assert len(result) == 5

    def test_max_per_bucket_with_fewer_top_k(self):
        """top_k < 单桶数量时，只取 MAX_PER_BUCKET 再截断"""
        events = [_ev(event_id=i, importance=0.9 + i * 0.001) for i in range(5)]
        result = select_diverse_events(events, top_k=2)
        assert len(result) == 2

    def test_diversity_across_buckets(self):
        """不同桶都有代表"""
        events = [
            _ev(event_id=1, tool_name="Edit", importance=0.9, summary="error in x"),
            _ev(event_id=2, tool_name="Edit", importance=0.5),
            _ev(event_id=3, tool_name="Write", importance=0.6),
            _ev(event_id=4, tool_name="Read", importance=0.3),
            _ev(event_id=5, tool_name="Edit", importance=0.8, summary="10 passed"),
        ]
        result = select_diverse_events(events, top_k=4)
        tool_names = {e["tool_name"] for e in result}
        # 应该有来自不同桶的工具
        assert len(tool_names) >= 2

    def test_sorted_by_event_id(self):
        """返回按 event_id 升序（时间顺序）"""
        events = [
            _ev(event_id=10, importance=0.9, summary="error"),
            _ev(event_id=1, importance=0.8, summary="passed"),
            _ev(event_id=5, tool_name="Write", importance=0.6),
        ]
        result = select_diverse_events(events, top_k=3)
        ids = [e["event_id"] for e in result]
        assert ids == sorted(ids)

    def test_top_k_cap(self):
        """不超过 top_k"""
        events = [_ev(event_id=i, tool_name=t, importance=0.5 + i * 0.01)
                  for i, t in enumerate(["Edit", "Write", "Read", "Bash",
                                         "Edit", "Write", "Read", "Bash"])]
        result = select_diverse_events(events, top_k=3)
        assert len(result) <= 3


# ── detect_incomplete_work ───────────────────────────────────


class TestDetectIncompleteWork:
    def test_empty_events(self):
        assert detect_incomplete_work([]) == []

    def test_no_errors(self):
        events = [_ev(summary="all good", importance=0.5) for _ in range(5)]
        assert detect_incomplete_work(events) == []

    def test_error_after_pass_detected(self):
        """最后错误在最后通过之后 → 未修复"""
        events = [
            _ev(event_id=1, summary="10 passed", importance=0.8),
            _ev(event_id=2, summary="error in module X", importance=0.9),
        ]
        result = detect_incomplete_work(events)
        assert len(result) == 1
        assert "error in module X" in result[0]

    def test_pass_after_error_is_resolved(self):
        """最后通过在最后错误之后 → 已修复"""
        events = [
            _ev(event_id=1, summary="error in module X", importance=0.9),
            _ev(event_id=2, summary="10 passed", importance=0.8),
        ]
        assert detect_incomplete_work(events) == []

    def test_error_by_summary_keyword(self):
        events = [
            _ev(event_id=1, summary="failed to compile", importance=0.5),
        ]
        result = detect_incomplete_work(events)
        assert len(result) == 1

    def test_tool_name_in_output(self):
        events = [
            _ev(event_id=1, tool_name="Bash", summary="error: exit 1", importance=0.9),
        ]
        result = detect_incomplete_work(events)
        assert "[Bash]" in result[0]

    def test_no_summary_fallback(self):
        """无 summary 时使用 'error unresolved'"""
        events = [
            _ev(event_id=1, tool_name="Bash", summary="", importance=0.9),
        ]
        result = detect_incomplete_work(events)
        assert "error unresolved" in result[0]

    def test_multiple_errors_only_returns_last(self):
        """多个未修复错误只返回最后一个"""
        events = [
            _ev(event_id=1, summary="error A", importance=0.9),
            _ev(event_id=2, summary="error B", importance=0.9),
        ]
        result = detect_incomplete_work(events)
        assert len(result) == 1
        assert "error B" in result[0]

    def test_importance_boundary_089_not_error(self):
        """importance=0.89 不触发错误检测（阈值 0.9）"""
        events = [_ev(event_id=1, importance=0.89, summary="some warning")]
        assert detect_incomplete_work(events) == []

    def test_importance_boundary_090_is_error(self):
        """importance=0.90 触发错误检测"""
        events = [_ev(event_id=1, importance=0.9, summary="critical issue")]
        result = detect_incomplete_work(events)
        assert len(result) == 1


# ── _parse_signals ───────────────────────────────────────────


class TestParseSignals:
    def test_valid_json_string(self):
        ev = {"signals_json": '{"file": "foo.py", "added_fn": ["bar"]}'}
        result = _parse_signals(ev)
        assert result["file"] == "foo.py"
        assert result["added_fn"] == ["bar"]

    def test_already_dict(self):
        ev = {"signals_json": {"file": "foo.py"}}
        result = _parse_signals(ev)
        assert result["file"] == "foo.py"

    def test_invalid_json_returns_empty(self):
        ev = {"signals_json": "not json{"}
        assert _parse_signals(ev) == {}

    def test_empty_string(self):
        ev = {"signals_json": ""}
        assert _parse_signals(ev) == {}

    def test_none_value(self):
        ev = {"signals_json": None}
        assert _parse_signals(ev) == {}

    def test_missing_key(self):
        assert _parse_signals({}) == {}


# ── _extract_outcome ─────────────────────────────────────────


class TestExtractOutcome:
    def test_finds_last_test_result(self):
        events = [
            _ev(summary="5 passed"),
            _ev(summary="some edit"),
            _ev(summary="10 passed 2 failed"),
        ]
        result = _extract_outcome(events)
        assert "10 passed 2 failed" in result

    def test_no_test_result(self):
        events = [_ev(summary="edited file")]
        assert _extract_outcome(events) == ""

    def test_empty_events(self):
        assert _extract_outcome([]) == ""


# ── _detect_patterns ─────────────────────────────────────────


class TestDetectPatterns:
    def test_error_fix_pattern(self):
        events = [
            _ev(importance=0.9, tool_name="Bash"),
            _ev(importance=0.5, tool_name="Edit"),
        ]
        patterns = _detect_patterns(events)
        assert "error-fix" in patterns

    def test_investigate_fix_pattern(self):
        events = [
            _ev(tool_name="Read", importance=0.3),
            _ev(tool_name="Edit", importance=0.5),
        ]
        patterns = _detect_patterns(events)
        assert "investigate-fix" in patterns

    def test_create_pattern(self):
        events = [_ev(tool_name="Write", importance=0.3)]
        patterns = _detect_patterns(events)
        assert "create" in patterns

    def test_no_patterns(self):
        events = [_ev(tool_name="Read", importance=0.1)]
        assert _detect_patterns(events) == []

    def test_multiple_patterns(self):
        events = [
            _ev(tool_name="Read", importance=0.3),
            _ev(tool_name="Bash", importance=0.9),
            _ev(tool_name="Edit", importance=0.5),
            _ev(tool_name="Write", importance=0.3),
        ]
        patterns = _detect_patterns(events)
        assert "error-fix" in patterns
        assert "investigate-fix" in patterns
        assert "create" in patterns


# ── session_review_l0 ────────────────────────────────────────


class TestSessionReviewL0:
    def _make_events(self) -> list[dict]:
        return [
            _ev(event_id=1, tool_name="Read", importance=0.1),
            _ev(
                event_id=2, tool_name="Edit", importance=0.6,
                signals_json='{"file": "src/foo.py", "added_fn": ["bar_func"]}',
            ),
            _ev(
                event_id=3, tool_name="Write", importance=0.7,
                signals_json='{"file": "src/new.py", "new_file": true}',
            ),
            _ev(event_id=4, tool_name="Bash", importance=0.8, summary="5 passed"),
        ]

    def test_returns_session_record_key(self):
        result = session_review_l0(self._make_events(), "test-proj")
        assert "session_record" in result

    def test_source_is_l0(self):
        result = session_review_l0(self._make_events(), "test-proj")
        assert result["session_record"]["_source"] == "l0"

    def test_files_changed_extracted(self):
        result = session_review_l0(self._make_events(), "test-proj")
        sr = result["session_record"]
        assert "src/foo.py" in sr["files_changed"]
        assert "src/new.py" in sr["files_changed"]

    def test_key_changes_from_high_importance(self):
        result = session_review_l0(self._make_events(), "test-proj")
        sr = result["session_record"]
        # event 2 (Edit, imp=0.6, file=src/foo.py) + event 3 (Write, imp=0.7, file=src/new.py)
        # + event 4 (Bash, imp=0.8, 无 file 无 identifiers → 不纳入)
        # event 1 (Read, imp=0.1) 低于阈值 0.5
        assert len(sr["key_changes"]) == 2

    def test_key_changes_action_types(self):
        result = session_review_l0(self._make_events(), "test-proj")
        actions = {kc["action"] for kc in result["session_record"]["key_changes"]}
        assert "edit" in actions
        assert "create" in actions

    def test_identifiers_extracted(self):
        result = session_review_l0(self._make_events(), "test-proj")
        sr = result["session_record"]
        all_ids = []
        for kc in sr["key_changes"]:
            all_ids.extend(kc.get("identifiers", []))
        assert "bar_func" in all_ids

    def test_new_file_tag(self):
        result = session_review_l0(self._make_events(), "test-proj")
        sr = result["session_record"]
        tags_list = [kc.get("tags", []) for kc in sr["key_changes"]]
        all_tags = [t for tags in tags_list for t in tags]
        assert "new_file" in all_tags

    def test_outcome_extracted(self):
        result = session_review_l0(self._make_events(), "test-proj")
        assert "5 passed" in result["session_record"]["outcome"]

    def test_patterns_detected(self):
        result = session_review_l0(self._make_events(), "test-proj")
        assert "create" in result["session_record"]["patterns"]

    def test_causal_chain_empty_for_l0(self):
        result = session_review_l0(self._make_events(), "test-proj")
        assert result["session_record"]["causal_chain"] == []

    def test_evaluation_fields_none_for_l0(self):
        result = session_review_l0(self._make_events(), "test-proj")
        ev = result["session_record"]["evaluation"]
        assert ev["went_well"] is None
        assert ev["went_wrong"] is None
        assert ev["next_time"] is None

    def test_empty_events(self):
        result = session_review_l0([], "test-proj")
        sr = result["session_record"]
        assert sr["files_changed"] == []
        assert sr["key_changes"] == []


# ── build_review_prompt ──────────────────────────────────────


class TestBuildReviewPrompt:
    def test_basic_prompt_structure(self):
        events = [_ev(summary="edited foo.py", importance=0.5)]
        top_events = [_ev(
            importance=0.6,
            payload=json.dumps({"tool_input": {"file_path": "foo.py"}}),
        )]
        prompt = build_review_prompt(events, top_events)
        assert "EVENT INDEX" in prompt
        assert "TOP EVENTS" in prompt
        assert "session_record" in prompt

    def test_stage_a_contains_summaries(self):
        events = [
            _ev(tool_name="Edit", summary="modified bar.py", importance=0.6),
            _ev(tool_name="Bash", summary="ran tests", importance=0.5),
        ]
        prompt = build_review_prompt(events, [])
        assert "modified bar.py" in prompt
        assert "ran tests" in prompt

    def test_stage_a_truncation(self):
        """Stage A 超过 2KB 被截断"""
        events = [_ev(summary="x" * 200, importance=0.5) for _ in range(20)]
        prompt = build_review_prompt(events, [])
        assert "truncated" in prompt

    def test_stage_b_shows_signals(self):
        top = [_ev(
            importance=0.7,
            signals_json='{"file": "a.py", "added_fn": ["func1"]}',
        )]
        prompt = build_review_prompt([], top)
        assert "func1" in prompt

    def test_prev_context_injected(self):
        prev = {
            "session_record": {
                "incomplete_work": ["unresolved error in X"],
                "key_changes": [{"file": "x.py", "action": "edit"}],
            },
        }
        prompt = build_review_prompt([], [], prev_record=prev)
        assert "PREVIOUS SESSION CONTEXT" in prompt
        assert "unresolved error in X" in prompt

    def test_no_prev_context_when_empty(self):
        prompt = build_review_prompt([], [], prev_record=None)
        assert "PREVIOUS SESSION CONTEXT" not in prompt

    def test_stage_b_truncation(self):
        """Stage B 超过 10KB 被截断"""
        top = [_ev(
            importance=0.7,
            payload=json.dumps({"tool_response": "x" * 600}),
        ) for _ in range(30)]
        prompt = build_review_prompt([], top)
        assert "truncated" in prompt

    def test_stage_b_tool_input_field_truncation(self):
        """tool_input 字段超过 300 字符被截断"""
        long_path = "a" * 500
        top = [_ev(
            importance=0.7,
            payload=json.dumps({"tool_input": {"file_path": long_path}}),
        )]
        prompt = build_review_prompt([], top)
        assert "a" * 300 in prompt
        assert "a" * 301 not in prompt

    def test_stage_b_invalid_payload_json(self):
        """payload 非法 JSON 降级为空 dict"""
        top = [_ev(importance=0.7, payload="not-json{")]
        # 不应抛异常
        prompt = build_review_prompt([], top)
        assert "importance=0.70" in prompt


# ── parse_review_output ──────────────────────────────────────


class TestParseReviewOutput:
    def test_valid_json_direct(self):
        raw = json.dumps({"session_record": {"files_changed": ["a.py"]}})
        result = parse_review_output(raw)
        assert result is not None
        assert result["session_record"]["files_changed"] == ["a.py"]

    def test_json_in_code_block(self):
        raw = '```json\n{"session_record": {"files_changed": []}}\n```'
        result = parse_review_output(raw)
        assert result is not None
        assert "session_record" in result

    def test_json_with_surrounding_text(self):
        raw = 'Here is the result:\n{"session_record": {"outcome": "ok"}}\nDone.'
        result = parse_review_output(raw)
        assert result is not None
        assert result["session_record"]["outcome"] == "ok"

    def test_invalid_json_returns_none(self):
        assert parse_review_output("not json at all") is None

    def test_empty_input_returns_none(self):
        assert parse_review_output("") is None
        assert parse_review_output(None) is None
        assert parse_review_output("   ") is None

    def test_json_without_session_record_layer1(self):
        """Layer 1 需要 session_record key，否则降级到 Layer 3"""
        raw = json.dumps({"other_key": 123})
        result = parse_review_output(raw)
        # Layer 3 会解析成功（找到 { } 边界）
        assert result is not None
        assert "other_key" in result

    def test_code_block_without_json_tag(self):
        raw = '```\n{"session_record": {"outcome": "5 passed"}}\n```'
        result = parse_review_output(raw)
        assert result is not None

    def test_multiline_json(self):
        """JSON 包含换行符"""
        raw = '{\n  "session_record": {\n    "outcome": "ok"\n  }\n}'
        result = parse_review_output(raw)
        assert result is not None
        assert result["session_record"]["outcome"] == "ok"

    def test_llm_explanation_before_and_after_json(self):
        """LLM 在 JSON 前后添加解释文字（真实场景）"""
        raw = (
            "I analyzed the session and here's my summary:\n\n"
            '{"session_record": {"outcome": "10 passed", "files_changed": ["a.py"]}}\n\n'
            "This covers the main changes made during the session."
        )
        result = parse_review_output(raw)
        assert result is not None
        assert result["session_record"]["outcome"] == "10 passed"


# ── make_session_record_fact ─────────────────────────────────


class TestMakeSessionRecordFact:
    def _sample_record(self, source: str = "l0") -> dict:
        return {
            "session_record": {
                "files_changed": ["src/foo.py", "src/bar.py"],
                "key_changes": [
                    {"file": "src/foo.py", "action": "edit",
                     "identifiers": ["my_func"]},
                ],
                "causal_chain": [],
                "patterns": ["error-fix"],
                "outcome": "10 passed",
                "incomplete_work": [],
                "evaluation": {"went_well": None, "went_wrong": None, "next_time": None},
                "_source": source,
            },
        }

    def test_basic_fact_structure(self):
        fact = make_session_record_fact(
            self._sample_record(), "test-proj", "sess-1",
        )
        assert fact["category"] == "session_record"
        assert fact["collection"] == "test-proj"
        assert fact["session_id"] == "sess-1"
        assert fact["scope"] == "project"

    def test_l0_source_confidence(self):
        fact = make_session_record_fact(
            self._sample_record("l0"), "test-proj", "s1",
        )
        assert fact["source"] == "session_review_l0"
        assert fact["confidence"] == 0.7

    def test_llm_source_confidence(self):
        fact = make_session_record_fact(
            self._sample_record("llm"), "test-proj", "s1",
        )
        assert fact["source"] == "session_review_llm"
        assert fact["confidence"] == 0.9

    def test_assertion_contains_file_count(self):
        fact = make_session_record_fact(
            self._sample_record(), "test-proj", "s1",
        )
        assert "2 files changed" in fact["assertion"]

    def test_assertion_contains_outcome(self):
        fact = make_session_record_fact(
            self._sample_record(), "test-proj", "s1",
        )
        assert "10 passed" in fact["assertion"]

    def test_assertion_keyword_dense_tail(self):
        """assertion 尾部含文件名 + 标识符 + 模式"""
        fact = make_session_record_fact(
            self._sample_record(), "test-proj", "s1",
        )
        assert "foo.py" in fact["assertion"]
        assert "my_func" in fact["assertion"]
        assert "error-fix" in fact["assertion"]

    def test_context_json_roundtrip(self):
        record = self._sample_record()
        fact = make_session_record_fact(record, "test-proj", "s1")
        parsed = json.loads(fact["context_json"])
        assert parsed["session_record"]["outcome"] == "10 passed"

    def test_none_session_id(self):
        fact = make_session_record_fact(
            self._sample_record(), "test-proj", None,
        )
        assert fact["session_id"] == ""

    def test_no_content_key(self):
        """content 字段不应存在（facts 表无此列）"""
        fact = make_session_record_fact(
            self._sample_record(), "test-proj", "s1",
        )
        assert "content" not in fact
