"""领券业务透传端点。

把客户端的 POST /api/v1/coupon/step 请求原样转发给 pricebot-backend
的 /api/coupon/step。MVP 阶段**不鉴权**(见 docs/待办与技术债.md P1:
device_id 透传给 pricebot 区分设备,待补 JWT + device_id↔user_id 绑定后
才能做用户级画像)。

pricebot 协议文档:
  pricebot-backend/docs/projects/领券-客户端对接协议.md
"""
from __future__ import annotations

import json
import logging
from typing import Any

import httpx
from fastapi import APIRouter, HTTPException, Request, status
from fastapi.concurrency import run_in_threadpool

from app.api.deps import CurrentUser, DbSession
from app.core.config import settings
from app.core.pricebot_client import get_pricebot_client
from app.core.pricebot_router import pick_pricebot
from app.db.session import SessionLocal
from app.repositories import coupon_state as coupon_repo
from app.schemas.coupon_state import (
    CouponCompletedTodayOut,
    CouponPromptDismissIn,
    CouponPromptShouldShowOut,
    CouponPromptShownIn,
    CouponSessionIn,
    CouponStatsOut,
)

logger = logging.getLogger("shagua.coupon")

router = APIRouter(prefix="/api/v1/coupon", tags=["coupon"])


def _to_int(v: object) -> int | None:
    """user_id 协议是字符串(登录态才带),转 int 存库;缺失/非法 → None。"""
    if v is None:
        return None
    try:
        return int(v)  # type: ignore[arg-type]
    except (TypeError, ValueError):
        return None


def _extract_coupon_results(resp_json: dict) -> list[dict]:
    """从 pricebot 响应抽本帧券结果,并**按 coupon_id 去重**。

    ⚠️ done/单券帧同时带 last_coupon_result(最后一张)+ action.params.coupon_results
    (全量含最后一张)→ 最后一张出现两次。必须去重:同批里同 coupon_id 两次,
    record_claims 在 autoflush=False 下两次 select 都查不到刚 add 的行 → 重复 add →
    commit 撞唯一约束 IntegrityError 回滚整批(done/单券记录全丢)。
    coupon_results(全量权威)覆盖 last_coupon_result。"""
    by_id: dict[str, dict] = {}
    last = resp_json.get("last_coupon_result")
    if isinstance(last, dict) and last.get("coupon_id"):
        by_id[last["coupon_id"]] = last
    params = (resp_json.get("action") or {}).get("params") or {}
    cr = params.get("coupon_results")
    if isinstance(cr, list):
        for x in cr:
            if isinstance(x, dict) and x.get("coupon_id"):
                by_id[x["coupon_id"]] = x
    return list(by_id.values())


def _mark_engagement_blocking(
    device_id: str, package: str, user_id: int | None, engage_type: str
) -> None:
    """独立 session 写 engagement(async 端点经 run_in_threadpool 调,不阻塞事件循环)。"""
    with SessionLocal() as db:
        coupon_repo.mark_engagement(db, device_id, package, user_id, engage_type)


def _record_claims_blocking(
    device_id: str, user_id: int | None, trace_id: str | None, results: list[dict]
) -> None:
    with SessionLocal() as db:
        # 取本次 session 环境，给每日资产和逐次事件同时打环境标。
        app_env = coupon_repo.session_app_env(db, trace_id)
        coupon_repo.record_claims(db, device_id, user_id, trace_id, results, app_env=app_env)
        # 顺带把本帧「成功平台」并入 coupon_session.platform_success(admin 领券数据 ②整单/③点位成功率;
        # 设计 route B,见 docs/guides/领券成功率指标-设计与埋点.md)。复用同一 SessionLocal、紧接 record_claims,
        # 不新增连接;并集幂等(无新平台不写),trace_id 缺失或 session 行未落库则跳过。
        if trace_id:
            coupon_repo.merge_session_platform_success(
                db, trace_id, coupon_repo.succeeded_platforms(results)
            )


def _mark_completed_blocking(
    device_id: str, user_id: int | None, trace_id: str | None
) -> None:
    """独立 session 写"今日已跑完整轮"(到 done 帧那刻调,best-effort 不阻塞领券)。"""
    with SessionLocal() as db:
        coupon_repo.mark_completed_today(db, device_id, user_id, trace_id)


