"""app/harness/inference.py 单元测试。

测试覆盖:
- run_inference 基本流程（mock LLM + tool_dispatch）
- 异常时 prediction 仍落库（stop_reason=error）
- _to_text_field 归一化
- run_id 空串 → ValueError
- _aggregate_results 内存聚合
- 空 questions 列表零值返回
- 并发控制 Semaphore
- plugins_factory 调用
"""

from __future__ import annotations

import json
from typing import Any
from unittest.mock import AsyncMock

import pytest

from app.harness.inference import (
    InferenceResult,
    _aggregate_results,
    _to_text_field,
    run_inference,
)
from app.harness.log import HarnessLog
from core.types import GeneratedQuestion, LLMResponse

# ── 测试基础设施 ──────────────────────────────────────────────────


def _make_question(
    question_id: str = "q1",
    video_id: str = "v1",
    task_type: str = "Action Reasoning",
    answer: str = "B",
) -> GeneratedQuestion:
    """构造测试用题目。"""
    return GeneratedQuestion(
        question_id=question_id,
        video_id=video_id,
        task_type=task_type,
        question="测试问题",
        options=("A. 选项A", "B. 选项B", "C. 选项C", "D. 选项D"),
        answer=answer,
        source_nodes=("L1_001",),
        difficulty="medium",
    )


def _make_llm_response(answer: str = "B") -> LLMResponse:
    """构造测试用 LLMResponse（submit_answer 场景）。"""
    content = json.dumps(
        {
            "reflect": {"observation": "找到答案"},
            "plan": {"next_step": "提交"},
            "action": {
                "tool": "submit_answer",
                "args": {
                    "answer": answer,
                    "evidence": "证据文本",
                    "reasoning": "推理过程",
                },
            },
        }
    )
    return LLMResponse(
        content=content,
        thinking="思考过程",
        model="test-model",
        provider="test",
        prompt_tokens=100,
        completion_tokens=50,
        latency_ms=200,
        ttft_ms=30.0,
        max_inter_token_ms=5.0,
        cache_hit=False,
        call_id="test-call-001",
    )


def _make_error_llm_response() -> LLMResponse:
    """构造触发解析失败的 LLMResponse。"""
    return LLMResponse(
        content="这不是JSON",
        thinking="",
        model="test-model",
        provider="test",
        prompt_tokens=10,
        completion_tokens=5,
        latency_ms=50,
        ttft_ms=10.0,
        max_inter_token_ms=2.0,
        cache_hit=False,
        call_id="test-call-err",
    )


async def _stub_tool_dispatch(
    tool_name: str, args: dict[str, Any], *, context: dict[str, Any]
) -> str:
    """测试用工具调度函数。"""
    if tool_name == "submit_answer":
        return "答案已提交"
    raise ValueError(f"未知工具: {tool_name}")


def _stub_prompt_builder(qa: GeneratedQuestion) -> tuple[str, str]:
    """测试用 prompt 构建函数。"""
    return "系统提示词", f"用户问题: {qa.question}"


@pytest.fixture
def harness_log(tmp_path: Any, request: Any) -> HarnessLog:
    """创建临时 HarnessLog 实例。

    使用 test 节点名称的 hash 作为 db 文件名，避免冲突。
    run_id 固定为 "test-run"，实际 run_inference 中传入的 run_id
    由 HarnessLog.insert 自动覆盖为 HarnessLog 构造时的值。
    """
    db_name = f"harness_{id(request)}.db"
    db_path = str(tmp_path / db_name)
    log = HarnessLog(db_path, "test-run")
    yield log
    log.close()


# ── 测试用例 ──────────────────────────────────────────────────


