"""看激励视频发奖 endpoint。

路由前缀 `/api/v1/ad`:
  GET  /pangle-callback   穿山甲 S2S 发奖回调(**无 JWT,靠验签**),穿山甲服务器调
  GET  /reward-status     客户端查今日看广告发奖进度(Bearer)
  GET  /reward-result/{ad_session_id}
                          客户端按会话查本次广告实发金币(Bearer,只读,弹窗金额用)

发奖走服务端:激励视频播完穿山甲回调本接口,验签通过后幂等发金币。客户端只负责
看完后刷新余额,不参与发奖,被破解也刷不到钱。
"""
from __future__ import annotations

import json
import logging
import uuid

from fastapi import APIRouter, Depends, HTTPException, Path, Request, status

from app.api.deps import CurrentUser, DbSession
from app.core import rewards
from app.core.config import settings
from app.core.ratelimit import rate_limit
from app.integrations import pangle
from app.repositories import ad_ecpm as crud_ecpm
from app.repositories import ad_feed_reward as crud_feed
from app.repositories import ad_reward as crud_ad
from app.repositories import ad_watch as crud_watch
from app.repositories import app_config
from app.schemas.ad import (
    AdRewardResultOut,
    AdRewardStatusOut,
    EcpmReportIn,
    EcpmReportOut,
    FeedRewardIn,
    FeedRewardOut,
    FeedRewardUnitsOut,
    PangleCallbackOut,
    RewardNoShowIn,
    RewardNoShowOut,
    TestGrantIn,
    TestGrantOut,
    WatchReportIn,
    WatchReportOut,
)

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

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

# GroMore 回调 is_verify=false 时透传给客户端 SDK 的错误码(自定义,仅用于排查/客户端提示)
REASON_OK = 0
REASON_BAD_PARAMS = 1  # 验签过但缺 trans_id / user_id 非数字
REASON_UNKNOWN_USER = 2  # user_id 不存在(可能伪造)

REWARD_SCENE_REWARD_VIDEO = "reward_video"
# 提现看视频:看完才能提现的「硬门槛」广告,**不发金币**,只记一条幂等记录(收益由 eCPM 上报口径
# ad_type="withdrawal_video" 单独统计)。故意不放进 SUPPORTED_REWARD_SCENES——它不走发币分支。
REWARD_SCENE_WITHDRAWAL_AD = "withdrawal_ad"
# 2026-07 下线 signin_boost(签到膨胀):它按固定 3000 金币发,与广告实际收益脱钩,产品确认
# 从来不是设计内的口径。签到弹窗里的「看广告膨胀」现在与福利页看视频走同一条 reward_video
# 路径(按 eCPM 公式发),奖励只剩「签到」+「看视频」两种。历史发币流水(coin_transaction
# .biz_type='signin_boost')保留不动——钱是真发过的,账必须留。
SUPPORTED_REWARD_SCENES = {REWARD_SCENE_REWARD_VIDEO}


def _parse_extra(raw_extra: str | None) -> dict[str, str]:
    """解析客户端 setMediaExtra 透传的 JSON;旧格式/异常返回空 dict。"""
    if not raw_extra:
        return {}
    try:
        data = json.loads(raw_extra)
    except (TypeError, ValueError):
        return {}
    if not isinstance(data, dict):
        return {}
    return {str(k): str(v) for k, v in data.items() if v is not None}


