"""Scope B 端到端：4 个竞品生成器（续写/扩写/降AI率/拆书）通用生成端点。

真实 DB（pg，无则 skip）+ 零 token MOCK 网关。证明 Scope B 竞品快赢闭环：
- 4 生成器均经一条通用执行路径 `POST /projects/{id}/skills/{tool_key}/generate`；
- 续写（with_prior_chapter）：端点先读该章最新 accepted/draft 正文，注入续写上下文
  （断 fake 适配器收到的 `req.input` 含前文正文 — 验证跨层数据流，不变量 #1 DB 为真相源）；
- 扩写/降AI率（text_input）：作者原文经 `body.text` 路由进上下文（断 `req.input` 含原文）；
- 拆书（text_input）：结构化输出 themes/archetypes/structure/hooks（断 preview 字段齐全）；
- **不变量 #3（预览不写库）**：4 者均仅记 usage_ledger、业务表（chapters/world_entities/
  outline）零新增；token/正文不入响应外日志（断响应体无敏感字样）。

镜像 `tests/test_t6_toolbox_e2e.py` 的范式：override `get_tier_gateway_builder` 返回单个真
`Gateway`（假适配器据 `req.output_schema` 分支返回 parsed + 真实 `SqlAlchemyLedgerSink`）。
fake 适配器额外按 output_schema 记下收到的 `req.input`，供路由断言。
"""

from __future__ import annotations

import uuid
from collections.abc import AsyncIterator, Awaitable, 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 sqlalchemy.orm import InstrumentedAttribute
from ww_agents.schemas import (
    BookTeardownResult,
    ContinuationResult,
    DeAiResult,
    PolishResult,
)
from ww_db import get_session, get_sessionmaker
from ww_db.models import (
    Chapter,
    Outline,
    Project,
    UsageLedger,
    WorldEntity,
)
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, Tier

# light/analyst/writer 默认都路由到 deepseek（config.tier_defaults）。
_PROVIDER = "deepseek"

# 各档位假用量（喂记账；证明记账落库）。
_USAGE = {
    "analyst": ProviderUsage(input_tokens=23, output_tokens=7),
    "writer": ProviderUsage(input_tokens=31, output_tokens=17),
}

# 前一章已写正文（accepted）——续写须无缝承接，端点读它注入上下文。
_PRIOR_ACCEPTED_TEXT = "林动握紧拳头，灵气在丹田翻涌，他知道下一步将踏入炼气九重。"
# 续写假产物（writer）。
_CONTINUATION = ContinuationResult(text="他深吸一口气，周身灵气如潮水般汇聚，瓶颈应声而破。")

# 扩写原文（作者经 body.text 提供）+ 扩写假产物（writer）。
_EXPAND_SOURCE = "他跑了。"
_POLISH = PolishResult(text="他撒腿狂奔，脚下碎石飞溅，心跳如擂鼓，只想逃离这片死地。")

# 降AI率原文 + 假产物（analyst）。
_DE_AI_SOURCE = "该角色在此刻进行了一次具有重要意义的决策行为。"
_DE_AI = DeAiResult(text="这一刻，他咬了咬牙，做出了那个改变一切的决定。")

# 拆书样本 + 结构化拆解假产物（analyst）。
_TEARDOWN_SOURCE = "第一章：少年得宝，立志复仇……（样本片段）"
_TEARDOWN = BookTeardownResult(
    themes=["逆袭", "复仇"],
    archetypes=["落魄天才", "腹黑反派"],
    structure="开篇遭难→获得机缘→步步升级→反攻高潮",
    hooks=["开局打脸", "扮猪吃虎"],
)


