"""钱包 CRUD:账户读取 + 金币入账(共享给签到 / 任务)+ 流水查询。

`grant_coins` 是所有"发金币"的唯一入口:它更新余额快照并写一笔流水,但**不 commit**,
由调用方在同一事务里 commit(签到要同时写 signin_record,任务要同时写 user_task,
保证"记录"和"加金币"原子化,不会只发钱不留痕或反之)。
"""
from __future__ import annotations

import logging
import re
import unicodedata
import uuid
from datetime import datetime, timedelta, timezone

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

from app.core import rewards
from app.core.config import settings
from app.core.rewards import COIN_PER_CENT, coins_to_cents
from app.integrations import wxpay
from app.models.user import User
from app.models.wallet import (
    CashTransaction,
    CoinAccount,
    CoinTransaction,
    InviteCashTransaction,
    WechatTransferAuthorization,
    WithdrawOrder,
)
from app.repositories.user import apply_wechat_display_identity
from app.services import notification_events

logger = logging.getLogger(__name__)

# 微信转账终态:成功 / 失败(失败/取消/关闭都退款)
_WX_STATE_SUCCESS = "SUCCESS"
_WX_STATE_FAILED = {"FAIL", "CANCELLED", "CLOSED"}
_WX_STATE_WAIT_CONFIRM = "WAIT_USER_CONFIRM"  # 用户还没在微信确认页确认
_WX_FAIL_REASON_LABELS = {
    "ACCOUNT_FROZEN": "用户微信账户被冻结",
    "ACCOUNT_NOT_EXIST": "用户微信账户不存在",
    "BANK_CARD_ACCOUNT_ABNORMAL": "用户银行卡已销户、冻结、作废或挂失",
    "BANK_CARD_BANK_INFO_WRONG": "用户登记的银行或分支行信息有误",
    "BANK_CARD_CARD_INFO_WRONG": "用户银行卡户名或卡号有误",
    "BANK_CARD_COLLECTIONS_ABOVE_QUOTA": "用户银行卡收款达到限额",
    "BANK_CARD_PARAM_ERROR": "用户收款银行卡信息错误",
    "BANK_CARD_STATUS_ABNORMAL": "用户银行卡状态异常",
    "BLOCK_B2C_USERLIMITAMOUNT_BSRULE_MONTH": "用户本月转账收款已达限额",
    "BLOCK_B2C_USERLIMITAMOUNT_MONTH": "用户账户存在风险，本月收款受限",
    "DAY_RECEIVED_COUNT_EXCEED": "用户当日收款次数已达上限",
    "DAY_RECEIVED_QUOTA_EXCEED": "用户当日收款额度已达上限",
    "EXCEEDED_ESTIMATED_AMOUNT": "转账金额超过预约金额范围",
    "ID_CARD_NOT_CORRECT": "收款人身份证校验不通过",
    "MCH_CANCEL": "商户已撤销付款",
    "MERCHANT_REJECT": "商户转账验密人已驳回",
    "MERCHANT_NOT_CONFIRM": "商户转账验密人超时未确认",
    "NAME_NOT_CORRECT": "收款人姓名校验不通过",
    "OPENID_INVALID": "用户 OpenID 无效或不属于当前 AppID",
    "OTHER_FAIL_REASON_TYPE": "微信返回其他失败原因",
    "OVERDUE_CLOSE": "超过微信系统重试期，订单自动关闭",
    "PAYEE_ACCOUNT_ABNORMAL": "用户微信账户收款异常",
    "PAYER_ACCOUNT_ABNORMAL": "商户账户付款受限",
    "PRODUCT_AUTH_CHECK_FAIL": "商户未开通转账权限或权限已冻结",
    "REALNAME_ACCOUNT_RECEIVED_QUOTA_EXCEED": "用户微信实名账户收款受限",
    "REAL_NAME_CHECK_FAIL": "用户未完成微信实名认证",
    "RECEIVE_ACCOUNT_NOT_CONFIGURE": "商户未配置收款用户列表",
    "RESERVATION_INFO_NOT_MATCH": "转账信息与预约信息不一致",
    "RESERVATION_SCENE_NOT_MATCH": "转账场景与预约场景不一致",
    "RESERVATION_STATE_INVALID": "预约转账单状态异常",
    "TRANSFER_QUOTA_EXCEED": "用户单笔收款额度已达上限",
    "TRANSFER_REMARK_SET_FAIL": "微信转账备注设置失败",
    "TRANSFER_RISK": "该笔转账存在风险，已被微信拦截",
    "TRANSFER_SCENE_INVALID": "商户未获取当前转账场景",
    "TRANSFER_SCENE_UNAVAILABLE": "当前转账场景暂不可用",
    "RELATED_ORDER_TRANSFER_AMOUNT_EXCEED": "关联订单累计付款金额超过上限",
    "RELATED_ORDER_TRANSFER_COUNT_EXCEED": "关联订单累计付款次数超过上限",
    "BUDGET_NOT_ENOUGH": "商户预算资金不足",
}
# 占用新人档「一次性」资格的提现状态:进行中(reviewing/pending)或成功打款(success)。
# 被拒/转账失败/解绑退回(rejected/failed,均已退款、钱没到手)不在此列 → 新人档恢复可提
# (2026-07-16 修正:此前判定不看状态,解绑微信退回后 0.1 被误判已用、资格永久锁死)。
_NEWBIE_TIER_HELD_STATUSES = {"reviewing", "pending", "success"}
# 免确认收款授权状态
_WX_AUTH_ACTIVE = "TAKING_EFFECT"   # 已生效,可免确认转账
_WX_AUTH_CLOSED = "CLOSED"          # 已关闭(用户/商户/风控),需重新开启


class InvalidExchangeAmountError(Exception):
    """兑换金币数非法(<最小额 或 不是整分倍数)。"""


class InsufficientCoinError(Exception):
    """金币余额不足。"""


class InvalidWithdrawAmountError(Exception):
    """提现金额超出允许范围。"""


class WechatNotBoundError(Exception):
    """用户未绑定微信(无 openid),无法提现。"""


class WechatAlreadyBoundError(Exception):
    """该微信(openid)已绑定到其他账号。"""


class InsufficientCashError(Exception):
    """现金余额不足。"""


class WithdrawTierUnavailableError(Exception):
    """该档位今日不可提:次数已满,或今天已选了其他额度(7-9 福利页档位规则)。"""


class WithdrawTransferError(Exception):
    """调用微信转账失败(已退回余额)。"""


class WithdrawOrderNotFound(Exception):
    """提现单不存在。"""


class WithdrawNotReviewable(Exception):
    """提现单当前状态不可审核(非 reviewing,可能已被处理过)。"""


def get_or_create_account(
    db: Session, user_id: int, *, commit: bool = True, lock: bool = False
) -> CoinAccount:
    """取用户金币账户,不存在则建一个空账户。

    lock=True 时对已存在的账户行加 SELECT FOR UPDATE(读-算-写余额的调用方串行化,防并发
    双写余额错位,如 admin set 模式连点);默认 False 不改 C 端发奖行为。SQLite 下为 no-op。
    """
    acc = db.get(CoinAccount, user_id, with_for_update=True) if lock else db.get(CoinAccount, user_id)
    if acc is None:
        acc = CoinAccount(
            user_id=user_id,
            coin_balance=0,
            cash_balance_cents=0,
            total_coin_earned=0,
        )
        db.add(acc)
        if commit:
            db.commit()
            db.refresh(acc)
        else:
            db.flush()
    return acc