@router.get(
    "/pangle-callback",
    response_model=PangleCallbackOut,
    summary="穿山甲激励视频发奖回调(S2S,验签)",
    dependencies=[Depends(rate_limit(300, 60, "pangle-callback"))],
)
def pangle_callback(request: Request, db: DbSession) -> PangleCallbackOut:
    """穿山甲 GroMore 在激励视频播完后回调,带 user_id / trans_id / ecpm / sign 等 query 参数。

    流程:开关/密钥就绪 → 验签(SHA256(m-key:trans_id)) → 解析 user_id/场景/eCPM → 幂等发金币。
    验签失败 403(留给真请求重试);参数缺/坏或 user 不存在 → is_verify=false + reason(不发,不重试)。
    granted / capped → is_verify=true + reason=0。
    """
    if not settings.PANGLE_CALLBACK_ENABLED:
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured"
        )

    params = dict(request.query_params)

    # 验签密钥:admin 后台配的 reward_mkey 优先,.env 的 PANGLE_REWARD_SECRET* 兜底(兼容/过渡)。
    # 换激励位时后台同步换 mkey 即可,无需改 .env。两者都空才视为未配置。
    ad_mkey = app_config.get_ad_config(db).get("reward_mkey") or ""
    secrets = ([ad_mkey] if ad_mkey else []) + settings.pangle_reward_secrets
    if not secrets:
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="pangle callback not configured"
        )
    if not pangle.verify_callback_sign_any(params, secrets):
        logger.warning("pangle callback bad sign trans_id=%s", params.get("trans_id"))
        raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="bad sign")

    trans_id = params.get("trans_id") or ""
    raw_user_id = params.get("user_id") or ""
    if not trans_id or not raw_user_id.isdigit():
        # 验签过了但参数缺/坏:不发奖(is_verify=false),带错误码
        logger.warning("pangle callback bad params: %s", params)
        return PangleCallbackOut(is_verify=False, reason=REASON_BAD_PARAMS)

    user_id = int(raw_user_id)
    raw = "&".join(f"{k}={v}" for k, v in sorted(params.items()) if k != "sign")
    extra = {}
    for extra_key in ("extra", "gromoreExtra", "gromore_extra"):
        extra.update(_parse_extra(params.get(extra_key)))
    reward_scene = extra.get("reward_scene") or REWARD_SCENE_REWARD_VIDEO
    ad_session_id = extra.get("ad_session_id")
    # 「这条广告属于哪一轮膨胀」。纯标签:不参与发奖判定,只让 reward-result 能把同一轮求和成
    # 弹窗要显示的累计值(见 crud_ad.round_coin_total)。老客户端不带 → NULL → 累计值返 null。
    boost_round_id = (extra.get("boost_round_id") or None)
    if boost_round_id is not None:
        boost_round_id = boost_round_id[:64]
    ecpm = params.get("ecpm")

    # 环境隔离:激励视频 mediaExtra 里带「这次观看属于哪个后端环境」(srv_env=dev/prod,客户端按
    # BuildConfig.DEBUG 决定),穿山甲 S2S 回调原样带回。回调 URL 在穿山甲后台只配一个(指向生产),
    # 所以测试包(dev)看广告的 S2S 也会打到生产——若不拦,生产会把奖发给「本库里同 user_id 的另一个
    # 真人」(user_id 是跨库不隔离的裸数字 → 跨库串号)。这里只处理「属于本服环境」的回调:环境不符
    # 直接受理但不发币、不写任何记录,保证各环境后台广告收益页只含本环境用户。is_verify=true 让穿山甲
    # 不再重试(测试用户的币由 localhost 的 test-grant 单独发,不依赖这条 S2S)。
    # 兼容:旧客户端不带 srv_env(取不到)→ 视为本环境,照常处理,不误伤存量正式用户。
    callback_env = extra.get("srv_env")
    if callback_env and callback_env != settings.APP_ENV:
        logger.info(
            "pangle callback foreign env skip: callback_env=%s self_env=%s user_id=%d trans_id=%s",
            callback_env, settings.APP_ENV, user_id, trans_id,
        )
        return PangleCallbackOut(is_verify=True, reason=REASON_OK)

    existing = crud_ad.find_by_trans(db, trans_id)
    if existing is not None:
        logger.info(
            "pangle callback idempotent user_id=%d trans_id=%s status=%s scene=%s",
            user_id, trans_id, existing.status, existing.reward_scene,
        )
        return PangleCallbackOut(is_verify=True, reason=REASON_OK)

    try:
        if reward_scene == REWARD_SCENE_WITHDRAWAL_AD:
            # 提现看视频:已知合法场景,只记一条幂等记录、**不动钱包**(发不发币由 reward_scene 决定)。
            # is_verify=true 让穿山甲不重试(区别于 unknown_scene 的 false)。
            crud_ad.record_external_reward(
                db, user_id, trans_id, coin=0, reward_scene=REWARD_SCENE_WITHDRAWAL_AD,
                ad_session_id=ad_session_id, ecpm=ecpm,
                reward_name=params.get("reward_name"), raw=raw[:1024],
                status="withdrawal_ad",
            )
            logger.info(
                "pangle callback withdrawal_ad user_id=%d trans_id=%s", user_id, trans_id,
            )
            return PangleCallbackOut(is_verify=True, reason=REASON_OK)
        if reward_scene not in SUPPORTED_REWARD_SCENES:
            rec = crud_ad.record_external_reward(
                db, user_id, trans_id, coin=0, reward_scene=reward_scene[:32],
                ad_session_id=ad_session_id, ecpm=ecpm,
                reward_name=params.get("reward_name"), raw=raw[:1024],
                status="unknown_scene",
            )
            logger.warning(
                "pangle callback unknown scene user_id=%d trans_id=%s scene=%s",
                user_id, trans_id, reward_scene,
            )
            return PangleCallbackOut(is_verify=False, reason=REASON_BAD_PARAMS)
        rec = crud_ad.grant_ad_reward(
            db, user_id, trans_id, ecpm=ecpm, ad_session_id=ad_session_id,
            reward_scene=REWARD_SCENE_REWARD_VIDEO, boost_round_id=boost_round_id,
            reward_name=params.get("reward_name"), raw=raw[:1024],
        )
    except crud_ad.UnknownUserError:
        logger.warning("pangle callback unknown user_id=%d trans_id=%s", user_id, trans_id)
        return PangleCallbackOut(is_verify=False, reason=REASON_UNKNOWN_USER)

    logger.info(
        "ad reward user_id=%d trans_id=%s scene=%s status=%s coin=%d",
        user_id, trans_id, rec.reward_scene, rec.status, rec.coin,
    )
    # granted / capped 均算"已处理":is_verify=true 不让穿山甲重试(capped 只是没加币)
    return PangleCallbackOut(is_verify=True, reason=REASON_OK)


