"""T2.2 审稿子图单测——审稿节点 + collect 留痕 + review SSE 归一（ARCH §5.2/§7.3）。

全部注入 mock 网关 / 内存 fake repo，无真 LLM、无真 Postgres（图用 MemorySaver）。
asyncio_mode=auto。
"""

from __future__ import annotations

import uuid
from typing import Any

from fakes_orchestrator import (
    FailingRunGateway,
    FakeReviewRepo,
    FakeRunGateway,
    SchemaRoutingRunGateway,
)
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.memory import MemorySaver
from ww_agents import (
    Conflict,
    ContinuityReview,
    ForeshadowReview,
    ForeshadowSuggestion,
    PaceIssue,
    PaceReview,
    continuity_spec,
    foreshadow_spec,
    pace_spec,
)
from ww_core.orchestrator import (
    CONTINUITY,
    EVENT_CONFLICT,
    EVENT_DONE,
    EVENT_ERROR,
    EVENT_FORESHADOW,
    EVENT_PACE,
    EVENT_SECTION,
    FORESHADOW,
    PACE,
    REVIEW_INCOMPLETE,
    REVIEW_OK,
    SECTION_DONE,
    SECTION_INCOMPLETE,
    ChapterState,
    build_review_context,
    build_review_graph,
    build_review_request,
    collect_reviews,
    extract_conflicts,
    extract_foreshadow_sug,
    extract_pace,
    make_review_node,
    normalize_review,
    run_review,
)
from ww_shared import AppError, ErrorCode

PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
USER = uuid.UUID("00000000-0000-0000-0000-0000000000aa")

STABLE = "## 世界观硬规则\n灵气可凝丹"
VOLATILE = "## 近况\n主角已破境"
DRAFT = "他一拳轰碎山岳。"

_CONFLICT = Conflict(
    type="能力不符",
    where="第3段「一拳轰碎山岳」",
    refs=["人物卡:主角:力量上限=裂石"],
    suggestion="改为震裂巨石",
    original="他一拳轰碎山岳。",
    replacement="他一拳震裂巨石。",
)


def _state(reviews: dict[str, Any] | None = None) -> ChapterState:
    state: ChapterState = {
        "project_id": PROJECT,
        "chapter_no": 7,
        "user_id": USER,
        "stable_core": STABLE,
        "volatile": VOLATILE,
        "draft": DRAFT,
        "review_context": build_review_context(draft=DRAFT, stable_core=STABLE, volatile=VOLATILE),
    }
    if reviews is not None:
        state["reviews"] = reviews
    return state


# ---- build_review_context：确定性、含草稿+材料 ----


def test_build_review_context_is_deterministic_and_contains_parts() -> None:
    ctx1 = build_review_context(draft=DRAFT, stable_core=STABLE, volatile=VOLATILE)
    ctx2 = build_review_context(draft=DRAFT, stable_core=STABLE, volatile=VOLATILE)

    assert ctx1 == ctx2  # 无时间戳/随机 → 同输入同输出
    assert DRAFT in ctx1
    assert STABLE in ctx1
    assert VOLATILE in ctx1


# ---- build_review_request：tier 不变量、缓存断点、output_schema、run 非 stream ----


def test_build_review_request_uses_spec_tier_and_cached_prompt() -> None:
    req = build_review_request(continuity_spec, _state())

    assert req.tier == "analyst"  # 不变量②：spec 档位透传，不传 model
    assert req.stream is False  # 续审用 run() 非 stream()
    assert len(req.system) == 1
    assert req.system[0].text == continuity_spec.system_prompt
    assert req.system[0].cache is True  # 不变量#9
    assert req.output_schema is ContinuityReview
    assert req.scope.user_id == USER
    assert req.scope.project_id == PROJECT


# ---- run_review：结构化结果回 state、只读不写库 ----


async def test_run_review_returns_ok_with_parsed_result() -> None:
    gateway = FakeRunGateway(ContinuityReview(conflicts=[_CONFLICT]))

    result = await run_review(continuity_spec, _state(), gateway=gateway)

    assert gateway.call_count == 1
    entry = result["reviews"][CONTINUITY]
    assert entry["status"] == REVIEW_OK
    assert entry["result"]["conflicts"][0]["type"] == "能力不符"


