"""新手引导视频:次数上限 + 发币幂等。

这条链路直接铸币,两处并发缺陷曾经都是真漏洞,所以本文件的重点不是走通 happy path,
而是**并发失败分支**:
  - `/start` 并发算出同一个 seq → (user_id, seq) 唯一键挡下,降级为"这次不放视频";
  - `/reward` 并发抢跑 → status 条件更新只让一个 rowcount=1,输的那个不得入账。

真并发在 SQLite 测试库里复现不了(读事务会直接把写方锁死,而不是让它读到旧快照),
所以用"把对手已经落库的状态先摆好、再让被测方按旧读数往下走"来模拟,断言的是同一件事:
输的一方必须空手而归,且账不能乱。
"""
from __future__ import annotations

import pytest
from sqlalchemy import func, select

from app.db.session import SessionLocal
from app.models.guide_video import GuideVideoPlay
from app.models.wallet import CoinAccount, CoinTransaction
from app.repositories import guide_video as crud_guide
from app.repositories.user import get_user_by_phone

VIDEO_URL = "/media/guide_video/pytest_guide.mp4"


def _login(client, phone: str) -> str:
    client.post("/api/v1/auth/sms/send", json={"phone": phone})
    r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456"})
    assert r.status_code == 200, r.text
    return r.json()["access_token"]


def _auth(token: str) -> dict[str, str]:
    return {"Authorization": f"Bearer {token}"}


def _user_id(phone: str) -> int:
    with SessionLocal() as db:
        user = get_user_by_phone(db, phone)
        assert user is not None
        return user.id


def _coin_balance(user_id: int) -> int:
    with SessionLocal() as db:
        acc = db.get(CoinAccount, user_id)
        return acc.coin_balance if acc else 0


def _guide_txns(user_id: int) -> list[CoinTransaction]:
    """该用户的引导视频金币流水(按 id 升序)。"""
    with SessionLocal() as db:
        return list(db.execute(
            select(CoinTransaction)
            .where(
                CoinTransaction.user_id == user_id,
                CoinTransaction.biz_type == crud_guide.BIZ_TYPE,
            )
            .order_by(CoinTransaction.id)
        ).scalars().all())


def _assert_ledger_balanced(user_id: int) -> None:
    """coin_balance 必须恒等于流水总和 —— 双发/丢更新都会先在这里露馅。"""
    with SessionLocal() as db:
        total = db.execute(
            select(func.coalesce(func.sum(CoinTransaction.amount), 0)).where(
                CoinTransaction.user_id == user_id
            )
        ).scalar_one()
        acc = db.get(CoinAccount, user_id)
        assert acc is not None
        assert acc.coin_balance == total, f"余额 {acc.coin_balance} != 流水总和 {total}"


@pytest.fixture()
def guide_configured():
    """给全局配置塞一支片子(默认 max_plays=3 / reward_coin=120),用完还原成未配片。"""
    with SessionLocal() as db:
        crud_guide.set_video(db, VIDEO_URL, admin_id=1)
        cfg = crud_guide.get_config(db)
    yield cfg
    with SessionLocal() as db:
        crud_guide.set_video(db, None, admin_id=1)


# ===== 基本闭环 =====


def test_start_miss_when_no_video_configured(client) -> None:
    """没配片 → should_play=False,客户端照旧放广告(本功能不配视频就等于没上线)。"""
    token = _login(client, "13920000001")
    r = client.post("/api/v1/guide-video/start", json={"scene": "coupon"}, headers=_auth(token))
    assert r.status_code == 200, r.text
    body = r.json()
    assert body["should_play"] is False
    assert body["play_token"] == ""


def test_start_then_reward_grants_once(client, guide_configured) -> None:
    """开播 → 上报 → 到账 120;重复上报不再加钱,只回已发金额。"""
    cfg = guide_configured
    phone = "13920000002"
    token = _login(client, phone)
    uid = _user_id(phone)

    r = client.post("/api/v1/guide-video/start", json={"scene": "coupon"}, headers=_auth(token))
    body = r.json()
    assert body["should_play"] is True
    assert body["video_url"] == VIDEO_URL
    assert body["seq"] == 1
    assert body["reward_coin"] == cfg["reward_coin"]
    play_token = body["play_token"]
    assert play_token

    r = client.post(
        "/api/v1/guide-video/reward",
        json={"play_token": play_token, "completed": True},
        headers=_auth(token),
    )
    assert r.status_code == 200, r.text
    assert r.json() == {"granted": True, "coin": cfg["reward_coin"], "status": "granted"}
    assert _coin_balance(uid) == cfg["reward_coin"]

    # 重复上报(客户端超时重试 / 播完与 ✕ 都报了一次)
    r = client.post(
        "/api/v1/guide-video/reward",
        json={"play_token": play_token, "completed": False},
        headers=_auth(token),
    )
    assert r.json() == {"granted": False, "coin": cfg["reward_coin"], "status": "already_granted"}
    assert _coin_balance(uid) == cfg["reward_coin"], "重复上报不得二次入账"
    assert len(_guide_txns(uid)) == 1, "一个 play_token 只能有一条金币流水"
    _assert_ledger_balanced(uid)