@router.get("/reward-status", response_model=AdRewardStatusOut, summary="今日看广告发奖进度")
def reward_status(user: CurrentUser, db: DbSession) -> AdRewardStatusOut:
    (used, limit, coin_per, round_count, cooldown_until,
     watched_sec, watch_limit) = crud_ad.today_status(db, user.id)
    return AdRewardStatusOut(
        used_today=used,
        daily_limit=limit,
        remaining=max(0, limit - used),
        coin_per_ad=coin_per,
        round_count=round_count,
        cooldown_until=cooldown_until,
        watched_seconds_today=watched_sec,
        watch_seconds_limit=watch_limit,
        watch_seconds_remaining=max(0, watch_limit - watched_sec),
    )


@router.get(
    "/reward-result/{ad_session_id}",
    response_model=AdRewardResultOut,
    summary="按 ad_session_id 查本次广告的权威发奖结果",
    dependencies=[Depends(rate_limit(120, 60, "ad-reward-result"))],
)
def reward_result(
    user: CurrentUser,
    db: DbSession,
    ad_session_id: str = Path(..., min_length=8, max_length=64, description="本次广告会话 id"),
) -> AdRewardResultOut:
    """客户端看完激励视频后轮询本接口拿**本次实发金币 + 本轮累计**用于弹窗,不再用余额差 /
    coin_per_ad 估算(修「弹窗数值与真实金币对不上」)。

    round_coin 是「恭喜累计获得奖励」弹窗真正显示的数:本轮(= 客户端的 boost_round_id)所有
    granted 记录之和。由服务端求和而不是客户端自己累加——客户端进程被杀/重建后本地累计会丢,
    发奖记录不会。取不到轮 id(pending / 老客户端 / extra 丢失)时为 null,客户端退回显示单条。

    S2S 回调异步:查不到记录 = 回调还没到 → 返 200 + status='pending' 让客户端继续重试,
    **不返 404**(404 只表示路由不存在)。纯只读:发奖仍只由验签过的 S2S 回调完成,
    这里不写库、不产生任何奖励,被刷也只是查自己的记录。
    """
    rec = crud_ad.find_by_session(db, user.id, ad_session_id)
    if rec is None:
        # 连记录都没有 → 不知道属于哪一轮,round_coin 一并为 null(不是 0,0 会被当成"本轮没赚到")
        return AdRewardResultOut(
            ad_session_id=ad_session_id, status="pending", coin=None, round_coin=None,
        )
    # 本条不是 granted 时**仍返本轮累计**(这条按 0 计):第 3 条撞每日上限那下,客户端的限额
    # toast 要显示的是前两条已到账的总额,不是空。
    round_coin = (
        crud_ad.round_coin_total(db, user.id, rec.boost_round_id)
        if rec.boost_round_id
        else None
    )
    return AdRewardResultOut(
        ad_session_id=ad_session_id, status=rec.status, coin=rec.coin, round_coin=round_coin,
    )


