"""T1.4 端点测试：立项 CRUD + 写章 SSE + 自动保存（内存替身，无 DB/无网络）。

DoD：端点入 OpenAPI；POST draft 回 SSE 流（mock 网关验证）；PUT draft 幂等。
"""

from __future__ import annotations

import uuid

import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import (
    FakeChapterRepo,
    FakeProjectRepo,
    FakeReviewGateway,
    FakeSession,
    FakeWriterGateway,
)
from fastapi import FastAPI
from ww_api.services.credentials import STUB_OWNER_ID
from ww_core.domain.project_repo import ProjectView
from ww_core.domain.repositories import (
    CharacterView,
    DigestView,
    ForeshadowView,
    MemoryRepos,
    OutlineView,
    ProjectSpecView,
    RuleView,
    StyleView,
    WorldEntityView,
)
from ww_shared import ErrorCode


class _EmptyOutlineRepo:
    async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
        return None

    async def list_for_project(
        self, project_id: uuid.UUID, *, volume: int | None = None
    ) -> list[OutlineView]:
        return []


class _EmptyCharacterRepo:
    async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]:
        return []


class _EmptyWorldEntityRepo:
    async def list_for_project(self, project_id: uuid.UUID) -> list[WorldEntityView]:
        return []


class _EmptyDigestRepo:
    async def recent(self, project_id: uuid.UUID, k: int) -> list[DigestView]:
        return []


class _EmptyForeshadowRepo:
    async def list_for_codes(self, project_id: uuid.UUID, codes: list[str]) -> list[ForeshadowView]:
        return []


class _EmptyStyleRepo:
    async def latest(self, project_id: uuid.UUID) -> StyleView | None:
        return None


class _EmptyRulesRepo:
    async def all_for_project(self, project_id: uuid.UUID) -> list[RuleView]:
        return []


class _StubProjectSpecRepo:
    async def spec(self, project_id: uuid.UUID) -> ProjectSpecView | None:
        return ProjectSpecView(title="测试作品", premise="测试前提")


class _NoInjectionRepo:
    """空注入覆盖 stub：draft 端点读它得 None（纯自动选择），不打真 DB。"""

    async def get(self, project_id: uuid.UUID, chapter_no: int) -> None:
        return None


def _empty_memory_repos() -> MemoryRepos:
    return MemoryRepos(
        outline=_EmptyOutlineRepo(),
        character=_EmptyCharacterRepo(),
        world_entity=_EmptyWorldEntityRepo(),
        digest=_EmptyDigestRepo(),
        foreshadow=_EmptyForeshadowRepo(),
        style=_EmptyStyleRepo(),
        rules=_EmptyRulesRepo(),
        project=_StubProjectSpecRepo(),
    )


def _make_client(
    *,
    project_repo: FakeProjectRepo | None = None,
    chapter_repo: FakeChapterRepo | None = None,
    gateway: FakeWriterGateway | None = None,
) -> tuple[httpx.AsyncClient, FakeProjectRepo, FakeChapterRepo, FakeWriterGateway]:
    import os

    os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
    from ww_api.main import create_app
    from ww_api.services.project_deps import (
        get_chapter_repo,
        get_injection_repo,
        get_memory_repos,
        get_project_repo,
        get_writer_gateway,
    )

    project_repo = project_repo or FakeProjectRepo()
    chapter_repo = chapter_repo or FakeChapterRepo()
    gateway = gateway or FakeWriterGateway()

    app = create_app()
    app.dependency_overrides[get_project_repo] = lambda: project_repo
    app.dependency_overrides[get_chapter_repo] = lambda: chapter_repo
    app.dependency_overrides[get_memory_repos] = _empty_memory_repos
    app.dependency_overrides[get_writer_gateway] = lambda: gateway
    # draft 端点现读注入覆盖：注空 stub，避免单测打真 DB（无覆盖 → 纯自动选择）。
    app.dependency_overrides[get_injection_repo] = lambda: _NoInjectionRepo()
    transport = httpx.ASGITransport(app=app)
    client = httpx.AsyncClient(transport=transport, base_url="http://test")
    return client, project_repo, chapter_repo, gateway


