"""福利 / 激励相关的业务常量与换算规则。

集中放在这里,业务代码引用,避免数值散落各处。这些是**产品规则**(签到奖励、
任务奖励、金币汇率)而非部署配置,所以不走环境变量;要调整直接改这里。
"""
from __future__ import annotations

from datetime import date, datetime, timedelta, timezone
from typing import NamedTuple

# 业务时区:签到的"今天"按北京时间算,不能用 UTC。
# 否则 UTC+8 的凌晨 0~8 点会被算成 UTC 的前一天,导致签到日期错位。
CN_TZ = timezone(timedelta(hours=8))


def cn_today() -> date:
    """当前北京时间的日期,签到去重 / 连续判断都以此为准。"""
    return datetime.now(CN_TZ).date()


# ===== 签到:7 天循环,断签重置回第 1 天(原型 2026-06 由 14 天改 7 天一轮)=====
# 第 1→7 天的金币奖励,签到逻辑用 cycle_day(1..7)索引;一轮 7 天累计 2500 金币(对原型 banner)。
SIGNIN_REWARDS: tuple[int, ...] = (
    200, 200, 300, 200, 400, 400, 800,
)
SIGNIN_CYCLE_LEN: int = len(SIGNIN_REWARDS)


def signin_reward(cycle_day: int) -> int:
    """cycle_day 取值 1..SIGNIN_CYCLE_LEN。"""
    return SIGNIN_REWARDS[cycle_day - 1]


# ===== 金币 → 现金 汇率 =====
# 10000 金币 = 1 元 = 100 分。
COIN_PER_YUAN: int = 10000
CENTS_PER_YUAN: int = 100
# 1 分对应多少金币 = 100。兑换金币数必须是它的整数倍,否则会出现不足 1 分的零头。
COIN_PER_CENT: int = COIN_PER_YUAN // CENTS_PER_YUAN
# 单次兑换最少 1 分(=COIN_PER_CENT;产品定:可兑 1 分起,与 step 同粒度)。早先为 1 元(=COIN_PER_YUAN),
# 用户 2026-06 改为 1 分:让"换现金"小额即可用(也方便联调验证资产卡飞金币动画)。
MIN_EXCHANGE_COIN: int = COIN_PER_CENT


def coins_to_cents(coin_amount: int) -> int:
    """金币换算成现金(分)。调用前应已校验 coin_amount 是 COIN_PER_CENT 的整数倍。"""
    return coin_amount // COIN_PER_CENT


# ===== 提现(现金 → 微信零钱)=====
# 单次提现额度(分)。下限 0.1 元是微信商家转账的最低额,低于它(如 0.01 测试档)真转账会被拒。
WITHDRAW_MIN_CENTS: int = 10
WITHDRAW_MAX_CENTS: int = 5_000_000  # 5 万元


# ===== 提现档位(福利页 coin_cash;7-9 对齐原型 withdrawal.html)=====
# 后端是档位唯一真相源:withdraw-info 按此下发,create_withdraw 按此校验(防绕过客户端刷)。
# 规则(2026-07-09 拍板):
#   - 新人档(is_newbie):账号历史一次性,"发起就算用过"(任意状态含被拒),用过即不再下发;
#     0.1 与 0.3 各自独立同天可各提一次,且不参与常规档"每日选一个额度"互斥。
#   - 常规档:按北京日计次(0.5×3 / 10×1 / 20×1),三档每天只能选一个。
# invite_cash(邀请页)本轮无档位概念,不在此表。改档位=改这里发版。
class WithdrawTier(NamedTuple):
    amount_cents: int
    label: str          # 客户端档位方块展示文案
    badge: str | None   # 角标文案;None=无角标
    daily_limit: int    # 每日次数上限(新人档的"历史一次性"另由 is_newbie 判定)
    is_newbie: bool


WITHDRAW_TIERS_COIN_CASH: tuple[WithdrawTier, ...] = (
    WithdrawTier(10, "0.1", "新人福利", 1, True),
    WithdrawTier(30, "0.3", "新人福利", 1, True),
    WithdrawTier(50, "0.5", None, 3, False),
    WithdrawTier(1000, "10", None, 1, False),
    WithdrawTier(2000, "20", None, 1, False),
)


# ===== 一次性任务(领一次,user_task 去重)=====
TASK_ENABLE_NOTIFICATION = "enable_notification"