@router.post(
    "/watch-report",
    response_model=WatchReportOut,
    summary="上报激励视频观看时长(旧客户端兼容字段)",
    dependencies=[Depends(rate_limit(120, 60, "ad-watch-report"))],
)
def watch_report(payload: WatchReportIn, user: CurrentUser, db: DbSession) -> WatchReportOut:
    """客户端在激励视频关闭(onAdClose)后上报本次实际观看秒数,服务端累计到当日总时长。

    Bearer 鉴权,user_id 取自 JWT(不信 body)。seconds 服务端夹 [0, MAX_SINGLE_WATCH_SECONDS]。
    当前产品只保留每日 500 次上限,DAILY_AD_WATCH_SECONDS_LIMIT=0 表示时长闸不启用;该接口
    仍保留用于旧客户端兼容和排查观看时长。
    """
    total = crud_watch.add_watch_seconds(db, user.id, payload.seconds)
    limit = rewards.DAILY_AD_WATCH_SECONDS_LIMIT
    logger.info(
        "ad watch report user_id=%d +%ds total=%d/%d",
        user.id, payload.seconds, total, limit,
    )
    return WatchReportOut(
        watched_seconds_today=total,
        watch_seconds_limit=limit,
        watch_seconds_remaining=max(0, limit - total),
    )


@router.post(
    "/ecpm-report",
    response_model=EcpmReportOut,
    summary="上报本次广告展示的 eCPM(内部收益统计)",
    dependencies=[Depends(rate_limit(120, 60, "ad-ecpm-report"))],
)
def ecpm_report(payload: EcpmReportIn, user: CurrentUser, db: DbSession) -> EcpmReportOut:
    """客户端在广告展示后(onAdShow 读 getShowEcpm)上报 eCPM,落库做内部收益统计/对账。

    Bearer 鉴权,user_id 取自 JWT(不信 body)。best-effort:落库即 ok,客户端 fire-and-forget,
    丢一两条不影响业务(穿山甲后台报表是结算权威)。eCPM 与发奖(S2S)是两条独立流,不逐条关联。
    """
    attributed_trace_id = crud_ecpm.attributable_trace_id(
        db,
        feed_scene=payload.feed_scene,
        trace_id=payload.trace_id,
        exposure_ms=payload.exposure_ms,
    )
    if payload.trace_id and attributed_trace_id is None:
        logger.info(
            "detach late coupon ad impression from failed trace user_id=%d trace=%s session=%s",
            user.id, payload.trace_id, payload.ad_session_id,
        )
    crud_ecpm.create_ecpm_record(
        db, user.id,
        ad_type=payload.ad_type, ecpm_raw=payload.ecpm,
        ad_session_id=payload.ad_session_id,
        adn=payload.adn, slot_id=payload.slot_id,
        feed_scene=payload.feed_scene,
        trace_id=attributed_trace_id,
        app_env=payload.app_env, our_code_id=payload.our_code_id,
        exposure_ms=payload.exposure_ms,
    )
    logger.info(
        "ad ecpm report user_id=%d type=%s scene=%s session=%s ecpm=%s exposure_ms=%s adn=%s slot=%s app=%s code=%s",
        user.id, payload.ad_type, payload.feed_scene, payload.ad_session_id, payload.ecpm,
        payload.exposure_ms, payload.adn, payload.slot_id, payload.app_env, payload.our_code_id,
    )
    return EcpmReportOut(ok=True)