def grant_coins(
    db: Session,
    user_id: int,
    amount: int,
    *,
    biz_type: str,
    ref_id: str | None = None,
    remark: str | None = None,
) -> tuple[CoinAccount, CoinTransaction]:
    """金币变动入口(正数入账 / 负数出账)。更新余额 + 写流水,不 commit。

    返回 (account, transaction)。调用方负责 commit。
    """
    acc = get_or_create_account(db, user_id, commit=False)
    acc.coin_balance += amount
    if amount > 0:
        acc.total_coin_earned += amount

    txn = CoinTransaction(
        user_id=user_id,
        amount=amount,
        balance_after=acc.coin_balance,
        biz_type=biz_type,
        ref_id=ref_id,
        remark=remark,
        created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),  # 存北京 wall-clock(客户端原样切片显示)
    )
    db.add(txn)
    db.flush()
    return acc, txn


def grant_cash(
    db: Session,
    user_id: int,
    amount_cents: int,
    *,
    biz_type: str,
    ref_id: str | None = None,
    remark: str | None = None,
) -> tuple[CoinAccount, CashTransaction]:
    """现金变动入口(正数入账 / 负数出账)。更新现金余额 + 写流水,不 commit。

    与 [grant_coins] 同模式(运营手动调现金 / 测试发现金用)。返回 (account, transaction),
    调用方负责 commit。不在此校验扣成负——由调用方(admin router)按业务保护。
    """
    acc = get_or_create_account(db, user_id, commit=False)
    acc.cash_balance_cents += amount_cents

    txn = CashTransaction(
        user_id=user_id,
        amount_cents=amount_cents,
        balance_after_cents=acc.cash_balance_cents,
        biz_type=biz_type,
        ref_id=ref_id,
        remark=remark,
        created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),  # 存北京 wall-clock
    )
    db.add(txn)
    db.flush()
    return acc, txn


def grant_invite_cash(
    db: Session,
    user_id: int,
    amount_cents: int,
    *,
    biz_type: str,
    ref_id: str | None = None,
    remark: str | None = None,
) -> tuple[CoinAccount, InviteCashTransaction]:
    """邀请奖励金变动入口(正数入账 / 负数出账)。更新 invite_cash_balance_cents + 写
    invite_cash_transaction,不 commit。与金币兑换的 cash_balance_cents **物理隔离**
    (产品红线:邀请奖励金 ≠ 金币现金,两本账不可累加)。返回 (account, transaction),
    调用方负责 commit。不在此校验扣成负——由调用方按业务保护。"""
    acc = get_or_create_account(db, user_id, commit=False)
    acc.invite_cash_balance_cents += amount_cents

    txn = InviteCashTransaction(
        user_id=user_id,
        amount_cents=amount_cents,
        balance_after_cents=acc.invite_cash_balance_cents,
        biz_type=biz_type,
        ref_id=ref_id,
        remark=remark,
        created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
    )
    db.add(txn)
    db.flush()
    return acc, txn


def list_coin_transactions(
    db: Session,
    user_id: int,
    *,
    limit: int = 20,
    cursor: int | None = None,
) -> tuple[list[CoinTransaction], int | None]:
    """金币流水分页(按 id 倒序,游标式)。

    cursor 为上一页最后一条的 id;返回 (本页列表, next_cursor)。
    next_cursor 为 None 表示没有下一页。
    """
    stmt = select(CoinTransaction).where(CoinTransaction.user_id == user_id)
    if cursor is not None:
        stmt = stmt.where(CoinTransaction.id < cursor)
    stmt = stmt.order_by(CoinTransaction.id.desc()).limit(limit)

    items = list(db.execute(stmt).scalars().all())
    next_cursor = items[-1].id if len(items) == limit else None
    return items, next_cursor


def exchange_coins_to_cash(
    db: Session,
    user_id: int,
    coin_amount: int,
    *,
    remark: str | None = None,
    enforce_min: bool = True,
) -> tuple[CoinAccount, int]:
    """金币兑现金。返回 (account, 本次兑入的分)。

    校验:金额为整分倍数(InvalidExchangeAmountError)、余额充足(InsufficientCoinError);
    `enforce_min=True`(手动兑默认)时还要 >= 后台配置的兑换下限,`enforce_min=False`
    (0 点自动兑「到分全额」用)只要够 1 分(COIN_PER_CENT)即可,不受手动下限约束。
    `remark` 不传则用默认「N金币兑M分」。扣金币 + 加现金,两条流水同事务 commit。
    """
    floor_min = rewards.get_min_exchange_coin(db) if enforce_min else COIN_PER_CENT
    if coin_amount < floor_min or coin_amount % COIN_PER_CENT != 0:
        raise InvalidExchangeAmountError

    acc = get_or_create_account(db, user_id, commit=False)
    if acc.coin_balance < coin_amount:
        raise InsufficientCoinError

    cents = coins_to_cents(coin_amount)
    tx_remark = remark or f"{coin_amount}金币兑{cents}分"

    # 1. 扣金币(写 coin_transaction)
    grant_coins(db, user_id, -coin_amount, biz_type="exchange_out", remark=tx_remark)
    # 2. 加现金(写 cash_transaction)
    acc.cash_balance_cents += cents
    db.add(
        CashTransaction(
            user_id=user_id,
            amount_cents=cents,
            balance_after_cents=acc.cash_balance_cents,
            biz_type="exchange_in",
            remark=tx_remark,
            created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
        )
    )
    db.commit()
    db.refresh(acc)
    return acc, cents


def _has_exchange_in_on(db: Session, user_id: int, day) -> bool:
    """该用户在指定日期(北京日)是否已有 exchange_in 现金流水。

    自动兑复用 exchange_in 类型,故用「当天是否已有 exchange_in」做幂等键:timer 重触发 /
    手动重跑当天不会重复兑。手动兑入口已下线,不会与之误判混淆(若日后恢复手动兑需改用独立类型)。
    created_at 落库为北京 naive 时间(见 exchange_coins_to_cash),按 [当天 0 点, 次日 0 点) 比较。
    """
    day_start = datetime.combine(day, datetime.min.time())
    next_day = day_start + timedelta(days=1)
    return (
        db.execute(
            select(CashTransaction.id)
            .where(
                CashTransaction.user_id == user_id,
                CashTransaction.biz_type == "exchange_in",
                CashTransaction.created_at >= day_start,
                CashTransaction.created_at < next_day,
            )
            .limit(1)
        ).first()
        is not None
    )


