"""v2 出题管线集成测试 — 覆盖 slot 分配、重出循环、重量抽检与完整编排。

测试策略：使用受控 mock VLM/LLM 返回，验证管线逻辑正确性。
"""

from __future__ import annotations

import asyncio
import json
import random
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from pathlib import Path

import numpy as np
import pytest

from app.question_gen.pipeline_v2 import (
    PipelineConfig,
    PipelineResult,
    SlotAssignment,
    _assign_slots,
    _process_one_slot,
    run_pipeline_v2,
)
from app.tree.index import (
    IndexMeta,
    L1Card,
    L1Node,
    L2Card,
    L2Node,
    L3Card,
    L3Node,
    TreeIndex,
)
from core.types import LLMResponse

# ---------------------------------------------------------------------------
# 测试用 fixtures
# ---------------------------------------------------------------------------


def _make_llm_response(content: str) -> LLMResponse:
    """构造标准 LLMResponse。"""
    return LLMResponse(
        content=content,
        thinking="",
        model="test-model",
        provider="test",
        prompt_tokens=10,
        completion_tokens=20,
        latency_ms=100,
        ttft_ms=50.0,
        max_inter_token_ms=10.0,
        cache_hit=False,
        call_id="call-001",
    )


def _make_gate_pass_response() -> str:
    """门控全部通过的 JSON 响应。"""
    return json.dumps({"verdict": "pass", "reason": "looks good"})


def _make_gate_fail_response(reason: str = "quality issue") -> str:
    """门控失败的 JSON 响应。"""
    return json.dumps({"verdict": "fail", "reason": reason})


def _make_candidate_json(
    question: str = "What happened next?",
    answer: str = "A",
) -> str:
    """构造 VLM 返回的候选题 JSON。"""
    return json.dumps(
        {
            "question": question,
            "options": [
                "A. The cat jumped",
                "B. The dog ran",
                "C. Nothing happened",
                "D. It rained",
            ],
            "answer": answer,
            "difficulty": "medium",
        }
    )


def _make_tree() -> TreeIndex:
    """构造最小合法三层树。"""
    l3_nodes = [
        L3Node(
            id=f"vid_L1_000_L2_000_L3_{i:03d}",
            card=L3Card(
                frame_summary=f"Frame {i} shows activity",
                visible_entities=["person", "object"],
                ongoing_actions=["walking"],
                visible_text=[],
                spatial_layout="center",
                visual_attributes={},
                subtitle=f"Subtitle sentence {i} with some unique content here",
            ),
            timestamp=float(i * 2),
            frame_path=f"frames/L1_000_L2_000_L3_{i:03d}.jpg",
        )
        for i in range(5)
    ]
    l2 = L2Node(
        id="vid_L1_000_L2_000",
        card=L2Card(
            event_description="A person walks through the park",
            entities=["person", "park"],
            actions=["walking"],
            action_subjects=["person"],
            visible_text=[],
            spatial_relations="person in center of park",
            state_changes=None,
            subtitle="Person walking in park doing activities",
        ),
        children=l3_nodes,
    )
    l2_b = L2Node(
        id="vid_L1_000_L2_001",
        card=L2Card(
            event_description="A dog runs across the field",
            entities=["dog", "field"],
            actions=["running"],
            action_subjects=["dog"],
            visible_text=[],
            spatial_relations="dog in the field",
            state_changes=None,
            subtitle="Dog running across the field",
        ),
        children=[
            L3Node(
                id=f"vid_L1_000_L2_001_L3_{i:03d}",
                card=L3Card(
                    frame_summary=f"Dog frame {i}",
                    visible_entities=["dog"],
                    ongoing_actions=["running"],
                    visible_text=[],
                    spatial_layout="wide",
                    visual_attributes={},
                    subtitle=f"Dog subtitle {i}",
                ),
                timestamp=float(10 + i * 2),
                frame_path=f"frames/L1_000_L2_001_L3_{i:03d}.jpg",
            )
            for i in range(4)
        ],
    )
    l1 = L1Node(
        id="vid_L1_000",
        card=L1Card(
            scene_summary="Outdoor activities in a park",
            main_setting="outdoor park",
            key_entities=["person", "dog"],
            main_actions=["walking", "running"],
            topic_keywords=["outdoor", "activity"],
            visible_text=[],
            temporal_flow="sequential activities",
        ),
        children=[l2, l2_b],
    )
    meta = IndexMeta(source_path="test_video.mp4", modality="video")
    return TreeIndex(metadata=meta, roots=[l1])