class TestToTextField:
    """_to_text_field 归一化测试。"""

    @pytest.mark.asyncio
    async def test_string_passthrough(self) -> None:
        """字符串原样返回。"""
        assert _to_text_field("hello") == "hello"

    @pytest.mark.asyncio
    async def test_empty_string(self) -> None:
        """空字符串原样返回。"""
        assert _to_text_field("") == ""

    @pytest.mark.asyncio
    async def test_list_serialized(self) -> None:
        """list 被 JSON 序列化。"""
        result = _to_text_field(["a", "b"])
        assert result == '["a", "b"]'

    @pytest.mark.asyncio
    async def test_dict_serialized(self) -> None:
        """dict 被 JSON 序列化。"""
        result = _to_text_field({"key": "值"})
        assert '"key"' in result
        assert '"值"' in result

    @pytest.mark.asyncio
    async def test_int_serialized(self) -> None:
        """int 被 JSON 序列化。"""
        assert _to_text_field(42) == "42"

    @pytest.mark.asyncio
    async def test_none_serialized(self) -> None:
        """None 被 JSON 序列化。"""
        assert _to_text_field(None) == "null"

    @pytest.mark.asyncio
    async def test_unicode_preserved(self) -> None:
        """ensure_ascii=False 保留中文。"""
        result = _to_text_field(["中文"])
        assert "中文" in result
        assert "\\u" not in result


def _single_record(
    question_id: str,
    *,
    prediction: str | None,
    answer: str,
    task_type: str,
    steps_used: int,
    prompt_tokens: int,
    completion_tokens: int,
    stop_reason: str,
) -> dict[str, Any]:
    """构造一条 single 题的 prediction record（含 question_id 供 unit 聚合）。"""
    return {
        "question_id": question_id,
        "prediction": prediction,
        "answer": answer,
        "task_type": task_type,
        "steps_used": steps_used,
        "prompt_tokens": prompt_tokens,
        "completion_tokens": completion_tokens,
        "stop_reason": stop_reason,
    }


class TestAggregateResults:
    """_aggregate_results unit 粒度聚合测试（single 题：unit 数 = 题数）。"""

    @pytest.mark.asyncio
    async def test_empty_records(self) -> None:
        """空列表返回零值 InferenceResult。"""
        result = _aggregate_results([], [], "run-empty")
        assert result.run_id == "run-empty"
        assert result.accuracy == 0.0
        assert result.total == 0
        assert result.correct == 0
        assert result.per_task_type == {}
        assert result.steps_mean == 0.0
        assert result.token_usage == {"prompt_tokens": 0, "completion_tokens": 0}
        assert result.stop_reason_counts == {}

    @pytest.mark.asyncio
    async def test_single_correct(self) -> None:
        """单条正确记录 → accuracy=1.0。"""
        questions = [_make_question(question_id="q1", task_type="AR", answer="B")]
        records = [
            _single_record(
                "q1",
                prediction="B",
                answer="B",
                task_type="AR",
                steps_used=3,
                prompt_tokens=100,
                completion_tokens=50,
                stop_reason="finished",
            )
        ]
        result = _aggregate_results(records, questions, "run-1")
        assert result.accuracy == 1.0
        assert result.total == 1
        assert result.correct == 1
        assert result.steps_mean == 3.0

    @pytest.mark.asyncio
    async def test_mixed_correct_wrong(self) -> None:
        """混合正确/错误 → 准确率与步数均正确聚合。"""
        questions = [
            _make_question(question_id="q1", task_type="AR", answer="B"),
            _make_question(question_id="q2", task_type="AR", answer="A"),
            _make_question(question_id="q3", task_type="SP", answer="D"),
        ]
        records = [
            _single_record(
                "q1",
                prediction="B",
                answer="B",
                task_type="AR",
                steps_used=2,
                prompt_tokens=100,
                completion_tokens=50,
                stop_reason="finished",
            ),
            _single_record(
                "q2",
                prediction="C",
                answer="A",
                task_type="AR",
                steps_used=4,
                prompt_tokens=200,
                completion_tokens=100,
                stop_reason="budget_exceeded",
            ),
            _single_record(
                "q3",
                prediction="D",
                answer="D",
                task_type="SP",
                steps_used=1,
                prompt_tokens=50,
                completion_tokens=25,
                stop_reason="finished",
            ),
        ]
        result = _aggregate_results(records, questions, "run-mix")
        assert result.total == 3
        assert result.correct == 2
        assert abs(result.accuracy - 2 / 3) < 1e-9
        assert abs(result.steps_mean - 7 / 3) < 1e-9
        assert result.token_usage == {"prompt_tokens": 350, "completion_tokens": 175}
        assert result.stop_reason_counts == {"finished": 2, "budget_exceeded": 1}

    @pytest.mark.asyncio
    async def test_per_task_type_grouping(self) -> None:
        """按 task_type 分组聚合（unit 粒度）。"""
        questions = [
            _make_question(question_id="q1", task_type="AR", answer="B"),
            _make_question(question_id="q2", task_type="AR", answer="C"),
            _make_question(question_id="q3", task_type="SP", answer="D"),
        ]
        records = [
            _single_record(
                "q1",
                prediction="B",
                answer="B",
                task_type="AR",
                steps_used=1,
                prompt_tokens=10,
                completion_tokens=5,
                stop_reason="finished",
            ),
            _single_record(
                "q2",
                prediction="A",
                answer="C",
                task_type="AR",
                steps_used=2,
                prompt_tokens=20,
                completion_tokens=10,
                stop_reason="finished",
            ),
            _single_record(
                "q3",
                prediction="D",
                answer="D",
                task_type="SP",
                steps_used=3,
                prompt_tokens=30,
                completion_tokens=15,
                stop_reason="finished",
            ),
        ]
        result = _aggregate_results(records, questions, "run-task")
        assert "AR" in result.per_task_type
        assert "SP" in result.per_task_type
        assert result.per_task_type["AR"]["total"] == 2
        assert result.per_task_type["AR"]["correct"] == 1
        assert result.per_task_type["AR"]["accuracy"] == 0.5
        assert result.per_task_type["SP"]["total"] == 1
        assert result.per_task_type["SP"]["correct"] == 1
        assert result.per_task_type["SP"]["accuracy"] == 1.0


