"""``POST/GET /strategy_candidates`` —— D-9 · LLM 自创策略候选池。

设计：

- ``POST`` 接收 LLM 写的源码字符串 → 跑三道沙盒 → 落表 → 返回 ``candidate_id``
- ``GET /strategy_candidates/{id}`` 返完整候选（含源码 / metrics / fitness）
- ``GET /strategy_candidates`` 列表（支持按 status / author_id 过滤；按 fitness DESC）
- ``POST /strategy_candidates/{id}/promote`` 把候选从 ``candidate`` 切到 ``promoted``

**审批门**：promote 暴露 HTTP，但 orchestration 端的 ``paper.promote_candidate`` tool
默认走 permission ``ask``——agent 调时由 permission engine 弹气泡让用户在对话里二次确认，
agent 不能自助。这是 ADR-0020 §关键约定 3 的精神：金融硬约束是"人保留最终决定权"，
而非"agent 物理上看不到 tool"。promote 后仅是状态切换，live trading runner 仍属 E2 / D-7。

跑回测复用 ``POST /backtest``，传 ``candidate_id`` 走候选分支；本路由不开"跑回测"端点。
"""
from __future__ import annotations

from typing import Annotated
from uuid import UUID

from fastapi import APIRouter, Depends, Query
from inalpha_shared.auth import User, get_current_user
from inalpha_shared.db import DBConn
from inalpha_shared.errors import ConflictError, NotFoundError, ValidationError

from ..account_id import account_id_from_user
from ..schemas import (
    AuthorStrategyRequest,
    AuthorStrategyResponse,
    PromoteCandidateRequest,
    StrategyCandidateRecord,
    StrategyCandidateSummary,
)
from ..storage import strategy_candidates as candidates_store
from ..strategy_authoring import (
    ContractError,
    DynamicLoadError,
    audit_strategy_code,
    load_strategy_class,
    verify_strategy_contract,
)

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


@router.post("/strategy_candidates", response_model=AuthorStrategyResponse)
async def post_strategy_candidate(
    req: AuthorStrategyRequest,
    db: DBConn,
    user: Annotated[User, Depends(get_current_user)],
) -> AuthorStrategyResponse:
    """LLM / 用户提交策略源码 → 三道沙盒 → 落候选表。

    任何沙盒拒绝 → 422 + 详细 findings；LLM 应据此重写源码。
    撞到同 ``code_hash`` 的现有候选 → 返已有 ID（幂等，``created=False``）。
    """
    code = req.code

    # 1. AST 审计（main 进程）
    audit_result = audit_strategy_code(code)
    if not audit_result.ok:
        raise ValidationError(
            audit_result.reason(),
            code="STRATEGY_AUDIT_FAILED",
            details={
                "findings": [
                    {
                        "code": f.code,
                        "message": f.message,
                        "lineno": f.lineno,
                        "col_offset": f.col_offset,
                    }
                    for f in audit_result.findings
                ],
            },
        )

    # 2. dynamic_loader（受限 exec）—— 失败说明 LLM 写法不对（不是安全问题，但加载不了）
    try:
        cls = load_strategy_class(code)
    except DynamicLoadError as exc:
        raise ValidationError(
            f"策略源码无法加载：{exc}",
            code="STRATEGY_LOAD_FAILED",
        ) from exc

    # 3. 协议契约
    try:
        verify_strategy_contract(cls)
    except ContractError as exc:
        raise ValidationError(
            f"策略不符合协议契约：{exc}",
            code="STRATEGY_CONTRACT_FAILED",
        ) from exc

    # 4. 落表 —— author_id 仅当 user.user_id 是合法 UUID 时记录（兼容字符串 sub）；
    #    owner_account_id 走 account_id_from_user（与 strategy_runs 的 account_id 同源），
    #    供 live runner 起跑时做归属校验（issue #36.1）——非 UUID sub 也有稳定 owner。
    author_id = _try_uuid(user.user_id)
    owner_account_id = account_id_from_user(user)

    # D-12 · 因子衰减前馈：血缘里 decay_state 已是 fading/decaying 的因子在
    # author 时就告警（而不是等 promote 后 FactorPatrol 巡检才发现）。只 warning
    # 不拒绝——策略可以引用衰减因子（如刻意做衰减对冲），但必须是知情决策。
    warnings = _decay_warnings(req.factor_snapshot)

    audit_dict = {
        "ok": True,
        "findings": [],
        "class_name": cls.__name__,
    }
    if warnings:
        audit_dict["authoring_warnings"] = warnings  # 可追溯：告警当时就给过
    candidate_id, created = await candidates_store.insert_candidate(
        db,
        code=code,
        description=req.description,
        author="llm",  # MVP 一律标 llm；user 手写策略以后另开端点
        author_id=author_id,
        owner_account_id=owner_account_id,
        audit=audit_dict,
        factor_snapshot=req.factor_snapshot,
    )

    return AuthorStrategyResponse(
        candidate_id=candidate_id,
        code_hash=candidates_store.compute_code_hash(code),
        created=created,
        audit=audit_dict,
        warnings=warnings,
    )


