"""Task 10：run_adversarial_filter 顶层入口 + _RealAgentRunner 真实装配 e2e。

覆盖两条路径：
- run_adversarial_filter 编排（mock agent + mock backfill）：过滤 filter_task_types、
  非 AR 题不进 agent 门、final 仅含 passed、断点续跑不重跑已判题。
- _RealAgentRunner 真实装配 smoke（I6）：predict 经 run_inference 落 predictions 表再
  读回（LLM mock，路径真穿过 HarnessLog / RunLogImpl，非假 runner 短路）。
"""

from __future__ import annotations

import json
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock

import numpy as np
import pytest

if TYPE_CHECKING:
    from pathlib import Path

from app.harness.deps_router import InferenceDepsRouter
from app.question_gen.adversarial_config import AdversarialFilterConfig
from app.question_gen.adversarial_filter import _RealAgentRunner, run_adversarial_filter
from app.question_gen.run_store import QuestionGenStore
from core.types import GeneratedQuestion, LLMResponse


def _ar_q(qid: str, video_id: str = "v1") -> dict:
    """构造一条 AR 题 JSON 记录；sub_pattern 缺省 → 不支持 flip，翻转门直接放行。"""
    return {
        "question_id": qid,
        "video_id": video_id,
        "task_type": "Action Recognition",
        "question": f"{qid} 之前做了什么？",
        "options": ["A. 蒸", "B. 炒", "C. 煮", "D. 炸"],
        "answer": "A",
        "source_nodes": ["n1"],
        "difficulty": "hard",
    }


def _non_ar_q(qid: str, video_id: str = "v1") -> dict:
    """构造一条非 AR 题 JSON 记录（不应进 agent 门）。"""
    return {
        "question_id": qid,
        "video_id": video_id,
        "task_type": "Object Recognition",
        "question": f"{qid} 里的物体是什么？",
        "options": ["A. 锅", "B. 碗", "C. 盘", "D. 勺"],
        "answer": "A",
        "source_nodes": ["n2"],
        "difficulty": "hard",
    }


class _FakeAgent:
    """完整 agent 试答桩：对每题返回固定预测，记录被调用的 question_id。"""

    def __init__(self, pred: str = "B", model: str = "m1", skill_mode: str = "auto") -> None:
        self._pred = pred
        self.model = model
        self.skill_mode = skill_mode
        self.calls: list[str] = []

    async def predict(
        self, questions: list[GeneratedQuestion], *, max_steps: int, run_id: str
    ) -> dict[str, str]:
        self.calls.extend(q.question_id for q in questions)
        return {q.question_id: self._pred for q in questions}


class _NoBackfill:
    """补生成回调桩：记录调用次数，永远返回空（首轮即达标时不应被调用）。"""

    def __init__(self) -> None:
        self.calls = 0

    async def __call__(
        self, deficit: int, round_no: int, existing: dict[str, GeneratedQuestion]
    ) -> list[GeneratedQuestion]:
        self.calls += 1
        return []


def _write_accepted(path: Path, records: list[dict]) -> None:
    """写 accepted_questions.json（Phase A 产物形态：JSON 列表）。"""
    path.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")


@pytest.mark.asyncio
async def test_filter_only_processes_ar_and_writes_passed(tmp_path: Path) -> None:
    """非 AR 题不进 agent 门；final 仅含 passed AR 题。"""
    accepted = tmp_path / "accepted_questions.json"
    _write_accepted(accepted, [_ar_q("q1"), _ar_q("q2"), _non_ar_q("obj1")])
    final_path = tmp_path / "accepted_questions_final.json"
    store = QuestionGenStore(str(tmp_path / "q.db"))
    agent = _FakeAgent(pred="B")  # 答错 → 过作弊门；sub_pattern=None → 过翻转门
    backfill = _NoBackfill()

    await run_adversarial_filter(
        accepted_path=accepted,
        final_path=final_path,
        agent=agent,
        vlm=object(),
        trees={},
        store=store,
        filter_config=AdversarialFilterConfig(adversarial_max_rounds=3),
        backfill=backfill,
        session_id="s",
    )

    # 只有 AR 题进 agent 门
    assert set(agent.calls) == {"q1", "q2"}
    # 非 AR 题不出现在 verdicts 表
    rows = store._conn.execute(
        "SELECT question_id FROM adversarial_verdicts WHERE question_id=?", ("obj1",)
    ).fetchall()
    assert rows == []
    # final 仅含 passed AR 题
    data = json.loads(final_path.read_text(encoding="utf-8"))
    assert {d["question_id"] for d in data} == {"q1", "q2"}
    assert backfill.calls == 0  # 首轮即达标（target=2, passed=2）
    store.close()


@pytest.mark.asyncio
async def test_filter_resume_does_not_rerun_judged(tmp_path: Path) -> None:
    """断点续跑：第二次调用不重跑已判题（agent 调用计数不变）。"""
    accepted = tmp_path / "accepted_questions.json"
    _write_accepted(accepted, [_ar_q("q1"), _ar_q("q2")])
    final_path = tmp_path / "accepted_questions_final.json"
    store = QuestionGenStore(str(tmp_path / "q.db"))
    agent = _FakeAgent(pred="B")
    backfill = _NoBackfill()

    async def _run() -> None:
        await run_adversarial_filter(
            accepted_path=accepted,
            final_path=final_path,
            agent=agent,
            vlm=object(),
            trees={},
            store=store,
            filter_config=AdversarialFilterConfig(adversarial_max_rounds=3),
            backfill=backfill,
            session_id="s",
        )

    await _run()
    first_calls = list(agent.calls)
    await _run()
    assert agent.calls == first_calls  # 第二次未新增 agent 调用
    store.close()