@router.post(
    "/test-grant",
    response_model=TestGrantOut,
    summary="[仅本地联调]模拟穿山甲回调发奖",
    dependencies=[Depends(rate_limit(60, 60, "ad-test-grant"))],
)
def test_grant(user: CurrentUser, db: DbSession, payload: TestGrantIn | None = None) -> TestGrantOut:
    """⚠️ 仅本地联调用:没部署公网、穿山甲 S2S 回调打不到本地时,客户端(debug 包)看完广告后
    调这个接口,直接走与回调相同的发奖逻辑(幂等 + 每日上限),验证"看广告→金币到账"全链路。

    必须 settings.AD_REWARD_TEST_GRANT_ENABLED=true 才开放(默认 False),否则 404 当不存在。
    它让已登录客户端能自助发奖 = 绕过反作弊,**生产必须关闭**。
    """
    if not settings.AD_REWARD_TEST_GRANT_ENABLED:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="not found")

    reward_scene = (payload.reward_scene if payload is not None else REWARD_SCENE_REWARD_VIDEO)
    if reward_scene not in SUPPORTED_REWARD_SCENES:
        raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="bad reward_scene")

    # 每次新 trans_id,模拟一次独立的穿山甲发奖回调(幂等键各不相同 → 每次都发,直到当日上限)
    trans_id = f"test-{user.id}-{uuid.uuid4().hex}"
    # 正式链路的轮次 id 走 S2S 的 mediaExtra;本接口不经 S2S,只能由 body 补,否则 debug 包
    # 的 reward-result 恒返 round_coin=null,「弹窗 40 → 60」那套累计验收在本地跑不起来。
    boost_round_id = (payload.boost_round_id if payload is not None else None) or None
    # 优先用客户端按 ad_session_id 上报的真实 eCPM(走与正式发奖相同的公式);
    # 取不到或 eCPM≤0(测试应用常返 0/假值)时兜底 200,保证本地联调仍能验出非零金币。
    ad_session_id = payload.ad_session_id if payload is not None else None
    ecpm_val = "200"
    if ad_session_id:
        ecpm_rec = crud_ecpm.find_by_session(db, user_id=user.id, ad_session_id=ad_session_id)
        if ecpm_rec is not None and rewards.parse_ecpm_fen(ecpm_rec.ecpm_raw) > 0:
            ecpm_val = ecpm_rec.ecpm_raw
    try:
        rec = crud_ad.grant_ad_reward(
            db, user.id, trans_id, ecpm=ecpm_val, ad_session_id=ad_session_id,
            boost_round_id=boost_round_id,
            reward_name="测试发奖", raw=f"client debug test-grant ecpm={ecpm_val}",
        )
    except crud_ad.UnknownUserError as e:
        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from e

    (used, limit, coin_per, round_count, cooldown_until,
     _watched, _watch_limit) = crud_ad.today_status(db, user.id)
    logger.info(
        "ad TEST grant user_id=%d scene=%s status=%s coin=%d",
        user.id, reward_scene, rec.status, rec.coin,
    )
    return TestGrantOut(
        granted=(rec.status == "granted"),
        status=rec.status,
        coin=rec.coin,
        used_today=used,
        daily_limit=limit,
        remaining=max(0, limit - used),
        coin_per_ad=coin_per,
        round_count=round_count,
        cooldown_until=cooldown_until,
    )


