"""创作工具箱通用端点（C3 扩 T6 / 通用生成器框架；不变量 #1/#2/#3/#9）。

声明驱动、一条执行路径——「加一个生成器」= 在 `ww_skills.TOOLBOX` 加一份声明，无需新端点：
- `GET  /skills/toolbox`                         列出全部生成器描述符（legacy + 新工具）。
- `POST /projects/:id/skills/:tool_key/generate` 按 context_strategy 组材料 → 网关结构化预览。
- `POST /projects/:id/skills/:tool_key/ingest`   仅可入库工具：过 continuity 预检 + 白名单 → 写库。

legacy 工具（spec=None）无通用执行路径——generate/ingest 对其返 404（前端走 legacy_route 跳现页）。
未知 tool_key→404；项目不存在→404；无凭据→503 LLM_UNAVAILABLE；缺章/大纲→空节拍不报错（预览）。

提交边界：网关 ledger + 写侧均只 flush → 端点末尾一次 `commit()`（含冲突 409 路径也 commit
落 precheck usage，同 characters 入库纪律，不变量 #3）。
"""

from __future__ import annotations

import uuid
from typing import Annotated, Any

from fastapi import APIRouter, Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession
from ww_agents import AgentSpec, Conflict, ContinuityReview, continuity_spec
from ww_core.domain.chapter_repo import ChapterRepo
from ww_core.domain.outline_write_repo import OutlineWriteRepo
from ww_core.domain.project_repo import ProjectRepo
from ww_core.domain.repositories import MemoryRepos, OutlineRepo
from ww_core.domain.rule_repo import RuleWriteRepo
from ww_core.domain.world_entity_repo import WorldEntityWriteRepo
from ww_core.orchestrator import run_generator
from ww_db import get_session
from ww_llm_gateway import Gateway
from ww_llm_gateway.types import Block, LlmRequest, Scope
from ww_shared import AppError, ErrorCode, ErrorEnvelope
from ww_skills import GeneratorTool, get_tool, partition_writes

from ww_api.logging_config import get_logger
from ww_api.routers.generation import _project_context, _world_context
from ww_api.schemas.generation import IngestConflictView, WorldEntityCardView
from ww_api.schemas.toolbox import (
    OutlineSceneIngestView,
    TeardownIngestView,
    ToolboxListResponse,
    ToolDescriptorView,
    ToolGeneratePreviewResponse,
    ToolGenerateRequest,
    ToolIngestRequest,
    ToolIngestResponse,
    ToolInputFieldView,
)
from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.project_deps import (
    TierGatewayBuilder,
    get_chapter_repo,
    get_memory_repos,
    get_outline_read_repo,
    get_outline_write_repo,
    get_project_repo,
    get_rule_write_repo,
    get_tier_gateway_builder,
    get_world_entity_write_repo,
)
from ww_api.services.toolbox_context import build_toolbox_context

log = get_logger("ww.api.toolbox")

router = APIRouter(tags=["toolbox"])

# OpenAPI 错误响应声明（类型化错误形，§7.1）。
_TOOL_ERRORS: dict[int | str, dict[str, Any]] = {
    404: {"model": ErrorEnvelope, "description": "工具或项目不存在 / legacy 工具无通用执行"},
    422: {"model": ErrorEnvelope, "description": "工具不支持该操作（如不可入库）"},
    503: {"model": ErrorEnvelope, "description": "LLM 不可用"},
}
_INGEST_ERRORS: dict[int | str, dict[str, Any]] = {
    **_TOOL_ERRORS,
    409: {"model": ErrorEnvelope, "description": "存在未裁决 continuity 冲突"},
}

ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
OutlineReadRepoDep = Annotated[OutlineRepo, Depends(get_outline_read_repo)]
ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)]
WorldWriteRepoDep = Annotated[WorldEntityWriteRepo, Depends(get_world_entity_write_repo)]
OutlineWriteRepoDep = Annotated[OutlineWriteRepo, Depends(get_outline_write_repo)]
RuleWriteRepoDep = Annotated[RuleWriteRepo, Depends(get_rule_write_repo)]
GatewayBuilderDep = Annotated[TierGatewayBuilder, Depends(get_tier_gateway_builder)]
SessionDep = Annotated[AsyncSession, Depends(get_session)]


