"""WFW-9 端到端：两阶段 AI 反问澄清预检（真实 DB，零 token，MOCK 网关）。

证明 WFW-9 路线A 两阶段之「问题」阶段的两条预检端点闭环（refine 侧 M1 + rewrite 侧 M2），
锁定其跨栈契约：HTTP → dep 解析 → 真实 `Gateway` → 结构化 `ClarifyDecision` →
`usage_ledger` 落真 pg → JSON 响应。两端点共用 `get_clarify_gateway`（analyst 档，默认路由
deepseek），均**非流式、只读**（绝不写业务表，只有末尾 `session.commit()` 让网关 ledger 落库）
且**失败开放**（网关产非 `ClarifyDecision` → `run_clarify` 确定性回退 need_clarification=false，
绝不 500）。

范式与 `tests/test_m2_e2e.py` / `tests/test_t6_toolbox_e2e.py` 一致：**在 provider/adapter 层
mock**（真实 `Gateway` + 据调用返回 `parsed` 的假适配器，绝不联网）+ 真实
`SqlAlchemyLedgerSink`（用量记账真落 pg）+ DB 真源断言。**不**在 dep 层塞假网关——那是端点单测
（`apps/api/tests/`）的层级；E2E 走真网关证明记账/路由/结构化透传全链路。无 pg → skip。

用例（真 pg + mock 网关 → 脚本化 ClarifyDecision，零 token）：
1. refine 含糊意见 → 反问：mock 产 need_clarification=true + 恰 1 问（2–3 选项 + 自由输入）
   → 200，need_clarification is True，恰 1 问带选项，ledger 落 analyst 一行。
2. refine 明确意见 → 放行：mock 产 need_clarification=false + verification → questions==[]、
   verification 非空。
3. rewrite 含糊意见 → 反问：POST rewrite/clarify（带 prior_draft 摘录）→ True + 1 问。
4. rewrite 明确意见 → 放行：→ need_clarification is False。
5. 失败开放：mock 让网关返回**非** ClarifyDecision（parsed=None）→ 端点仍 200 且
   need_clarification is False（绝不 500），且 ledger 仍落一行（预检是真计费 LLM 调用）。
6. 只读 + 记账：clarify 后真 pg 中业务表**零变更**（seed 的 chapter 内容字节一致、无新
   chapter/review/digest 行）但 usage_ledger **真落一行**；响应 JSON 绝不回灌原文摘录。
7. 404：clarify 打不存在的 project_id → 404 错误信封，且无 ledger/业务行。

坑（见 memory/gotchas，同 M2/T6）：
- `LifespanManager` 触发 lifespan → `seed_stub_user`（project/chapter owner FK 依赖它）。
- `get_sessionmaker` engine 绑定首个 loop → 每 DB 测试清缓存重建、结束 dispose。
- 网关 ledger 只 flush；端点末 `session.commit()` → usage_ledger 方落库。
- ASGITransport `raise_app_exceptions=True`：404 是 `AppError` → 正常返回信封（不上抛）。
"""

from __future__ import annotations

import json
import uuid
from collections.abc import AsyncIterator, Callable
from typing import Annotated

import httpx
import pytest
from asgi_lifespan import LifespanManager
from fastapi import Depends
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ww_agents import ClarifyDecision, ClarifyOption, ClarifyQuestion
from ww_db import get_session, get_sessionmaker
from ww_db.models import Chapter, ChapterDigest, ChapterReview, Project, UsageLedger
from ww_llm_gateway import Gateway, SqlAlchemyLedgerSink, resolve_route
from ww_llm_gateway.adapters.base import (
    Capabilities,
    ProviderResult,
    ProviderUsage,
    StreamChunk,
)
from ww_llm_gateway.types import LlmRequest

# analyst 默认路由 deepseek（config.tier_defaults），同 M2/T6。
_PROVIDER = "deepseek"

