"""领券今日状态(弹窗频控 + 领券记录)两张表。

- `coupon_prompt_engagement`:按 (device, 自然日) 记"今天是否对领券引导窗表达过意向"
  ——点「一键领取」(claim_started) 或 点拒绝/关闭 (dismissed) 都算。切到外卖 App 时
  据此决定弹不弹:今天 engage 过就不再弹。判断维度是 **device_id**——券发到的是设备上
  登录的那个外卖账号,device 比 user 更贴近"哪个登录环境",且 device_id 全链路现成、
  不依赖领券鉴权。

- `coupon_claim_record`:按 (device, 券, 自然日) 记每张券的领取结果,纯沉淀(资产/画像/
  排查)。当前**不参与**"要不要领"的判断(MVP 先不去重:今天 engage 过就不弹,A 路径
  主动领则全跑)。留作以后做按券去重 / CPS 归因 / 用户画像的数据源。

口径:
- 日期 = Asia/Shanghai 的自然日(claim_date / engage_date)。
- user_id 领券登录态有就记(资产),可空、不进唯一键、不阻塞判断。
- device_id 客户端生成存 SP,卸载重装会变 → 重装当新设备重新弹一次(产品预期)。
"""
from __future__ import annotations

from datetime import date, datetime

from sqlalchemy import (
    JSON,
    Date,
    DateTime,
    Index,
    Integer,
    String,
    UniqueConstraint,
    func,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column

from app.db.base import Base

# PG 上用 JSONB,SQLite(本地/测试)退化为通用 JSON(同 price_observation / comparison)。
_JSON = JSON().with_variant(JSONB(), "postgresql")


class CouponClaimRecord(Base):
    """单张券一天一条领取记录(资产层,当前不做去重判断)。"""

    __tablename__ = "coupon_claim_record"
    __table_args__ = (
        # 同设备、同券、同一天只一条:领券每帧 last_coupon_result + done 帧 coupon_results
        # 会重复上报同一张券,靠它幂等 upsert。
        UniqueConstraint(
            "device_id", "coupon_id", "claim_date",
            name="uq_coupon_claim_device_coupon_date",
        ),
        # 去重/统计查询按 (device, 日) 取一天所有券。
        Index("ix_coupon_claim_device_date", "device_id", "claim_date"),
    )

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)

    # 判断/聚合维度。client getOrCreateDeviceId 生成,重装会变。
    device_id: Mapped[str] = mapped_column(String(64), nullable=False)
    # 登录态有就记(资产/画像),可空、不进唯一键。
    user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)

    coupon_id: Mapped[str] = mapped_column(String(64), nullable=False)
    # Asia/Shanghai 自然日。每日可领的券(签到/天天红包)靠这天然每天一条。
    claim_date: Mapped[date] = mapped_column(Date, nullable=False)

    # success / already_claimed / failed / skipped(原样取 pricebot coupon 结果)
    status: Mapped[str] = mapped_column(String(24), nullable=False)
    # 领券所属 session 的环境 prod/dev(/step 按 trace_id 查 coupon_session.app_env 打标)。
    # 旧行 NULL(不回填)。admin「按券成功率」表据此过滤环境。见设计 §13。
    app_env: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)
    vendor: Mapped[str | None] = mapped_column(String(48), nullable=True)
    coupon_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
    # 这张领到几张(pricebot display_count;给不出时为 None)
    claimed_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
    # 哪次任务领的,回指 pricebot work_logs / 排查
    trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)
    # failed / skipped 原因
    reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
    # 杂项兜底:券的结构化信息(面额/入口/关键节点摘要等),免得加字段就迁移。
    # ⚠️ 别塞原始无障碍树(几十 KB → 行膨胀);原始大树看 trace_id 指过去的 work_logs。
    extra: Mapped[dict | None] = mapped_column(_JSON, nullable=True)

    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now(), nullable=False
    )
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now(), onupdate=func.now(),
        nullable=False,
    )

    def __repr__(self) -> str:  # pragma: no cover
        return (
            f"<CouponClaimRecord device={self.device_id} coupon={self.coupon_id} "
            f"date={self.claim_date} status={self.status}>"
        )