@router.post("/step", summary="领券任务步进 (透传到 pricebot)")
async def coupon_step(
    request: Request,
) -> dict[str, Any]:
    """把请求体原样转发给 pricebot-backend 的 /api/coupon/step。

    - 鉴权: MVP 阶段不鉴权(待补 JWT,见 docs/待办与技术债.md P1)
    - 透传: 不做 schema 校验,pricebot 自己校验
    - 失败: 网络不可达 / pricebot 5xx → 502 + 友好 message
    """
    # 读原始字节,避免"反序列化→再序列化"的双重 JSON(省透传 CPU)。只 loads 一次拿
    # trace_id 做亲和 + 打日志,转发时直接发原始 bytes,不重新 dumps。
    raw = await request.body()
    try:
        meta = json.loads(raw)
    except Exception as e:
        raise HTTPException(status_code=400, detail=f"invalid json body: {e}") from e
    if not isinstance(meta, dict):
        meta = {}

    device_id = meta.get("device_id")
    user_id = _to_int(meta.get("user_id"))  # 登录态才带;判断不靠它,资产留痕用
    trace_id = meta.get("trace_id")
    # 发起领券时前台 App 包名(step body 带 "package")。频控按 App,这条 engagement 要记到
    # 对应 App 上。App 内「去领取」发起时 package 可能缺/为空 → 退化为 "" 占位(全局态)。
    pkg = meta.get("package") or ""

    # 领券任务首帧(step=0)= 用户已发起领券 → 记一条今日 engagement(claim_started),
    # 今天**这个 App** 不再弹引导窗(对齐前台"点一键领取即 markEngaged")。写库失败绝不能
    # 连累领券主流程,整段吞掉。
    if device_id and meta.get("step") == 0:
        try:
            await run_in_threadpool(
                _mark_engagement_blocking, device_id, pkg, user_id, "claim_started"
            )
        except Exception as e:  # noqa: BLE001
            logger.warning("coupon engagement write failed: %s", e)

    # 按 trace_id 一致性 hash 选 pricebot 实例(同一领券任务的所有帧落同一进程)
    base = pick_pricebot(meta.get("trace_id"))
    url = f"{base.rstrip('/')}/api/coupon/step"
    timeout = settings.PRICEBOT_REQUEST_TIMEOUT_SEC

    logger.info(
        "coupon_step device_id=%s trace_id=%s step=%s",
        meta.get("device_id"),
        meta.get("trace_id"),
        meta.get("step"),
    )

    try:
        client = get_pricebot_client()
        resp = await client.post(
            url, content=raw, headers={"Content-Type": "application/json"}, timeout=timeout
        )
    except httpx.RequestError as e:
        logger.error("[pricebot] request failed: %s", e)
        raise HTTPException(
            status_code=status.HTTP_502_BAD_GATEWAY,
            detail=f"pricebot upstream unreachable: {e}",
        ) from e

    if resp.status_code >= 500:
        logger.error(
            "[pricebot] 5xx status=%d body=%s",
            resp.status_code,
            resp.text[:500],
        )
        raise HTTPException(
            status_code=status.HTTP_502_BAD_GATEWAY,
            detail=f"pricebot upstream returned {resp.status_code}",
        )

    resp_json = resp.json()

    # 领券结果沉淀:每日资产 + 逐次事件；中间帧和 done 全量帧均幂等写库。
    # 当前只记录、不参与"要不要领"判断(MVP 先不去重)。写库失败不影响返回。
    if device_id:
        results = _extract_coupon_results(resp_json)
        if results:
            try:
                await run_in_threadpool(
                    _record_claims_blocking, device_id, user_id, trace_id, results
                )
            except Exception as e:  # noqa: BLE001
                logger.warning("coupon claim write failed: %s", e)

    # 整轮跑完(done 帧)→ 记一条"今日已完成",首页「去领取」卡据此置灰。
    # pricebot 把中途单券 done 改写成 wait+continue=true,只有整套全跑完那帧才保留
    # command=="done"(见 pricebot main.py),故 done 已等价"整轮完成"(用户决策 A 方案:
    # 到 done 即算,不管单券成败),无需再判 continue。写库失败绝不连累领券返回。
    action = resp_json.get("action") or {}
    if device_id and action.get("command") == "done":
        try:
            await run_in_threadpool(
                _mark_completed_blocking, device_id, user_id, trace_id
            )
        except Exception as e:  # noqa: BLE001
            logger.warning("coupon completion write failed: %s", e)

    return resp_json


@router.post("/session", summary="领券任务流水上报(admin 领券数据看板数据源)")
def coupon_session(payload: CouponSessionIn, db: DbSession) -> dict[str, bool]:
    """客户端两段上报一次领券流水(发起 started / 收尾 completed-failed-abandoned),按 trace_id upsert
    到 coupon_session。不鉴权(同领券循环 MVP,按 device_id/trace_id);供 admin「领券数据」看板算
    发起/完成数、耗时分位、机型维度。写库失败不应连累客户端(本就 fire-and-forget),吞掉返回 ok。"""
    try:
        coupon_repo.upsert_coupon_session(
            db,
            trace_id=payload.trace_id,
            device_id=payload.device_id,
            status=payload.status,
            started_at_ms=payload.started_at_ms,
            user_id=payload.user_id,
            platforms=payload.platforms,
            origin_package=payload.origin_package,
            device_model=payload.device_model,
            rom=payload.rom,
            app_env=payload.app_env,
            elapsed_ms=payload.elapsed_ms,
            platform_elapsed=payload.platform_elapsed,
            claimed_count=payload.claimed_count,
            trace_url=payload.trace_url,
        )
    except Exception as e:  # noqa: BLE001
        logger.warning("coupon session write failed: %s", e)
    return {"ok": True}