class MockVLM:
    """受控 VLM mock — 自动区分生成请求和门控请求。

    检测 prompt 中是否包含 'verdict' 关键词判断请求类型：
    - 门控请求 → 返回 gate_response（默认 pass）
    - 生成请求 → 按序返回 candidate JSON
    """

    def __init__(
        self,
        responses: list[str] | None = None,
        gate_response: str | None = None,
    ) -> None:
        self._responses = responses or [_make_candidate_json()]
        self._gate_response = gate_response or _make_gate_pass_response()
        self._gen_count = 0

    async def chat_with_images(
        self,
        messages: list[dict[str, Any]],
        images: list[str | Path],
        *,
        session_id: str | None = None,
        parent_call_id: str | None = None,
    ) -> LLMResponse:
        prompt_text = str(messages)
        system_text = messages[0].get("content", "") if messages else ""
        if "verdict" in prompt_text.lower():
            return _make_llm_response(self._gate_response)
        if "distractor" in system_text.lower() and "grader" not in system_text.lower():
            # 候选池请求：返回 4 个 grounded 干扰项
            return _make_llm_response('{"distractors": ["蒸", "煮", "炸", "烤"]}')
        if "grader" in system_text.lower():
            # 打分请求：正解高分、3 个落区间、1 个负空间
            return _make_llm_response('{"scores": [0.90, 0.80, 0.70, 0.60, 0.20]}')
        idx = min(self._gen_count, len(self._responses) - 1)
        self._gen_count += 1
        return _make_llm_response(self._responses[idx])


class MockLLM:
    """受控 LLM mock — 支持配置门控 pass/fail 序列。"""

    def __init__(self, responses: list[str] | None = None) -> None:
        self._responses = responses or [_make_gate_pass_response()]
        self._call_count = 0

    async def chat(
        self,
        messages: list[dict[str, Any]],
        *,
        session_id: str | None = None,
        parent_call_id: str | None = None,
    ) -> LLMResponse:
        idx = min(self._call_count, len(self._responses) - 1)
        self._call_count += 1
        return _make_llm_response(self._responses[idx])


def _mock_embed_fn(text: str) -> np.ndarray:
    """确定性 embedding：基于文本 hash 生成向量。"""
    rng = np.random.default_rng(hash(text) % (2**32))
    vec = rng.standard_normal(64).astype(np.float32)
    return vec / np.linalg.norm(vec)


@pytest.fixture
def tree() -> TreeIndex:
    return _make_tree()


@pytest.fixture
def default_config(tmp_path: Path) -> PipelineConfig:
    return PipelineConfig(
        per_type=2,
        retry_limit=3,
        heavy_sample_rate=0.15,
        dedup_threshold=0.85,
        concurrency=2,
        seed=42,
        output_dir=tmp_path / "output",
    )


@pytest.fixture
def store(tmp_path: Path):
    from app.question_gen.run_store import QuestionGenStore

    db_path = tmp_path / "test_qgen.db"
    s = QuestionGenStore(db_path=db_path)
    yield s
    s.close()


# ---------------------------------------------------------------------------
# TestSlotAssignment
# ---------------------------------------------------------------------------


class TestSlotAssignment:
    """_assign_slots 单元测试。"""

    def test_per_type_count(self):
        """验证生成的 slot 总数 = len(task_types) * per_type。"""
        video_ids = ["vid_001", "vid_002"]
        task_types = ["Action Recognition", "Object Recognition", "Counting Problem"]
        per_type = 4

        slots = _assign_slots(video_ids, task_types, per_type)

        assert len(slots) == len(task_types) * per_type

    def test_round_robin_across_videos(self):
        """验证 slot 在视频间轮转分配。"""
        video_ids = ["vid_A", "vid_B"]
        task_types = ["Action Recognition"]
        per_type = 4

        slots = _assign_slots(video_ids, task_types, per_type)

        video_assignments = [s.video_id for s in slots]
        # 应该轮转分配
        assert video_assignments.count("vid_A") == 2
        assert video_assignments.count("vid_B") == 2

    def test_deterministic(self):
        """相同参数产出相同 slot 序列（无随机性）。"""
        video_ids = ["vid_001", "vid_002"]
        task_types = ["Action Recognition", "Temporal Reasoning"]
        per_type = 3

        slots1 = _assign_slots(video_ids, task_types, per_type)
        slots2 = _assign_slots(video_ids, task_types, per_type)

        for s1, s2 in zip(slots1, slots2, strict=True):
            assert s1.slot_id == s2.slot_id
            assert s1.video_id == s2.video_id
            assert s1.task_type == s2.task_type