class _FakeGeneratorAdapter:
    """实现 `ProviderAdapter` Protocol：按 `req.output_schema` 分支返回 parsed，绝不联网。

    额外把每路收到的 `req.input` 记进 `seen_input`（供路由断言：续写注入前文、
    扩写/降AI 注入原文）。
    """

    def __init__(self, provider: str = _PROVIDER) -> None:
        self.provider = provider
        self.seen_input: dict[type, str] = {}

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

    async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
        schema = req.output_schema
        assert schema is not None
        # 注入材料进 input（str；断点后），system 才是 list[Block]——记下供路由断言。
        assert isinstance(req.input, str)
        self.seen_input[schema] = req.input
        if schema is ContinuationResult:
            return ProviderResult(
                text=_CONTINUATION.model_dump_json(), parsed=_CONTINUATION, usage=_USAGE["writer"]
            )
        if schema is PolishResult:
            return ProviderResult(
                text=_POLISH.model_dump_json(), parsed=_POLISH, usage=_USAGE["writer"]
            )
        if schema is DeAiResult:
            return ProviderResult(
                text=_DE_AI.model_dump_json(), parsed=_DE_AI, usage=_USAGE["analyst"]
            )
        if schema is BookTeardownResult:
            return ProviderResult(
                text=_TEARDOWN.model_dump_json(), parsed=_TEARDOWN, usage=_USAGE["analyst"]
            )
        raise AssertionError(f"unexpected output_schema in Scope B fake adapter: {schema!r}")

    async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
        yield StreamChunk(usage=_USAGE["analyst"])
        raise AssertionError("competitor generators must not 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 _tier_builder_override(
    adapter: _FakeGeneratorAdapter,
) -> Callable[[AsyncSession], Callable[[Tier], Awaitable[Gateway]]]:
    """依赖覆盖：`get_tier_gateway_builder` → 返回「按 tier 建网关」的 builder。

    builder 忽略 tier 返回同一个真 `Gateway`（单 provider 假适配器据 output_schema 分支 +
    真实 ledger，请求 session）——覆盖 writer（续写/扩写）与 analyst（降AI/拆书）两路。
    """

    def _override(
        session: Annotated[AsyncSession, Depends(get_session)],
    ) -> Callable[[Tier], Awaitable[Gateway]]:
        async def _build(_tier: Tier) -> Gateway:
            return Gateway(
                adapters={adapter.provider: adapter},
                ledger=SqlAlchemyLedgerSink(session),
                resolver=resolve_route,
            )

        return _build

    return _override


async def _count(
    sm: async_sessionmaker[AsyncSession],
    project_id_col: InstrumentedAttribute[uuid.UUID | None],
    project_uuid: uuid.UUID,
) -> int:
    async with sm() as s:
        return int(
            (
                await s.execute(select(func.count()).where(project_id_col == project_uuid))
            ).scalar_one()
        )


async def _seed_prior_chapter(
    sm: async_sessionmaker[AsyncSession], project_uuid: uuid.UUID, chapter_no: int
) -> None:
    """种入第 `chapter_no` 章的 accepted 正文（续写承接的前文，DB 为真相源 #1）。"""
    async with sm() as s:
        s.add(
            Chapter(
                project_id=project_uuid,
                volume=1,
                chapter_no=chapter_no,
                content=_PRIOR_ACCEPTED_TEXT,
                status="accepted",
                version=2,
            )
        )
        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(Chapter).where(Chapter.project_id == project_uuid))
        await cleanup.execute(delete(Outline).where(Outline.project_id == project_uuid))
        await cleanup.execute(delete(WorldEntity).where(WorldEntity.project_id == project_uuid))
        await cleanup.execute(delete(Project).where(Project.id == project_uuid))
        await cleanup.commit()


def _assert_no_secret_leak(payload: object) -> None:
    """负向：响应体序列化后绝不含 token/secret/api_key 等敏感字样。"""
    import json

    blob = json.dumps(payload, ensure_ascii=False).lower()
    for needle in ("api_key", "secret", "token", "bearer", "sk-", "credential"):
        assert needle not in blob, f"sensitive marker leaked into response: {needle!r}"


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 _assert_business_tables_untouched(
    sm: async_sessionmaker[AsyncSession],
    project_uuid: uuid.UUID,
    *,
    expected_chapters: int,
) -> None:
    """不变量 #3：预览路径不写任何业务表（chapters 仅有种入的前文、world_entities/outline 空）。"""
    assert await _count(sm, Chapter.project_id, project_uuid) == expected_chapters
    assert await _count(sm, WorldEntity.project_id, project_uuid) == 0
    assert await _count(sm, Outline.project_id, project_uuid) == 0


async def test_continue_generate_injects_prior_chapter_text(
    e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
    """续写：端点读该章 accepted 正文注入续写上下文 → 200 预览；ledger 1 行、业务表零新增。"""
    from ww_api.main import create_app
    from ww_api.services.project_deps import get_tier_gateway_builder

    adapter = _FakeGeneratorAdapter()
    app = create_app()
    app.dependency_overrides[get_tier_gateway_builder] = _tier_builder_override(adapter)

    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, "Scope B 续写作品")
                await _seed_prior_chapter(e2e_sm, project_uuid, chapter_no=1)

                resp = await client.post(
                    f"/projects/{project_uuid}/skills/continue/generate",
                    json={"chapter_no": 1, "brief": "推进突破桥段"},
                )
                assert resp.status_code == 200
                body = resp.json()
                assert body["tool_key"] == "continue"
                assert body["output_kind"] == "ContinuationResult"
                assert body["preview"]["text"] == _CONTINUATION.text
                _assert_no_secret_leak(body)

        # 路由断言：端点把前一章 accepted 正文注入了续写上下文（跨层数据流 #1）。
        injected = adapter.seen_input[ContinuationResult]
        assert _PRIOR_ACCEPTED_TEXT in injected

        # DB 真源：ledger 落 1 行（writer）；业务表零新增（除种入的 1 章前文，不变量 #3）。
        assert project_uuid is not None
        assert await _count(e2e_sm, UsageLedger.project_id, project_uuid) == 1
        await _assert_business_tables_untouched(e2e_sm, project_uuid, expected_chapters=1)
    finally:
        if project_uuid is not None:
            await _cleanup(e2e_sm, project_uuid)