@router.post("/prompt/shown", summary="领券引导窗弹出即上报(按 App 记 shown)")
def coupon_prompt_shown(payload: CouponPromptShownIn, db: DbSession) -> dict[str, bool]:
    """客户端弹出引导窗那刻调 → 记一条今日 engagement(shown),今天**这个 App** 不再自动弹。

    频控主判据(管跨重装):弹出即占用今天这个 App 的"一次"。用户领/拒/无视都算用掉。
    后续点领取/拒绝再由 step/dismiss 把 type 升级。按 (device, package, 日) 记。
    """
    coupon_repo.mark_engagement(
        db, payload.device_id, payload.package, payload.user_id, "shown"
    )
    return {"ok": True}


@router.post("/prompt/dismiss", summary="用户拒绝/关闭领券引导窗(记今日已 engage)")
def coupon_prompt_dismiss(payload: CouponPromptDismissIn, db: DbSession) -> dict[str, bool]:
    """客户端点关闭引导窗时调用 → 记一条今日 engagement(dismissed),今天**这个 App** 不再弹。

    server 在透传链路里看不到"用户拒绝"(拒绝不发起领券),故必须客户端通知。
    频控按 (device, package, 日),各 App 独立。MVP 不鉴权,按 device_id 记。
    """
    coupon_repo.mark_engagement(
        db, payload.device_id, payload.package, payload.user_id, "dismissed"
    )
    return {"ok": True}


@router.get(
    "/prompt/should-show",
    response_model=CouponPromptShouldShowOut,
    summary="切到外卖 App 时是否还应弹领券引导窗",
)
def coupon_prompt_should_show(
    device_id: str, db: DbSession, package: str = ""
) -> CouponPromptShouldShowOut:
    """今天这台设备**这个 App** 已 engage(弹/领/拒)过 → should_show=false。各 App 独立:
    美团弹过不压淘宝/京东。客户端切到目标 App 时带 package 查(老客户端不带 → "" 全局态)。"""
    return CouponPromptShouldShowOut(
        should_show=not coupon_repo.has_engaged_today(db, device_id, package)
    )


@router.post("/prompt/reset", summary="重置今日领券引导窗 engagement(开发测频控用)")
def coupon_prompt_reset(payload: CouponPromptDismissIn, db: DbSession) -> dict[str, bool]:
    """删这台设备今天的 engagement → has_engaged_today 变 false,今天又能弹。
    开发设置「重置今日领券弹窗状态」按钮调。MVP 不鉴权,按 device_id。"""
    coupon_repo.reset_today_engagement(db, payload.device_id)
    return {"ok": True}


@router.get(
    "/completed-today",
    response_model=CouponCompletedTodayOut,
    summary="这台设备今天是否已跑完整轮领券(到 done 帧)",
)
def coupon_completed_today(
    device_id: str, db: DbSession
) -> CouponCompletedTodayOut:
    """今天这台设备已跑完整轮(到 done)→ completed=true。客户端据此把首页「去领取」
    卡置灰、不可点(用户决策 A 方案:到 done 即算,不管单券成败)。判断维度 device_id
    必须与领券循环上报的一致(客户端两端都用 ANDROID_ID)。"""
    return CouponCompletedTodayOut(
        completed=coupon_repo.has_completed_today(db, device_id)
    )


@router.post(
    "/completed-today/reset",
    summary="重置今日已完成(开发设置全重置用,恢复首页「去领取」卡可点)",
)
def coupon_completed_today_reset(
    payload: CouponPromptDismissIn, db: DbSession
) -> dict[str, bool]:
    """删这台设备今天的 completion → has_completed_today 变 false,首页「去领取」卡恢复可点。
    与 /prompt/reset 配套:开发设置「重置今日领券弹窗状态」一键把今日状态全清。MVP 不鉴权。"""
    coupon_repo.reset_today_completion(db, payload.device_id)
    return {"ok": True}


@router.get(
    "/stats",
    response_model=CouponStatsOut,
    summary="累计领券数(「我的」页战绩卡「领取优惠券 X 张」)",
)
def coupon_stats(user: CurrentUser, db: DbSession) -> CouponStatsOut:
    """该登录用户累计领到的券数(SUM(claimed_count),口径见 coupon_repo.sum_claimed_count)。
    **鉴权(CurrentUser)**——区别于同文件不鉴权的 /step 透传与 /prompt 频控(那些按 device_id):
    个人战绩按 user_id 聚合,必须有登录态。"""
    return CouponStatsOut(coupon_count=coupon_repo.sum_claimed_count(db, user.id))
