"""广告 eCPM 上报 CRUD(内部收益统计/对账)。

客户端在广告展示后(onAdShow)读到 eCPM,经鉴权接口上报,这里落库。鉴权接口已确保
user 存在(JWT),故不做 UnknownUser 校验。best-effort 上报:丢一两条不影响业务,
穿山甲后台报表是结算权威兜底。
"""
from __future__ import annotations

from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

from app.core import rewards
from app.core.rewards import cn_today
from app.models.ad_ecpm import AdEcpmRecord
from app.models.coupon_state import CouponSession

MIN_REVENUE_EXPOSURE_MS = 1000


def effective_ecpm_raw(ecpm_raw: str, exposure_ms: int | None) -> str:
    """曝光不足一秒时保留展示记录，但把该条有效 eCPM 归零。"""
    if exposure_ms is not None and exposure_ms < MIN_REVENUE_EXPOSURE_MS:
        return "0"
    return ecpm_raw


def attributable_trace_id(
    db: Session,
    *,
    feed_scene: str | None,
    trace_id: str | None,
    exposure_ms: int | None = None,
) -> str | None:
    """返回广告展示允许归属的业务 trace。

    领券任务可能在 Draw 广告异步加载完成前已经失败或被用户放弃。非完成终态先落库、
    广告回调后到时，收益仍需保留在总广告报表中，但不能再挂到该死亡领券明细，
    因此清空关联 trace。其它场景、找不到 session、进行中或已完成状态保持原值，
    由客户端生命周期修复负责主防线。
    """
    if feed_scene != "coupon" or not trace_id:
        return trace_id
    session_status = db.execute(
        select(CouponSession.status).where(CouponSession.trace_id == trace_id)
    ).scalar_one_or_none()
    if session_status not in {"failed", "abandoned"}:
        return trace_id
    # 已真实上墙但不足一秒的曝光要在终态明细中明确显示 0，而不是被误判成“未填充”。
    if exposure_ms is not None and exposure_ms < MIN_REVENUE_EXPOSURE_MS:
        return trace_id
    return None


def create_ecpm_record(
    db: Session,
    user_id: int,
    *,
    ad_type: str,
    ecpm_raw: str,
    ad_session_id: str | None = None,
    adn: str | None = None,
    slot_id: str | None = None,
    feed_scene: str | None = None,
    trace_id: str | None = None,
    app_env: str | None = None,
    our_code_id: str | None = None,
    exposure_ms: int | None = None,
) -> AdEcpmRecord:
    """落一条 eCPM 上报记录。report_date 用北京时间当天,供按天聚合。

    app_env(prod/test)与 our_code_id(我们后台配置的 104xxx 代码位)供广告收益报表按
    应用/代码位聚合;与 adn(实际填充子渠道)/slot_id(底层 rit)是两组不同口径。
    """
    if ad_session_id:
        existing = find_by_session(db, user_id=user_id, ad_session_id=ad_session_id)
        if existing is not None:
            return existing
    rec = AdEcpmRecord(
        user_id=user_id,
        ad_type=ad_type,
        ad_session_id=ad_session_id,
        adn=adn,
        slot_id=slot_id,
        feed_scene=feed_scene,
        trace_id=trace_id,
        app_env=app_env,
        our_code_id=our_code_id,
        ecpm_raw=effective_ecpm_raw(ecpm_raw, exposure_ms),
        report_date=cn_today().isoformat(),
    )
    db.add(rec)
    try:
        db.commit()
    except IntegrityError:
        db.rollback()
        # 撞唯一约束 uq_ad_ecpm_record_session(全局按 ad_session_id、不含 user_id):并发同会话重复上报,
        # 或同一 ad_session_id 已被先到的上报占用。本接口 fire-and-forget、best-effort —— 丢一条不影响业务
        # (穿山甲后台才是结算权威),绝不向客户端抛 500。兜底查找须与唯一约束**同口径**(只按 ad_session_id、
        # 不带 user_id):否则不同 user 上报了同一 ad_session_id 时,带 user_id 的查找会漏掉那条别人的记录 →
        # 旧逻辑在此 raise 成 500(本应静默吞掉)。
        existing = _find_by_session_global(db, ad_session_id)
        if existing is not None:
            return existing
        # 极少:rollback 后既存记录又查不到(并发删除 / 竞态)。吞掉、返回未入库的内存对象(调用方不读返回值)。
        return rec
    db.refresh(rec)
    return rec


def find_by_session(
    db: Session, *, user_id: int, ad_session_id: str | None
) -> AdEcpmRecord | None:
    """按广告会话找 eCPM。旧客户端无 ad_session_id 时返回 None。"""
    if not ad_session_id:
        return None
    return db.execute(
        select(AdEcpmRecord).where(
            AdEcpmRecord.user_id == user_id,
            AdEcpmRecord.ad_session_id == ad_session_id,
        )
    ).scalar_one_or_none()


def _find_by_session_global(db: Session, ad_session_id: str | None) -> AdEcpmRecord | None:
    """按 ad_session_id **全局**查找(与唯一约束 uq_ad_ecpm_record_session 同口径,不含 user_id)。

    仅 create_ecpm_record 撞约束后兜底用:此时撞的是全局会话约束,既存记录可能属于**另一个 user**,
    带 user_id 的 find_by_session 会漏掉它、导致误判「查无 → raise 500」。其它业务查「某 user 的某次
    展示 eCPM」仍用 find_by_session(带 user_id,语义更准),不走这里。
    """
    if not ad_session_id:
        return None
    return db.execute(
        select(AdEcpmRecord).where(AdEcpmRecord.ad_session_id == ad_session_id)
    ).scalar_one_or_none()


def count_today(db: Session, user_id: int) -> int:
    """该用户今日(北京时间)上报的 eCPM 条数,排查/对账辅助用。"""
    return db.execute(
        select(func.count())
        .select_from(AdEcpmRecord)
        .where(
            AdEcpmRecord.user_id == user_id,
            AdEcpmRecord.report_date == cn_today().isoformat(),
        )
    ).scalar_one()


def revenue_yuan_by_trace(db: Session, trace_ids: list[str]) -> dict[str, float]:
    """各 trace_id 的广告预估收益(元):按 trace_id 聚合 ad_ecpm_record 的展示收益。

    单条展示收益 = min(eCPM元, AD_ECPM_MAX_FEN/100) / 1000(与 admin 广告收益报表同口径)。
    ecpm_raw 是字符串且需逐条钳顶,故取回后 Python 求和(行数=本页各 trace 的展示条数,很小)。
    trace_id 仅信息流(比价/领券)场景客户端带,激励视频/旧数据为 NULL,按 trace_id 过滤天然只算对应场景。
    只喂**当前页**的 trace_id(≤ 一页条数);空集合直接返回(避免 IN () 非法)。
    """
    if not trace_ids:
        return {}
    rows = db.execute(
        select(AdEcpmRecord.trace_id, AdEcpmRecord.ecpm_raw).where(
            AdEcpmRecord.trace_id.in_(trace_ids),
        )
    ).all()
    cap_yuan = rewards.AD_ECPM_MAX_FEN / 100.0
    out: dict[str, float] = {}
    for tid, ecpm_raw in rows:
        if not tid:
            continue
        out[tid] = out.get(tid, 0.0) + min(rewards.parse_ecpm_yuan(ecpm_raw), cap_yuan) / 1000.0
    return {tid: round(v, 6) for tid, v in out.items()}