# analyst 档假用量（喂记账；证明预检记账真落 pg + input_tokens 可回指本档位调用）。
_ANALYST_USAGE = ProviderUsage(input_tokens=23, output_tokens=7)

# 脚本化「反问」决策：need_clarification=true + 恰 1 问（v1 硬上限）+ 3 选项 + 自由输入兜底。
_ASK_DECISION = ClarifyDecision(
    need_clarification=True,
    questions=[
        ClarifyQuestion(
            question="你想让这段往哪个方向改？",
            options=[
                ClarifyOption(label="加快节奏", value="拆短句、压缩铺陈"),
                ClarifyOption(label="加重冲突", value="强化人物对立、提高赌注"),
                ClarifyOption(label="补足细节", value="增加环境与心理描写"),
            ],
            allow_free_text=True,
        )
    ],
    verification=None,
)

# 脚本化「放行」决策：need_clarification=false + 一句确认语，questions 留空。
_PROCEED_DECISION = ClarifyDecision(
    need_clarification=False,
    verification="我的理解是把这段节奏压紧、突出主角的犹豫，对吗？",
)

# 原文标记串：塞进 prior_draft/segment，断言**响应绝不回灌**（预检只判不回抄原文）。
_MANUSCRIPT_MARKER = "秘密正文标记：主角在雨夜独自登上钟楼敲响了第七下钟"


class _FakeClarifyAdapter:
    """实现 `ProviderAdapter` Protocol：`complete()` 返回脚本化 `parsed`，绝不联网。

    `parsed` 由构造注入——正常用例给 `ClarifyDecision`；失败开放用例给 `None`（模拟
    instructor 重试上限后仍畸形），验证 `run_clarify` 端到端确定性回退。每路带
    `ProviderUsage` → 网关据此落一条 usage_ledger（预检是真计费调用）。clarify 走 run()
    非流式，`stream()` 仅为满足 Protocol、被调即判失败。
    """

    provider = _PROVIDER

    def __init__(self, parsed: ClarifyDecision | None) -> None:
        self._parsed = parsed

    def capabilities(self) -> Capabilities:
        return Capabilities(structured_output=True)

    async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
        text = self._parsed.model_dump_json() if self._parsed is not None else "{}"
        return ProviderResult(text=text, parsed=self._parsed, usage=_ANALYST_USAGE)

    async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
        yield StreamChunk(usage=_ANALYST_USAGE)
        raise AssertionError("clarify 预检必须非流式，不得走 stream()")


@pytest.fixture
async def e2e_sm() -> AsyncIterator[async_sessionmaker[AsyncSession]]:
    """真实 DB session 工厂；无 pg 时跳过（每测试清缓存重建 engine、结束 dispose）。"""
    get_sessionmaker.cache_clear()
    maker = get_sessionmaker()
    try:
        async with maker() as probe:
            await probe.execute(select(1))
    except Exception:
        pytest.skip("postgres not reachable")
    yield maker
    await maker.kw["bind"].dispose()
    get_sessionmaker.cache_clear()


def _gateway_override_factory(
    adapter: _FakeClarifyAdapter,
) -> Callable[[AsyncSession], Gateway]:
    """依赖覆盖：真实 Gateway + 给定假适配器 + 真实 ledger（请求 session）。

    ledger 用**请求 session**（`Depends(get_session)`，FastAPI 按请求缓存，与端点末尾
    `commit()` 同一实例）→ 由端点 `session.commit()` 落库（同 M2 记账闭环）。
    """

    def _override(session: Annotated[AsyncSession, Depends(get_session)]) -> Gateway:
        return Gateway(
            adapters={_PROVIDER: adapter},
            ledger=SqlAlchemyLedgerSink(session),
            resolver=resolve_route,
        )

    return _override


async def _count(
    sm: async_sessionmaker[AsyncSession],
    model: type[Chapter] | type[ChapterReview] | type[ChapterDigest] | type[UsageLedger],
    project_uuid: uuid.UUID,
) -> int:
    async with sm() as s:
        return int(
            (
                await s.execute(
                    select(func.count()).select_from(model).where(model.project_id == project_uuid)
                )
            ).scalar_one()
        )