# task_key -> 奖励金币
# 打开消息提醒: 750 金币(客户端原型展示口径 2026-06 调 1000→750; 量级与签到/里程碑相称)。
# 注意: 不再 = 兑换下限(下限已降到 MIN_EXCHANGE_COIN=COIN_PER_CENT=100), test_exchange_flow 改走 grant_coins 直接供款。
# ⚠️打开消息提醒是「可重复领取」任务(2026-06):每次开启通知发一次,金额逐次减半(见 notification_reward),
# 这里的 750 是首次(基数)。客户端只在「通知权限关闭」时展示该任务,开着就隐藏。
TASK_REWARDS: dict[str, int] = {
    TASK_ENABLE_NOTIFICATION: 750,
}


def notification_reward(base: int, times_claimed: int) -> int:
    """打开消息提醒第 (times_claimed+1) 次领取的金币。

    基数 base(=750)起,每次减半、四舍五入到整数、最低 1:
      750 → 375 → 188 → 94 → 47 → 24 → 12 → 6 → 3 → 2 → 1 → 1 …
    (四舍五入用 (x+1)//2,匹配产品给的 750/375/188/94 序列。)
    """
    r = max(1, base)
    for _ in range(max(0, times_claimed)):
        r = max(1, (r + 1) // 2)
    return r


def notification_max_claims(base: int) -> int:
    """打开消息提醒最多可领取的次数:减半到最低 1 那次为止(含),之后不再可领。

    防止「通知一直关着」时无限刷最低档 1 金币(发放上限闸,服务端硬限,不依赖客户端只在
    通知关闭时才展示的约定)。base=750 时序列 750→…→1 在第 11 次落到 1,故上限 11 次。
    """
    n = 0
    while notification_reward(base, n) > 1:
        n += 1
    return n + 1  # 含第一次减半到 1 的那一次


# ===== 比价战绩里程碑(累计成功比价 N 次,逐档解锁领金币)=====
# 第 1→6 次的金币奖励(1-based:第 N 次比价解锁第 N 档)。值沿用客户端原型档位。
# 数据源是 comparison_record 里 status='success' 的条数;每档领一次,
# comparison_milestone_claim 去重(仿一次性任务)。要调档位/金额直接改这里。
RECORD_MILESTONES: tuple[int, ...] = (120, 180, 300, 500, 800, 1200)
RECORD_MILESTONE_COUNT: int = len(RECORD_MILESTONES)


def record_milestone_reward(milestone: int) -> int:
    """第 milestone 档(1..RECORD_MILESTONE_COUNT)的金币。越界抛 IndexError。"""
    return RECORD_MILESTONES[milestone - 1]


# ===== 上报更低价(人工审核通过发固定金币)=====
# 用户上报"某平台比我们算的最低价更便宜"+ 截图,经运营后台人工审核通过后发放的固定金币奖励。
# 与广告/任务同量级;固定值(产品 2026-06 定),要调直接改这里;客户端记录页按 reward_coins 显示。
PRICE_REPORT_REWARD_COINS: int = 1000

# ===== 意见反馈采纳奖励(人工审核按质量发放)=====
# 后台审核反馈时允许发放的单条金币上限。只做后端硬保护,具体档位由 admin-web 呈现。
FEEDBACK_REWARD_MAX_COINS: int = 10000


# ===== 邀请好友(绑定即生效,绑定只建归因关系、双方都不发钱)=====
# v3(冰 2026-06-26):废除 v1 的"绑定双方各发金币"——被邀请人无奖励、邀请人改比价后发现金
# (见下方 INVITE_COMPARE_REWARD_CENTS)。原 INVITE_INVITER_COINS / INVITE_INVITEE_COINS 已删。
# "新用户闸":被邀请人必须在注册后此窗口内绑定才发奖(挡存量老用户互相填码薅羊毛)。
# 自动绑(剪贴板)在首次注册登录后几秒内发生;留 72h 给手动填码兜底。
INVITE_NEW_USER_WINDOW_HOURS: int = 72

# 指纹归因兜底(剪贴板被覆盖时):落地页 POST /landing-track 存的指纹记录,在此窗口内可被
# /bind 反查撞库。窗口取舍:太短(24h)覆盖不到"晚上看链接、第二天装"的常见场景;太宽
# (30d)IP/设备会漂、匹配错率上升。7 天兼顾"看广告→使用"周期与匹配精度。
INVITE_FP_WINDOW_DAYS: int = 7


# ===== 邀请好友 v2(好友"下载+登录+比价一次"→ 给邀请人发邀请奖励金·现金)=====
# v2 新规则:不再注册即发金币,改"好友完成首次成功比价"才给【邀请人】发奖,发的是【现金·分】
# 进独立的邀请奖励金账户(coin_account.invite_cash_balance_cents),与金币体系物理隔离。
# 200 分 = 2 元。⚠️ 金额待产品定准:邀请主页=2元 / 福利入口=3.5元 不一致,定后改此处。
INVITE_COMPARE_REWARD_CENTS: int = 200


# ===== 看激励视频 / 信息流广告发金币 =====
# eCPM 取自穿山甲 SDK getShowEcpm().getEcpm(),官方口径单位是【分/千次展示】(不是元!
# csjplatform 文档原文"通过 getEcpm 获取的单位是分")。计算时先 ÷100 转成元;
# 因子1 档位阈值按【元/千次】定(100/200/400 元 = ¥100/¥200/¥400 CPM,产品口径 2026-06-09)。
# 注:真实 eCPM 一般 <¥100 CPM,故多落最低档 0.1,高档基本不触发——这是产品有意的取舍。
# 单次展示收益(元) = eCPM元 ÷ 1000(每千次→单次)。
AD_ECPM_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = (
    (0.1, 0, 100),
    (0.3, 101, 200),
    (0.4, 201, 400),
    (0.6, 401, None),
)
AD_LT_FACTOR_TABLE: tuple[tuple[float, int, int | None], ...] = (
    (2.0, 1, 1),
    (1.5, 2, 2),
    (1.3, 3, 3),
    (1.1, 4, 10),
    (1.0, 11, None),
)

# 客户端可影响的 eCPM 可信上限(分/千次展示):信息流广告一期由客户端上报 eCPM,伪造天价 eCPM
# 可铸出天量金币(见 calculate_ad_reward_coin)。真实 eCPM 一般 <¥100 CPM(=10000 分),档位表顶档
# 为 >¥400(=40000 分);取 ¥500 CPM=50000 分,留足真实头部余量又封死伪造值。钳在唯一计算口
# calculate_ad_reward_coin,故 feed 与 reward_video(回退客户端上报 eCPM 时)一并护住;阈值设在所有
# 真实值之上,不会少发正规奖励。
AD_ECPM_MAX_FEN: int = 50_000


def parse_ecpm_fen(ecpm: str | int | float | None) -> float:
    """解析 eCPM 原始值(穿山甲 getEcpm 原值,单位=分/千次展示)。非法/缺失→0。"""
    if ecpm is None:
        return 0.0
    try:
        value = float(str(ecpm).strip())
    except (TypeError, ValueError):
        return 0.0
    return max(0.0, value)


def parse_ecpm_yuan(ecpm: str | int | float | None) -> float:
    """eCPM 转成元(getEcpm 原值是分,÷100)。因子档位判定与收益换算都用元。"""
    return parse_ecpm_fen(ecpm) / 100.0


def ad_ecpm_factor(ecpm_yuan: float) -> float:
    """eCPM 档位因子(阈值单位=元/千次):≤100=0.1,101-200=0.3,201-400=0.4,>400=0.6。
    产品口径(2026-06-09):阈值按元判档;真实 eCPM(<¥100 CPM)多落最低档 0.1。"""
    if ecpm_yuan > 400:
        return 0.6
    if ecpm_yuan > 200:
        return 0.4
    if ecpm_yuan > 100:
        return 0.3
    return 0.1


def ad_lt_factor(count_after_this: int) -> float:
    """LT 因子。count_after_this 是该账号累计第 N 条/份看视频奖励(不按天重置)。"""
    count = max(1, count_after_this)
    for factor, lo, hi in AD_LT_FACTOR_TABLE:
        if count >= lo and (hi is None or count <= hi):
            return factor
    return 1.0


def calculate_ad_reward_coin(ecpm: str | int | float | None, count_after_this: int) -> int:
    """按金币数值体系计算单份广告奖励金币。

    eCPM 是穿山甲 getEcpm 原值,单位【分/千次展示】;先 ÷100 转成元(因子判档 + 收益换算都用元)。
    单次收益(元)= eCPM元 ÷ 1000(每千次→单次) × 因子1(eCPM 元档) × 因子2(LT);
    再按 1 元=10000 金币取整。count_after_this 为账号累计第 N 次看视频(LT 因子用,不按天重置)。

    eCPM 在此先钳到 AD_ECPM_MAX_FEN(¥500 CPM):信息流广告一期 eCPM 由客户端上报,伪造天价值
    会铸天量金币;钳在这唯一入口,feed 与 reward_video 回退客户端 eCPM 的路径都护住,且阈值高于
    所有真实值,不影响正规发奖。
    """
    ecpm_yuan = min(parse_ecpm_yuan(ecpm), AD_ECPM_MAX_FEN / 100.0)
    yuan = (ecpm_yuan / 1000.0) * ad_ecpm_factor(ecpm_yuan) * ad_lt_factor(count_after_this)
    return max(0, round(yuan * COIN_PER_YUAN))


# 签到膨胀(SIGNIN_BOOST_COIN,固定 3000)已于 2026-07 下线:它与广告实际收益脱钩,产品确认
# 非设计内口径。签到弹窗的「看广告膨胀」现与福利页看视频同走 calculate_ad_reward_coin。


# ===== 看激励视频发金币(穿山甲 S2S 服务端回调发奖)=====
# 历史固定金币口径保留项。正式激励视频实发按 calculate_ad_reward_coin(eCPM, 当日第 N 次)
# 计算;该值只用于旧接口兼容、配置页展示和本地联调兜底。
AD_REWARD_COIN: int = 666
# 历史 reward_amount 口径保留项。正式激励视频实发按 eCPM 公式计算。
MAX_AD_REWARD_COIN: int = 1000
# 每用户每日发奖次数上限。产品口径:一天最多看 500 次广告。
DAILY_AD_REWARD_LIMIT: int = 500

# ===== 看激励视频每日总时长上限(已停用)=====
# 旧版本用 50 分钟时长闸;当前产品只保留每日 500 次上限。保留字段返回给旧客户端,
# 但值为 0,客户端按 limit<=0 视为未启用。
DAILY_AD_WATCH_SECONDS_LIMIT: int = 0
# 单次上报观看时长的合理上限(夹紧异常值):激励视频一般 15~60 秒,超 120 秒按 120 记,防刷大值。
MAX_SINGLE_WATCH_SECONDS: int = 120
# 每次广告关闭后 3 秒内不允许再次点击观看。沿用 round_count/cooldown_until 字段表达:
# round_size=1 表示每次成功发奖后都会派生一个 3 秒 cooldown_until。
VIDEO_ROUND_REQUIRED_COUNT: int = 1
VIDEO_ROUND_COOLDOWN_SECONDS: int = 3


def resolve_ad_reward_coin(db, reward_amount: str | int | None) -> int:  # noqa: ANN001
    """历史 reward_amount 口径解析函数,保留给旧脚本/旧配置兼容。

    缺失 / 非数字 / ≤0 → 回退配置单次金币;超过配置单次上限 → 夹紧(均从 app_config 读)。
    注:reward_amount 不参与穿山甲 sign(只签 trans_id),但伪造回调需先伪造合法 sign
    (要 SecurityKey),故能过验签的 reward_amount 即视为可信,这里只防后台配错。
    """
    default_coin = get_ad_reward_coin(db)
    try:
        coin = int(reward_amount)  # type: ignore[arg-type]
    except (TypeError, ValueError):
        return default_coin
    if coin <= 0:
        return default_coin
    return min(coin, get_ad_max_coin(db))


# ===== 配置读取(运营后台可覆盖;app_config 表空时返回上面的常量默认 = 现状不变)=====
# 业务处用这些 getter(传 db)取值,而非直接用常量,这样 admin 改 app_config 即生效。
# 函数内延迟 import,避免 rewards ← app_config ← config_schema ← rewards 的模块级循环。

def _cfg(db, key: str):  # noqa: ANN001
    from app.repositories import app_config
    return app_config.get_value(db, key)


def get_signin_rewards(db) -> list[int]:  # noqa: ANN001
    return list(_cfg(db, "signin_rewards"))


def get_min_exchange_coin(db) -> int:  # noqa: ANN001
    return int(_cfg(db, "min_exchange_coin"))


def get_withdraw_min_cents(db) -> int:  # noqa: ANN001
    return int(_cfg(db, "withdraw_min_cents"))


def get_withdraw_max_cents(db) -> int:  # noqa: ANN001
    return int(_cfg(db, "withdraw_max_cents"))


def get_task_rewards(db) -> dict[str, int]:  # noqa: ANN001
    return dict(_cfg(db, "task_rewards"))


def get_record_milestones(db) -> list[int]:  # noqa: ANN001
    return list(_cfg(db, "record_milestones"))


def get_ad_reward_coin(db) -> int:  # noqa: ANN001
    return int(_cfg(db, "ad_reward_coin"))


def get_ad_daily_limit(db) -> int:  # noqa: ANN001
    return int(_cfg(db, "ad_daily_limit"))


def get_ad_max_coin(db) -> int:  # noqa: ANN001
    return int(_cfg(db, "ad_max_coin"))


def get_ad_round_count(db) -> int:  # noqa: ANN001
    return int(_cfg(db, "ad_round_count"))


def get_ad_cooldown_sec(db) -> int:  # noqa: ANN001
    return int(_cfg(db, "ad_cooldown_sec"))