def test_reward_rejects_unknown_and_others_token(client, guide_configured) -> None:
    """乱填 token / 拿别人的 token 都发不出币(grant 按 user_id + token 双条件定位)。"""
    victim = _login(client, "13920000003")
    r = client.post("/api/v1/guide-video/start", json={"scene": "coupon"}, headers=_auth(victim))
    stolen = r.json()["play_token"]

    attacker_phone = "13920000004"
    attacker = _login(client, attacker_phone)
    attacker_uid = _user_id(attacker_phone)

    r = client.post(
        "/api/v1/guide-video/reward",
        json={"play_token": stolen, "completed": True},
        headers=_auth(attacker),
    )
    assert r.json() == {"granted": False, "coin": 0, "status": "not_found"}

    r = client.post(
        "/api/v1/guide-video/reward",
        json={"play_token": "deadbeef" * 4, "completed": True},
        headers=_auth(attacker),
    )
    assert r.json() == {"granted": False, "coin": 0, "status": "not_found"}
    assert _coin_balance(attacker_uid) == 0


def test_start_stops_at_max_plays(client, guide_configured) -> None:
    """开播即计次:用满 max_plays 后不再下发,客户端回到广告链路。"""
    max_plays = int(guide_configured["max_plays"])
    token = _login(client, "13920000005")

    for i in range(1, max_plays + 1):
        body = client.post(
            "/api/v1/guide-video/start", json={"scene": "coupon"}, headers=_auth(token)
        ).json()
        assert body["should_play"] is True, f"第 {i} 次应该还能放"
        assert body["seq"] == i
        assert body["remaining"] == max_plays - i

    body = client.post(
        "/api/v1/guide-video/start", json={"scene": "coupon"}, headers=_auth(token)
    ).json()
    assert body["should_play"] is False
    assert body["remaining"] == 0


# ===== 并发失败分支(回归) =====


def test_start_loses_seq_race_degrades_to_ad(client, guide_configured, monkeypatch) -> None:
    """并发 /start 抢到同一个 seq 时,输的一方降级成"不放视频",而不是多拿一个 token。

    模拟:对手已经提交了 seq=1,而本次请求读到的还是旧计数(used=0)—— 这正是无锁
    check-then-insert 的race window。没有 (user_id, seq) 唯一键的话,这里会插成第二行、
    换回第二个 play_token,3 次上限就能被并发无限绕过(改包即可 N 倍刷币)。
    """
    phone = "13920000006"
    token = _login(client, phone)
    uid = _user_id(phone)

    # 对手先落一行 seq=1
    first = client.post(
        "/api/v1/guide-video/start", json={"scene": "coupon"}, headers=_auth(token)
    ).json()
    assert first["should_play"] is True and first["seq"] == 1

    # 本次请求读到的是过期计数 → 仍会算出 seq=1
    monkeypatch.setattr(crud_guide, "used_plays", lambda db, user_id, scene="coupon": 0)
    with SessionLocal() as db:
        result = crud_guide.start_play(db, uid)

    assert result["should_play"] is False, "撞 seq 唯一键后必须降级,不能再发一个 token"
    assert result["play_token"] == ""

    monkeypatch.undo()
    with SessionLocal() as db:
        rows = db.execute(
            select(func.count()).select_from(GuideVideoPlay).where(
                GuideVideoPlay.user_id == uid
            )
        ).scalar_one()
    assert rows == 1, "抢输的那次不该留下播放行"


def test_reward_loses_race_does_not_double_mint(client, guide_configured) -> None:
    """并发 /reward 抢输的一方不得入账 —— 条件更新(status 进 WHERE)的失败分支。

    模拟的是真并发的**必要条件**:输的一方在对手提交之前就已经读到 status='playing',
    之后带着这个旧认知继续往下走(「播完」与「✕ 关闭」抢跑、或超时重试都会造出这一幕)。
    这里靠 Session 的 identity map 固化那次旧读数 —— 后续同一 Session 再查同一行,拿回的
    还是这份旧快照,等价于 PG READ COMMITTED 下两个事务都读到 playing。

    旧实现在这里会照发第二笔:它先读再判再写,而 `except IntegrityError` 兜底根本不可能
    触发 —— 发币走 UPDATE 撞不到 uq_guide_video_play_token,biz_type='guide_video' 的流水
    也不在 ux_coin_transaction_task_ref 的谓词(biz_type LIKE 'task%')覆盖内。
    """
    coin = int(guide_configured["reward_coin"])
    phone = "13920000007"
    token = _login(client, phone)
    uid = _user_id(phone)

    play_token = client.post(
        "/api/v1/guide-video/start", json={"scene": "coupon"}, headers=_auth(token)
    ).json()["play_token"]

    db_loser = SessionLocal()
    try:
        # 输的一方先读到 playing —— 并发下两个请求都会读到它
        stale = db_loser.execute(
            select(GuideVideoPlay).where(GuideVideoPlay.play_token == play_token)
        ).scalar_one()
        assert stale.status == "playing"

        # 对手抢先发币并提交
        with SessionLocal() as db_winner:
            won = crud_guide.grant_play(db_winner, uid, play_token=play_token, completed=True)
        assert won == {"granted": True, "coin": coin, "status": "granted"}

        # 输的一方带着旧认知继续:必须空手而归
        lost = crud_guide.grant_play(db_loser, uid, play_token=play_token, completed=False)
        assert lost == {"granted": False, "coin": coin, "status": "already_granted"}
    finally:
        db_loser.close()

    assert _coin_balance(uid) == coin, "一次播放只能发一次币"
    assert len(_guide_txns(uid)) == 1, "一个 play_token 只能有一条金币流水"
    _assert_ledger_balanced(uid)