# ---------------------------------------------------------------------------
# I6：_RealAgentRunner 真实装配 smoke（LLM mock，路径真穿过 predictions 表）
# ---------------------------------------------------------------------------


class _MockLLM:
    """最小 LLM 桩：一步即产出 submit_answer，让真实 AgentLoop 稳定收敛。"""

    def __init__(self, answer: str) -> None:
        self._answer = answer

    async def chat(
        self,
        messages: list[dict[str, Any]],
        *,
        session_id: str | None = None,
        parent_call_id: str | None = None,
    ) -> LLMResponse:
        content = json.dumps(
            {"action": {"tool": "submit_answer", "args": {"answer": self._answer}}}
        )
        return LLMResponse(
            content=content,
            thinking="",
            model="mock",
            provider="mock",
            prompt_tokens=1,
            completion_tokens=1,
            latency_ms=1,
            ttft_ms=None,
            max_inter_token_ms=None,
            cache_hit=False,
            call_id="mock-call",
        )


def _build_real_router(tmp_path: Path) -> InferenceDepsRouter:
    """构建真实 InferenceDepsRouter（仅 embed/vlm 打桩，router/deps 装配全真实）。"""
    vid = "smoke_vid"
    vid_dir = tmp_path / "videos" / vid
    (vid_dir / "frames").mkdir(parents=True)
    minimal_tree = {
        "metadata": {"source_path": "test", "modality": "video"},
        "roots": [
            {
                "id": "L1_000",
                "card": {
                    "scene_summary": "s",
                    "main_setting": "s",
                    "key_entities": [],
                    "main_actions": [],
                    "topic_keywords": [],
                    "visible_text": [],
                    "temporal_flow": "s",
                },
                "time_range": [0, 10],
                "children": [],
            }
        ],
    }
    (vid_dir / "tree.json").write_text(json.dumps(minimal_tree))
    prompts_dir = tmp_path / "prompts"
    prompts_dir.mkdir()
    (prompts_dir / "system.md").write_text("You are a search agent.")

    fake_embed = MagicMock()
    fake_embed.dim = 4
    fake_embed.embed = lambda t: np.zeros((1, 4), dtype=np.float32)

    return InferenceDepsRouter(
        store_dir=tmp_path,
        embed_provider=fake_embed,
        llm=_MockLLM(answer="B"),
        vlm=AsyncMock(),
        ocr=None,
        default_prompts_dir=prompts_dir,
        default_skills_dir=None,
        skill_mode="none",
        verify_vision=False,
        anchor=False,
        assemble_mode="ids",
    )


def _smoke_q(qid: str) -> GeneratedQuestion:
    """构造 smoke 题（video_id 对应真实 router 的树 fixture）。"""
    return GeneratedQuestion(
        question_id=qid,
        video_id="smoke_vid",
        task_type="Action Recognition",
        question="他之前做了什么？",
        options=("A. 蒸", "B. 炒", "C. 煮", "D. 炸"),
        answer="A",
        source_nodes=("L1_000",),
        difficulty="hard",
    )


@pytest.mark.asyncio
async def test_real_agent_runner_predict_roundtrips_predictions(tmp_path: Path) -> None:
    """真实装配 smoke：predict 经 run_inference 落 predictions 表再读回（LLM mock）。"""
    router = _build_real_router(tmp_path)
    runner = _RealAgentRunner(
        llm=_MockLLM(answer="B"),
        tool_dispatch_fn=router.create_dispatch(),
        prompt_builder=router.create_prompt_builder(),
        db_path=str(tmp_path / "harness.db"),
        concurrency=1,
        skill_mode="none",
        model="mock",
    )
    preds = await runner.predict([_smoke_q("smoke")], max_steps=2, run_id="smoke_r0")
    assert preds["smoke"] == "B"  # 真的从 predictions 表读回，非 mock 直返

    # 断言确实写进了 predictions 表（穿过 HarnessLog / RunLogImpl）
    from app.harness.log import RunLogImpl

    rows = await RunLogImpl(str(tmp_path / "harness.db")).get_predictions(
        "smoke_r0", question_ids=["smoke"]
    )
    assert rows and rows[0]["prediction"] == "B"


@pytest.mark.asyncio
async def test_real_agent_runner_predict_fills_none_for_missing(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """predict 对 predictions 表缺失的 qid 返回 None（契约：每个入参 qid 都有键）。"""

    async def _noop_run_inference(*args: Any, **kwargs: Any) -> None:
        """空跑：不写 predictions 表，模拟回读缺行。"""
        return None

    monkeypatch.setattr("app.harness.inference.run_inference", _noop_run_inference)
    runner = _RealAgentRunner(
        llm=_MockLLM(answer="B"),
        tool_dispatch_fn=lambda *a, **k: None,
        prompt_builder=lambda q: ("s", "u"),
        db_path=str(tmp_path / "harness.db"),
        concurrency=1,
        skill_mode="none",
        model="mock",
    )
    preds = await runner.predict([_smoke_q("missing")], max_steps=1, run_id="r_none")
    assert preds == {"missing": None}  # 缺回读行 → 该 qid 键存在且为 None
