"""F1 拆书 teardown 落库成 rules 端到端（真 pg + mock 网关零 token）。

证明拆书入库闭环（不变量 #3：入库经白名单 gate；预览不写库）：
1. teardown `generate` → 200 预览（BookTeardownResult）；断言 `rules` 表零新增（预览不写库）。
2. teardown `ingest`（teardown 产物）→ 201；`rules` 表真落行（拆书结论拍平成可读规则条目）。

镜像 `tests/test_t6_toolbox_e2e.py`：真 `Gateway` + 据 `req.output_schema` 分支返 parsed 的
假适配器（绝不联网）+ 真 `SqlAlchemyLedgerSink`。注入缝 `get_tier_gateway_builder`（generate
调 `build_gateway(spec.tier)`；rules ingest 无 continuity 预检故不调网关）。无 pg → skip。
"""

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 BookTeardownResult
from ww_db import get_session, get_sessionmaker
from ww_db.models import Project, Rule, 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, Tier

_PROVIDER = "deepseek"
_USAGE = ProviderUsage(input_tokens=29, output_tokens=13)

# teardown 假产物：themes/archetypes/structure/hooks 全非空（拍平成 4 条 rules）。
_TEARDOWN = BookTeardownResult(
    themes=["逆袭打脸", "守护苍生"],
    archetypes=["扮猪吃虎的主角", "口嫌体正的女主"],
    structure="开篇废柴受辱 → 奇遇得宝 → 步步攀升 → 终战封神",
    hooks=["每章末留悬念", "三章一小高潮"],
)


class _FakeTeardownAdapter:
    """实现 `ProviderAdapter`：仅 BookTeardownResult 一路（拆书 generate），绝不联网。"""

    provider = _PROVIDER

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

    async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
        if req.output_schema is BookTeardownResult:
            return ProviderResult(text=_TEARDOWN.model_dump_json(), parsed=_TEARDOWN, usage=_USAGE)
        raise AssertionError(f"unexpected output_schema in F1 fake adapter: {req.output_schema!r}")

    async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
        yield StreamChunk(usage=_USAGE)
        raise AssertionError("teardown generation must not stream")


@pytest.fixture
async def e2e_sm() -> AsyncIterator[async_sessionmaker[AsyncSession]]:
    """真 DB session 工厂；无 pg 时跳过。"""
    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: _FakeTeardownAdapter,
) -> Callable[[AsyncSession], Callable[[Tier], Awaitable[Gateway]]]:
    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 _rule_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(Rule).where(Rule.project_id == project_uuid)
                )
            ).scalar_one()
        )


async def _cleanup(sm: async_sessionmaker[AsyncSession], project_uuid: uuid.UUID) -> None:
    async with sm() as cleanup:
        await cleanup.execute(delete(UsageLedger).where(UsageLedger.project_id == project_uuid))
        await cleanup.execute(delete(Rule).where(Rule.project_id == project_uuid))
        await cleanup.execute(delete(Project).where(Project.id == project_uuid))
        await cleanup.commit()


async def test_teardown_generate_preview_does_not_write_rules(
    e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
    """用例 1（负向）：teardown generate → 200 预览；rules 表零新增（不变量 #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(
        _FakeTeardownAdapter()
    )
    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": "F1 拆书预览作品"})
                assert create_resp.status_code == 201
                project_id = create_resp.json()["id"]
                project_uuid = uuid.UUID(project_id)

                gen_resp = await client.post(
                    f"/projects/{project_id}/skills/teardown/generate",
                    json={"text": "第一章……（待拆解的样章正文）", "kind": "某爆款玄幻"},
                )
                assert gen_resp.status_code == 200, gen_resp.text
                body = gen_resp.json()
                assert body["tool_key"] == "teardown"
                assert body["output_kind"] == "BookTeardownResult"
                assert body["preview"]["themes"] == ["逆袭打脸", "守护苍生"]

        # DB 真源：预览不写 rules 业务表（守不变量 #3）。
        assert project_uuid is not None
        assert await _rule_count(e2e_sm, project_uuid) == 0
    finally:
        if project_uuid is not None:
            await _cleanup(e2e_sm, project_uuid)


async def test_teardown_ingest_persists_rules(
    e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
    """用例 2：teardown ingest → 201；rules 表真落行（拆书结论拍平成可读规则条目）。"""
    from ww_api.main import create_app
    from ww_api.services.project_deps import get_tier_gateway_builder

    app = create_app()
    # ingest（rules 无 continuity 预检）不触达网关；override 防真凭据探测路径。
    app.dependency_overrides[get_tier_gateway_builder] = _tier_builder_override(
        _FakeTeardownAdapter()
    )
    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": "F1 拆书入库作品"})
                assert create_resp.status_code == 201
                project_id = create_resp.json()["id"]
                project_uuid = uuid.UUID(project_id)

                assert await _rule_count(e2e_sm, project_uuid) == 0

                ingest_resp = await client.post(
                    f"/projects/{project_id}/skills/teardown/ingest",
                    json={
                        "teardown": {
                            "themes": _TEARDOWN.themes,
                            "archetypes": _TEARDOWN.archetypes,
                            "structure": _TEARDOWN.structure,
                            "hooks": _TEARDOWN.hooks,
                        }
                    },
                )
                assert ingest_resp.status_code == 201, ingest_resp.text
                ok = ingest_resp.json()
                assert ok["table"] == "rules"
                # 四个非空字段 → 四条 rules（确定性顺序）。
                assert ok["created"] == ["themes", "archetypes", "structure", "hooks"]
                assert ok["rejected_tables"] == []

        # DB 真源：rules 行真落 pg（拍平成可读条目，内容含拆书结论）。
        assert project_uuid is not None
        assert await _rule_count(e2e_sm, project_uuid) == 4
        async with e2e_sm() as verify:
            rows = (
                (await verify.execute(select(Rule).where(Rule.project_id == project_uuid)))
                .scalars()
                .all()
            )
            blob = "\n".join(r.content for r in rows)
            # 拍平内容须含拆书结论（断真落行 + 形变保留）。
            assert "逆袭打脸" in blob
            assert "扮猪吃虎的主角" in blob
            assert "开篇废柴受辱" in blob
            assert "每章末留悬念" in blob
    finally:
        if project_uuid is not None:
            await _cleanup(e2e_sm, project_uuid)
