"""T6.4 端到端：创作工具箱通用生成器端点（真实 DB，零 token，MOCK 网关）。

证明 T6 通用生成器框架闭环（声明驱动、一条执行路径 + 不变量 #3 预览不写库 / 入库必过
continuity gate + 白名单）。镜像 `tests/test_m5_e2e.py` 的范式：真实 `Gateway` + 据
`req.output_schema` 分支返回 `parsed` 的假适配器（绝不联网）+ 真实 `SqlAlchemyLedgerSink`
（记账真落 pg）+ DB 真源断言。无 pg → skip。

可注入缝（Wave A）：`get_tier_gateway_builder` 返回 `TierGatewayBuilder`
（`Callable[[Tier], Awaitable[Gateway]]`）。toolbox 端点 generate 调 `build_gateway(spec.tier)`、
ingest 预检调 `build_gateway("analyst")`——故 override **这一个 builder**，让它返回单个真
`Gateway`（假适配器据 output_schema 分支：生成产物 vs ContinuityReview）即可覆盖两路。

用例：
1. `GET /skills/toolbox` 返回全 15 key（legacy 3 带 legacy_route+is_legacy=true；新 12）；
   brainstorm/glossary/golden-finger 的 ingestable 标志符合预期。
2. brainstorm `generate` → 200 ToolGeneratePreviewResponse（output_kind + preview.ideas）；
   断言 **ledger 真落行** 且 **业务表零新增**（world_entities 计数不变）——不变量 #3。
3. glossary `ingest` → world_entities：先**不带 acknowledge** + 预检有冲突 → **409
   CONFLICT_UNRESOLVED**，断言 world_entities **此刻无新行**；再带 acknowledge_conflicts=true
   → 201，world_entities 行**真落 pg**（rules list→JSONB dict 形变保留）；rejected_tables 空。
4. 未知 tool_key `generate` → 404 NOT_FOUND envelope。
5. 无凭据（该 tier 无配置）→ 503 LLM_UNAVAILABLE envelope（不 override builder，走真依赖）。
负向：响应体绝不泄露 token/secret；预览路径只 commit ledger。
"""

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 ww_agents import (
    Conflict,
    ContinuityReview,
    GlossaryResult,
    GlossaryTerm,
    GoldenFingerResult,
    IdeaListResult,
)
from ww_agents.schemas import GoldenFinger, Idea
from ww_db import get_session, get_sessionmaker
from ww_db.models import 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 = {
    "light": ProviderUsage(input_tokens=19, output_tokens=11),
    "analyst": ProviderUsage(input_tokens=23, output_tokens=7),
    "writer": ProviderUsage(input_tokens=31, output_tokens=17),
}

# brainstorm 假产物：3 条脑洞（纯预览，不入库）。
_IDEAS = IdeaListResult(
    ideas=[
        Idea(premise="天降系统但只在深夜生效", hook="主角被迫昼伏夜出", genre_fit="都市异能"),
        Idea(premise="转生成反派的家养灵宠", hook="一边卖萌一边算计", genre_fit="搞笑修真"),
        Idea(premise="末世里唯一能种田的人", hook="种子即权力", genre_fit="末世种田"),
    ]
)

# glossary 假产物（生成预览用；ingest 由前端映射成 world_entities，故端点不强依赖此形）。
_GLOSSARY = GlossaryResult(
    terms=[
        GlossaryTerm(
            name="玄晶", type="货币", definition="修真界硬通货", rules=["不可伪造", "遇火则碎"]
        ),
    ]
)

# golden-finger 假产物（生成预览用）。
_GOLDEN = GoldenFingerResult(
    systems=[
        GoldenFinger(
            name="九转炼体诀",
            mechanism="吞噬天地灵气淬体",
            growth="每转突破一境",
            limits="每转需一枚玄晶，不可逆修",
        )
    ]
)

# 待入库的 world_entities（贴 WorldEntityCardView：type/name/rules）。
_INGEST_ENTITIES = [
    {"type": "货币", "name": "玄晶", "rules": ["不可伪造", "遇火则碎"]},
    {"type": "力量体系", "name": "九转炼体诀", "rules": ["每转需一枚玄晶", "不可逆修"]},
]