def daily_auto_exchange(db: Session) -> dict:
    """0 点定时任务:把每个用户金币「到分全额」兑成现金(复用 exchange_in 流水类型)。

    - **到分全额**:coin_amount = floor(余额 / 100) * 100;余额不足 1 分(<100)的零头留到下次。
    - **幂等**:当天已有 exchange_in 的用户跳过(见 [_has_exchange_in_on])。
    - **逐用户独立事务**:单个用户异常 rollback 不影响其他人;exchange_coins_to_cash 内部按用户 commit。
    返回统计 dict(scanned/converted/skipped_done/skipped_dust/failed/total_cents)。
    """
    # ⚠️「今天」写死北京时(rewards.cn_today() = datetime.now(CN_TZ).date(), CN_TZ=+8),与服务器/用户
    #   时区无关(用户 2026-07-01 硬约束:兑换一律北京 0 点日切)。**禁止**改成 date.today() / 裸
    #   datetime.now().date()——那会跟随进程本地时区,服务器非 CST 时会在错误的"天"兑/重复兑。
    today = rewards.cn_today()
    stats = {
        "scanned": 0, "converted": 0, "skipped_done": 0,
        "skipped_dust": 0, "failed": 0, "total_cents": 0,
    }
    user_ids = db.execute(
        select(CoinAccount.user_id).where(CoinAccount.coin_balance >= COIN_PER_CENT)
    ).scalars().all()

    for user_id in user_ids:
        stats["scanned"] += 1
        try:
            if _has_exchange_in_on(db, user_id, today):
                stats["skipped_done"] += 1
                continue
            acc = get_or_create_account(db, user_id, commit=False)
            coin_amount = (acc.coin_balance // COIN_PER_CENT) * COIN_PER_CENT
            if coin_amount < COIN_PER_CENT:
                stats["skipped_dust"] += 1
                continue
            _, cents = exchange_coins_to_cash(
                db, user_id, coin_amount, remark="0 点自动兑现金", enforce_min=False
            )
            stats["converted"] += 1
            stats["total_cents"] += cents
        except Exception:  # noqa: BLE001 - 批处理不因单用户异常中断
            db.rollback()
            stats["failed"] += 1

    return stats


def list_cash_transactions(
    db: Session,
    user_id: int,
    *,
    limit: int = 20,
    cursor: int | None = None,
) -> tuple[list[CashTransaction], int | None]:
    """现金流水分页(按 id 倒序,游标式)。"""
    stmt = select(CashTransaction).where(CashTransaction.user_id == user_id)
    if cursor is not None:
        stmt = stmt.where(CashTransaction.id < cursor)
    stmt = stmt.order_by(CashTransaction.id.desc()).limit(limit)

    items = list(db.execute(stmt).scalars().all())
    next_cursor = items[-1].id if len(items) == limit else None
    return items, next_cursor


# ===== 提现(现金 → 微信零钱) =====


def bind_wechat_openid(db: Session, user_id: int, code: str) -> dict:
    """用微信授权 code 换 openid + 昵称头像并绑定到用户。失败抛 ValueError。
    返回 {openid, nickname, avatar_url}(昵称头像可能为 None,见 wxpay.code_to_userinfo)。"""
    info = wxpay.code_to_userinfo(code)  # 失败抛 ValueError
    user = db.get(User, user_id)
    if user is None:
        raise WithdrawOrderNotFound  # 理论上当前用户必存在

    # #5 一个微信只能绑一个账号:若该 openid 已属于别的用户,拒绝(防多账号薅羊毛/重复提现)
    other = db.execute(
        select(User.id).where(User.wechat_openid == info["openid"], User.id != user_id)
    ).scalar_one_or_none()
    if other is not None:
        raise WechatAlreadyBoundError

    user.wechat_openid = info["openid"]
    user.wechat_nickname = info["nickname"]
    user.wechat_avatar_url = info["avatar_url"]
    apply_wechat_display_identity(user, wechat_nickname=info["nickname"], wechat_avatar_url=info["avatar_url"])
    db.commit()
    return info


def get_cash_balance_cents(db: Session, user_id: int) -> int:
    """只读查现金余额(分)。账户不存在返回 0,**不创建账户**——注销前置校验用,
    不该给一个即将被删除的用户凭空建一行空账户(get_or_create_account 会建)。"""
    val = db.execute(
        select(CoinAccount.cash_balance_cents).where(CoinAccount.user_id == user_id)
    ).scalar_one_or_none()
    return val or 0


def has_reviewing_withdraw(db: Session, user_id: int) -> bool:
    """用户是否有「待审核(reviewing)」的提现单。

    只看 reviewing:这类单还没打款,用户确认解绑时会被立即退回现金
    (refund_reviewing_withdraws_on_unbind),解绑前据此提示用户知情确认。
    pending 单转账已在途(execute 已过 openid 闸),解绑影响不到、照常打款,故不计入。
    """
    return (
        db.execute(
            select(WithdrawOrder.id)
            .where(
                WithdrawOrder.user_id == user_id,
                WithdrawOrder.status == "reviewing",
            )
            .limit(1)
        ).first()
        is not None
    )


def unbind_wechat_openid(db: Session, user_id: int) -> None:
    """解绑微信:清空 openid。已绑定才清,未绑定为幂等空操作。"""
    user = db.get(User, user_id)
    if user is None:
        raise WithdrawOrderNotFound
    if user.wechat_openid is not None:
        user.wechat_openid = None
        user.wechat_nickname = None
        user.wechat_avatar_url = None
        db.commit()


def refund_reviewing_withdraws_on_unbind(db: Session, user_id: int) -> int:
    """解绑微信时,把该用户所有「待审核(reviewing)」提现单立即退回现金并置终态。

    reviewing 单还没打款(钱在 create_withdraw 时已从现金扣进待审核单),解绑后这笔无法
    打款到微信,直接退回现金最干净:用户即时拿回钱、后台不再挂死单(不必等管理员审核)。
    复用 _refund_withdraw(退现金 + 流水 + 幂等)。返回退掉的单数。
    """
    orders = (
        db.execute(
            select(WithdrawOrder).where(
                WithdrawOrder.user_id == user_id,
                WithdrawOrder.status == "reviewing",
            )
        )
        .scalars()
        .all()
    )
    for order in orders:
        _refund_withdraw(
            db,
            order,
            reason="用户解绑微信,待审核提现自动退回",
            remark="解绑微信,提现已退回现金余额",
        )
    return len(orders)


_OUT_BILL_NO_RE = re.compile(r"^[0-9A-Za-z_-]{8,32}$")


def _balance_col(source: str):
    """提现账户来源 → CoinAccount 余额列。invite_cash=邀请奖励金;否则金币兑换的现金。
    两账户物理隔离,提现扣款/退款都按 source 走对应列,互不串。"""
    return (
        CoinAccount.invite_cash_balance_cents
        if source == "invite_cash"
        else CoinAccount.cash_balance_cents
    )


def _try_deduct_cash(db: Session, user_id: int, amount_cents: int, source: str = "coin_cash") -> bool:
    """原子扣减指定账户余额:仅当余额足够时扣,返回是否成功。

    用带条件的 UPDATE(`WHERE <col> >= amount`)避免"读-判断-写"竞态——并发/重试时不会两次
    都通过余额检查导致超额扣款(SQLite 串行写、Postgres 行级,均安全)。source 决定扣
    cash_balance_cents(coin_cash) 还是 invite_cash_balance_cents(invite_cash)。
    """
    col = _balance_col(source)
    res = db.execute(
        update(CoinAccount)
        .where(CoinAccount.user_id == user_id, col >= amount_cents)
        .values({col: col - amount_cents})
    )
    return res.rowcount == 1


def _add_cash(db: Session, user_id: int, amount_cents: int, source: str = "coin_cash") -> int:
    """原子增加指定账户余额(退款用),返回加后余额。source 决定退回哪个账户(两账户隔离)。"""
    # Normal withdrawals always create the account before deducting funds. This
    # fallback covers legacy rows, hand-written fixtures, and broken migrations:
    # a missing balance snapshot must not make a legitimate refund fail with 500.
    if db.get(CoinAccount, user_id) is None:
        logger.error(
            "withdraw refund found missing coin_account; recreating empty account: "
            "user_id=%s source=%s amount_cents=%s",
            user_id,
            source,
            amount_cents,
        )
        get_or_create_account(db, user_id, commit=False)

    col = _balance_col(source)
    db.execute(
        update(CoinAccount)
        .where(CoinAccount.user_id == user_id)
        .values({col: col + amount_cents})
    )
    db.flush()
    bal = db.execute(
        select(col).where(CoinAccount.user_id == user_id)
    ).scalar_one()
    return bal


def _refund_withdraw(
    db: Session, order: WithdrawOrder, reason: str, *, final_status: str = "failed",
    remark: str | None = None,
) -> None:
    """退回现金 + 写 withdraw_refund 流水 + 单子置终态。幂等:已是退款终态不重复退。

    final_status:
      - "failed"  (默认):微信转账失败/取消 → 自动退款
      - "rejected":管理员审核拒绝 → 退款(走 reject_withdraw)
    两种都已退款,用 in 判定防重复退(并发/对账/拒绝重复点)。
    remark:给用户可见的现金流水文案;省略则按 final_status 取默认(解绑退回等场景可自定义)。
    """
    if order.status in ("failed", "rejected"):
        return  # 防重复退款(并发/对账与查单/重复拒绝同时触发)
    # 账户隔离:按 order.source 退回对应账户 + 写对应退款流水表
    is_invite = order.source == "invite_cash"
    txn_model = InviteCashTransaction if is_invite else CashTransaction
    refund_biz = "invite_withdraw_refund" if is_invite else "withdraw_refund"
    refunded_txn_id = db.execute(
        select(txn_model.id).where(
            txn_model.user_id == order.user_id,
            txn_model.biz_type == refund_biz,
            txn_model.ref_id == order.out_bill_no,
        ).limit(1)
    ).scalar_one_or_none()
    if refunded_txn_id is not None:
        order.status = final_status
        order.fail_reason = reason[:256]
        db.commit()
        # 上次退款后没走完终态(如中途崩溃)的补账路径:这里补发通知(dedup 防重)
        notification_events.notify_withdraw_failed(db, order)
        return
    bal = _add_cash(db, order.user_id, order.amount_cents, order.source)
    db.add(
        txn_model(
            user_id=order.user_id,
            amount_cents=order.amount_cents,
            balance_after_cents=bal,
            biz_type=refund_biz,
            ref_id=order.out_bill_no,
            # 用户可见文案区分"未成功(自动退)"vs"审核未通过";技术原因记在 order.fail_reason
            remark=(
                remark
                or ("提现审核未通过,金额已退回" if final_status == "rejected" else "提现未成功,金额已退回")
            ),
            created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
        )
    )
    order.status = final_status
    order.fail_reason = reason[:256]
    out_bill_no = order.out_bill_no
    user_id = order.user_id
    try:
        db.commit()
    except IntegrityError:
        # 并发退款兜底:唯一退款流水已被另一事务写入时,回滚本事务的加钱和流水,
        # 再把订单状态补到终态。这样无论拒绝/查单/对账怎么并发,金额最多退一次。
        db.rollback()
        refunded_txn_id = db.execute(
            select(txn_model.id).where(
                txn_model.user_id == user_id,
                txn_model.biz_type == refund_biz,
                txn_model.ref_id == out_bill_no,
            ).limit(1)
        ).scalar_one_or_none()
        if refunded_txn_id is None:
            raise
        fresh_order = db.execute(
            select(WithdrawOrder).where(WithdrawOrder.out_bill_no == out_bill_no)
        ).scalar_one_or_none()
        if fresh_order is not None and fresh_order.status not in ("failed", "rejected"):
            fresh_order.status = final_status
            fresh_order.fail_reason = reason[:256]
            db.commit()
            notification_events.notify_withdraw_failed(db, fresh_order)
        return
    # PRD #4 提现失败通知:所有退款终态(failed/rejected)在此收口下发;
    # dedup=out_bill_no,与上面并发路径重复触发时未读期间只落一条。
    notification_events.notify_withdraw_failed(db, order)


def _wx_not_found(result: dict) -> bool:
    """微信查单/转账响应是否表示'此单不存在'(说明转账从未创建,退款安全)。"""
    if result.get("status_code") == 404:
        return True
    data = result.get("data")
    code = data.get("code", "") if isinstance(data, dict) else ""
    return "NOT_FOUND" in str(code)


def _wechat_api_error_reason(data: object) -> str:
    """Format a non-200 WeChat API response for operator display."""
    if not isinstance(data, dict):
        return f"微信发起转账失败：{data}"
    code = str(data.get("code") or "").strip()
    message = str(data.get("message") or "").strip()
    if code and message:
        return f"微信发起转账失败：{message}（{code}）"
    return f"微信发起转账失败：{message or code or '未知错误'}"


def _wechat_terminal_failure_reason(data: dict, state: str) -> str:
    """Translate WeChat query ``fail_reason`` while preserving unknown codes."""
    code = str(data.get("fail_reason") or "").strip()
    if code:
        label = _WX_FAIL_REASON_LABELS.get(code)
        if label:
            return f"微信转账失败：{label}（{code}）"
        return f"微信转账失败：{code}"
    if state == "CANCELLED":
        return "微信转账已撤销（CANCELLED）"
    if state == "CLOSED":
        return "微信转账已关闭（CLOSED）"
    return f"微信转账失败（状态：{state or 'FAIL'}）"


def _settle_after_ambiguous(db: Session, order: WithdrawOrder, reason: str) -> None:
    """转账调用结果不明(超时/异常/非200)时,**先查单再决定**,绝不盲目退款(防退款后又到账)。
      - 微信查到 SUCCESS        → 钱已出,置 success,不退款
      - 微信查到 FAIL/取消/关闭  → 退款
      - 微信查到在途/待确认       → 保持 pending(带上 package_info/state),交给查单/对账后续处理
      - 微信明确无此单           → 从未创建,退款安全
      - 查询本身也失败           → 保持 pending,留给对账任务(reconcile)兜底,绝不退款
    """
    try:
        q = wxpay.query_transfer(order.out_bill_no)
    except Exception:  # noqa: BLE001 — 查询都失败,保持 pending 等对账
        order.fail_reason = (reason + " | 查单异常,保持pending")[:256]
        db.commit()
        return

    if q["status_code"] != 200:
        if _wx_not_found(q):
            _refund_withdraw(db, order, reason=reason + " (微信无此单,已退回)")
        else:
            order.fail_reason = (reason + " | 查单非200,保持pending")[:256]
            db.commit()
        return

    state = q["data"].get("state", "")
    order.wechat_state = state
    order.transfer_bill_no = q["data"].get("transfer_bill_no") or order.transfer_bill_no
    if state == _WX_STATE_SUCCESS:
        order.status = "success"
        db.commit()
        notification_events.notify_withdraw_success(db, order)  # PRD #3 提现到账
    elif state in _WX_STATE_FAILED:
        _refund_withdraw(
            db,
            order,
            reason=_wechat_terminal_failure_reason(q["data"], state),
        )
    else:
        order.package_info = q["data"].get("package_info") or order.package_info
        db.commit()


def _beijing_today_start_utc() -> datetime:
    """北京时今日 0 点(转 UTC)。WithdrawOrder.created_at 是 func.now()(UTC)存储,
    比较时统一转 UTC,与 admin 看板 today_start 同口径(admin/repositories/queries.py)。"""
    return (
        datetime.now(rewards.CN_TZ)
        .replace(hour=0, minute=0, second=0, microsecond=0)
        .astimezone(timezone.utc)
    )


def withdraw_tier_states(db: Session, user_id: int, source: str = "coin_cash") -> list[dict]:
    """福利页(coin_cash)提现档位的可提现状态。withdraw-info 下发与 create_withdraw 校验共用此口径。

    规则(2026-07-09 拍板,7-9提现ui对齐;新人档判定 2026-07-16 修正):
      - 新人档(0.1/0.3):账号历史一次性——进行中(reviewing/pending)或成功打款(success)即视为
        已用,直接**从返回列表消失**;被拒/转账失败/解绑退回(均已退款、钱没到手)则恢复可提,不永久
        占用资格。两档各自独立互不影响,不参与"每日选一个额度"互斥。
      - 常规档(0.5×3 / 10×1 / 20×1):按北京日计次,"发起就算占用"(当天创建的单不论最终状态
        都计入,被拒/失败不退当天名额);三档每天只能选一个,选定后其余两档当天 other_tier_selected。
      - invite_cash 本轮无档位概念 → 返回空列表(邀请页客户端仍用本地写死档位,行为不变)。
    余额是否足够由客户端本地判断(余额随兑换实时变化,不在此快照)。
    """
    if source != "coin_cash":
        return []
    tiers = rewards.WITHDRAW_TIERS_COIN_CASH
    amounts = [t.amount_cents for t in tiers]
    newbie_amounts = [t.amount_cents for t in tiers if t.is_newbie]
    # 新人档历史是否用过:进行中或已成功打款的单占用资格;被拒/失败/解绑退回(已退款)不算(恢复可提)
    used_newbie: set[int] = set(
        db.execute(
            select(WithdrawOrder.amount_cents)
            .distinct()
            .where(
                WithdrawOrder.user_id == user_id,
                WithdrawOrder.source == "coin_cash",
                WithdrawOrder.amount_cents.in_(newbie_amounts),
                WithdrawOrder.status.in_(_NEWBIE_TIER_HELD_STATUSES),
            )
        ).scalars()
    ) if newbie_amounts else set()
    # 今日(北京日)每档已发起次数(任意状态)
    today_counts: dict[int, int] = {
        int(amount): int(cnt)
        for amount, cnt in db.execute(
            select(WithdrawOrder.amount_cents, func.count(WithdrawOrder.id))
            .where(
                WithdrawOrder.user_id == user_id,
                WithdrawOrder.source == "coin_cash",
                WithdrawOrder.amount_cents.in_(amounts),
                WithdrawOrder.created_at >= _beijing_today_start_utc(),
            )
            .group_by(WithdrawOrder.amount_cents)
        )
    }
    # "每日选一个额度":今天发起过的常规档(新人档不算)
    selected_regular = next(
        (t.amount_cents for t in tiers if not t.is_newbie and today_counts.get(t.amount_cents, 0) > 0),
        None,
    )
    out: list[dict] = []
    for t in tiers:
        if t.is_newbie:
            if t.amount_cents in used_newbie:
                continue  # 用过即消失,不再下发
            out.append({
                "amount_cents": t.amount_cents, "label": t.label, "badge": t.badge,
                "is_newbie": True, "available": True, "disabled_reason": None,
                "remaining_today": 1,
            })
            continue
        used = today_counts.get(t.amount_cents, 0)
        if selected_regular is not None and selected_regular != t.amount_cents:
            available, reason, remaining = False, "other_tier_selected", 0
        elif used >= t.daily_limit:
            available, reason, remaining = False, "quota_exhausted", 0
        else:
            available, reason, remaining = True, None, t.daily_limit - used
        out.append({
            "amount_cents": t.amount_cents, "label": t.label, "badge": t.badge,
            "is_newbie": False, "available": available, "disabled_reason": reason,
            "remaining_today": remaining,
        })
    return out


def create_withdraw(
    db: Session,
    user_id: int,
    amount_cents: int,
    *,
    source: str = "coin_cash",
    user_name: str | None = None,
    out_bill_no: str | None = None,
    allow_sub_min: bool = False,
) -> WithdrawOrder:
    """发起提现:原子扣款 + 建单 reviewing(待人工审核),**不打款**。

    打款推迟到管理员在 admin 后台审核通过(approve_withdraw → execute_withdraw_transfer)才发起;
    拒绝则退回现金(reject_withdraw)。
    #1 用原子条件 UPDATE 扣款,杜绝并发超额——且"待审核期间钱已扣减",防止用户拿同一笔余额
       重复发起多笔提现(审核拒绝再退回)。
    #2 out_bill_no 客户端幂等键:同号重试返回该单现状(reviewing 等审核),不重复扣款建单。
    实名 user_name 在此存下(WithdrawOrder.user_name),供异步审核打款时传给微信(达额需实名)。

    allow_sub_min:放行低于"提现最低额"的小额(用于 0.01 元调试提现)。仅由 endpoint 在
    `skip_review and not is_prod`(debug 包 + 非生产双闸)时置 True;仍受 schema gt=0 与 max 上限约束。
    生产恒为 False → 最低额校验照常,绝不可能提 0.01。
    """
    min_c = 0 if allow_sub_min else rewards.get_withdraw_min_cents(db)
    if amount_cents < min_c or amount_cents > rewards.get_withdraw_max_cents(db):
        raise InvalidWithdrawAmountError

    # 提现即要求已绑微信:否则审核通过也打不了款,提前拦更友好
    user = db.get(User, user_id)
    openid = user.wechat_openid if user else None
    if not openid:
        raise WechatNotBoundError

    # #2 幂等:该单已存在 → 直接返回现状(reviewing 等审核 / 已处理态原样返回),不重复扣款。
    # 不再像旧版对 pending 查微信:本函数不再涉及微信,查单交给 /withdraw/status 与对账任务。
    if out_bill_no and _OUT_BILL_NO_RE.match(out_bill_no):
        existing = db.execute(
            select(WithdrawOrder).where(
                WithdrawOrder.out_bill_no == out_bill_no, WithdrawOrder.user_id == user_id
            )
        ).scalar_one_or_none()
        if existing is not None:
            return existing
    else:
        out_bill_no = uuid.uuid4().hex

    # 福利页档位闸(7-9):coin_cash 只能提预设档位,且该档今日可提(服务端权威口径,防绕过
    # 客户端刷)。放在幂等返回之后:同号重试仍原样返回旧单,不被档位闸误杀。
    # allow_sub_min(0.01 调试直发)保持原样放行,不受档位约束;invite_cash 本轮无档位概念不校验。
    if source == "coin_cash" and not allow_sub_min:
        tier_state = next(
            (t for t in withdraw_tier_states(db, user_id, source) if t["amount_cents"] == amount_cents),
            None,
        )
        if tier_state is None:  # 非预设档位金额,或新人档已用过(已从列表消失)
            raise InvalidWithdrawAmountError
        if not tier_state["available"]:
            raise WithdrawTierUnavailableError

    # 账户须存在(原子扣款的 UPDATE 不会建账户)
    get_or_create_account(db, user_id, commit=True)

    # #1 原子扣款:余额不足时影响行数为 0(按 source 扣对应账户)
    if not _try_deduct_cash(db, user_id, amount_cents, source):
        db.rollback()
        raise InsufficientCashError

    is_invite = source == "invite_cash"
    txn_model = InviteCashTransaction if is_invite else CashTransaction
    withdraw_biz = "invite_withdraw" if is_invite else "withdraw"
    bal = db.execute(
        select(_balance_col(source)).where(CoinAccount.user_id == user_id)
    ).scalar_one()
    db.add(
        txn_model(
            user_id=user_id,
            amount_cents=-amount_cents,
            balance_after_cents=bal,
            biz_type=withdraw_biz,
            ref_id=out_bill_no,
            remark="提现到微信零钱(待审核)",
            created_at=datetime.now(rewards.CN_TZ).replace(tzinfo=None),
        )
    )
    order = WithdrawOrder(
        user_id=user_id,
        out_bill_no=out_bill_no,
        amount_cents=amount_cents,
        source=source,
        user_name=user_name,
        status="reviewing",
    )
    db.add(order)
    try:
        db.commit()
    except IntegrityError:
        db.rollback()
        # 唯一冲突只可能来自 out_bill_no 幂等键并发重试:原样返回既有单;否则未知冲突,上抛。
        existing = db.execute(
            select(WithdrawOrder).where(
                WithdrawOrder.out_bill_no == out_bill_no, WithdrawOrder.user_id == user_id
            )
        ).scalar_one_or_none()
        if existing is not None:
            return existing
        raise
    db.refresh(order)
    return order  # 待管理员审核;**不在此处打款**


# ===== 免确认收款授权(用户授权免确认模式)=====
# 用户授权一次后,后续提现走 transfer_with_authorization 免逐笔确认直接到账。
# WechatTransferAuthorization 一个用户一条;out_authorization_no 我方生成,authorization_id 微信生效后返回。


def _auth_display_name(user: User | None) -> str:
    """微信授权页展示的"开通账号"昵称(≤32,utf8)。

    微信对 user_display_name 校验极严:**连空格和标点(. 等)都算"控制字符"拒收**——实测
    'wonderable ai'(带空格)被 400 拒、'wonderableai'/'周周'/'傻瓜比价用户8888' 才过。
    故只保留 文字(Unicode L*,中英文/CJK)与数字(N*),其余(emoji/符号/空格/标点/控制符/
    零宽连接符/变体选择符/星形面字符)一律剔除;清空则兜底手机号尾号。
    """
    raw = ((user.nickname if user else None) or (user.wechat_nickname if user else None) or "").strip()
    kept = [
        ch for ch in raw
        if ord(ch) <= 0xFFFF and unicodedata.category(ch)[0] in ("L", "N")
    ]
    name = "".join(kept).strip()
    if not name and user and user.phone:
        tail = user.phone[-4:] if len(user.phone) >= 4 else user.phone
        name = f"傻瓜比价用户{tail}"
    return (name or "傻瓜比价用户")[:32]


def _refresh_active_auth(db: Session, user_id: int) -> None:
    """免确认转账失败后回查授权有效性(权威判定,不靠猜错误码):
      微信侧明确非生效(用户在微信关闭 / 风控 / 无此单)→ 标 closed,下次提现自动回退方式一重新授权;
      仍生效(失败实为商户余额不足等与授权无关的原因)→ 保持 active,下次免确认重试。
    仅对本地 active 记录查询;查询本身失败(5xx/网络)不改状态,下次再核,避免误关有效授权。"""
    auth = db.get(WechatTransferAuthorization, user_id)
    if auth is None or auth.state != "active" or not settings.wxpay_configured:
        return
    try:
        res = wxpay.query_transfer_authorization(auth.out_authorization_no)
    except Exception:  # noqa: BLE001 — 查询失败保持 active,下次再核
        return
    if res["status_code"] == 200:
        if res["data"].get("state") != _WX_AUTH_ACTIVE:  # CLOSED / 其他非生效 → 失效
            auth.state = "closed"
            db.commit()
    elif _wx_not_found(res):  # 明确无此授权单 → 失效
        auth.state = "closed"
        db.commit()
    # 其他查询错误(5xx 等):不改状态,保持 active,下次再核


def sync_transfer_auth(db: Session, user_id: int) -> WechatTransferAuthorization | None:
    """同步待确认授权最新状态(仅 pending 时查微信):
      TAKING_EFFECT → active + 落 authorization_id;CLOSED/超期无此单 → closed。返回最新记录(或 None)。
    非 pending(active/closed)直接返回不查;查询异常或缺凭证则保持原状。"""
    auth = db.get(WechatTransferAuthorization, user_id)
    if auth is None or auth.state != "pending" or not settings.wxpay_configured:
        return auth
    try:
        res = wxpay.query_transfer_authorization(auth.out_authorization_no)
    except Exception:  # noqa: BLE001 — 查询失败保持 pending,下次再同步
        return auth
    if res["status_code"] != 200:
        if _wx_not_found(res):  # 超期未确认被关闭 → closed;其他错误保持 pending
            auth.state = "closed"
            db.commit()
        return auth
    state = res["data"].get("state", "")
    if state == _WX_AUTH_ACTIVE:
        auth.state = "active"
        auth.authorization_id = res["data"].get("authorization_id") or auth.authorization_id
        db.commit()
    elif state == _WX_AUTH_CLOSED:
        auth.state = "closed"
        db.commit()
    # WAIT_USER_CONFIRM:保持 pending,等用户确认
    return auth


def _ensure_pending_auth_no(db: Session, user_id: int, openid: str) -> str:
    """为"方式一(转账顺带授权)"准备待确认授权单号:
      已有 pending → 复用其 out_authorization_no(避免重复申请刷满"同场景≤5"上限);
      无 / 已 closed → 生成新单号并 upsert 成 pending。返回 out_authorization_no。"""
    auth = db.get(WechatTransferAuthorization, user_id)
    if auth is not None and auth.state == "pending":
        if auth.openid != openid:  # 换绑过 → 刷新快照
            auth.openid = openid
            db.commit()
        return auth.out_authorization_no
    new_no = uuid.uuid4().hex  # 32 位,符合 [0-9A-Za-z_-]{8,32}
    if auth is None:
        db.add(
            WechatTransferAuthorization(
                user_id=user_id, openid=openid, out_authorization_no=new_no,
                authorization_id=None, state="pending",
            )
        )
    else:  # closed → 重新开启,换新单号
        auth.openid = openid
        auth.out_authorization_no = new_no
        auth.authorization_id = None
        auth.state = "pending"
    db.commit()
    return new_no


def apply_transfer_auth(db: Session, user_id: int) -> dict:
    """方式二:显式开启免确认收款(申请授权,不转账)。
    返回 {already_active, package_info, mch_id, app_id};already_active=True 表示已开启无需再授权。
    未绑微信抛 WechatNotBoundError;微信返回非 200 抛 WithdrawTransferError。"""
    user = db.get(User, user_id)
    openid = user.wechat_openid if user else None
    if not openid:
        raise WechatNotBoundError

    auth = db.get(WechatTransferAuthorization, user_id)
    if auth is not None and auth.state == "active" and auth.authorization_id:
        return {
            "already_active": True, "package_info": None,
            "mch_id": settings.WXPAY_MCH_ID, "app_id": settings.WECHAT_APP_ID,
        }

    out_auth_no = _ensure_pending_auth_no(db, user_id, openid)
    result = wxpay.apply_transfer_authorization(
        out_auth_no, openid, _auth_display_name(user), settings.WXPAY_AUTH_NOTIFY_URL
    )
    if result["status_code"] != 200:
        raise WithdrawTransferError(str(result["data"].get("message") or result["data"]))
    return {
        "already_active": False,
        "package_info": result["data"].get("package_info"),
        "mch_id": settings.WXPAY_MCH_ID,
        "app_id": settings.WECHAT_APP_ID,
    }


def close_transfer_auth(db: Session, user_id: int) -> None:
    """关闭免确认收款(解除授权)。best-effort 调微信解除 + 本地置 closed。"""
    auth = db.get(WechatTransferAuthorization, user_id)
    if auth is None:
        return
    if auth.state != "closed" and settings.wxpay_configured:
        try:
            wxpay.close_transfer_authorization(auth.out_authorization_no)
        except Exception:  # noqa: BLE001 — 微信解除失败不阻塞本地置 closed(用户也可在微信侧自行关闭)
            pass
    auth.state = "closed"
    db.commit()


def _apply_transfer_result(db: Session, order: WithdrawOrder, data: dict) -> WithdrawOrder:
    """落微信转账应答到提现单:记 state/转账单号/package_info,SUCCESS 即 success。"""
    order.wechat_state = data.get("state")
    order.transfer_bill_no = data.get("transfer_bill_no")
    order.package_info = data.get("package_info")  # 免确认转账无此字段(None);确认模式带它供拉确认页
    if data.get("state") == _WX_STATE_SUCCESS:
        order.status = "success"
    elif data.get("state") in _WX_STATE_FAILED:
        _refund_withdraw(
            db,
            order,
            reason=_wechat_terminal_failure_reason(data, str(data.get("state") or "")),
        )
        db.refresh(order)
        return order
    db.commit()
    db.refresh(order)
    if order.status == "success":  # 免确认转账直接到账 → PRD #3 提现到账
        notification_events.notify_withdraw_success(db, order)
    return order


def execute_withdraw_transfer(db: Session, order: WithdrawOrder) -> WithdrawOrder:
    """审核通过后真正发起微信转账(复用原"转账 + 模糊失败查单"逻辑,绝不盲目退款)。**不抛异常**。

    免确认收款(用户授权免确认模式)分叉:
      ① 已有生效授权(active)→ transfer_with_authorization 免确认转账,直接到账,无 package_info。
      ② 无生效授权:
         - 已配 WXPAY_AUTH_NOTIFY_URL → pre_transfer_with_authorization(方式一):转账 + 顺带申请授权,
           返回 WAIT_USER_CONFIRM + package_info,用户在确认这笔收款时一并授权,下次起免确认。
         - 未配回调地址(未启用免确认)→ 退化为原 create_transfer 确认模式,行为同改造前。
    免确认转账失败 → 先 _settle_after_ambiguous 保金额安全,再 _refresh_active_auth 回查授权,失效则标 closed(下次回退方式一)。
    用户审核期间解绑微信 → 退款 failed。结果不明(超时/非200)→ 先查单再决定,绝不盲目退款。
    """
    user = db.get(User, order.user_id)
    openid = user.wechat_openid if user else None
    if not openid:
        _refund_withdraw(db, order, reason="用户已解绑微信,无法打款")
        db.refresh(order)
        return order

    # 先同步待确认授权:捕获首单确认后微信已生效的 authorization_id(pending→active),或失效(→closed)
    auth = sync_transfer_auth(db, order.user_id)

    order.status = "pending"  # 进入打款在途(转账调用前崩溃留 pending,交对账兜底)
    db.commit()

    # ① 有生效授权 → 免确认转账(无需用户确认,直接到账)
    if auth is not None and auth.state == "active" and auth.authorization_id:
        try:
            result = wxpay.transfer_with_authorization(
                auth.authorization_id, order.amount_cents, order.out_bill_no, user_name=order.user_name
            )
        except Exception as e:  # noqa: BLE001 — 超时/网络异常≠失败,查单确认
            _settle_after_ambiguous(db, order, reason=f"免确认转账调用异常: {e}")
            db.refresh(order)
            return order
        if result["status_code"] != 200:
            # 金额安全:查转账单后定夺,绝不盲退(未创建→退款,已创建→按真实状态)
            _settle_after_ambiguous(
                db,
                order,
                reason=_wechat_api_error_reason(result["data"]),
            )
            # 授权有效性:回查授权单,微信侧已失效(用户关闭/风控)→标 closed,下次提现自动回退方式一重新授权
            _refresh_active_auth(db, order.user_id)
            db.refresh(order)
            return order
        return _apply_transfer_result(db, order, result["data"])

    # ② 无生效授权 → 启用免确认则转账+顺带授权(方式一),否则退化为原确认模式
    try:
        if settings.WXPAY_AUTH_NOTIFY_URL:
            out_auth_no = _ensure_pending_auth_no(db, order.user_id, openid)
            result = wxpay.pre_transfer_with_authorization(
                openid, order.amount_cents, order.out_bill_no,
                out_authorization_no=out_auth_no,
                user_display_name=_auth_display_name(user),
                notify_url=settings.WXPAY_AUTH_NOTIFY_URL,
                user_name=order.user_name,
            )
        else:
            result = wxpay.create_transfer(
                openid, order.amount_cents, order.out_bill_no, user_name=order.user_name
            )
    except Exception as e:  # noqa: BLE001 — 超时/网络异常≠失败,查单确认
        _settle_after_ambiguous(db, order, reason=f"转账调用异常: {e}")
        db.refresh(order)
        return order

    if result["status_code"] != 200:
        _settle_after_ambiguous(
            db,
            order,
            reason=_wechat_api_error_reason(result["data"]),
        )
        db.refresh(order)
        return order

    return _apply_transfer_result(db, order, result["data"])


def _get_withdraw_or_raise(db: Session, out_bill_no: str) -> WithdrawOrder:
    """按 out_bill_no 取提现单(admin 跨用户,不限 user_id)。不存在抛 WithdrawOrderNotFound。"""
    order = db.execute(
        select(WithdrawOrder).where(WithdrawOrder.out_bill_no == out_bill_no)
    ).scalar_one_or_none()
    if order is None:
        raise WithdrawOrderNotFound
    return order


def approve_withdraw(db: Session, out_bill_no: str) -> WithdrawOrder:
    """管理员审核通过:校验 reviewing → 发起微信转账。返回最终 order(pending/success/failed)。

    幂等防误操作:非 reviewing(已通过/已拒绝/已打款)抛 WithdrawNotReviewable,不会重复打款。
    """
    order = _get_withdraw_or_raise(db, out_bill_no)
    if order.status != "reviewing":
        raise WithdrawNotReviewable(f"提现单状态为 {order.status},非待审核,不能通过")
    return execute_withdraw_transfer(db, order)


def reject_withdraw(db: Session, out_bill_no: str, reason: str) -> WithdrawOrder:
    """管理员审核拒绝:校验 reviewing → 退回现金 + 置 rejected。

    非 reviewing 抛 WithdrawNotReviewable(防对已打款单误退导致重复退款)。
    """
    order = _get_withdraw_or_raise(db, out_bill_no)
    if order.status != "reviewing":
        raise WithdrawNotReviewable(f"提现单状态为 {order.status},非待审核,不能拒绝")
    _refund_withdraw(db, order, reason=reason or "审核未通过", final_status="rejected")
    db.refresh(order)
    return order


def refresh_withdraw_status(
    db: Session, user_id: int, out_bill_no: str, *, cancel_if_unconfirmed: bool = False
) -> WithdrawOrder:
    """查微信单状态并归一化:SUCCESS→success;FAIL/CANCELLED/CLOSED→退款+failed;其余保持 pending。

    cancel_if_unconfirmed:查到 WAIT_USER_CONFIRM 时,是否当作"用户放弃"撤销并退款。
      - True:用户从确认页返回后查单、或对账老单时传 → 撤销+退款。
      - False(默认):幂等重试只想读状态时传 → 保留 pending,不能误撤用户还想确认的单。
    """
    order = db.execute(
        select(WithdrawOrder).where(
            WithdrawOrder.out_bill_no == out_bill_no, WithdrawOrder.user_id == user_id
        )
    ).scalar_one_or_none()
    if order is None:
        raise WithdrawOrderNotFound
    if order.status not in {"pending", "failed"}:
        return order
    enrich_failed_order = order.status == "failed"

    try:
        result = wxpay.query_transfer(out_bill_no)
    except wxpay.WxPayNotConfiguredError:
        raise
    except Exception as exc:  # noqa: BLE001 - 查单失败不能把运营后台打成 500
        if enrich_failed_order:
            return order
        order.fail_reason = f"微信查单异常,保持pending: {exc}"[:256]
        db.commit()
        db.refresh(order)
        return order
    if result["status_code"] != 200:
        if enrich_failed_order:
            return order
        if _wx_not_found(result):
            # 微信明确无此单 → 转账从未创建(如崩溃在扣款后/调用前),退款安全
            _refund_withdraw(db, order, reason="微信无此单,已退回")
            db.refresh(order)
        return order  # 其他查询失败不改状态,保持 pending 等下次

    state = result["data"].get("state", "")
    order.wechat_state = state
    order.transfer_bill_no = (
        result["data"].get("transfer_bill_no") or order.transfer_bill_no
    )
    if enrich_failed_order:
        if state in _WX_STATE_FAILED:
            order.fail_reason = _wechat_terminal_failure_reason(
                result["data"], state
            )[:256]
        elif state == _WX_STATE_SUCCESS:
            order.fail_reason = (
                "资金状态异常：本地已退款，但微信查单显示已到账，请人工核查"
            )
        db.commit()
        db.refresh(order)
        return order

    if state == _WX_STATE_SUCCESS:
        order.status = "success"
        db.commit()
        notification_events.notify_withdraw_success(db, order)  # PRD #3 提现到账
    elif state in _WX_STATE_FAILED:
        _refund_withdraw(
            db,
            order,
            reason=_wechat_terminal_failure_reason(result["data"], state),
        )
    elif state == _WX_STATE_WAIT_CONFIRM and cancel_if_unconfirmed:
        # 用户从确认页回来了却仍未确认 → 视为放弃:撤销微信单(防事后确认导致重复打款)后退款。
        # 撤单失败(可能已被确认进 ACCEPTED 的竞态)则保持 pending,等下次查询。
        cancel = wxpay.cancel_transfer(out_bill_no)
        if cancel["status_code"] in (200, 202):
            _refund_withdraw(db, order, reason="用户未确认,已撤销提现")
        else:
            db.commit()
    else:
        db.commit()  # ACCEPTED / PROCESSING 等真在途,仍 pending
    db.refresh(order)
    return order


def reconcile_pending_withdraws(db: Session, *, older_than_minutes: int = 15) -> dict:
    """对账:扫描超过 N 分钟仍 pending 的提现单,逐单查微信归一化(成功/退款/撤销未确认)。
    防止"扣了款但转账没发起/没确认"的孤儿单永久锁住用户余额。建议用 cron/systemd timer 定时跑。
    返回 {checked, resolved} 计数。"""
    cutoff = datetime.now(timezone.utc) - timedelta(minutes=older_than_minutes)
    orders = list(
        db.execute(
            select(WithdrawOrder).where(
                WithdrawOrder.status == "pending", WithdrawOrder.created_at < cutoff
            )
        ).scalars().all()
    )
    resolved = 0
    for o in orders:
        try:
            # 老单视同"用户已放弃确认",查到 WAIT_USER_CONFIRM 也撤销退款
            refreshed = refresh_withdraw_status(
                db, o.user_id, o.out_bill_no, cancel_if_unconfirmed=True
            )
            if refreshed.status != "pending":
                resolved += 1
        except Exception:  # noqa: BLE001 — 单笔失败不影响其余,下轮再试
            db.rollback()
            continue
    return {"checked": len(orders), "resolved": resolved}


def list_withdraw_orders(
    db: Session, user_id: int, *, source: str | None = None, limit: int = 20, cursor: int | None = None
) -> tuple[list[WithdrawOrder], int | None]:
    """提现单分页(按 id 倒序,游标式)。source 非空时只返回该账户来源的单(coin_cash / invite_cash)。"""
    stmt = select(WithdrawOrder).where(WithdrawOrder.user_id == user_id)
    if source is not None:
        stmt = stmt.where(WithdrawOrder.source == source)
    if cursor is not None:
        stmt = stmt.where(WithdrawOrder.id < cursor)
    stmt = stmt.order_by(WithdrawOrder.id.desc()).limit(limit)
    items = list(db.execute(stmt).scalars().all())
    next_cursor = items[-1].id if len(items) == limit else None
    return items, next_cursor
