"""T3.5 端点测试：大纲生成 + 持久化 + 伏笔看板（内存替身，无 DB/无网络）。

覆盖：
- POST /outline：mock 网关产 `OutlineResult` → 逐章 upsert（含 foreshadow_windows）→ 端点 commit；
- 幂等：同 (project_id, chapter_no) 再生成覆盖；
- 项目不存在 → 404；无凭据 → LLM_UNAVAILABLE（503）；
- GET /foreshadow?status=：按状态过滤；非法 status → 422 VALIDATION。
"""

from __future__ import annotations

import os
import uuid

import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import (
    FakeForeshadowRepo,
    FakeOutlineReadRepo,
    FakeOutlineWriteRepo,
    FakeProjectRepo,
    FakeReviewGateway,
    FakeSession,
)
from test_projects import _empty_memory_repos
from ww_agents import ForeshadowWindow, OutlineChapter, OutlineResult
from ww_core.domain.project_repo import ProjectCreate
from ww_shared import AppError, ErrorCode


def _outline_result() -> OutlineResult:
    return OutlineResult(
        chapters=[
            OutlineChapter(
                no=1,
                beats=["开篇引入主角", "埋下信物伏笔"],
                foreshadow_windows=[
                    ForeshadowWindow(code="F1", plant_chapter=1, expected_close_to=10)
                ],
            ),
            OutlineChapter(no=2, beats=["冲突升级"], foreshadow_windows=[]),
        ]
    )


def _make_client(
    *,
    project_repo: FakeProjectRepo | None = None,
    foreshadow_repo: FakeForeshadowRepo | None = None,
    outline_repo: FakeOutlineWriteRepo | None = None,
    gateway: FakeReviewGateway | None = None,
    session: FakeSession | None = None,
) -> tuple[httpx.AsyncClient, FakeOutlineWriteRepo, FakeSession]:
    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_foreshadow_repo,
        get_memory_repos,
        get_outline_gateway,
        get_outline_write_repo,
        get_project_repo,
    )
    from ww_db import get_session

    project_repo = project_repo or FakeProjectRepo()
    foreshadow_repo = foreshadow_repo or FakeForeshadowRepo()
    outline_repo = outline_repo or FakeOutlineWriteRepo()
    gateway = gateway or FakeReviewGateway(parsed=_outline_result())
    session = session or FakeSession()

    app = create_app()
    app.dependency_overrides[get_project_repo] = lambda: project_repo
    app.dependency_overrides[get_foreshadow_repo] = lambda: foreshadow_repo
    app.dependency_overrides[get_memory_repos] = _empty_memory_repos
    app.dependency_overrides[get_outline_write_repo] = lambda: outline_repo
    app.dependency_overrides[get_session] = lambda: session
    app.dependency_overrides[get_outline_gateway] = lambda: gateway

    transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
    client = httpx.AsyncClient(transport=transport, base_url="http://test")
    return client, outline_repo, session


async def _seed_project(repo: FakeProjectRepo) -> uuid.UUID:
    view = await repo.create(uuid.UUID(int=1), ProjectCreate(title="测试作品", genre="玄幻"))
    return uuid.UUID(str(view.id))


# ---- 大纲生成 ----


@pytest.mark.asyncio
async def test_generate_outline_upserts_chapters_and_commits() -> None:
    project_repo = FakeProjectRepo()
    pid = await _seed_project(project_repo)
    client, outline_repo, session = _make_client(project_repo=project_repo)

    async with client:
        resp = await client.post(f"/projects/{pid}/outline", json={"volume": 1})

    assert resp.status_code == 200
    body = resp.json()
    assert [c["no"] for c in body["chapters"]] == [1, 2]
    ch1 = body["chapters"][0]
    assert ch1["beats"] == ["开篇引入主角", "埋下信物伏笔"]
    assert ch1["foreshadow_windows"][0]["code"] == "F1"
    assert ch1["foreshadow_windows"][0]["expected_close_to"] == 10
    # 逐章 upsert（2 章）+ 端点末尾一次 commit。
    assert outline_repo.upsert_calls == 2
    assert (pid, 1) in outline_repo.rows
    assert outline_repo.rows[(pid, 1)][1] == ["开篇引入主角", "埋下信物伏笔"]
    assert session.commits == 1


