"""T4.3 端点测试：学文风（jobs 异步）+ 回炉 + 最新指纹（内存替身，无 DB/无网络）。

覆盖：
- POST /style：写一行 job 返 202 `{job_id}`；BackgroundTask 跑完写 style_fingerprint
  + 置 job done（ASGITransport 下 background task 在请求返回前跑完，见 gotcha）；
- mode="update" → 第二次 append version+1；
- 项目不存在 → 404；无凭据 → 503（dep 解析阶段拦下，未写 job）；
- GET /style：返最新指纹（dimensions/evidence/version）；无指纹 → 404；
- POST /refine：返 {original, refined}（refiner 纯文本）+ 末尾 commit（记账落库）；无凭据 → 503。

学文风 work 在 `run_job` 自建独立 session 上自造网关 + repo（请求 session 已关闭）——
测试 monkeypatch `build_gateway_for_tier`（产 StyleFingerprintResult）+ 写侧 repo
（指到共享 fake repo），并 override `get_session_factory` 为 FakeSessionFactory，断言后台落库。
"""

from __future__ import annotations

import os
import uuid

import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import (
    FakeJobRepo,
    FakeProjectRepo,
    FakeReviewGateway,
    FakeSession,
    FakeSessionFactory,
    FakeStyleWriteRepo,
)
from fastapi import FastAPI
from ww_agents import StyleDimension, StyleFingerprintResult
from ww_core.domain.job_repo import STATUS_DONE
from ww_core.domain.project_repo import ProjectCreate
from ww_shared import AppError, ErrorCode


def _fingerprint() -> StyleFingerprintResult:
    return StyleFingerprintResult(
        dimensions=[
            StyleDimension(name="句长节奏", value="短句为主", evidence=["他来了。", "她走了。"]),
            StyleDimension(name="叙事人称", value="第三人称", evidence=["他看着远方。"]),
        ]
    )


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))


def _app_with_overrides(
    *,
    project_repo: FakeProjectRepo,
    job_repo: FakeJobRepo | None = None,
    style_repo: FakeStyleWriteRepo | None = None,
    session: FakeSession | None = None,
    session_factory: FakeSessionFactory | None = None,
    extract_gateway: object | None = None,
    refine_gateway: object | None = None,
) -> FastAPI:
    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_job_repo,
        get_project_repo,
        get_refine_gateway,
        get_session_factory,
        get_style_extract_gateway,
        get_style_write_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 job_repo is not None:
        app.dependency_overrides[get_job_repo] = lambda: job_repo
    if style_repo is not None:
        app.dependency_overrides[get_style_write_repo] = lambda: style_repo
    if session_factory is not None:
        app.dependency_overrides[get_session_factory] = lambda: session_factory
    if extract_gateway is not None:
        app.dependency_overrides[get_style_extract_gateway] = lambda: extract_gateway
    if refine_gateway is not None:
        app.dependency_overrides[get_refine_gateway] = lambda: refine_gateway
    return app


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


# ---- POST /style（学文风 jobs 异步）----