@router.post(
    "/feed-reward",
    response_model=FeedRewardOut,
    summary="信息流广告完成后结算金币",
    dependencies=[Depends(rate_limit(120, 60, "ad-feed-reward"))],
)
def feed_reward(payload: FeedRewardIn, user: CurrentUser, db: DbSession) -> FeedRewardOut:
    """点位 2:信息流广告每展示满 10 秒累计一份奖励,视频完成后一次性入账。

    当前一期由客户端完成回调携带 eCPM / 展示秒数上报;client_event_id 做幂等键,避免重试重复发。
    """
    rec = crud_feed.grant_feed_reward(
        db,
        user.id,
        client_event_id=payload.client_event_id,
        ecpm=payload.ecpm,
        duration_seconds=payload.duration_seconds,
        ad_type=payload.ad_type,
        ad_session_id=payload.ad_session_id,
        adn=payload.adn,
        slot_id=payload.slot_id,
        feed_scene=payload.feed_scene,
        trace_id=payload.trace_id,
        app_env=payload.app_env,
        our_code_id=payload.our_code_id,
        aborted=payload.aborted,
        display_coin=payload.display_coin,
    )
    logger.info(
        "feed ad reward user_id=%d event=%s status=%s units=%d coin=%d",
        user.id, rec.client_event_id, rec.status, rec.unit_count, rec.coin,
    )
    return FeedRewardOut(
        granted=(rec.status == "granted"),
        status=rec.status,
        coin=rec.coin,
        unit_count=rec.unit_count,
        daily_limit=rewards.get_ad_daily_limit(db),
    )


@router.get(
    "/feed-reward/units",
    response_model=FeedRewardUnitsOut,
    summary="查账号累计信息流发奖份数(前端算因子2 LT、做实时金币进度条用)",
    dependencies=[Depends(rate_limit(120, 60, "ad-feed-units"))],
)
def feed_reward_units(user: CurrentUser, db: DbSession) -> FeedRewardUnitsOut:
    """返回账号累计已发奖份数(因子2 LT 基线)。

    信息流金币进度条:前端 show 时拉一次,之后每满 10 秒一份、逐份用 (granted_units + offset)
    查 LT 因子,精确复刻发奖公式 → 进度条数值 ≈ 实际到账。
    """
    return FeedRewardUnitsOut(granted_units=crud_feed.granted_unit_total(db, user.id))


@router.post(
    "/reward-noshow",
    response_model=RewardNoShowOut,
    summary="激励视频提前关闭/未发奖留痕",
    dependencies=[Depends(rate_limit(120, 60, "ad-reward-noshow"))],
)
def reward_noshow(payload: RewardNoShowIn, user: CurrentUser, db: DbSession) -> RewardNoShowOut:
    """激励视频展示了但用户提前关/跳过、未触发 S2S 发奖时,客户端 best-effort 上报一条留痕,
    让广告收益报表能呈现「有展示、没发金币」的原因。不发金币;同一 session 已发奖则跳过。
    """
    rec = crud_ad.record_reward_noshow(
        db,
        user.id,
        ad_session_id=payload.ad_session_id,
        ecpm=payload.ecpm,
        adn=payload.adn,
        slot_id=payload.slot_id,
        app_env=payload.app_env,
        our_code_id=payload.our_code_id,
    )
    logger.info(
        "ad reward noshow user_id=%d session=%s watched=%ds -> status=%s",
        user.id, payload.ad_session_id, payload.watched_seconds, rec.status,
    )
    return RewardNoShowOut(ok=True, status=rec.status)