def _decay_warnings(factor_snapshot: dict | None) -> list[str]:
    """factor_snapshot 里衰减中因子 → 告警文案列表（缺血缘 / 格式不对返空）。"""
    if not isinstance(factor_snapshot, dict):
        return []
    factors = factor_snapshot.get("factors")
    if not isinstance(factors, list):
        return []
    out: list[str] = []
    for f in factors:
        if not isinstance(f, dict):
            continue
        state = f.get("decay_state")
        if state in ("fading", "decaying"):
            fid = f.get("id") or f.get("name") or "(unnamed factor)"
            out.append(
                f"factor {fid!r} is already {state} at authoring time "
                f"(rank_ic_recent={f.get('rank_ic_recent')}) — do not use it as a "
                "core signal; if kept, downweight and state why"
            )
    return out


@router.get(
    "/strategy_candidates/{candidate_id}",
    response_model=StrategyCandidateRecord,
)
async def get_strategy_candidate(
    candidate_id: UUID,
    db: DBConn,
    _user: Annotated[User, Depends(get_current_user)],
) -> StrategyCandidateRecord:
    """取候选完整内容（含完整源码 + 历次回测的最新 metrics）。"""
    row = await candidates_store.get_candidate(db, candidate_id)
    if row is None:
        raise NotFoundError(
            f"candidate {candidate_id} not found",
            code="CANDIDATE_NOT_FOUND",
        )
    return StrategyCandidateRecord(**row)


@router.get(
    "/strategy_candidates",
    response_model=list[StrategyCandidateSummary],
)
async def list_strategy_candidates(
    db: DBConn,
    _user: Annotated[User, Depends(get_current_user)],
    status: Annotated[str | None, Query(description="可选过滤 status")] = None,
    author_id: Annotated[
        UUID | None,
        Query(description="可选只看某用户创建的候选"),
    ] = None,
    limit: Annotated[int, Query(ge=1, le=200)] = 50,
) -> list[StrategyCandidateSummary]:
    """列候选（fitness DESC, NULLS LAST, created_at DESC）。

    不返回 ``code`` 字段省带宽；要看源码用 ``GET /strategy_candidates/{id}``。
    """
    rows = await candidates_store.list_candidates(
        db,
        status=status,
        author_id=author_id,
        limit=limit,
    )
    return [
        StrategyCandidateSummary(
            id=r["id"],
            code_hash=r["code_hash"],
            description=r["description"],
            author=r["author"],
            status=r["status"],
            metrics=r["metrics"],
            fitness=r["fitness"],
            last_backtest_run_id=r["last_backtest_run_id"],
            created_at=r["created_at"],
            updated_at=r["updated_at"],
        )
        for r in rows
    ]