async def _seed_chapter(
    sm: async_sessionmaker[AsyncSession], project_uuid: uuid.UUID, content: str
) -> None:
    """直插一行草稿 chapter（供只读用例断言 clarify 后内容字节一致、未被触碰）。"""
    async with sm() as s:
        s.add(
            Chapter(
                project_id=project_uuid,
                volume=1,
                chapter_no=1,
                content=content,
                status="draft",
                version=1,
            )
        )
        await s.commit()


async def _cleanup(sm: async_sessionmaker[AsyncSession], project_uuid: uuid.UUID) -> None:
    """按 FK 顺序清理。"""
    async with sm() as cleanup:
        await cleanup.execute(delete(UsageLedger).where(UsageLedger.project_id == project_uuid))
        await cleanup.execute(delete(ChapterDigest).where(ChapterDigest.project_id == project_uuid))
        await cleanup.execute(delete(ChapterReview).where(ChapterReview.project_id == project_uuid))
        await cleanup.execute(delete(Chapter).where(Chapter.project_id == project_uuid))
        await cleanup.execute(delete(Project).where(Project.id == project_uuid))
        await cleanup.commit()


def _assert_no_manuscript_leak(payload: object) -> None:
    """负向：响应体序列化后绝不含原文标记（预检只判含糊，不回抄原文）。"""
    blob = json.dumps(payload, ensure_ascii=False)
    assert _MANUSCRIPT_MARKER not in blob, "原文标记泄漏进 clarify 响应"


def _build_app_with_adapter(adapter: _FakeClarifyAdapter):  # type: ignore[no-untyped-def]
    """建 app 并把 `get_clarify_gateway` 覆盖为「真 Gateway + 假适配器」——refine/rewrite 共用。"""
    from ww_api.main import create_app
    from ww_api.services.project_deps import get_clarify_gateway

    app = create_app()
    app.dependency_overrides[get_clarify_gateway] = _gateway_override_factory(adapter)
    return app


async def _create_project(client: httpx.AsyncClient, title: str) -> uuid.UUID:
    resp = await client.post("/projects", json={"title": title, "genre": "玄幻"})
    assert resp.status_code == 201
    return uuid.UUID(resp.json()["id"])