async def test_expand_generate_routes_source_text_into_context(
    e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
    """扩写：作者原文经 body.text 路由进上下文 → 200 预览；ledger 1 行、业务表零新增。"""
    from ww_api.main import create_app
    from ww_api.services.project_deps import get_tier_gateway_builder

    adapter = _FakeGeneratorAdapter()
    app = create_app()
    app.dependency_overrides[get_tier_gateway_builder] = _tier_builder_override(adapter)

    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, "Scope B 扩写作品")

                resp = await client.post(
                    f"/projects/{project_uuid}/skills/expand/generate",
                    json={"text": _EXPAND_SOURCE, "brief": "丰富细节"},
                )
                assert resp.status_code == 200
                body = resp.json()
                assert body["tool_key"] == "expand"
                assert body["output_kind"] == "PolishResult"
                assert body["preview"]["text"] == _POLISH.text
                _assert_no_secret_leak(body)

        # 路由断言：作者原文进入了扩写上下文（text_input 策略）。
        assert _EXPAND_SOURCE in adapter.seen_input[PolishResult]

        assert project_uuid is not None
        assert await _count(e2e_sm, UsageLedger.project_id, project_uuid) == 1
        await _assert_business_tables_untouched(e2e_sm, project_uuid, expected_chapters=0)
    finally:
        if project_uuid is not None:
            await _cleanup(e2e_sm, project_uuid)


async def test_de_ai_generate_routes_source_text_into_context(
    e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
    """降AI率：作者原文经 body.text 路由进上下文 → 200 预览；ledger 1 行、业务表零新增。"""
    from ww_api.main import create_app
    from ww_api.services.project_deps import get_tier_gateway_builder

    adapter = _FakeGeneratorAdapter()
    app = create_app()
    app.dependency_overrides[get_tier_gateway_builder] = _tier_builder_override(adapter)

    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, "Scope B 降AI作品")

                resp = await client.post(
                    f"/projects/{project_uuid}/skills/de-ai/generate",
                    json={"text": _DE_AI_SOURCE, "brief": "去机翻腔"},
                )
                assert resp.status_code == 200
                body = resp.json()
                assert body["tool_key"] == "de-ai"
                assert body["output_kind"] == "DeAiResult"
                assert body["preview"]["text"] == _DE_AI.text
                _assert_no_secret_leak(body)

        # 路由断言：作者原文进入了降AI上下文（text_input 策略）。
        assert _DE_AI_SOURCE in adapter.seen_input[DeAiResult]

        assert project_uuid is not None
        assert await _count(e2e_sm, UsageLedger.project_id, project_uuid) == 1
        await _assert_business_tables_untouched(e2e_sm, project_uuid, expected_chapters=0)
    finally:
        if project_uuid is not None:
            await _cleanup(e2e_sm, project_uuid)


async def test_teardown_generate_returns_structured_output(
    e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
    """拆书：text_input → 200 结构化预览（themes/archetypes/structure/hooks）；业务表零新增。"""
    from ww_api.main import create_app
    from ww_api.services.project_deps import get_tier_gateway_builder

    adapter = _FakeGeneratorAdapter()
    app = create_app()
    app.dependency_overrides[get_tier_gateway_builder] = _tier_builder_override(adapter)

    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, "Scope B 拆书作品")

                resp = await client.post(
                    f"/projects/{project_uuid}/skills/teardown/generate",
                    json={"text": _TEARDOWN_SOURCE, "kind": "某爽文", "brief": "拆解套路"},
                )
                assert resp.status_code == 200
                body = resp.json()
                assert body["tool_key"] == "teardown"
                assert body["output_kind"] == "BookTeardownResult"
                preview = body["preview"]
                # 结构化输出四字段齐全（断结构而非仅 text）。
                assert preview["themes"] == ["逆袭", "复仇"]
                assert preview["archetypes"] == ["落魄天才", "腹黑反派"]
                assert preview["structure"] == _TEARDOWN.structure
                assert preview["hooks"] == ["开局打脸", "扮猪吃虎"]
                _assert_no_secret_leak(body)

        # 路由断言：作者样本原文进入了拆书上下文（text_input 策略）。
        assert _TEARDOWN_SOURCE in adapter.seen_input[BookTeardownResult]

        assert project_uuid is not None
        assert await _count(e2e_sm, UsageLedger.project_id, project_uuid) == 1
        await _assert_business_tables_untouched(e2e_sm, project_uuid, expected_chapters=0)
    finally:
        if project_uuid is not None:
            await _cleanup(e2e_sm, project_uuid)