# precheck 假冲突（让入库 gate 触发 409）。
_PRECHECK_CONFLICT = ContinuityReview(
    conflicts=[
        Conflict(
            type="设定违例",
            where="玄晶",
            refs=["既有货币体系"],
            suggestion="玄晶『遇火则碎』与既有设定冲突，请裁决",
        )
    ]
)


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

    - `IdeaListResult` → 3 条脑洞（light）。
    - `GlossaryResult` → 术语表（analyst）。
    - `GoldenFingerResult` → 金手指（writer）。
    - `ContinuityReview` → 带冲突的预检结果（analyst）——触发 ingest 409 gate。
    每路带 `ProviderUsage` → 网关据此落 usage_ledger。
    """

    def __init__(self, provider: str = _PROVIDER) -> None:
        self.provider = provider

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

    async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
        schema = req.output_schema
        if schema is IdeaListResult:
            return ProviderResult(
                text=_IDEAS.model_dump_json(), parsed=_IDEAS, usage=_USAGE["light"]
            )
        if schema is GlossaryResult:
            return ProviderResult(
                text=_GLOSSARY.model_dump_json(), parsed=_GLOSSARY, usage=_USAGE["analyst"]
            )
        if schema is GoldenFingerResult:
            return ProviderResult(
                text=_GOLDEN.model_dump_json(), parsed=_GOLDEN, usage=_USAGE["writer"]
            )
        if schema is ContinuityReview:
            return ProviderResult(
                text=_PRECHECK_CONFLICT.model_dump_json(),
                parsed=_PRECHECK_CONFLICT,
                usage=_USAGE["analyst"],
            )
        raise AssertionError(f"unexpected output_schema in T6 fake adapter: {schema!r}")

    async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
        # 工具箱生成走 run()（结构化），不走 stream——但 Protocol 要求实现。
        yield StreamChunk(usage=_USAGE["light"])
        raise AssertionError("T6 toolbox generation 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: _FakeToolboxAdapter,
) -> Callable[[AsyncSession], Callable[[Tier], Awaitable[Gateway]]]:
    """依赖覆盖：`get_tier_gateway_builder` → 返回「按 tier 建网关」的 builder。

    builder 忽略 tier 返回同一个真 `Gateway`（单 provider 假适配器据 output_schema 分支 +
    真实 ledger，请求 session）——同时覆盖 generate（按 spec.tier）与 ingest 预检（analyst）。
    """

    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 _ledger_count(sm: async_sessionmaker[AsyncSession], project_uuid: uuid.UUID) -> int:
    async with sm() as s:
        return int(
            (
                await s.execute(
                    select(func.count())
                    .select_from(UsageLedger)
                    .where(UsageLedger.project_id == project_uuid)
                )
            ).scalar_one()
        )


async def _world_count(sm: async_sessionmaker[AsyncSession], project_uuid: uuid.UUID) -> int:
    async with sm() as s:
        return int(
            (
                await s.execute(
                    select(func.count())
                    .select_from(WorldEntity)
                    .where(WorldEntity.project_id == project_uuid)
                )
            ).scalar_one()
        )


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(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 test_t6_list_toolbox_returns_all_fifteen_tools(
    e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
    """用例 1：GET /skills/toolbox 返回全 15 key；legacy 3 带 legacy_route，新 12 走通用执行器。"""
    from ww_api.main import create_app

    app = create_app()
    transport = httpx.ASGITransport(app=app)
    async with LifespanManager(app):
        async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
            resp = await client.get("/skills/toolbox")
            assert resp.status_code == 200
            body = resp.json()

    tools = {t["key"]: t for t in body["tools"]}
    # 全 15 key（legacy 3 + 新 8 + 竞品快赢 4：续写/扩写/降AI率/拆书）。
    assert set(tools) == {
        "worldbuilding",
        "character",
        "outline",
        "brainstorm",
        "book-title",
        "blurb",
        "name",
        "golden-finger",
        "glossary",
        "opening",
        "fine-outline",
        "continue",
        "expand",
        "de-ai",
        "teardown",
    }
    # legacy（spec=None）：is_legacy=true + 带 legacy_route，不可经通用执行入库。
    legacy_keys = {"worldbuilding", "character", "outline"}
    for key in legacy_keys:
        assert tools[key]["is_legacy"] is True
        assert tools[key]["legacy_route"]
    # 新工具：is_legacy=false。
    for key in set(tools) - legacy_keys:
        assert tools[key]["is_legacy"] is False
    # ingestable 标志：glossary/golden-finger/fine-outline 可入库；brainstorm 纯预览。
    assert tools["glossary"]["ingestable"] is True
    assert tools["golden-finger"]["ingestable"] is True
    assert tools["fine-outline"]["ingestable"] is True
    assert tools["brainstorm"]["ingestable"] is False
    # brainstorm 描述符含一句话需求字段。
    assert any(f["name"] == "brief" for f in tools["brainstorm"]["input_fields"])
    _assert_no_secret_leak(body)


async def test_t6_brainstorm_generate_previews_without_writing_business_tables(
    e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
    """用例 2：brainstorm generate → 200 预览；ledger 真落行 + 业务表零新增（不变量 #3）。"""
    from ww_api.main import create_app
    from ww_api.services.project_deps import get_tier_gateway_builder

    app = create_app()
    app.dependency_overrides[get_tier_gateway_builder] = _tier_builder_override(
        _FakeToolboxAdapter()
    )

    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:
                create_resp = await client.post(
                    "/projects", json={"title": "T6 脑洞验证作品", "genre": "玄幻"}
                )
                assert create_resp.status_code == 201
                project_id = create_resp.json()["id"]
                project_uuid = uuid.UUID(project_id)

                world_before = await _world_count(e2e_sm, project_uuid)

                gen_resp = await client.post(
                    f"/projects/{project_id}/skills/brainstorm/generate",
                    json={"brief": "想要三个突破常规的脑洞"},
                )
                assert gen_resp.status_code == 200
                body = gen_resp.json()
                assert body["tool_key"] == "brainstorm"
                assert body["output_kind"] == "IdeaListResult"
                ideas = body["preview"]["ideas"]
                assert [i["premise"] for i in ideas] == [
                    "天降系统但只在深夜生效",
                    "转生成反派的家养灵宠",
                    "末世里唯一能种田的人",
                ]
                _assert_no_secret_leak(body)

        # DB 真源：ledger 落 1 行（light）；业务表（world_entities）零新增（预览不写库）。
        assert project_uuid is not None
        assert await _ledger_count(e2e_sm, project_uuid) == 1
        assert await _world_count(e2e_sm, project_uuid) == world_before
        async with e2e_sm() as verify:
            row = (
                (
                    await verify.execute(
                        select(UsageLedger).where(UsageLedger.project_id == project_uuid)
                    )
                )
                .scalars()
                .one()
            )
            assert row.provider == _PROVIDER
            assert row.input_tokens == _USAGE["light"].input_tokens
    finally:
        if project_uuid is not None:
            await _cleanup(e2e_sm, project_uuid)