class TestRunIdValidation:
    """run_id 校验测试。"""

    @pytest.mark.asyncio
    async def test_empty_string_raises(self, harness_log: HarnessLog) -> None:
        """空串 run_id → ValueError。"""
        llm = AsyncMock()
        with pytest.raises(ValueError, match="run_id 不得为空"):
            await run_inference(
                [],
                llm=llm,
                tool_dispatch_fn=_stub_tool_dispatch,
                prompt_builder=_stub_prompt_builder,
                log=harness_log,
                run_id="",
                concurrency=1,
                max_steps=10,
                skill_mode="auto",
            )

    @pytest.mark.asyncio
    async def test_whitespace_only_raises(self, harness_log: HarnessLog) -> None:
        """纯空白 run_id → ValueError。"""
        llm = AsyncMock()
        with pytest.raises(ValueError, match="run_id 不得为空"):
            await run_inference(
                [],
                llm=llm,
                tool_dispatch_fn=_stub_tool_dispatch,
                prompt_builder=_stub_prompt_builder,
                log=harness_log,
                run_id="   ",
                concurrency=1,
                max_steps=10,
                skill_mode="auto",
            )


class TestEmptyQuestions:
    """空题目列表测试。"""

    @pytest.mark.asyncio
    async def test_empty_questions_returns_zero(self, harness_log: HarnessLog) -> None:
        """空 questions 列表直接返回零值 InferenceResult。"""
        llm = AsyncMock()
        result = await run_inference(
            [],
            llm=llm,
            tool_dispatch_fn=_stub_tool_dispatch,
            prompt_builder=_stub_prompt_builder,
            log=harness_log,
            run_id="run-empty",
            concurrency=1,
            max_steps=10,
            skill_mode="auto",
        )
        assert isinstance(result, InferenceResult)
        assert result.run_id == "run-empty"
        assert result.accuracy == 0.0
        assert result.total == 0
        assert result.correct == 0
        # LLM 未被调用
        llm.chat.assert_not_called()