def _seed_project(project_repo: FakeProjectRepo) -> uuid.UUID:
    """Seed 一个属于 STUB_OWNER_ID 的项目，返回其 pid。

    stream_draft 现先校验项目存在（QA C1），流式用例须用已存在项目，否则 404。
    """
    pid = uuid.uuid4()
    project_repo.rows[pid] = (STUB_OWNER_ID, ProjectView(id=pid, title="测试"))
    return pid


@pytest.mark.asyncio
async def test_create_project_returns_201() -> None:
    client, _, _, _ = _make_client()
    async with client:
        resp = await client.post(
            "/projects",
            json={"title": "我的网文", "genre": "玄幻", "selling_points": ["爽点密集"]},
        )
    assert resp.status_code == 201
    body = resp.json()
    assert body["title"] == "我的网文"
    assert body["genre"] == "玄幻"
    assert body["selling_points"] == ["爽点密集"]
    assert uuid.UUID(body["id"])


@pytest.mark.asyncio
async def test_create_project_persists_tone_ending_pov() -> None:
    # ④ 立项新增基调/结局/视角：请求 → repo 落库 → 响应回显（snake_case 契约）。
    client, repo, _, _ = _make_client()
    async with client:
        resp = await client.post(
            "/projects",
            json={
                "title": "基调作品",
                "tone": "热血",
                "ending_type": "HE",
                "narrative_pov": "第一人称",
            },
        )
    assert resp.status_code == 201
    body = resp.json()
    assert body["tone"] == "热血"
    assert body["ending_type"] == "HE"
    assert body["narrative_pov"] == "第一人称"
    (_owner, view) = next(iter(repo.rows.values()))
    assert view.tone == "热血"
    assert view.ending_type == "HE"
    assert view.narrative_pov == "第一人称"


@pytest.mark.asyncio
async def test_get_project_exposes_tone_ending_pov() -> None:
    client, _, _, _ = _make_client()
    async with client:
        created = (
            await client.post(
                "/projects",
                json={
                    "title": "书",
                    "tone": "沉重",
                    "ending_type": "BE",
                    "narrative_pov": "多视角",
                },
            )
        ).json()
        resp = await client.get(f"/projects/{created['id']}")
    assert resp.status_code == 200
    body = resp.json()
    assert body["tone"] == "沉重"
    assert body["ending_type"] == "BE"
    assert body["narrative_pov"] == "多视角"


@pytest.mark.asyncio
async def test_list_projects() -> None:
    client, repo, _, _ = _make_client()
    async with client:
        await client.post("/projects", json={"title": "甲"})
        await client.post("/projects", json={"title": "乙"})
        resp = await client.get("/projects")
    assert resp.status_code == 200
    projects = resp.json()["projects"]
    titles = {p["title"] for p in projects}
    assert titles == {"甲", "乙"}
    assert all("updated_at" in p for p in projects)
    assert all(p["pending_review_count"] == 0 for p in projects)


@pytest.mark.asyncio
async def test_list_projects_paginates_with_limit_and_offset() -> None:
    client, _, _, _ = _make_client()
    async with client:
        for t in ("甲", "乙", "丙"):
            await client.post("/projects", json={"title": t})
        page1 = await client.get("/projects?limit=2")
        page2 = await client.get("/projects?limit=2&offset=2")
    assert page1.status_code == 200
    assert [p["title"] for p in page1.json()["projects"]] == ["甲", "乙"]
    assert page2.status_code == 200
    assert [p["title"] for p in page2.json()["projects"]] == ["丙"]


@pytest.mark.asyncio
async def test_list_projects_rejects_out_of_range_pagination() -> None:
    client, _, _, _ = _make_client()
    async with client:
        too_small = await client.get("/projects?limit=0")
        too_large = await client.get("/projects?limit=201")
        neg_offset = await client.get("/projects?offset=-1")
    # 边界越界 → FastAPI 422（limit∈[1,200]、offset≥0）。
    assert too_small.status_code == 422
    assert too_large.status_code == 422
    assert neg_offset.status_code == 422


@pytest.mark.asyncio
async def test_get_project_detail() -> None:
    client, _, _, _ = _make_client()
    async with client:
        created = (await client.post("/projects", json={"title": "详情"})).json()
        resp = await client.get(f"/projects/{created['id']}")
    assert resp.status_code == 200
    body = resp.json()
    assert body["title"] == "详情"
    assert "updated_at" in body
    assert body["pending_review_count"] == 0