def _descriptor_view(tool: GeneratorTool) -> ToolDescriptorView:
    return ToolDescriptorView(
        key=tool.key,
        title=tool.title,
        subtitle=tool.subtitle,
        genre=tool.genre,
        is_legacy=tool.spec is None,
        ingestable=tool.ingest is not None,
        input_fields=[
            ToolInputFieldView(
                name=f.name,
                label=f.label,
                type=f.type,
                required=f.required,
                default=f.default,
                help=f.help,
            )
            for f in tool.input_fields
        ],
        legacy_route=tool.legacy_route,
    )


@router.get("/skills/toolbox")
async def list_toolbox() -> ToolboxListResponse:
    """创作工具箱全量描述符（legacy + 新工具；前端据此渲染卡片栅格 + 表单 + 路由）。"""
    from ww_skills import TOOLBOX

    # 确定性顺序（按 key 升序），便于前端稳定渲染与测试断言。
    tools = [_descriptor_view(TOOLBOX[key]) for key in sorted(TOOLBOX)]
    return ToolboxListResponse(tools=tools)


def _require_real_tool(tool_key: str) -> GeneratorTool:
    """解析工具：未知 → 404；legacy（无 spec）→ 404（前端走 legacy_route）。"""
    tool = get_tool(tool_key)
    if tool is None:
        raise AppError(ErrorCode.NOT_FOUND, f"未知工具：{tool_key}", {"tool_key": tool_key})
    if tool.spec is None:
        raise AppError(
            ErrorCode.NOT_FOUND,
            f"工具 {tool_key} 无通用执行路径，请使用其专属页面（legacy_route）",
            {"tool_key": tool_key, "legacy_route": tool.legacy_route},
        )
    return tool


async def _chapter_beats(
    outline_repo: OutlineRepo, project_id: uuid.UUID, chapter_no: int
) -> list[str]:
    """读指定章的大纲粗节拍（缺章/缺大纲 → 空 list，不报错；DB JSONB dict→list 解包）。"""
    view = await outline_repo.get(project_id, chapter_no)
    if view is None:
        return []
    raw = view.beats or {}
    return list(raw.get("beats", [])) if isinstance(raw, dict) else []


async def _prior_chapter_text(
    chapter_repo: ChapterRepo, project_id: uuid.UUID, chapter_no: int
) -> str:
    """读续写承接的前文正文：优先该章最新 accepted，无则草稿，皆无 → 空串（端点降级）。

    core 的 builder 不 import apps/api——正文在端点读 chapter 领域表后经参数注入
    （仿链 accept_op 的注入模式），守不变量 #1（DB 为真相源）。
    """
    accepted = await chapter_repo.latest_accepted(project_id, chapter_no)
    if accepted is not None and accepted.content.strip():
        return accepted.content
    draft = await chapter_repo.get_draft(project_id, chapter_no)
    if draft is not None and draft.content.strip():
        return draft.content
    return ""