class TestRunInferenceBasic:
    """run_inference 基本流程测试。"""

    @pytest.mark.asyncio
    async def test_single_question_correct(self, harness_log: HarnessLog) -> None:
        """单题正确推理 → accuracy=1.0, stop_reason=finished。"""
        llm = AsyncMock()
        llm.chat.return_value = _make_llm_response(answer="B")

        result = await run_inference(
            [_make_question(answer="B")],
            llm=llm,
            tool_dispatch_fn=_stub_tool_dispatch,
            prompt_builder=_stub_prompt_builder,
            log=harness_log,
            run_id="run-basic",
            concurrency=1,
            max_steps=10,
            skill_mode="auto",
        )

        assert result.accuracy == 1.0
        assert result.total == 1
        assert result.correct == 1
        assert result.stop_reason_counts.get("finished") == 1

    @pytest.mark.asyncio
    async def test_single_question_wrong(self, harness_log: HarnessLog) -> None:
        """单题错误推理 → accuracy=0.0。"""
        llm = AsyncMock()
        llm.chat.return_value = _make_llm_response(answer="C")

        result = await run_inference(
            [_make_question(answer="B")],
            llm=llm,
            tool_dispatch_fn=_stub_tool_dispatch,
            prompt_builder=_stub_prompt_builder,
            log=harness_log,
            run_id="run-wrong",
            concurrency=1,
            max_steps=10,
            skill_mode="auto",
        )

        assert result.accuracy == 0.0
        assert result.total == 1
        assert result.correct == 0

    @pytest.mark.asyncio
    async def test_multiple_questions_concurrent(self, harness_log: HarnessLog) -> None:
        """3 题并发推理 → 结果正确聚合。"""
        llm = AsyncMock()
        llm.chat.return_value = _make_llm_response(answer="B")

        questions = [
            _make_question(question_id="q1", answer="B"),
            _make_question(question_id="q2", answer="B"),
            _make_question(question_id="q3", answer="A"),
        ]

        result = await run_inference(
            questions,
            llm=llm,
            tool_dispatch_fn=_stub_tool_dispatch,
            prompt_builder=_stub_prompt_builder,
            log=harness_log,
            run_id="run-multi",
            concurrency=3,
            max_steps=10,
            skill_mode="auto",
        )

        assert result.total == 3
        assert result.correct == 2
        assert abs(result.accuracy - 2 / 3) < 1e-9

    @pytest.mark.asyncio
    async def test_token_usage_accumulated(self, harness_log: HarnessLog) -> None:
        """多题 token 累加验证。"""
        llm = AsyncMock()
        llm.chat.return_value = _make_llm_response(answer="B")

        questions = [
            _make_question(question_id="q1"),
            _make_question(question_id="q2"),
        ]

        result = await run_inference(
            questions,
            llm=llm,
            tool_dispatch_fn=_stub_tool_dispatch,
            prompt_builder=_stub_prompt_builder,
            log=harness_log,
            run_id="run-token",
            concurrency=2,
            max_steps=10,
            skill_mode="auto",
        )

        assert result.token_usage["prompt_tokens"] == 200
        assert result.token_usage["completion_tokens"] == 100


class TestPredictionAlwaysWritten:
    """异常时 prediction 仍落库测试。"""

    @pytest.mark.asyncio
    async def test_error_still_persisted(self, harness_log: HarnessLog) -> None:
        """LLM 调用异常时，prediction 仍以 stop_reason=error 落库。"""
        llm = AsyncMock()
        llm.chat.side_effect = RuntimeError("LLM API 不可用")

        result = await run_inference(
            [_make_question()],
            llm=llm,
            tool_dispatch_fn=_stub_tool_dispatch,
            prompt_builder=_stub_prompt_builder,
            log=harness_log,
            run_id="run-error",
            concurrency=1,
            max_steps=10,
            skill_mode="auto",
        )

        assert result.total == 1
        assert result.correct == 0
        assert result.stop_reason_counts.get("error") == 1

        # 验证 DB 中的记录（HarnessLog.insert 使用构造时的 run_id）
        rows = harness_log.query("SELECT * FROM predictions WHERE run_id = ?", ("test-run",))
        assert len(rows) == 1
        assert rows[0]["stop_reason"] == "error"
        assert rows[0]["prediction"] is None

    @pytest.mark.asyncio
    async def test_parse_error_still_persisted(self, harness_log: HarnessLog) -> None:
        """LLM 返回非 JSON 内容，parse_error 后 prediction 仍落库。"""
        llm = AsyncMock()
        llm.chat.return_value = _make_error_llm_response()

        result = await run_inference(
            [_make_question()],
            llm=llm,
            tool_dispatch_fn=_stub_tool_dispatch,
            prompt_builder=_stub_prompt_builder,
            log=harness_log,
            run_id="run-parse-err",
            concurrency=1,
            max_steps=10,
            skill_mode="auto",
        )

        assert result.total == 1
        # HarnessLog.insert 使用构造时的 run_id
        rows = harness_log.query("SELECT * FROM predictions WHERE run_id = ?", ("test-run",))
        assert len(rows) == 1
        assert rows[0]["prediction"] is None