# ---------------------------------------------------------------------------
# TestProcessOneSlot
# ---------------------------------------------------------------------------


class TestProcessOneSlot:
    """_process_one_slot 集成测试。"""

    @pytest.mark.asyncio
    async def test_happy_path(self, tree, default_config, store, tmp_path):
        """首次生成即通过门控 → 返回 GeneratedQuestion。"""
        vlm = MockVLM()
        llm = MockLLM([_make_gate_pass_response()] * 4)
        sem = asyncio.Semaphore(2)
        used_node_ids: set[str] = set()
        rng = random.Random(42)

        slot = SlotAssignment(
            slot_id="slot_001",
            video_id="vid_L1_000",
            task_type="Action Recognition",
            seq=1,
        )

        run_id = "test-run-001"
        store.record_run_start(run_id, "abc123", "{}")

        result = await _process_one_slot(
            slot=slot,
            tree=tree,
            vlm=vlm,
            llm=llm,
            embed_fn=_mock_embed_fn,
            embed_pool=[],
            store=store,
            config=default_config,
            used_node_ids=used_node_ids,
            rng=rng,
            sem=sem,
            session_id="sess-001",
            run_id=run_id,
        )

        assert result is not None
        assert result.question_id
        # strategy 根据 task_type 决定 skill_target
        from app.question_gen.strategy import get_strategy

        expected_strategy = get_strategy("Action Recognition")
        assert result.skill_target == expected_strategy.skill_target

    @pytest.mark.asyncio
    async def test_retry_on_fail(self, tree, default_config, store, tmp_path):
        """第一次门控失败，第二次通过 → 重出成功。"""
        vlm = MockVLM(
            [
                _make_candidate_json("First question?"),
                _make_candidate_json("Second better question?"),
            ]
        )
        # 第一轮 4 个门有一个 fail，第二轮 4 个门全 pass
        llm_responses = [
            _make_gate_fail_response("answer not grounded"),
            _make_gate_pass_response(),
            _make_gate_pass_response(),
            _make_gate_pass_response(),
            # 第二轮
            _make_gate_pass_response(),
            _make_gate_pass_response(),
            _make_gate_pass_response(),
            _make_gate_pass_response(),
        ]
        llm = MockLLM(llm_responses)
        sem = asyncio.Semaphore(2)
        used_node_ids: set[str] = set()
        rng = random.Random(42)

        slot = SlotAssignment(
            slot_id="slot_002",
            video_id="vid_L1_000",
            task_type="Action Recognition",
            seq=2,
        )

        run_id = "test-run-002"
        store.record_run_start(run_id, "abc123", "{}")

        result = await _process_one_slot(
            slot=slot,
            tree=tree,
            vlm=vlm,
            llm=llm,
            embed_fn=_mock_embed_fn,
            embed_pool=[],
            store=store,
            config=default_config,
            used_node_ids=used_node_ids,
            rng=rng,
            sem=sem,
            session_id="sess-002",
            run_id=run_id,
        )

        assert result is not None
        # 确认第二个问题被接受
        assert "Second" in result.question or result.question_id is not None

    @pytest.mark.asyncio
    async def test_max_retries_none(self, tree, default_config, store, tmp_path):
        """所有重试均失败 → 返回 None。"""
        vlm = MockVLM([_make_candidate_json()] * 5)
        # 所有门控均失败
        llm = MockLLM([_make_gate_fail_response("always fails")] * 20)
        sem = asyncio.Semaphore(2)
        used_node_ids: set[str] = set()
        rng = random.Random(42)

        slot = SlotAssignment(
            slot_id="slot_003",
            video_id="vid_L1_000",
            task_type="Action Recognition",
            seq=3,
        )

        run_id = "test-run-003"
        store.record_run_start(run_id, "abc123", "{}")

        result = await _process_one_slot(
            slot=slot,
            tree=tree,
            vlm=vlm,
            llm=llm,
            embed_fn=_mock_embed_fn,
            embed_pool=[],
            store=store,
            config=default_config,
            used_node_ids=used_node_ids,
            rng=rng,
            sem=sem,
            session_id="sess-003",
            run_id=run_id,
        )

        assert result is None