@pytest.mark.asyncio
async def test_get_unknown_project_404() -> None:
    client, _, _, _ = _make_client()
    async with client:
        resp = await client.get(f"/projects/{uuid.uuid4()}")
    assert resp.status_code == 404
    assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND


@pytest.mark.asyncio
async def test_create_project_rejects_blank_title() -> None:
    client, _, _, _ = _make_client()
    async with client:
        resp = await client.post("/projects", json={"title": ""})
    assert resp.status_code == 422


@pytest.mark.asyncio
async def test_draft_stream_yields_sse_tokens_and_done() -> None:
    gateway = FakeWriterGateway(chunks=["阿福", "走进门。"])
    client, project_repo, _, _ = _make_client(gateway=gateway)
    pid = _seed_project(project_repo)
    async with client:
        resp = await client.post(f"/projects/{pid}/chapters/1/draft")
    assert resp.status_code == 200
    assert resp.headers["content-type"].startswith("text/event-stream")
    text = resp.text
    assert "event: token" in text
    assert '"text": "阿福"' in text
    assert "event: done" in text
    # done 带累计长度（不回灌全文）
    assert '"length": 6' in text
    # 网关确实被调用，且只传 tier（不传具体 model）
    assert len(gateway.requests) == 1
    assert gateway.requests[0].tier == "writer"


@pytest.mark.asyncio
async def test_draft_stream_threads_directive_into_volatile() -> None:
    gateway = FakeWriterGateway(chunks=["正文"])
    client, project_repo, _, _ = _make_client(gateway=gateway)
    pid = _seed_project(project_repo)
    async with client:
        resp = await client.post(
            f"/projects/{pid}/chapters/1/draft", json={"directive": "多写战斗"}
        )
    assert resp.status_code == 200
    assert len(gateway.requests) == 1
    # 指令直通 assemble→volatile（write 节点把 volatile 放进 LlmRequest.input）。
    assert "多写战斗" in str(gateway.requests[0].input)


@pytest.mark.asyncio
async def test_draft_stream_backward_compatible_without_body() -> None:
    gateway = FakeWriterGateway(chunks=["正文"])
    client, project_repo, _, _ = _make_client(gateway=gateway)
    pid = _seed_project(project_repo)
    async with client:
        resp = await client.post(f"/projects/{pid}/chapters/1/draft")
    assert resp.status_code == 200
    assert len(gateway.requests) == 1
    assert "本章指令" not in str(gateway.requests[0].input)


@pytest.mark.asyncio
async def test_draft_stream_maps_error_to_sse_error_event() -> None:
    from ww_shared import AppError

    gateway = FakeWriterGateway(chunks=["半段"], error=AppError(ErrorCode.LLM_UNAVAILABLE, "boom"))
    client, project_repo, _, _ = _make_client(gateway=gateway)
    pid = _seed_project(project_repo)
    async with client:
        resp = await client.post(f"/projects/{pid}/chapters/1/draft")
    assert resp.status_code == 200
    text = resp.text
    assert "event: token" in text
    assert "event: error" in text
    assert ErrorCode.LLM_UNAVAILABLE in text


@pytest.mark.asyncio
async def test_draft_stream_unknown_project_returns_404_without_calling_gateway() -> None:
    # QA C1 回归：对不存在的 project 流式写章必须 404，且绝不触网关（不烧 LLM 调用）。
    gateway = FakeWriterGateway(chunks=["不该被生成"])
    client, _project_repo, _, _ = _make_client(gateway=gateway)
    async with client:
        resp = await client.post(f"/projects/{uuid.uuid4()}/chapters/1/draft")
    assert resp.status_code == 404
    assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
    assert len(gateway.requests) == 0  # 未触达网关


@pytest.mark.asyncio
async def test_put_draft_is_idempotent() -> None:
    chapter_repo = FakeChapterRepo()
    client, _, _, _ = _make_client(chapter_repo=chapter_repo)
    pid = uuid.uuid4()
    async with client:
        r1 = await client.put(f"/projects/{pid}/chapters/3/draft", json={"text": "初稿"})
        r2 = await client.put(f"/projects/{pid}/chapters/3/draft", json={"text": "改稿更长"})
    assert r1.status_code == 200
    assert r2.status_code == 200
    # 同章节只一条草稿（版次不爆炸）
    assert len(chapter_repo.drafts) == 1
    saved = chapter_repo.drafts[(pid, 3)]
    assert saved.content == "改稿更长"
    assert saved.version == 1
    assert r2.json()["length"] == len("改稿更长")