async def test_run_review_isolates_gateway_failure_as_incomplete() -> None:
    gateway = FailingRunGateway(AppError(ErrorCode.LLM_UNAVAILABLE, "boom"))

    result = await run_review(continuity_spec, _state(), gateway=gateway)

    entry = result["reviews"][CONTINUITY]
    assert entry["status"] == REVIEW_INCOMPLETE  # §5.2：失败隔离、不上抛
    assert entry["result"] is None


async def test_make_review_node_binds_gateway() -> None:
    gateway = FakeRunGateway(ContinuityReview(conflicts=[]))
    node = make_review_node(continuity_spec, gateway)

    result = await node(_state())

    assert result["reviews"][CONTINUITY]["status"] == REVIEW_OK


# ---- collect：抽冲突 + 落 chapter_reviews 留痕（只 flush，不在节点 commit） ----


def test_extract_conflicts_only_from_ok_continuity() -> None:
    ok = {CONTINUITY: {"status": REVIEW_OK, "result": {"conflicts": [_CONFLICT.model_dump()]}}}
    incomplete = {CONTINUITY: {"status": REVIEW_INCOMPLETE, "result": None}}

    assert len(extract_conflicts(ok)) == 1
    assert extract_conflicts(incomplete) == []
    assert extract_conflicts({}) == []


async def test_collect_records_conflicts_to_review_repo() -> None:
    repo = FakeReviewRepo()
    reviews = {CONTINUITY: {"status": REVIEW_OK, "result": {"conflicts": [_CONFLICT.model_dump()]}}}

    out = await collect_reviews(_state(reviews), review_repo=repo)

    assert out == {}  # 不作真相源：留痕在表（不变量#5）
    assert len(repo.records) == 1
    rec = repo.records[0]
    assert rec["project_id"] == PROJECT
    assert rec["chapter_no"] == 7
    assert rec["chapter_version"] is None  # version 绑定/裁决归 T2.4
    assert rec["conflicts"][0]["type"] == "能力不符"


async def test_collect_records_empty_when_review_incomplete() -> None:
    repo = FakeReviewRepo()
    reviews = {CONTINUITY: {"status": REVIEW_INCOMPLETE, "result": None}}

    await collect_reviews(_state(reviews), review_repo=repo)

    assert repo.records[0]["conflicts"] == []  # 未完成 → 不臆造冲突


# ---- 图：START→continuity→collect→END，全程 mock，MemorySaver ----


def test_build_review_graph_compiles_with_nodes() -> None:
    graph = build_review_graph(
        FakeRunGateway(ContinuityReview(conflicts=[])),
        FakeReviewRepo(),
        checkpointer=MemorySaver(),
    )

    nodes = graph.get_graph().nodes
    assert CONTINUITY in nodes
    assert "collect" in nodes
    assert graph.checkpointer is not None


async def test_review_graph_runs_continuity_then_collect_end_to_end() -> None:
    gateway = FakeRunGateway(ContinuityReview(conflicts=[_CONFLICT]))
    repo = FakeReviewRepo()
    graph = build_review_graph(gateway, repo, checkpointer=MemorySaver())
    config: RunnableConfig = {"configurable": {"thread_id": "rev-1"}}

    final = await graph.ainvoke(_state(), config=config)

    assert final["reviews"][CONTINUITY]["status"] == REVIEW_OK
    assert len(repo.records) == 1  # collect 落了留痕
    assert repo.records[0]["conflicts"][0]["type"] == "能力不符"


async def test_review_graph_does_not_block_on_review_failure() -> None:
    gateway = FailingRunGateway(RuntimeError("down"))
    repo = FakeReviewRepo()
    graph = build_review_graph(gateway, repo, checkpointer=MemorySaver())
    config: RunnableConfig = {"configurable": {"thread_id": "rev-2"}}

    final = await graph.ainvoke(_state(), config=config)

    # 审失败被隔离为 incomplete，图照常走到 collect 并留痕（空冲突）
    assert final["reviews"][CONTINUITY]["status"] == REVIEW_INCOMPLETE
    assert repo.records[0]["conflicts"] == []


# ---- review SSE 归一：section + conflict + done；incomplete 路径；异常→error ----