@router.post("/projects/{project_id}/skills/{tool_key}/generate", responses=_TOOL_ERRORS)
async def generate_with_tool(
    project_id: uuid.UUID,
    tool_key: str,
    body: ToolGenerateRequest,
    request: Request,
    project_repo: ProjectRepoDep,
    memory: MemoryReposDep,
    outline_repo: OutlineReadRepoDep,
    chapter_repo: ChapterRepoDep,
    build_gateway: GatewayBuilderDep,
    session: SessionDep,
) -> ToolGeneratePreviewResponse:
    """通用生成（结构化预览，不入库）。未知/ legacy 工具→404；项目不存在→404；无凭据→503。"""
    request_id = getattr(request.state, "request_id", None)
    tool = _require_real_tool(tool_key)
    spec = tool.spec
    assert spec is not None  # _require_real_tool 已保证（mypy 收窄）

    project = await project_repo.get(STUB_OWNER_ID, project_id)
    if project is None:
        raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")

    # brief_only 给精简设定、其余给完整设定（确定性序列化，无时间戳/UUID）。
    if tool.context_strategy == "brief_only":
        project_context = _project_context(project.title, project.genre, None, None)
    else:
        project_context = _project_context(
            project.title, project.genre, project.premise, project.theme
        )

    world_context = ""
    beats: list[str] = []
    prior_text = ""
    text = body.text or ""
    if tool.context_strategy == "with_world":
        world_context = await _world_context(memory, project_id)
    elif tool.context_strategy == "with_outline_chapter":
        chapter_no = body.chapter_no or 1
        beats = await _chapter_beats(outline_repo, project_id, chapter_no)
    elif tool.context_strategy == "with_prior_chapter":
        # 续写：端点先读该章最新 accepted/draft 正文（DB 为真相源，#1）+ 该章节拍，注入 builder。
        chapter_no = body.chapter_no or 1
        prior_text = await _prior_chapter_text(chapter_repo, project_id, chapter_no)
        beats = await _chapter_beats(outline_repo, project_id, chapter_no)
    elif tool.context_strategy == "text_input":
        # text_input 工具（扩写/降AI/拆书）必须有原文样本——空原文会让 LLM 空跑（守输入校验纪律）。
        if not text.strip():
            raise AppError(
                ErrorCode.VALIDATION,
                f"工具 {tool_key} 需要原文输入（text 不可为空）",
                {"tool_key": tool_key},
            )
        # 拆书的书名（body.kind）前置入原文，供拆解对照（teardown system prompt 期待书名）。
        kind = (body.kind or "").strip()
        if kind:
            text = f"书名：{kind}\n\n{text}"

    context = build_toolbox_context(
        tool.context_strategy,
        brief=body.brief,
        project_context=project_context,
        world_context=world_context,
        chapter_no=body.chapter_no,
        beats=beats,
        prior_text=prior_text,
        text=text,
    )

    gateway = await build_gateway(spec.tier)
    parsed = await run_generator(
        spec,
        context=context,
        gateway=gateway,
        user_id=STUB_OWNER_ID,
        project_id=project_id,
    )
    # 提交边界：预览不写业务表，但网关 ledger 只 flush → 末尾 commit 落 usage（否则丢失）。
    await session.commit()
    log.info(
        "toolbox_generate_done",
        tool_key=tool_key,
        project_id=str(project_id),
        request_id=request_id,
        output_kind=type(parsed).__name__,
    )
    return ToolGeneratePreviewResponse(
        tool_key=tool_key,
        output_kind=type(parsed).__name__,
        preview=parsed.model_dump(),
    )


async def _precheck_world_entities(
    spec: AgentSpec,
    *,
    items: list[WorldEntityCardView],
    world_context: str,
    gateway: Gateway,
    project_id: uuid.UUID,
) -> list[Conflict]:
    """入库前 continuity 预检（world_entities）：把待入库实体 vs 既有世界观真相源比对。

    仿 `precheck_generated_cards` 但服务于世界观实体（金手指/词条）。复用 `continuity_spec`
    （analyst 档，只读，不变量 #3）——直接构 LlmRequest 走网关，节点逻辑无 LLM 非确定性。
    """
    lines = [
        f"- [{e.type}] {e.name}：{('；'.join(e.rules) if e.rules else '（无硬规则）')}"
        for e in items
    ]
    context = (
        "## 待校验：本次生成的世界观实体（尚未入库）\n"
        f"{chr(10).join(lines) or '（无）'}\n\n"
        "## 真相源：既有世界观硬规则\n"
        f"{world_context or '（暂无世界观设定）'}"
    )
    req = LlmRequest(
        tier=spec.tier,
        system=[Block(text=spec.system_prompt, cache=True)],
        input=context,
        output_schema=spec.output_schema,
        scope=Scope(user_id=STUB_OWNER_ID, project_id=project_id),
    )
    resp = await gateway.run(req)
    parsed = resp.parsed
    if not isinstance(parsed, ContinuityReview):
        raise ValueError("gateway returned no parsed ContinuityReview for toolbox precheck")
    return list(parsed.conflicts)


def _conflict_409(conflicts: list[Conflict]) -> AppError:
    return AppError(
        ErrorCode.CONFLICT_UNRESOLVED,
        "生成内容与现有设定存在 continuity 冲突，请裁决后确认入库",
        {
            "conflicts": [
                IngestConflictView(
                    type=c.type, where=c.where, refs=list(c.refs), suggestion=c.suggestion
                ).model_dump()
                for c in conflicts
            ],
            "conflict_count": len(conflicts),
        },
    )