@pytest.mark.asyncio
async def test_get_draft_returns_saved_content() -> None:
    chapter_repo = FakeChapterRepo()
    client, _, _, _ = _make_client(chapter_repo=chapter_repo)
    pid = uuid.uuid4()
    async with client:
        await client.put(f"/projects/{pid}/chapters/5/draft", json={"text": "阿福重访工作台"})
        resp = await client.get(f"/projects/{pid}/chapters/5/draft")
    assert resp.status_code == 200
    body = resp.json()
    assert body["content"] == "阿福重访工作台"
    assert body["status"] == "draft"
    assert body["version"] == 1
    assert body["chapter_no"] == 5
    assert body["length"] == len("阿福重访工作台")


@pytest.mark.asyncio
async def test_get_draft_404_when_no_draft() -> None:
    client, _, _, _ = _make_client()
    pid = uuid.uuid4()
    async with client:
        resp = await client.get(f"/projects/{pid}/chapters/9/draft")
    assert resp.status_code == 404
    assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND


# ---- POST /rewrite/clarify（整章重写预检澄清，WFW-9 M2）----

# 摘录上界外的哨兵：喂超长 prior_draft，断言超界正文不进网关 input（不喂全章、不泄漏）。
_LEAK_SENTINEL = "泄漏哨兵段落禁止外泄"


def _need_clarification_decision() -> object:
    from ww_agents import ClarifyDecision, ClarifyOption, ClarifyQuestion

    return ClarifyDecision(
        need_clarification=True,
        questions=[
            ClarifyQuestion(
                question="你说本章节奏太慢，是想往哪个方向重写整章？",
                options=[
                    ClarifyOption(label="砍支线压缩篇幅", value="删掉次要支线，压缩铺陈直入主线"),
                    ClarifyOption(label="提前引爆冲突", value="把本章高潮冲突提前到前半段"),
                ],
                allow_free_text=True,
            )
        ],
    )


def _clear_rewrite_decision() -> object:
    from ww_agents import ClarifyDecision

    return ClarifyDecision(
        need_clarification=False,
        verification="我会把本章高潮提前、删掉开头回忆插叙，直接进冲突，对吗？",
    )


def _clarify_app(
    *,
    project_repo: FakeProjectRepo,
    session: FakeSession | None = None,
    clarify_gateway: object | None = None,
) -> FastAPI:
    """构造只挂 rewrite/clarify 端点依赖的 app（project_repo + session + clarify 网关）。"""
    import os

    os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
    from ww_api.main import create_app
    from ww_api.services.project_deps import get_clarify_gateway, get_project_repo
    from ww_db import get_session

    app = create_app()
    app.dependency_overrides[get_project_repo] = lambda: project_repo
    app.dependency_overrides[get_session] = lambda: session or FakeSession()
    if clarify_gateway is not None:
        app.dependency_overrides[get_clarify_gateway] = lambda: clarify_gateway
    return app


def _clarify_client(app: FastAPI) -> httpx.AsyncClient:
    transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
    return httpx.AsyncClient(transport=transport, base_url="http://test")


@pytest.mark.asyncio
async def test_rewrite_clarify_vague_feedback_asks_one_question_and_commits() -> None:
    # (a) 含糊章级意见 → need_clarification True + 恰 1 问；analyst 档 + 末尾 commit 落账。
    project_repo = FakeProjectRepo()
    pid = _seed_project(project_repo)
    session = FakeSession()
    gateway = FakeReviewGateway(parsed=_need_clarification_decision())
    app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway)

    async with _clarify_client(app) as client:
        resp = await client.post(
            f"/projects/{pid}/chapters/3/rewrite/clarify",
            json={"feedback": "节奏太慢", "prior_draft": "本章开头……"},
        )

    assert resp.status_code == 200
    body = resp.json()
    assert body["need_clarification"] is True
    assert len(body["questions"]) == 1
    assert body["questions"][0]["options"][0]["label"] == "砍支线压缩篇幅"
    assert body["questions"][0]["allow_free_text"] is True
    # 不变量 #2：analyst 档；意见折进网关 input；末尾一次 commit（网关 usage_ledger 落库）。
    assert gateway.requests[0].tier == "analyst"
    assert "节奏太慢" in str(gateway.requests[0].input)
    assert session.commits == 1