@pytest.mark.asyncio
async def test_learn_style_returns_202_and_writes_job_row(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    project_repo = FakeProjectRepo()
    pid = await _seed_project(project_repo)
    job_repo = FakeJobRepo()
    session = FakeSession()
    app = _app_with_overrides(
        project_repo=project_repo,
        job_repo=job_repo,
        session=session,
        session_factory=FakeSessionFactory(),
        extract_gateway=FakeReviewGateway(parsed=_fingerprint()),
    )

    # background task work 触网关/repo → monkeypatch 成 no-op fake（本用例不验落库）；
    # run_job 默认 repo_factory 用 SqlJobRepo(FakeSession) 会触 .execute → 改用 fake job repo。
    import ww_api.routers.style as style_mod
    import ww_api.services.job_runner as runner_mod

    async def _noop_work(_session: object) -> dict[str, int]:
        return {"version": 1, "dims_count": 2}

    monkeypatch.setattr(style_mod, "_make_style_learn_work", lambda *_a, **_k: _noop_work)
    monkeypatch.setattr(runner_mod, "SqlJobRepo", lambda _s: job_repo)

    async with _client(app) as client:
        resp = await client.post(f"/projects/{pid}/style", json={"samples": ["样本正文一"]})

    assert resp.status_code == 202
    body = resp.json()
    job_id = uuid.UUID(body["job_id"])
    # job 行已创建 + 请求阶段 commit 一次（供前端立即轮询）。
    assert job_id in job_repo.rows
    assert job_repo.rows[job_id].kind == "style_learn"
    assert session.commits == 1


@pytest.mark.asyncio
async def test_learn_style_background_task_writes_fingerprint_and_completes_job(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    project_repo = FakeProjectRepo()
    pid = await _seed_project(project_repo)
    job_repo = FakeJobRepo()
    style_repo = FakeStyleWriteRepo()
    session_factory = FakeSessionFactory()

    import ww_api.routers.style as style_mod

    # work 内 build_gateway_for_tier → fake 网关产指纹；写侧 repo → 共享 fake repo。
    async def _fake_build_gateway(_session, _store, _tier):  # type: ignore[no-untyped-def]
        return FakeReviewGateway(parsed=_fingerprint())

    monkeypatch.setattr(style_mod, "build_gateway_for_tier", _fake_build_gateway)
    monkeypatch.setattr(style_mod, "SqlStyleFingerprintWriteRepo", lambda _s: style_repo)
    # `run_job` 用 SqlJobRepo（默认 repo_factory）会触 FakeSession.execute → 改用 fake job repo。
    import ww_api.services.job_runner as runner_mod

    monkeypatch.setattr(runner_mod, "SqlJobRepo", lambda _s: job_repo)

    app = _app_with_overrides(
        project_repo=project_repo,
        job_repo=job_repo,
        session=FakeSession(),
        session_factory=session_factory,
        extract_gateway=FakeReviewGateway(parsed=_fingerprint()),
    )

    async with _client(app) as client:
        resp = await client.post(
            f"/projects/{pid}/style", json={"samples": ["样本一", "样本二"], "mode": "create"}
        )

    assert resp.status_code == 202
    job_id = uuid.UUID(resp.json()["job_id"])
    # BackgroundTask 已跑完（ASGITransport 等其完成）：style_fingerprint 落库 + job done。
    assert style_repo.append_calls == 1
    assert style_repo.rows[1][0] == {"句长节奏": "短句为主", "叙事人称": "第三人称"}
    assert style_repo.rows[1][1]["句长节奏"] == ["他来了。", "她走了。"]
    assert job_repo.rows[job_id].status == STATUS_DONE
    assert job_repo.rows[job_id].result == {"version": 1, "dims_count": 2}


@pytest.mark.asyncio
async def test_learn_style_update_mode_bumps_version(monkeypatch: pytest.MonkeyPatch) -> None:
    project_repo = FakeProjectRepo()
    pid = await _seed_project(project_repo)
    job_repo = FakeJobRepo()
    style_repo = FakeStyleWriteRepo()
    # 预置 version 1（首学已存在）。
    await style_repo.append(pid, dimensions_json={"x": "y"}, evidence_json={"x": []})

    import ww_api.routers.style as style_mod
    import ww_api.services.job_runner as runner_mod

    async def _fake_build_gateway(_session, _store, _tier):  # type: ignore[no-untyped-def]
        return FakeReviewGateway(parsed=_fingerprint())

    monkeypatch.setattr(style_mod, "build_gateway_for_tier", _fake_build_gateway)
    monkeypatch.setattr(style_mod, "SqlStyleFingerprintWriteRepo", lambda _s: style_repo)
    monkeypatch.setattr(runner_mod, "SqlJobRepo", lambda _s: job_repo)

    app = _app_with_overrides(
        project_repo=project_repo,
        job_repo=job_repo,
        session=FakeSession(),
        session_factory=FakeSessionFactory(),
        extract_gateway=FakeReviewGateway(parsed=_fingerprint()),
    )

    async with _client(app) as client:
        resp = await client.post(
            f"/projects/{pid}/style", json={"samples": ["新样本"], "mode": "update"}
        )

    assert resp.status_code == 202
    job_id = uuid.UUID(resp.json()["job_id"])
    # 第二次 append → version 2（version+1）。
    assert max(style_repo.rows) == 2
    assert job_repo.rows[job_id].result == {"version": 2, "dims_count": 2}


@pytest.mark.asyncio
async def test_learn_style_unknown_project_404() -> None:
    project_repo = FakeProjectRepo()
    app = _app_with_overrides(
        project_repo=project_repo,
        job_repo=FakeJobRepo(),
        session_factory=FakeSessionFactory(),
        extract_gateway=FakeReviewGateway(parsed=_fingerprint()),
    )
    async with _client(app) as client:
        resp = await client.post(f"/projects/{uuid.uuid4()}/style", json={"samples": ["x"]})
    assert resp.status_code == 404
    assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND


@pytest.mark.asyncio
async def test_learn_style_without_credentials_503_and_no_job() -> None:
    project_repo = FakeProjectRepo()
    pid = await _seed_project(project_repo)
    job_repo = FakeJobRepo()
    session = FakeSession()

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

    app = _app_with_overrides(
        project_repo=project_repo,
        job_repo=job_repo,
        session=session,
        session_factory=FakeSessionFactory(),
    )
    from ww_api.services.project_deps import get_style_extract_gateway

    app.dependency_overrides[get_style_extract_gateway] = _no_creds

    async with _client(app) as client:
        resp = await client.post(f"/projects/{pid}/style", json={"samples": ["x"]})

    assert resp.status_code == 503
    assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
    # 凭据探测在调度 job 之前 → 未写 job、未 commit。
    assert job_repo.rows == {}
    assert session.commits == 0


@pytest.mark.asyncio
async def test_learn_style_empty_samples_422() -> None:
    project_repo = FakeProjectRepo()
    pid = await _seed_project(project_repo)
    app = _app_with_overrides(
        project_repo=project_repo,
        job_repo=FakeJobRepo(),
        session_factory=FakeSessionFactory(),
        extract_gateway=FakeReviewGateway(parsed=_fingerprint()),
    )
    async with _client(app) as client:
        resp = await client.post(f"/projects/{pid}/style", json={"samples": []})
    assert resp.status_code == 422


# ---- GET /style（最新指纹）----


@pytest.mark.asyncio
async def test_get_style_returns_latest_fingerprint() -> None:
    project_repo = FakeProjectRepo()
    pid = await _seed_project(project_repo)
    style_repo = FakeStyleWriteRepo()
    await style_repo.append(
        pid, dimensions_json={"句长": "短"}, evidence_json={"句长": ["他来了。"]}
    )
    app = _app_with_overrides(project_repo=project_repo, style_repo=style_repo)

    async with _client(app) as client:
        resp = await client.get(f"/projects/{pid}/style")

    assert resp.status_code == 200
    body = resp.json()
    assert body["version"] == 1
    # P2 codegen：dimensions 改为 list[DimensionEntry]（name/value/evidence 合并），
    # 替代此前两列并行裸 dict（强类型给 TS 客户端）。
    assert body["dimensions"] == [{"name": "句长", "value": "短", "evidence": ["他来了。"]}]


@pytest.mark.asyncio
async def test_get_style_no_fingerprint_404() -> None:
    project_repo = FakeProjectRepo()
    pid = await _seed_project(project_repo)
    app = _app_with_overrides(project_repo=project_repo, style_repo=FakeStyleWriteRepo())
    async with _client(app) as client:
        resp = await client.get(f"/projects/{pid}/style")
    assert resp.status_code == 404
    assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND


@pytest.mark.asyncio
async def test_get_style_unknown_project_404() -> None:
    app = _app_with_overrides(project_repo=FakeProjectRepo(), style_repo=FakeStyleWriteRepo())
    async with _client(app) as client:
        resp = await client.get(f"/projects/{uuid.uuid4()}/style")
    assert resp.status_code == 404


# ---- POST /refine（同步回炉）----


@pytest.mark.asyncio
async def test_refine_returns_original_and_refined_and_commits() -> None:
    project_repo = FakeProjectRepo()
    pid = await _seed_project(project_repo)
    session = FakeSession()
    gateway = FakeReviewGateway(text="重写后的段落。")
    app = _app_with_overrides(project_repo=project_repo, session=session, refine_gateway=gateway)

    async with _client(app) as client:
        resp = await client.post(
            f"/projects/{pid}/chapters/1/refine",
            json={"segment": "原始段落。", "instruction": "去掉机翻腔"},
        )

    assert resp.status_code == 200
    body = resp.json()
    assert body["original"] == "原始段落。"
    assert body["refined"] == "重写后的段落。"
    # writer 档 + 末尾 commit（记账落库）。
    assert gateway.requests[0].tier == "writer"
    assert session.commits == 1
    # 指令进入了输入文本。
    assert "去掉机翻腔" in str(gateway.requests[0].input)


@pytest.mark.asyncio
async def test_refine_unknown_project_404() -> None:
    app = _app_with_overrides(
        project_repo=FakeProjectRepo(), refine_gateway=FakeReviewGateway(text="x")
    )
    async with _client(app) as client:
        resp = await client.post(
            f"/projects/{uuid.uuid4()}/chapters/1/refine", json={"segment": "原段"}
        )
    assert resp.status_code == 404
    assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND


@pytest.mark.asyncio
async def test_refine_empty_segment_422() -> None:
    project_repo = FakeProjectRepo()
    pid = await _seed_project(project_repo)
    app = _app_with_overrides(project_repo=project_repo, refine_gateway=FakeReviewGateway(text="x"))
    async with _client(app) as client:
        resp = await client.post(f"/projects/{pid}/chapters/1/refine", json={"segment": ""})
    assert resp.status_code == 422


@pytest.mark.asyncio
async def test_refine_without_credentials_503() -> None:
    project_repo = FakeProjectRepo()
    pid = await _seed_project(project_repo)
    session = FakeSession()

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

    app = _app_with_overrides(project_repo=project_repo, session=session)
    from ww_api.services.project_deps import get_refine_gateway

    app.dependency_overrides[get_refine_gateway] = _no_creds

    async with _client(app) as client:
        resp = await client.post(f"/projects/{pid}/chapters/1/refine", json={"segment": "原段"})

    assert resp.status_code == 503
    assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
    assert session.commits == 0