async def test_refine_vague_instruction_asks_one_clarifying_question(
    e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
    """用例 1：refine 含糊意见「改改」→ 200，need_clarification True，恰 1 问带选项 + ledger。"""
    app = _build_app_with_adapter(_FakeClarifyAdapter(_ASK_DECISION))
    transport = httpx.ASGITransport(app=app)
    project_uuid: uuid.UUID | None = None
    try:
        async with LifespanManager(app):
            async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
                project_uuid = await _create_project(client, "WFW-9 refine 含糊作品")

                resp = await client.post(
                    f"/projects/{project_uuid}/chapters/1/refine/clarify",
                    json={"segment": "他走进房间坐下。", "instruction": "改改"},
                )
                assert resp.status_code == 200
                body = resp.json()
                assert body["need_clarification"] is True
                assert len(body["questions"]) == 1  # v1 硬上限 1 问
                question = body["questions"][0]
                assert question["question"]
                assert 2 <= len(question["options"]) <= 3
                assert question["allow_free_text"] is True
                _assert_no_manuscript_leak(body)

        # DB 真源：预检是真计费 analyst 调用 → ledger 落 1 行；业务表零新增。
        assert project_uuid is not None
        assert await _count(e2e_sm, UsageLedger, project_uuid) == 1
        assert await _count(e2e_sm, Chapter, project_uuid) == 0
        async with e2e_sm() as verify:
            row = (
                await verify.execute(
                    select(UsageLedger).where(UsageLedger.project_id == project_uuid)
                )
            ).scalar_one()
            assert row.provider == _PROVIDER
            assert row.input_tokens == _ANALYST_USAGE.input_tokens
    finally:
        if project_uuid is not None:
            await _cleanup(e2e_sm, project_uuid)


async def test_refine_clear_instruction_proceeds_without_questions(
    e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
    """用例 2：refine 明确意见 → need_clarification False，questions==[]，verification 非空。"""
    app = _build_app_with_adapter(_FakeClarifyAdapter(_PROCEED_DECISION))
    transport = httpx.ASGITransport(app=app)
    project_uuid: uuid.UUID | None = None
    try:
        async with LifespanManager(app):
            async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
                project_uuid = await _create_project(client, "WFW-9 refine 明确作品")

                resp = await client.post(
                    f"/projects/{project_uuid}/chapters/1/refine/clarify",
                    json={
                        "segment": "他走进房间坐下。",
                        "instruction": "把这句拆成两个短句，突出他的迟疑",
                    },
                )
                assert resp.status_code == 200
                body = resp.json()
                assert body["need_clarification"] is False
                assert body["questions"] == []
                assert body["verification"]  # 明确时给一句确认语

        # 明确放行同样是真计费调用 → ledger 落 1 行。
        assert project_uuid is not None
        assert await _count(e2e_sm, UsageLedger, project_uuid) == 1
    finally:
        if project_uuid is not None:
            await _cleanup(e2e_sm, project_uuid)


async def test_rewrite_vague_feedback_asks_one_clarifying_question(
    e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
    """用例 3：rewrite 含糊意见「节奏太慢」（带 prior_draft）→ True + 恰 1 问。"""
    app = _build_app_with_adapter(_FakeClarifyAdapter(_ASK_DECISION))
    transport = httpx.ASGITransport(app=app)
    project_uuid: uuid.UUID | None = None
    try:
        async with LifespanManager(app):
            async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
                project_uuid = await _create_project(client, "WFW-9 rewrite 含糊作品")

                resp = await client.post(
                    f"/projects/{project_uuid}/chapters/1/rewrite/clarify",
                    json={
                        "feedback": "节奏太慢",
                        "prior_draft": "第一章：清晨的雾还没散。" + _MANUSCRIPT_MARKER,
                    },
                )
                assert resp.status_code == 200
                body = resp.json()
                assert body["need_clarification"] is True
                assert len(body["questions"]) == 1
                assert body["questions"][0]["options"]
                _assert_no_manuscript_leak(body)

        assert project_uuid is not None
        assert await _count(e2e_sm, UsageLedger, project_uuid) == 1
    finally:
        if project_uuid is not None:
            await _cleanup(e2e_sm, project_uuid)


async def test_rewrite_clear_feedback_proceeds_without_questions(
    e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
    """用例 4：rewrite 明确意见 → need_clarification False。"""
    app = _build_app_with_adapter(_FakeClarifyAdapter(_PROCEED_DECISION))
    transport = httpx.ASGITransport(app=app)
    project_uuid: uuid.UUID | None = None
    try:
        async with LifespanManager(app):
            async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
                project_uuid = await _create_project(client, "WFW-9 rewrite 明确作品")

                resp = await client.post(
                    f"/projects/{project_uuid}/chapters/1/rewrite/clarify",
                    json={
                        "feedback": "把第 3 段主角的犹豫删掉，直接进入冲突，全章提速",
                        "prior_draft": "第一章：清晨的雾还没散。",
                    },
                )
                assert resp.status_code == 200
                body = resp.json()
                assert body["need_clarification"] is False
                assert body["questions"] == []
                assert body["verification"]

        assert project_uuid is not None
        assert await _count(e2e_sm, UsageLedger, project_uuid) == 1
    finally:
        if project_uuid is not None:
            await _cleanup(e2e_sm, project_uuid)


async def test_clarify_fails_open_when_gateway_returns_non_decision(
    e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
    """用例 5：网关返回非 ClarifyDecision（parsed=None）→ 端点仍 200、need_clarification False。

    证明 `run_clarify` 的确定性回退端到端生效（不 500、不阻塞润色），且该次调用仍真计费落库。
    """
    app = _build_app_with_adapter(_FakeClarifyAdapter(None))  # 畸形：parsed=None
    transport = httpx.ASGITransport(app=app)
    project_uuid: uuid.UUID | None = None
    try:
        async with LifespanManager(app):
            async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
                project_uuid = await _create_project(client, "WFW-9 失败开放作品")

                resp = await client.post(
                    f"/projects/{project_uuid}/chapters/1/refine/clarify",
                    json={"segment": "他走进房间坐下。", "instruction": "随便改"},
                )
                # 绝不 500——确定性回退为「不问、放行」。
                assert resp.status_code == 200
                body = resp.json()
                assert body["need_clarification"] is False
                assert body["questions"] == []

        # 回退不改「这是一次真 LLM 调用」的事实 → ledger 仍落一行。
        assert project_uuid is not None
        assert await _count(e2e_sm, UsageLedger, project_uuid) == 1
    finally:
        if project_uuid is not None:
            await _cleanup(e2e_sm, project_uuid)


async def test_clarify_is_read_only_and_records_ledger(
    e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
    """用例 6：clarify 后业务表零变更（seed 的 chapter 字节一致）但 usage_ledger 真落一行。"""
    app = _build_app_with_adapter(_FakeClarifyAdapter(_ASK_DECISION))
    transport = httpx.ASGITransport(app=app)
    project_uuid: uuid.UUID | None = None
    seeded_content = "第一章：清晨的雾还没散。" + _MANUSCRIPT_MARKER
    try:
        async with LifespanManager(app):
            async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
                project_uuid = await _create_project(client, "WFW-9 只读记账作品")
                await _seed_chapter(e2e_sm, project_uuid, seeded_content)

                resp = await client.post(
                    f"/projects/{project_uuid}/chapters/1/rewrite/clarify",
                    json={"feedback": "改改", "prior_draft": seeded_content},
                )
                assert resp.status_code == 200
                _assert_no_manuscript_leak(resp.json())

        assert project_uuid is not None
        # 业务表零变更：seed 的 chapter 仍是唯一一行且内容字节一致；无 review/digest 落库。
        assert await _count(e2e_sm, Chapter, project_uuid) == 1
        assert await _count(e2e_sm, ChapterReview, project_uuid) == 0
        assert await _count(e2e_sm, ChapterDigest, project_uuid) == 0
        async with e2e_sm() as verify:
            chapter = (
                await verify.execute(select(Chapter).where(Chapter.project_id == project_uuid))
            ).scalar_one()
            assert chapter.content == seeded_content
            assert chapter.status == "draft"
            assert chapter.version == 1
        # 但预检本身是真计费 analyst 调用 → usage_ledger 恰一行。
        assert await _count(e2e_sm, UsageLedger, project_uuid) == 1
    finally:
        if project_uuid is not None:
            await _cleanup(e2e_sm, project_uuid)


async def test_clarify_nonexistent_project_returns_404(
    e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
    """用例 7：clarify 打不存在的 project_id → 404 错误信封，且无 ledger/业务行。"""
    app = _build_app_with_adapter(_FakeClarifyAdapter(_ASK_DECISION))
    transport = httpx.ASGITransport(app=app)
    missing = uuid.uuid4()
    async with LifespanManager(app):
        async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
            resp = await client.post(
                f"/projects/{missing}/chapters/1/refine/clarify",
                json={"segment": "他走进房间坐下。", "instruction": "改改"},
            )
            assert resp.status_code == 404
            err = resp.json()["error"]
            assert err["code"] == "NOT_FOUND"

    # 项目不存在触网关前 fail-fast → ledger/业务表均无该 project 的行。
    assert await _count(e2e_sm, UsageLedger, missing) == 0
    assert await _count(e2e_sm, Chapter, missing) == 0