@pytest.mark.asyncio
async def test_rewrite_clarify_clear_feedback_proceeds_read_only() -> None:
    # (b) 明确意见 → need_clarification False + verification；只读、无回滚。
    project_repo = FakeProjectRepo()
    pid = _seed_project(project_repo)
    session = FakeSession()
    gateway = FakeReviewGateway(parsed=_clear_rewrite_decision())
    app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway)

    async with _clarify_client(app) as client:
        resp = await client.post(
            f"/projects/{pid}/chapters/3/rewrite/clarify",
            json={"feedback": "把本章高潮提前到前半段，删掉开头回忆插叙。"},
        )

    assert resp.status_code == 200
    body = resp.json()
    assert body["need_clarification"] is False
    assert body["questions"] == []
    assert body["verification"] is not None
    # 只读不写业务表：仅一次 commit 落网关 usage，无回滚（不变量 #3）。
    assert session.commits == 1
    assert session.rollbacks == 0


@pytest.mark.asyncio
async def test_rewrite_clarify_unknown_project_404() -> None:
    # (c) 项目不存在 → 404（触网关前 fail-fast）。
    app = _clarify_app(
        project_repo=FakeProjectRepo(),
        clarify_gateway=FakeReviewGateway(parsed=_clear_rewrite_decision()),
    )
    async with _clarify_client(app) as client:
        resp = await client.post(
            f"/projects/{uuid.uuid4()}/chapters/1/rewrite/clarify",
            json={"feedback": "节奏太慢"},
        )
    assert resp.status_code == 404
    assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND


@pytest.mark.asyncio
async def test_rewrite_clarify_fails_open_on_wrong_parsed() -> None:
    # (d) 网关返回非 ClarifyDecision → 端点仍 200 + need_clarification False（韧性回退，非 500）。
    from ww_agents import WorldGenResult

    project_repo = FakeProjectRepo()
    pid = _seed_project(project_repo)
    session = FakeSession()
    gateway = FakeReviewGateway(parsed=WorldGenResult(entities=[]))
    app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway)

    async with _clarify_client(app) as client:
        resp = await client.post(
            f"/projects/{pid}/chapters/3/rewrite/clarify",
            json={"feedback": "重写一遍"},
        )

    assert resp.status_code == 200
    assert resp.json()["need_clarification"] is False


@pytest.mark.asyncio
async def test_rewrite_clarify_empty_feedback_422() -> None:
    project_repo = FakeProjectRepo()
    pid = _seed_project(project_repo)
    app = _clarify_app(
        project_repo=project_repo,
        clarify_gateway=FakeReviewGateway(parsed=_clear_rewrite_decision()),
    )
    async with _clarify_client(app) as client:
        resp = await client.post(
            f"/projects/{pid}/chapters/1/rewrite/clarify", json={"feedback": ""}
        )
    assert resp.status_code == 422


@pytest.mark.asyncio
async def test_rewrite_clarify_bounds_excerpt_and_never_leaks_manuscript() -> None:
    # 有界摘录：超长 prior_draft 的越界正文不进网关 input，响应也绝不回灌正文（脱敏）。
    project_repo = FakeProjectRepo()
    pid = _seed_project(project_repo)
    session = FakeSession()
    gateway = FakeReviewGateway(parsed=_clear_rewrite_decision())
    app = _clarify_app(project_repo=project_repo, session=session, clarify_gateway=gateway)

    # 哨兵放在远超摘录上界处（10k 填充 + 哨兵），确保被截断在摘录外。
    prior_draft = ("正" * 10_000) + _LEAK_SENTINEL

    async with _clarify_client(app) as client:
        resp = await client.post(
            f"/projects/{pid}/chapters/3/rewrite/clarify",
            json={"feedback": "重写本章", "prior_draft": prior_draft},
        )

    assert resp.status_code == 200
    # 越界正文既不进 LLM 输入，也不出现在响应体（正文只喂有界摘录、绝不回灌）。
    assert _LEAK_SENTINEL not in str(gateway.requests[0].input)
    assert _LEAK_SENTINEL not in resp.text