@router.post(
    "/projects/{project_id}/skills/{tool_key}/ingest", status_code=201, responses=_INGEST_ERRORS
)
async def ingest_with_tool(
    project_id: uuid.UUID,
    tool_key: str,
    body: ToolIngestRequest,
    request: Request,
    project_repo: ProjectRepoDep,
    memory: MemoryReposDep,
    world_write_repo: WorldWriteRepoDep,
    outline_write_repo: OutlineWriteRepoDep,
    rule_write_repo: RuleWriteRepoDep,
    build_gateway: GatewayBuilderDep,
    session: SessionDep,
) -> ToolIngestResponse:
    """通用入库（仅可入库工具）：过 continuity 预检 + 白名单 → 写 ingest.table。

    未知/legacy 工具→404；不可入库工具→422；有冲突且未确认→409；越权写表丢弃+审计。
    """
    request_id = getattr(request.state, "request_id", None)
    tool = _require_real_tool(tool_key)
    spec = tool.spec
    assert spec is not None
    if tool.ingest is None:
        raise AppError(
            ErrorCode.VALIDATION,
            f"工具 {tool_key} 不支持入库（纯预览）",
            {"tool_key": tool_key},
        )

    project = await project_repo.get(STUB_OWNER_ID, project_id)
    if project is None:
        raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")

    table = tool.ingest.table

    if table == "world_entities":
        return await _ingest_world_entities(
            spec,
            body=body,
            project_id=project_id,
            memory=memory,
            world_write_repo=world_write_repo,
            session=session,
            build_gateway=build_gateway,
            request_id=request_id,
            tool_key=tool_key,
        )
    if table == "outline":
        return await _ingest_outline(
            spec,
            body=body,
            project_id=project_id,
            outline_write_repo=outline_write_repo,
            session=session,
            request_id=request_id,
            tool_key=tool_key,
        )
    if table == "rules":
        return await _ingest_rules(
            spec,
            body=body,
            project_id=project_id,
            rule_write_repo=rule_write_repo,
            session=session,
            request_id=request_id,
            tool_key=tool_key,
        )
    # 描述符的 IngestSpec.table 已限白名单；其余表本期通用入库未实现。
    raise AppError(ErrorCode.VALIDATION, f"入库表 {table} 暂未支持通用入库", {"table": table})


async def _ingest_world_entities(
    spec: AgentSpec,
    *,
    body: ToolIngestRequest,
    project_id: uuid.UUID,
    memory: MemoryRepos,
    world_write_repo: WorldEntityWriteRepo,
    session: AsyncSession,
    build_gateway: TierGatewayBuilder,
    request_id: str | None,
    tool_key: str,
) -> ToolIngestResponse:
    """world_entities 入库（金手指/词条）：continuity 预检 + 白名单 → 写 world_entities。"""
    items = body.world_entities
    # gate 1：入库前 continuity 预检（analyst 网关；编排器追加的检查，守不变量 #1/#3）。
    world_context = await _world_context(memory, project_id)
    precheck_gateway = await build_gateway("analyst")
    conflicts = await _precheck_world_entities(
        continuity_spec,
        items=items,
        world_context=world_context,
        gateway=precheck_gateway,
        project_id=project_id,
    )
    if conflicts and not body.acknowledge_conflicts:
        await session.commit()  # 落 precheck 网关 usage；不写业务表。
        log.info(
            "toolbox_ingest_blocked_by_conflicts",
            tool_key=tool_key,
            project_id=str(project_id),
            request_id=request_id,
            conflict_count=len(conflicts),
        )
        raise _conflict_409(conflicts)

    # gate 2：权限白名单——按 spec 声明的 writes 过滤（越权表丢弃 + 审计）。
    allowed, rejected = partition_writes(spec, {"world_entities": items})
    if rejected:
        log.warning(
            "toolbox_ingest_rejected_over_permission",
            tool_key=tool_key,
            project_id=str(project_id),
            request_id=request_id,
            rejected_tables=rejected,
        )

    created: list[str] = []
    for entity in allowed.get("world_entities", []):
        view = await world_write_repo.create(
            project_id, type=entity.type, name=entity.name, rules=list(entity.rules)
        )
        created.append(view.name)

    await session.commit()
    log.info(
        "toolbox_ingest_done",
        tool_key=tool_key,
        project_id=str(project_id),
        request_id=request_id,
        table="world_entities",
        created_count=len(created),
    )
    return ToolIngestResponse(table="world_entities", created=created, rejected_tables=rejected)