async def test_t6_glossary_ingest_gate_then_acknowledge_persists(
    e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
    """用例 3：glossary ingest → 409 gate（无 ack + 有冲突）→ ack 放行 → world_entities 真落 pg。"""
    from ww_api.main import create_app
    from ww_api.services.project_deps import get_tier_gateway_builder

    app = create_app()
    app.dependency_overrides[get_tier_gateway_builder] = _tier_builder_override(
        _FakeToolboxAdapter()
    )

    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:
                create_resp = await client.post(
                    "/projects", json={"title": "T6 词条入库作品", "genre": "玄幻"}
                )
                assert create_resp.status_code == 201
                project_id = create_resp.json()["id"]
                project_uuid = uuid.UUID(project_id)

                # 3a) 入库（未 acknowledge）→ precheck 有冲突 → 409 CONFLICT_UNRESOLVED。
                blocked = await client.post(
                    f"/projects/{project_id}/skills/glossary/ingest",
                    json={"world_entities": _INGEST_ENTITIES},
                )
                assert blocked.status_code == 409
                err = blocked.json()["error"]
                assert err["code"] == "CONFLICT_UNRESOLVED"
                details = err["details"]
                assert details["conflict_count"] == 1
                assert details["conflicts"][0]["type"] == "设定违例"
                assert details["conflicts"][0]["where"] == "玄晶"
                _assert_no_secret_leak(blocked.json())

                # 入库被拦 → 此刻 world_entities 表应无行（不静默入库，守不变量 #3）。
                assert await _world_count(e2e_sm, project_uuid) == 0

                # 3b) 作者裁决后带 acknowledge_conflicts=true 重发 → 201 入库。
                ok = await client.post(
                    f"/projects/{project_id}/skills/glossary/ingest",
                    json={"world_entities": _INGEST_ENTITIES, "acknowledge_conflicts": True},
                )
                assert ok.status_code == 201
                ok_body = ok.json()
                assert ok_body["table"] == "world_entities"
                assert ok_body["created"] == ["玄晶", "九转炼体诀"]
                # 合法路径（glossary writes=["world_entities"]）无越权 → rejected_tables 空。
                assert ok_body["rejected_tables"] == []
                _assert_no_secret_leak(ok_body)

        # DB 真源断言：world_entities 行真落 pg + rules list→JSONB dict 形变保留。
        assert project_uuid is not None
        async with e2e_sm() as verify:
            rows = (
                (
                    await verify.execute(
                        select(WorldEntity)
                        .where(WorldEntity.project_id == project_uuid)
                        .order_by(WorldEntity.name)
                    )
                )
                .scalars()
                .all()
            )
            assert {r.name for r in rows} == {"玄晶", "九转炼体诀"}
            xuanjing = next(r for r in rows if r.name == "玄晶")
            assert xuanjing.type == "货币"
            # 形变：rules list → JSONB dict {"rules":[...]}（rules 列表保留）。
            assert xuanjing.rules == {"rules": ["不可伪造", "遇火则碎"]}

            # 记账真落 pg：precheck(409 路径) + precheck(入库路径) = 2 条（入库本体不调网关）。
            ledger_rows = (
                (
                    await verify.execute(
                        select(UsageLedger).where(UsageLedger.project_id == project_uuid)
                    )
                )
                .scalars()
                .all()
            )
            assert len(ledger_rows) == 2
            assert all(r.provider == _PROVIDER for r in ledger_rows)
    finally:
        if project_uuid is not None:
            await _cleanup(e2e_sm, project_uuid)


async def test_t6_unknown_tool_key_generate_returns_404(
    e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
    """用例 4：未知 tool_key generate → 404 NOT_FOUND envelope。"""
    from ww_api.main import create_app
    from ww_api.services.project_deps import get_tier_gateway_builder

    app = create_app()
    # builder 即使被覆盖，也应在工具解析阶段 404（不触达网关）。
    app.dependency_overrides[get_tier_gateway_builder] = _tier_builder_override(
        _FakeToolboxAdapter()
    )

    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:
                create_resp = await client.post("/projects", json={"title": "T6 未知工具作品"})
                assert create_resp.status_code == 201
                project_id = create_resp.json()["id"]
                project_uuid = uuid.UUID(project_id)

                resp = await client.post(
                    f"/projects/{project_id}/skills/does-not-exist/generate",
                    json={"brief": "x"},
                )
                assert resp.status_code == 404
                err = resp.json()["error"]
                assert err["code"] == "NOT_FOUND"
                assert err["details"]["tool_key"] == "does-not-exist"
                _assert_no_secret_leak(resp.json())
    finally:
        if project_uuid is not None:
            await _cleanup(e2e_sm, project_uuid)


async def test_t6_no_provider_returns_503(
    e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
    """用例 5：无凭据（不 override builder，走真 build_gateway_for_tier）→ 503 LLM_UNAVAILABLE。"""
    from ww_api.main import create_app

    app = create_app()
    # 不 override get_tier_gateway_builder → 真依赖在无凭据时抛 LLM_UNAVAILABLE。

    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:
                create_resp = await client.post("/projects", json={"title": "T6 无凭据作品"})
                assert create_resp.status_code == 201
                project_id = create_resp.json()["id"]
                project_uuid = uuid.UUID(project_id)

                resp = await client.post(
                    f"/projects/{project_id}/skills/brainstorm/generate",
                    json={"brief": "脑洞"},
                )
                assert resp.status_code == 503
                err = resp.json()["error"]
                assert err["code"] == "LLM_UNAVAILABLE"
                _assert_no_secret_leak(resp.json())

                # 503 后业务表与 ledger 均无新增（凭据探测在生成前拦下）。
                assert await _world_count(e2e_sm, project_uuid) == 0
                assert await _ledger_count(e2e_sm, project_uuid) == 0
    finally:
        if project_uuid is not None:
            await _cleanup(e2e_sm, project_uuid)