class TestPluginsFactory:
    """plugins_factory 调用测试。"""

    @pytest.mark.asyncio
    async def test_factory_called_per_question(self, harness_log: HarnessLog) -> None:
        """每题调用 plugins_factory，传入 (video_id, question_id)。"""
        llm = AsyncMock()
        llm.chat.return_value = _make_llm_response(answer="B")

        factory_calls: list[tuple[str, str]] = []

        def _factory(video_id: str, question_id: str) -> list[object]:
            factory_calls.append((video_id, question_id))
            return []

        questions = [
            _make_question(question_id="q1", video_id="v1"),
            _make_question(question_id="q2", video_id="v2"),
        ]

        await run_inference(
            questions,
            llm=llm,
            tool_dispatch_fn=_stub_tool_dispatch,
            prompt_builder=_stub_prompt_builder,
            log=harness_log,
            run_id="run-factory",
            concurrency=2,
            max_steps=10,
            skill_mode="auto",
            plugins_factory=_factory,
        )

        assert len(factory_calls) == 2
        call_set = set(factory_calls)
        assert ("v1", "q1") in call_set
        assert ("v2", "q2") in call_set

    @pytest.mark.asyncio
    async def test_no_factory_uses_empty_plugins(self, harness_log: HarnessLog) -> None:
        """plugins_factory=None 时使用空 plugins 列表。"""
        llm = AsyncMock()
        llm.chat.return_value = _make_llm_response(answer="B")

        result = await run_inference(
            [_make_question()],
            llm=llm,
            tool_dispatch_fn=_stub_tool_dispatch,
            prompt_builder=_stub_prompt_builder,
            log=harness_log,
            run_id="run-no-factory",
            concurrency=1,
            max_steps=10,
            skill_mode="auto",
            plugins_factory=None,
        )

        assert result.total == 1
        assert result.stop_reason_counts.get("finished") == 1


class TestConcurrencyControl:
    """并发控制 Semaphore 测试。"""

    @pytest.mark.asyncio
    async def test_concurrency_semaphore_limits(self, harness_log: HarnessLog) -> None:
        """Semaphore(1) 限制并发为 1 — 通过最大并发计数器验证。"""
        import asyncio

        llm = AsyncMock()
        current_concurrent = 0
        max_concurrent = 0

        original_response = _make_llm_response(answer="B")

        async def _slow_chat(
            messages: Any,
            *,
            session_id: str | None = None,
            parent_call_id: str | None = None,
        ) -> LLMResponse:
            nonlocal current_concurrent, max_concurrent
            current_concurrent += 1
            max_concurrent = max(max_concurrent, current_concurrent)
            await asyncio.sleep(0.01)
            current_concurrent -= 1
            return original_response

        llm.chat.side_effect = _slow_chat

        questions = [_make_question(question_id=f"q{i}") for i in range(5)]

        await run_inference(
            questions,
            llm=llm,
            tool_dispatch_fn=_stub_tool_dispatch,
            prompt_builder=_stub_prompt_builder,
            log=harness_log,
            run_id="run-sem",
            concurrency=1,
            max_steps=10,
            skill_mode="auto",
        )

        assert max_concurrent == 1


class TestTablesCreated:
    """表创建测试。"""

    @pytest.mark.asyncio
    async def test_five_tables_created(self, harness_log: HarnessLog) -> None:
        """run_inference 启动时创建 5 张推理表。"""
        llm = AsyncMock()

        await run_inference(
            [],
            llm=llm,
            tool_dispatch_fn=_stub_tool_dispatch,
            prompt_builder=_stub_prompt_builder,
            log=harness_log,
            run_id="run-tables",
            concurrency=1,
            max_steps=10,
            skill_mode="auto",
        )

        expected_tables = [
            "predictions",
            "traces",
            "validation_flags",
            "anchor_check",
            "observe_frame_health",
        ]
        for table_name in expected_tables:
            rows = harness_log.query(
                "SELECT name FROM sqlite_master WHERE type='table' AND name=?",
                (table_name,),
            )
            assert len(rows) == 1, f"表 {table_name} 未创建"