@pytest.mark.asyncio
async def test_generate_outline_is_idempotent_overwrites_same_chapter() -> None:
    project_repo = FakeProjectRepo()
    pid = await _seed_project(project_repo)
    outline_repo = FakeOutlineWriteRepo()
    client, _, _ = _make_client(project_repo=project_repo, outline_repo=outline_repo)

    async with client:
        await client.post(f"/projects/{pid}/outline", json={"volume": 1})
        await client.post(f"/projects/{pid}/outline", json={"volume": 1})

    # 同 (project_id, chapter_no) 覆盖，不堆叠 → 仍只 2 行。
    assert len(outline_repo.rows) == 2


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


@pytest.mark.asyncio
async def test_generate_outline_without_credentials_maps_to_llm_unavailable() -> None:
    # 无凭据时 `build_gateway_for_tier` 抛 LLM_UNAVAILABLE（503），在网关 dep 解析阶段发生。
    project_repo = FakeProjectRepo()
    pid = await _seed_project(project_repo)
    session = FakeSession()
    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_foreshadow_repo,
        get_memory_repos,
        get_outline_gateway,
        get_outline_write_repo,
        get_project_repo,
    )
    from ww_db import get_session

    async def _no_creds() -> object:
        raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"})

    app = create_app()
    app.dependency_overrides[get_project_repo] = lambda: project_repo
    app.dependency_overrides[get_foreshadow_repo] = lambda: FakeForeshadowRepo()
    app.dependency_overrides[get_memory_repos] = _empty_memory_repos
    app.dependency_overrides[get_outline_write_repo] = lambda: FakeOutlineWriteRepo()
    app.dependency_overrides[get_session] = lambda: session
    app.dependency_overrides[get_outline_gateway] = _no_creds
    transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        resp = await client.post(f"/projects/{pid}/outline", json={})

    assert resp.status_code == 503
    assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
    # 网关阶段失败 → 未生成、未提交。
    assert session.commits == 0


@pytest.mark.asyncio
async def test_generate_outline_upper_error_propagates() -> None:
    project_repo = FakeProjectRepo()
    pid = await _seed_project(project_repo)
    gateway = FakeReviewGateway(error=AppError(ErrorCode.LLM_UNAVAILABLE, "boom"))
    client, _, session = _make_client(project_repo=project_repo, gateway=gateway)

    async with client:
        resp = await client.post(f"/projects/{pid}/outline", json={})

    assert resp.status_code == 503
    assert session.commits == 0


# ---- 大纲读取（GET /outline）----


def _make_read_client(
    *,
    project_repo: FakeProjectRepo,
    outline_read_repo: FakeOutlineReadRepo,
) -> httpx.AsyncClient:
    """装配 GET /outline 测试客户端（注入 fake 项目 repo + 读侧 outline repo）。"""
    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_outline_read_repo, get_project_repo

    app = create_app()
    app.dependency_overrides[get_project_repo] = lambda: project_repo
    app.dependency_overrides[get_outline_read_repo] = lambda: outline_read_repo

    transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
    return httpx.AsyncClient(transport=transport, base_url="http://test")


@pytest.mark.asyncio
async def test_get_outline_returns_persisted_chapters_in_order_with_unpacked_beats() -> None:
    project_repo = FakeProjectRepo()
    pid = await _seed_project(project_repo)
    read_repo = FakeOutlineReadRepo()
    # 乱序入种，断言端点按 chapter_no 升序返回。
    read_repo.add_chapter(pid, volume=1, chapter_no=2, beats=["冲突升级"])
    read_repo.add_chapter(
        pid,
        volume=1,
        chapter_no=1,
        beats=["开篇引入主角", "埋下信物伏笔"],
        foreshadow_windows=[{"code": "F1", "plant_chapter": 1, "expected_close_to": 10}],
    )
    client = _make_read_client(project_repo=project_repo, outline_read_repo=read_repo)

    async with client:
        resp = await client.get(f"/projects/{pid}/outline")

    assert resp.status_code == 200
    body = resp.json()
    assert [c["no"] for c in body["chapters"]] == [1, 2]
    ch1 = body["chapters"][0]
    # beats 由 DB dict `{"beats": [...]}` 解包成裸 list（与 POST 响应同形）。
    assert ch1["beats"] == ["开篇引入主角", "埋下信物伏笔"]
    assert ch1["foreshadow_windows"][0]["code"] == "F1"
    assert ch1["foreshadow_windows"][0]["expected_close_to"] == 10
    assert body["chapters"][1]["beats"] == ["冲突升级"]