# ---------------------------------------------------------------------------
# TestPipelineV2
# ---------------------------------------------------------------------------


class TestPipelineV2:
    """run_pipeline_v2 完整流程测试。"""

    @pytest.mark.asyncio
    async def test_full_flow(self, tree, default_config, store, tmp_path):
        """完整管线运行，产出 PipelineResult。"""
        vlm = MockVLM([_make_candidate_json(f"Question {i}?") for i in range(50)])
        llm = MockLLM([_make_gate_pass_response()] * 200)

        config = PipelineConfig(
            per_type=2,
            retry_limit=2,
            heavy_sample_rate=0.5,  # 高比例便于测试
            dedup_threshold=0.85,
            concurrency=2,
            seed=42,
            output_dir=tmp_path / "out",
        )

        # 只用一个 task_type 确保树能满足采样
        result = await run_pipeline_v2(
            video_ids=["vid_L1_000"],
            trees={"vid_L1_000": tree},
            vlm=vlm,
            llm=llm,
            embed_fn=_mock_embed_fn,
            store=store,
            config=config,
            task_types=["Action Recognition"],
        )

        assert isinstance(result, PipelineResult)
        assert result.rejected_count >= 0
        # 至少一题被接受（VLM 和 LLM 全部正常返回）
        assert len(result.accepted) > 0

    @pytest.mark.asyncio
    async def test_progress_resume(self, tree, default_config, store, tmp_path):
        """传入 progress dict → 已处理 slot 被跳过。"""
        llm = MockLLM([_make_gate_pass_response()] * 100)

        config = PipelineConfig(
            per_type=2,
            retry_limit=2,
            heavy_sample_rate=0.0,  # 不做 heavy check
            dedup_threshold=0.85,
            concurrency=2,
            seed=42,
            output_dir=tmp_path / "out",
        )

        # _assign_slots 现在是确定性的（无随机性）
        preview_slots = _assign_slots(
            ["vid_L1_000"],
            ["Action Recognition"],
            config.per_type,
        )
        # 构造 progress 标记所有 slot 已完成
        progress = {s.slot_id: "accepted" for s in preview_slots}

        # 用 progress 跑管线 → 所有 slot 被跳过
        vlm2 = MockVLM([_make_candidate_json()] * 20)

        result2 = await run_pipeline_v2(
            video_ids=["vid_L1_000"],
            trees={"vid_L1_000": tree},
            vlm=vlm2,
            llm=llm,
            embed_fn=_mock_embed_fn,
            store=store,
            config=config,
            task_types=["Action Recognition"],
            progress=progress,
        )

        # 所有 slot 在 progress 中 → VLM 零调用
        assert vlm2._gen_count == 0
        assert len(result2.accepted) == 0

    @pytest.mark.asyncio
    async def test_heavy_check_samples(self, tree, default_config, store, tmp_path):
        """heavy_sample_rate > 0 时有题被抽检。"""
        # LLM 响应：前面是 gate pass，后面增加 heavy check 的步骤响应
        heavy_response = json.dumps(
            {
                "steps": [
                    {"thought": "step 1"},
                    {"thought": "step 2"},
                    {"thought": "step 3"},
                ],
                "answer": "A",
            }
        )
        llm = MockLLM([_make_gate_pass_response()] * 100 + [heavy_response] * 20)
        vlm = MockVLM([_make_candidate_json(f"Q{i}?") for i in range(20)])

        config = PipelineConfig(
            per_type=2,
            retry_limit=2,
            heavy_sample_rate=1.0,  # 100% 抽检
            dedup_threshold=0.85,
            concurrency=2,
            seed=42,
            output_dir=tmp_path / "out",
        )

        result = await run_pipeline_v2(
            video_ids=["vid_L1_000"],
            trees={"vid_L1_000": tree},
            vlm=vlm,
            llm=llm,
            embed_fn=_mock_embed_fn,
            store=store,
            config=config,
            task_types=["Action Recognition"],
        )

        # 100% 抽检 → heavy_sampled 等于 accepted 数
        if result.accepted:
            assert len(result.heavy_sampled) == len(result.accepted)

    @pytest.mark.asyncio
    async def test_backfill_params_do_not_mutate_caller_objects(
        self, tree, default_config, store, tmp_path
    ):
        """补生成三参：传入的 used_node_ids / embed_pool 对象不被就地修改。

        内部应复制而非别名 —— run 完之后调用方传入的容器长度/内容保持原样。
        """
        vlm = MockVLM([_make_candidate_json(f"Q{i}?") for i in range(20)])
        llm = MockLLM([_make_gate_pass_response()] * 100)

        config = PipelineConfig(
            per_type=2,
            retry_limit=2,
            heavy_sample_rate=0.0,
            dedup_threshold=0.85,
            concurrency=1,
            seed=42,
            output_dir=tmp_path / "out",
        )

        initial_used_node_ids = {"some_node"}
        seed_vec = _mock_embed_fn("seed embedding text")
        initial_embed_pool = [seed_vec]

        result = await run_pipeline_v2(
            video_ids=["vid_L1_000"],
            trees={"vid_L1_000": tree},
            vlm=vlm,
            llm=llm,
            embed_fn=_mock_embed_fn,
            store=store,
            config=config,
            task_types=["Action Recognition"],
            initial_used_node_ids=initial_used_node_ids,
            initial_embed_pool=initial_embed_pool,
        )

        # 至少接受一题，确保内部确实往副本里追加了 node/embedding
        assert len(result.accepted) > 0

        # 调用方对象未被 mutation：集合仍只含原始节点
        assert initial_used_node_ids == {"some_node"}
        # embed_pool 仍只含最初的一个向量，且内容未变
        assert len(initial_embed_pool) == 1
        assert np.array_equal(initial_embed_pool[0], seed_vec)

    @pytest.mark.asyncio
    async def test_seq_offset_continues_question_ids(
        self, tree, default_config, store, tmp_path
    ):
        """补生成三参：seq_offset 端到端续编 question_id 的 seq，避开旧 run 号段。"""
        vlm = MockVLM([_make_candidate_json(f"Q{i}?") for i in range(20)])
        llm = MockLLM([_make_gate_pass_response()] * 100)

        config = PipelineConfig(
            per_type=2,
            retry_limit=2,
            heavy_sample_rate=0.0,
            dedup_threshold=0.85,
            concurrency=1,
            seed=42,
            output_dir=tmp_path / "out",
        )

        result = await run_pipeline_v2(
            video_ids=["vid_L1_000"],
            trees={"vid_L1_000": tree},
            vlm=vlm,
            llm=llm,
            embed_fn=_mock_embed_fn,
            store=store,
            config=config,
            task_types=["Action Recognition"],
            seq_offset=30,
        )

        assert len(result.accepted) > 0
        # question_id 格式 "{video_id}_{task_type}_{seq:04d}"，seq 是最后一段 4 位数字
        seqs = [int(q.question_id.rsplit("_", 1)[1]) for q in result.accepted]
        assert all(s > 30 for s in seqs), seqs
        # per_type=2 → seq 从 31 起续编，不与 0-30 撞
        assert min(seqs) == 31

    @pytest.mark.asyncio
    async def test_store_records_all(self, tree, default_config, store, tmp_path):
        """验证 store 中记录了每道题的生成与门控结果。"""
        vlm = MockVLM([_make_candidate_json()] * 10)
        llm = MockLLM([_make_gate_pass_response()] * 50)

        config = PipelineConfig(
            per_type=2,
            retry_limit=2,
            heavy_sample_rate=0.0,
            dedup_threshold=0.85,
            concurrency=1,
            seed=42,
            output_dir=tmp_path / "out",
        )

        result = await run_pipeline_v2(
            video_ids=["vid_L1_000"],
            trees={"vid_L1_000": tree},
            vlm=vlm,
            llm=llm,
            embed_fn=_mock_embed_fn,
            store=store,
            config=config,
            task_types=["Action Recognition"],
        )

        # 查询 store 中的 items
        cursor = store._conn.execute("SELECT COUNT(*) FROM question_gen_items")
        item_count = cursor.fetchone()[0]
        # 至少有 accepted 数量的记录
        assert item_count >= len(result.accepted)