async def test_normalize_review_emits_section_conflict_done() -> None:
    reviews = {CONTINUITY: {"status": REVIEW_OK, "result": {"conflicts": [_CONFLICT.model_dump()]}}}

    events = [e async for e in normalize_review(reviews)]

    assert [e.event for e in events] == [EVENT_SECTION, EVENT_CONFLICT, EVENT_DONE]
    assert events[0].data == {"name": CONTINUITY, "status": SECTION_DONE}
    assert events[1].data["type"] == "能力不符"
    assert events[1].data["refs"] == ["人物卡:主角:力量上限=裂石"]
    # 一键采纳补丁对透传到 conflict 事件（前端 find-replace 用）。
    assert events[1].data["original"] == "他一拳轰碎山岳。"
    assert events[1].data["replacement"] == "他一拳震裂巨石。"
    assert events[-1].data == {"length": 1}


async def test_normalize_review_marks_incomplete_section_without_conflicts() -> None:
    reviews = {CONTINUITY: {"status": REVIEW_INCOMPLETE, "result": None}}

    events = [e async for e in normalize_review(reviews)]

    assert [e.event for e in events] == [EVENT_SECTION, EVENT_DONE]
    assert events[0].data == {"name": CONTINUITY, "status": SECTION_INCOMPLETE}


async def test_normalize_review_maps_unexpected_error_to_error_event() -> None:
    # 畸形 entry（result 非 dict）触发归一内部异常 → error 事件，不上抛
    reviews = {CONTINUITY: {"status": REVIEW_OK, "result": 123}}

    events = [e async for e in normalize_review(reviews, request_id="req-9")]

    assert events[-1].event == EVENT_ERROR
    assert events[-1].data["code"] == ErrorCode.INTERNAL
    assert events[-1].data["request_id"] == "req-9"


# ============================================================================
# T3.3 三审齐：foreshadow + pace 并入并行审 / collect 列映射 / SSE surface
# ============================================================================

_FORESHADOW = ForeshadowReview(
    planted=[
        ForeshadowSuggestion(code="FS-MARK", title="胸口胎记", where="第2段", note="皇族暗示")
    ],
    resolved=[ForeshadowSuggestion(code="FS-BIRTH", title="身世", where="第9段", note="回收")],
)
_PACE = PaceReview(
    water=[PaceIssue(where="第4-6段", reason="信息密度低")],
    hook=True,
    beat_map=[1, 3, 5, 0, 4],
)


def _three_reviews() -> dict[str, Any]:
    """三审皆 ok 的 state['reviews']（continuity/foreshadow/pace）。"""
    return {
        CONTINUITY: {"status": REVIEW_OK, "result": {"conflicts": [_CONFLICT.model_dump()]}},
        FORESHADOW: {"status": REVIEW_OK, "result": _FORESHADOW.model_dump()},
        PACE: {"status": REVIEW_OK, "result": _PACE.model_dump()},
    }


# ---- collect 三审列映射 ----


def test_extract_foreshadow_sug_flattens_planted_and_resolved() -> None:
    sug = extract_foreshadow_sug(_three_reviews())

    assert len(sug) == 2  # 1 planted + 1 resolved 扁平
    kinds = {s["kind"] for s in sug}
    assert kinds == {"planted", "resolved"}
    planted = next(s for s in sug if s["kind"] == "planted")
    assert planted["code"] == "FS-MARK"


def test_extract_pace_returns_dict() -> None:
    pace = extract_pace(_three_reviews())

    assert pace is not None
    assert pace["hook"] is True
    assert pace["beat_map"] == [1, 3, 5, 0, 4]


def test_extract_foreshadow_and_pace_empty_when_incomplete() -> None:
    reviews = {
        FORESHADOW: {"status": REVIEW_INCOMPLETE, "result": None},
        PACE: {"status": REVIEW_INCOMPLETE, "result": None},
    }
    assert extract_foreshadow_sug(reviews) == []
    assert extract_pace(reviews) is None


async def test_collect_records_three_audits_to_their_columns() -> None:
    repo = FakeReviewRepo()

    await collect_reviews(_state(_three_reviews()), review_repo=repo)

    assert len(repo.records) == 1  # 一次 record 落齐三审
    rec = repo.records[0]
    assert rec["conflicts"][0]["type"] == "能力不符"  # continuity→conflicts
    assert len(rec["foreshadow_sug"]) == 2  # foreshadow→foreshadow_sug（扁平）
    assert rec["pace"]["hook"] is True  # pace→pace


# ---- 三审并行图跑通（mock 网关按 schema 路由产三 parsed）----