@pytest.mark.asyncio
async def test_get_outline_with_volume_filters_to_that_volume() -> None:
    # ?volume=N 只返回该卷章节；无该参数返回全部（向后兼容）。
    project_repo = FakeProjectRepo()
    pid = await _seed_project(project_repo)
    read_repo = FakeOutlineReadRepo()
    read_repo.add_chapter(pid, volume=1, chapter_no=1, beats=["卷一·开篇"])
    read_repo.add_chapter(pid, volume=1, chapter_no=2, beats=["卷一·冲突"])
    read_repo.add_chapter(pid, volume=2, chapter_no=3, beats=["卷二·新篇"])
    client = _make_read_client(project_repo=project_repo, outline_read_repo=read_repo)

    async with client:
        all_resp = await client.get(f"/projects/{pid}/outline")
        vol2_resp = await client.get(f"/projects/{pid}/outline?volume=2")

    # 不带 volume → 全部三章。
    assert all_resp.status_code == 200
    assert [c["no"] for c in all_resp.json()["chapters"]] == [1, 2, 3]
    # ?volume=2 → 仅卷二的第 3 章。
    assert vol2_resp.status_code == 200
    vol2_chapters = vol2_resp.json()["chapters"]
    assert [c["no"] for c in vol2_chapters] == [3]
    assert all(c["volume"] == 2 for c in vol2_chapters)


@pytest.mark.asyncio
async def test_get_outline_returns_empty_list_when_no_outline() -> None:
    project_repo = FakeProjectRepo()
    pid = await _seed_project(project_repo)
    client = _make_read_client(project_repo=project_repo, outline_read_repo=FakeOutlineReadRepo())

    async with client:
        resp = await client.get(f"/projects/{pid}/outline")

    # 项目存在但无大纲 → 200 空列表（非 404）。
    assert resp.status_code == 200
    assert resp.json()["chapters"] == []


@pytest.mark.asyncio
async def test_get_outline_unknown_project_404() -> None:
    client = _make_read_client(
        project_repo=FakeProjectRepo(), outline_read_repo=FakeOutlineReadRepo()
    )

    async with client:
        resp = await client.get(f"/projects/{uuid.uuid4()}/outline")

    assert resp.status_code == 404
    assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND


# ---- 伏笔看板 ----


@pytest.mark.asyncio
async def test_foreshadow_board_filters_by_status() -> None:
    repo = FakeForeshadowRepo()
    project_repo = FakeProjectRepo()
    pid = await _seed_project(project_repo)
    await repo.register(pid, code="F1", title="开放伏笔")
    await repo.register(pid, code="F2", title="部分回收")
    await repo.transition(pid, "F2", to_status="PARTIAL")
    from ww_api.main import create_app
    from ww_api.services.project_deps import get_foreshadow_repo, get_project_repo

    os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
    app = create_app()
    app.dependency_overrides[get_foreshadow_repo] = lambda: repo
    # 读端点现做 project 存在性 404 校验（P2）——注入有该 project 的 FakeProjectRepo。
    app.dependency_overrides[get_project_repo] = lambda: project_repo
    transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        all_resp = await client.get(f"/projects/{pid}/foreshadow")
        open_resp = await client.get(f"/projects/{pid}/foreshadow", params={"status": "OPEN"})
        bad_resp = await client.get(f"/projects/{pid}/foreshadow", params={"status": "NOPE"})

    assert {f["code"] for f in all_resp.json()["foreshadow"]} == {"F1", "F2"}
    open_codes = [f["code"] for f in open_resp.json()["foreshadow"]]
    assert open_codes == ["F1"]
    assert bad_resp.status_code == 422
    assert bad_resp.json()["error"]["code"] == ErrorCode.VALIDATION
    assert bad_resp.json()["error"]["details"]["reason"] == "invalid_status"