@router.post(
    "/strategy_candidates/{candidate_id}/promote",
    response_model=StrategyCandidateRecord,
)
async def promote_strategy_candidate(
    candidate_id: UUID,
    req: PromoteCandidateRequest,
    db: DBConn,
    user: Annotated[User, Depends(get_current_user)],
) -> StrategyCandidateRecord:
    """把候选从 ``status='candidate'`` 切到 ``'promoted'``。

    审批门：orchestration 端 ``paper.promote_candidate`` tool 默认 permission ``ask``——
    agent 调时 permission engine 弹气泡让用户在对话里二次确认。本端点本身**不**做
    "调用者是 agent 还是人"的区分（鉴权由 JWT + service token 负责）。

    校验：

    - 候选不存在 → 404 ``CANDIDATE_NOT_FOUND``
    - 当前 status ≠ ``candidate``（已 promoted / rejected）→ 409 ``CANDIDATE_NOT_PROMOTABLE``
    - ``fitness IS NULL``（没跑过回测）→ 422 ``CANDIDATE_NOT_BACKTESTED``——必须先跑
      ``POST /backtest`` 拿到 fitness，避免误把没验证的策略推上线

    成功：把 ``status='promoted'``，``audit.promotion`` 写 ``{reason, promoted_by, promoted_at}``。
    """
    row = await candidates_store.get_candidate(db, candidate_id)
    if row is None:
        raise NotFoundError(
            f"candidate {candidate_id} not found",
            code="CANDIDATE_NOT_FOUND",
        )

    current_status = row["status"]
    if current_status != "candidate":
        raise ConflictError(
            f"candidate {candidate_id} is in status {current_status!r}; "
            "only 'candidate' rows can be promoted",
            code="CANDIDATE_NOT_PROMOTABLE",
            details={"current_status": current_status},
        )

    if row["fitness"] is None:
        raise ValidationError(
            f"candidate {candidate_id} has no fitness (never backtested); "
            "run POST /backtest with candidate_id first to compute fitness",
            code="CANDIDATE_NOT_BACKTESTED",
        )

    # D-12 · soft check：holdout / 敏感性未过或缺失 → 写 audit.promotion.warnings
    # 留痕，不 hard reject（老候选没这些字段，硬拒会误杀；观察期后再评估收紧）
    promotion_warnings = _promotion_soft_warnings(row.get("metrics"))

    await candidates_store.promote_candidate(
        db,
        candidate_id,
        reason=req.reason,
        promoted_by=user.user_id,
        warnings=promotion_warnings,
    )

    updated = await candidates_store.get_candidate(db, candidate_id)
    # promote 函数保证行存在；updated is None 不可达，但 mypy 仍要求 narrow
    assert updated is not None, "candidate disappeared mid-transaction"
    return StrategyCandidateRecord(**updated)


def _promotion_soft_warnings(metrics: dict | None) -> list[str]:
    """promote 时检 holdout / 敏感性软门槛 → 告警文案（D-12，只留痕不拒绝）。

    缺字段（validation / sensitivity 为 None）= 老候选或没跑过验证，告警提示而不
    硬拒——避免误杀 D-12 前落库的候选。观察期后若要收紧成 hard gate 再改端点层。
    """
    if not isinstance(metrics, dict):
        return ["promotion soft-check skipped: candidate has no metrics"]
    out: list[str] = []

    validation = metrics.get("validation")
    if not isinstance(validation, dict):
        out.append("no holdout validation on record — robustness not verified")
    else:
        decay = validation.get("decay_ratio")
        holdout = validation.get("holdout") or {}
        holdout_sharpe = holdout.get("sharpe") if isinstance(holdout, dict) else None
        if isinstance(decay, (int, float)) and decay < 0.5:
            out.append(
                f"holdout decay_ratio {decay:.2f} < 0.5 — out-of-sample performance "
                "decayed sharply (overfit signal)"
            )
        if isinstance(holdout_sharpe, (int, float)) and holdout_sharpe < 0:
            out.append(
                f"holdout sharpe {holdout_sharpe:.2f} < 0 — strategy loses money "
                "out-of-sample"
            )
        flags = set(validation.get("flags") or [])
        if "insufficient_sample" in flags:
            out.append("holdout validation flagged insufficient_sample — inconclusive")
        # train_sharpe_nonpositive / sharpe_undefined：decay_ratio 被置 None（避免负除负
        # 假装没衰减），但这恰恰让上面 decay<0.5 的判据失效——样本内亏损策略会零告警
        # promote。这两个 flag 自身就是 overfitting 不可验证的信号，必须留痕（CR #86 major）。
        for flag in sorted({"train_sharpe_nonpositive", "sharpe_undefined"} & flags):
            out.append(
                f"holdout validation flagged {flag} — decay_ratio unavailable, "
                "overfit signal unverifiable (train likely unprofitable in-sample)"
            )

    sensitivity = metrics.get("sensitivity")
    if not isinstance(sensitivity, dict):
        out.append("no parameter sensitivity check on record — run check_sensitivity")
    elif sensitivity.get("verdict") == "cliff":
        out.append(
            "parameter sensitivity verdict=cliff — neighbourhood fitness collapses "
            "under small perturbation (overfit signal)"
        )

    return out


def _try_uuid(s: str) -> UUID | None:
    """字符串 → UUID 或 None（兼容非 UUID 的 sub claim）。"""
    try:
        return UUID(s)
    except (ValueError, TypeError):
        return None