async def test_three_review_graph_runs_all_audits_end_to_end() -> None:
    gateway = SchemaRoutingRunGateway(
        {
            ContinuityReview: ContinuityReview(conflicts=[_CONFLICT]),
            ForeshadowReview: _FORESHADOW,
            PaceReview: _PACE,
        }
    )
    repo = FakeReviewRepo()
    graph = build_review_graph(
        gateway,
        repo,
        review_specs=(continuity_spec, foreshadow_spec, pace_spec),
        checkpointer=MemorySaver(),
    )
    config: RunnableConfig = {"configurable": {"thread_id": "rev-3"}}

    final = await graph.ainvoke(_state(), config=config)

    assert final["reviews"][CONTINUITY]["status"] == REVIEW_OK
    assert final["reviews"][FORESHADOW]["status"] == REVIEW_OK
    assert final["reviews"][PACE]["status"] == REVIEW_OK
    assert len(gateway.calls) == 3  # 三审各调一次网关
    # collect 一次 record 落齐三列
    rec = repo.records[0]
    assert rec["conflicts"] and rec["foreshadow_sug"] and rec["pace"]


async def test_three_review_graph_isolates_one_failing_audit() -> None:
    # foreshadow schema 缺失 → SchemaRoutingRunGateway 抛 KeyError → run_review 隔离为 incomplete
    gateway = SchemaRoutingRunGateway(
        {
            ContinuityReview: ContinuityReview(conflicts=[_CONFLICT]),
            PaceReview: _PACE,
        }
    )
    repo = FakeReviewRepo()
    graph = build_review_graph(
        gateway,
        repo,
        review_specs=(continuity_spec, foreshadow_spec, pace_spec),
        checkpointer=MemorySaver(),
    )
    config: RunnableConfig = {"configurable": {"thread_id": "rev-4"}}

    final = await graph.ainvoke(_state(), config=config)

    # foreshadow 失败被隔离，其余两审照常 + collect 照常留痕（foreshadow_sug 空）
    assert final["reviews"][FORESHADOW]["status"] == REVIEW_INCOMPLETE
    assert final["reviews"][CONTINUITY]["status"] == REVIEW_OK
    assert final["reviews"][PACE]["status"] == REVIEW_OK
    rec = repo.records[0]
    assert rec["conflicts"]  # continuity 落库
    assert rec["foreshadow_sug"] == []  # 失败列留空，不阻塞其余
    assert rec["pace"]["hook"] is True  # pace 落库


# ---- SSE：normalize_review surface 三审（section + conflict + foreshadow + pace + done）----


async def test_normalize_review_surfaces_three_audits() -> None:
    events = [e async for e in normalize_review(_three_reviews())]
    kinds = [e.event for e in events]

    # 字典序遍历：continuity < foreshadow < pace
    # continuity: section + 1 conflict
    # foreshadow: section + 2（planted+resolved）；pace: section + 1
    assert kinds == [
        EVENT_SECTION,
        EVENT_CONFLICT,
        EVENT_SECTION,
        EVENT_FORESHADOW,
        EVENT_FORESHADOW,
        EVENT_SECTION,
        EVENT_PACE,
        EVENT_DONE,
    ]
    # foreshadow 事件带 kind + code
    fs_events = [e for e in events if e.event == EVENT_FORESHADOW]
    assert {e.data["kind"] for e in fs_events} == {"planted", "resolved"}
    # pace 事件带节拍图
    pace_evt = next(e for e in events if e.event == EVENT_PACE)
    assert pace_evt.data["beat_map"] == [1, 3, 5, 0, 4]
    assert pace_evt.data["hook"] is True
    assert events[-1].data == {"length": 3}  # 三审项


async def test_normalize_review_skips_results_for_incomplete_audit() -> None:
    reviews = {
        CONTINUITY: {"status": REVIEW_OK, "result": {"conflicts": []}},
        FORESHADOW: {"status": REVIEW_INCOMPLETE, "result": None},
        PACE: {"status": REVIEW_OK, "result": _PACE.model_dump()},
    }

    events = [e async for e in normalize_review(reviews)]
    kinds = [e.event for e in events]

    # foreshadow incomplete → 仅 section，无 foreshadow 事件
    assert kinds == [EVENT_SECTION, EVENT_SECTION, EVENT_SECTION, EVENT_PACE, EVENT_DONE]
    fs_section = next(e for e in events if e.data.get("name") == FORESHADOW)
    assert fs_section.data["status"] == SECTION_INCOMPLETE