class CouponClaimEvent(Base):
    """一次领券任务中的单券结果，按 ``(trace_id, coupon_id)`` 幂等。"""

    __tablename__ = "coupon_claim_event"
    __table_args__ = (
        UniqueConstraint(
            "trace_id", "coupon_id",
            name="uq_coupon_claim_event_trace_coupon",
        ),
        Index("ix_coupon_claim_event_date_env", "claim_date", "app_env"),
    )

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    trace_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
    device_id: Mapped[str] = mapped_column(String(64), nullable=False)
    user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
    coupon_id: Mapped[str] = mapped_column(String(64), nullable=False)
    claim_date: Mapped[date] = mapped_column(Date, nullable=False)
    status: Mapped[str] = mapped_column(String(24), nullable=False)
    app_env: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)
    vendor: Mapped[str | None] = mapped_column(String(48), nullable=True)
    coupon_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
    claimed_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
    reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
    extra: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now(), nullable=False
    )
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now(), onupdate=func.now(),
        nullable=False,
    )


class CouponDailyCompletion(Base):
    """按 (device, 自然日) 记"今天是否已跑完整轮领券(到 done 帧)"——首页置灰源。

    与 engagement 区别:engagement = 用户表达过意向(点了一键领取/拒绝即记,不管跑没跑完);
    completion = 这一轮真跑到了 done(整套流程走完)。首页「去领取」卡据此置灰:跑完了
    今天就不能再领。判断口径(用户决策 2026-06-10 A 方案):**到 done 即算**,不管单券
    成败(失败/跳过常是无障碍/环境问题,重复点也补不回来)。判断维度 device_id,与
    engagement/claim 一致。
    """

    __tablename__ = "coupon_daily_completion"
    __table_args__ = (
        # 一台设备一天一条:今天跑完过就置灰。
        UniqueConstraint(
            "device_id", "complete_date",
            name="uq_coupon_completion_device_date",
        ),
    )

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)

    device_id: Mapped[str] = mapped_column(String(64), nullable=False)
    user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
    # Asia/Shanghai 自然日。
    complete_date: Mapped[date] = mapped_column(Date, nullable=False)
    # 哪次任务跑到 done,回指 pricebot work_logs / 排查。
    trace_id: Mapped[str | None] = mapped_column(String(64), index=True, nullable=True)

    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now(), nullable=False
    )
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now(), onupdate=func.now(),
        nullable=False,
    )

    def __repr__(self) -> str:  # pragma: no cover
        return (
            f"<CouponDailyCompletion device={self.device_id} "
            f"date={self.complete_date}>"
        )


class CouponPromptEngagement(Base):
    """按 (device, **App**, 自然日) 记"今天这个 App 是否对领券引导窗表达过意向"——弹窗频控源。

    2026-06-14:频控维度从 (device, 日) 改为 (device, package, 日)。需求是美团/淘宝/京东
    各自独立——在美团弹过/领过,不影响淘宝、京东今天仍各弹一次。原来缺 package → 任一 App
    弹过就把整台设备当天标记 engage,其余 App 被压住不弹(bug)。
    """

    __tablename__ = "coupon_prompt_engagement"
    __table_args__ = (
        # 一台设备、一个 App、一天一条:今天**这个 App** engage 过(领或拒)才不再弹该 App。
        UniqueConstraint(
            "device_id", "package", "engage_date",
            name="uq_coupon_engage_device_pkg_date",
        ),
    )

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)

    device_id: Mapped[str] = mapped_column(String(64), nullable=False)
    # 触发弹窗的目标 App 包名(com.sankuai.meituan / com.taobao.taobao / com.jingdong.app.mall)。
    # 频控维度,各 App 独立。旧行(改造前)无此值 → 迁移用占位 "" 填,不影响新逻辑判断。
    package: Mapped[str] = mapped_column(
        String(64), nullable=False, server_default=""
    )
    user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)
    # Asia/Shanghai 自然日。
    engage_date: Mapped[date] = mapped_column(Date, nullable=False)
    # claim_started(点了一键领取)/ dismissed(点了拒绝/关闭)/ shown(自动弹出即记)。
    # 仅记录区分,判断只看"今天这个 App 有没有这条",type 不影响弹不弹。
    engage_type: Mapped[str] = mapped_column(String(16), nullable=False)

    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now(), nullable=False
    )
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now(), onupdate=func.now(),
        nullable=False,
    )

    def __repr__(self) -> str:  # pragma: no cover
        return (
            f"<CouponPromptEngagement device={self.device_id} "
            f"date={self.engage_date} type={self.engage_type}>"
        )