async def _ingest_outline(
    spec: AgentSpec,
    *,
    body: ToolIngestRequest,
    project_id: uuid.UUID,
    outline_write_repo: OutlineWriteRepo,
    session: AsyncSession,
    request_id: str | None,
    tool_key: str,
) -> ToolIngestResponse:
    """outline 入库（细纲）：白名单 → 把场景节拍 upsert 到目标章的 outline 行。

    细纲不做 continuity 预检（场景是粗节拍的展开，非与世界观真相源比对的设定/角色）；
    仍过 partition_writes 白名单。无章号 → VALIDATION。
    """
    if body.chapter_no is None:
        raise AppError(ErrorCode.VALIDATION, "细纲入库需指定 chapter_no", {"tool_key": tool_key})

    # gate：权限白名单（细纲 spec 声明 writes=["outline"]）。
    scenes: list[OutlineSceneIngestView] = body.scenes
    allowed, rejected = partition_writes(spec, {"outline": scenes})
    if rejected:
        log.warning(
            "toolbox_ingest_rejected_over_permission",
            tool_key=tool_key,
            project_id=str(project_id),
            request_id=request_id,
            rejected_tables=rejected,
        )

    allowed_scenes: list[OutlineSceneIngestView] = allowed.get("outline", [])
    # 场景节拍序列化为该章 beats（按 idx 升序拼装，确定性）。
    ordered = sorted(allowed_scenes, key=lambda s: s.idx)
    beats = [s.beat for s in ordered]
    created: list[str] = []
    if "outline" in allowed:
        view = await outline_write_repo.upsert_chapter(
            project_id,
            volume=1,
            chapter_no=body.chapter_no,
            beats=beats,
            foreshadow_windows=[],
        )
        created = [str(s.idx) for s in ordered]
        log.info(
            "toolbox_outline_upserted",
            tool_key=tool_key,
            project_id=str(project_id),
            chapter_no=view.chapter_no,
            scene_count=len(ordered),
        )

    await session.commit()
    log.info(
        "toolbox_ingest_done",
        tool_key=tool_key,
        project_id=str(project_id),
        request_id=request_id,
        table="outline",
        created_count=len(created),
    )
    return ToolIngestResponse(table="outline", created=created, rejected_tables=rejected)


# 拆书入库的 rules 级别（meta 规则非设定卡，落项目级；单用户原型 owner stub）。
_TEARDOWN_RULE_LEVEL = "project"


def _flatten_teardown_rules(teardown: TeardownIngestView) -> list[tuple[str, str]]:
    """把拆书结论拍平成可读 rules 条目：返回 (label, content) 列表（确定性顺序，跳过空字段）。

    主题/原型/钩子各一条（清单拼成可读文本）；结构一条。空清单/空串的字段跳过，不落空规则。
    """
    entries: list[tuple[str, str]] = []
    if teardown.themes:
        entries.append(("themes", f"核心主题：{'；'.join(teardown.themes)}"))
    if teardown.archetypes:
        entries.append(("archetypes", f"人物原型：{'；'.join(teardown.archetypes)}"))
    if teardown.structure.strip():
        entries.append(("structure", f"叙事结构：{teardown.structure.strip()}"))
    if teardown.hooks:
        entries.append(("hooks", f"钩子套路：{'；'.join(teardown.hooks)}"))
    return entries


async def _ingest_rules(
    spec: AgentSpec,
    *,
    body: ToolIngestRequest,
    project_id: uuid.UUID,
    rule_write_repo: RuleWriteRepo,
    session: AsyncSession,
    request_id: str | None,
    tool_key: str,
) -> ToolIngestResponse:
    """rules 入库（拆书）：白名单 → 把拆书结论拍平为可读 rules 条目写入项目级 rules。

    meta 规则非设定卡，无需 continuity 预检（仿 outline 无 409 路径）；仍过 partition_writes
    白名单（teardown spec 声明 writes=["rules"]）。无 teardown 产物 → VALIDATION。
    """
    if body.teardown is None:
        raise AppError(ErrorCode.VALIDATION, "拆书入库需提供 teardown 产物", {"tool_key": tool_key})

    entries = _flatten_teardown_rules(body.teardown)

    # gate：权限白名单（teardown spec 声明 writes=["rules"]）。
    allowed, rejected = partition_writes(spec, {"rules": entries})
    if rejected:
        log.warning(
            "toolbox_ingest_rejected_over_permission",
            tool_key=tool_key,
            project_id=str(project_id),
            request_id=request_id,
            rejected_tables=rejected,
        )

    created: list[str] = []
    for label, content in allowed.get("rules", []):
        await rule_write_repo.create(project_id, level=_TEARDOWN_RULE_LEVEL, content=content)
        created.append(label)

    await session.commit()
    log.info(
        "toolbox_ingest_done",
        tool_key=tool_key,
        project_id=str(project_id),
        request_id=request_id,
        table="rules",
        created_count=len(created),
    )
    return ToolIngestResponse(table="rules", created=created, rejected_tables=rejected)