class CouponSession(Base):
    """一次领券任务的全程流水(admin「领券数据」看板数据源,2026-06-30)。

    与 coupon_claim_record(按券一天一条去重)、coupon_daily_completion(按设备一天一条)都不同:
    本表**一次领券一条**(trace_id 唯一),记从发起(started)到收尾(completed/failed/abandoned)的
    全程耗时 + 各平台耗时 + 机型/ROM。客户端 POST /api/v1/coupon/session 两段上报:发起建行、
    收尾按 trace_id 更新同一行。发起即落库 → admin 可算「发起数」与中途流失(started 无终态=未完成)。

    口径:elapsed_ms 由客户端全程计时(点发起→收尾)、权威;started_at/finished_at 为时刻留痕。
    started_date = started_at 的 Asia/Shanghai 自然日,供 admin 按天聚合 / 日期筛选(索引)。
    """

    __tablename__ = "coupon_session"
    __table_args__ = (
        # 一次领券一行:trace_id 幂等 upsert(发起建、收尾更新同一行)。
        UniqueConstraint("trace_id", name="uq_coupon_session_trace"),
        # admin 主聚合/筛选:按上海自然日 + 环境(报表默认只看 prod,避免测试数据串台)。
        Index("ix_coupon_session_date_env", "started_date", "app_env"),
    )

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)

    # 一次领券唯一 id(客户端 UUID,全程贯穿),upsert 键。
    trace_id: Mapped[str] = mapped_column(String(64), nullable=False)
    device_id: Mapped[str] = mapped_column(String(64), nullable=False)
    # 登录态才带(admin join 用户表出手机号/昵称);匿名领券为空。
    user_id: Mapped[int | None] = mapped_column(Integer, index=True, nullable=True)

    # started / completed / failed / abandoned。started 无终态 = 中途流失(未完成)。
    status: Mapped[str] = mapped_column(String(16), nullable=False)
    # prod / dev(客户端 BuildConfig.DEBUG)。admin 报表默认只看 prod(对齐广告报表防串台口径)。
    app_env: Mapped[str | None] = mapped_column(String(16), index=True, nullable=True)

    # 发起勾选平台 ["meituan-waimai", ...](空=全领)。
    platforms: Mapped[list | None] = mapped_column(_JSON, nullable=True)
    # 发起来源外卖 App 包名;null=App 内(傻瓜比价首页)发起,非空=从美团/淘宝/京东弹券发起。
    # admin「发起平台」列据此区分(空→傻瓜比价,包名→对应平台)。
    origin_package: Mapped[str | None] = mapped_column(String(64), nullable=True)
    # 机型(Build.MANUFACTURER + MODEL)与 ROM(OemDetector,如 "ColorOS 14")。明细「机型/ROM」列 + 维度筛选。
    device_model: Mapped[str | None] = mapped_column(String(128), nullable=True)
    rom: Mapped[str | None] = mapped_column(String(64), nullable=True)

    # 发起时刻(客户端墙钟):明细「时间」列、趋势 X 轴。
    started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
    # 发起的 Asia/Shanghai 自然日:按天聚合 / 日期范围筛选(索引)。
    started_date: Mapped[date] = mapped_column(Date, nullable=False)
    # 收尾时刻(服务端 now);未收尾(流失)为空。
    finished_at: Mapped[datetime | None] = mapped_column(
        DateTime(timezone=True), nullable=True
    )

    # 全程耗时(ms,客户端点发起→收尾):平均 / 分位都基于它(只统计 completed)。
    elapsed_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
    # 各平台领券耗时 {"meituan-waimai":3200,...}(ms)。明细美团/淘宝/京东耗时列。
    platform_elapsed: Mapped[dict | None] = mapped_column(_JSON, nullable=True)
    # 领到总张数(收尾帧带)。
    claimed_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
    # 本次 session 至少领到一张(status∈{success,already_claimed})的平台 id 列表,如 ["meituan-waimai","jd-waimai"]。
    # admin「领券数据」据此算整单成功率(②)/点位成功率(③);服务端 /step 逐帧按 trace_id 并集写入
    # (见 coupon_state.merge_session_platform_success)。旧行=NULL → 视作空集。
    platform_success: Mapped[list | None] = mapped_column(_JSON, nullable=True)
    # pricebot done 帧回传的公网调试链接(price.shaguabijia.com/traces/{dir});含落盘时分秒、拼不出,只能存
    # (同 ComparisonRecord.trace_url)。admin「领券数据」明细据此渲染可点 trace 链接;未到 done(failed/abandoned)为空。
    trace_url: Mapped[str | None] = mapped_column(String(512), nullable=True)

    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now(), nullable=False
    )
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now(), onupdate=func.now(),
        nullable=False,
    )

    def __repr__(self) -> str:  # pragma: no cover
        return (
            f"<CouponSession trace={self.trace_id} status={self.status} "
            f"elapsed_ms={self.elapsed_ms}>"
        )
