"""15 天不活跃清零:模型 / 活跃口径 / 清零 / 预警 / 配置 / worker。"""
from __future__ import annotations

from datetime import date, datetime, timedelta, timezone

import pytest
from sqlalchemy import delete, select, update

from app.db.session import SessionLocal
from app.models.inactivity import InactivityNotificationLog, InactivityResetLog
from app.repositories import activity


def test_reset_and_notification_models_persist() -> None:
    db = SessionLocal()
    try:
        db.add(InactivityResetLog(
            user_id=1, coin_balance_before=10, cash_balance_cents_before=20,
            invite_cash_balance_cents_before=30,
            last_active_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
            inactive_days=15, reason="inactive_15d",
        ))
        db.add(InactivityNotificationLog(
            user_id=1, stage=7, inactive_days=8, coin_balance=10,
            cash_balance_cents=20, invite_cash_balance_cents=30,
            channel="log", status="placeholder",
        ))
        db.commit()
        r = db.execute(select(InactivityResetLog).where(InactivityResetLog.user_id == 1)).scalar_one()
        assert r.reason == "inactive_15d" and r.reset_at is not None
        n = db.execute(select(InactivityNotificationLog).where(InactivityNotificationLog.user_id == 1)).scalar_one()
        assert n.stage == 7 and n.created_at is not None
    finally:
        db.rollback()
        db.close()


def test_reset_cutoff_is_cn_midnight_of_today_minus_days_minus_1() -> None:
    # RESET_DAYS=15, today=1/20 → cutoff = 北京 00:00 of 1/6 = 1/5 16:00 UTC
    cutoff = activity.reset_cutoff(15, today=date(2026, 1, 20))
    assert cutoff == datetime(2026, 1, 5, 16, 0, tzinfo=timezone.utc)


def test_active_event_constants() -> None:
    # 首页可见:前端埋点确认 event=home_visible,单一 event 名,在 ACTIVE_EVENTS 中
    assert activity.HOME_VISIBLE_EVENT == "home_visible"
    assert activity.HOME_VISIBLE_EVENT in activity.ACTIVE_EVENTS
    assert "real_compare_start" in activity.ACTIVE_EVENTS
    assert "real_coupon_start" in activity.ACTIVE_EVENTS
    assert activity.ACTIVE_ENGAGE_TYPE == "claim_started"


def test_as_utc_normalizes() -> None:
    assert activity.as_utc(datetime(2026, 1, 1)) == datetime(2026, 1, 1, tzinfo=timezone.utc)
    cn = datetime(2026, 1, 1, tzinfo=activity.CN_TZ)  # 北京 0 点 = 前一天 16:00 UTC
    assert activity.as_utc(cn) == datetime(2025, 12, 31, 16, 0, tzinfo=timezone.utc)


from app.core.rewards import CN_TZ
from app.models.analytics_event import AnalyticsEvent
from app.models.coupon_state import CouponPromptEngagement
from app.models.user import User
from app.models.wallet import CoinAccount
from app.repositories import wallet as wallet_repo

_PHONE_SEQ = [0]


@pytest.fixture(autouse=True)
def _isolate_inactivity_state():
    """本文件的测试都做全表扫描 + 全局计数,而 SQLite 测试库 session 级共享、无逐用例回滚
    (commit 后的 rollback 是 no-op),故先把可能泄漏的余额清零 + 清掉活跃事件/审计行,
    保证每个用例干净起步。不删 User(零余额用户不会被扫描选中,避免跨文件/外键影响)。"""
    db = SessionLocal()
    try:
        db.execute(update(CoinAccount).values(
            coin_balance=0, cash_balance_cents=0, invite_cash_balance_cents=0))
        for model in (AnalyticsEvent, CouponPromptEngagement,
                      InactivityResetLog, InactivityNotificationLog):
            db.execute(delete(model))
        db.commit()
    finally:
        db.close()
    yield


def _new_user(db, *, created_at, coin=0, cash=0, invite=0) -> int:
    """直接建一个 User + CoinAccount,created_at 可控。返回 user_id。"""
    _PHONE_SEQ[0] += 1
    # 199 前缀 + 递增序号:共享测试库跨文件累积用户,别的文件用固定手机号(如 test_admin_write
    # 的 13900000001..),这里用没人用的 199 段避免撞 user.phone / username 的 UNIQUE。
    u = User(phone=f"199{_PHONE_SEQ[0]:08d}", created_at=created_at,
             last_login_at=created_at, status="active",
             username=f"inact{_PHONE_SEQ[0]}")
    db.add(u)
    db.flush()
    acc = wallet_repo.get_or_create_account(db, u.id, commit=False)
    acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents = coin, cash, invite
    acc.total_coin_earned = coin
    db.flush()
    return u.id


def _add_event(db, user_id, event, when: datetime, page=None) -> None:
    db.add(AnalyticsEvent(event=event, device_id="d", user_id=user_id, client_ts=0,
                          created_at=when, page=page))


def _add_engage(db, user_id, when: datetime, engage_type="claim_started") -> None:
    db.add(CouponPromptEngagement(device_id=f"dev{user_id}", package="p", user_id=user_id,
                                  engage_date=when.date(), engage_type=engage_type, created_at=when))


def test_last_active_expr_takes_max_of_baseline_and_events() -> None:
    from sqlalchemy import select
    db = SessionLocal()
    try:
        base = datetime(2026, 1, 1, tzinfo=timezone.utc)
        uid = _new_user(db, created_at=base, coin=5)
        _add_event(db, uid, "real_compare_start", datetime(2026, 1, 10, tzinfo=timezone.utc))
        db.commit()
        ev_sub, eng_sub = activity.last_active_subqueries(db)
        dialect = db.get_bind().dialect.name
        expr = activity.last_active_expr(User.created_at, ev_sub, eng_sub, dialect)
        stmt = (select(expr).select_from(User)
                .outerjoin(ev_sub, ev_sub.c.user_id == User.id)
                .outerjoin(eng_sub, eng_sub.c.user_id == User.id)
                .where(User.id == uid))
        got = activity.norm_utc(db.execute(stmt).scalar_one())
        assert got == datetime(2026, 1, 10, tzinfo=timezone.utc)  # 事件 > 基线
    finally:
        db.rollback()
        db.close()


def test_home_signal_uses_home_visible_event() -> None:
    """首页可见活跃口径 = event=home_visible(单一事件名,前端埋点已确认);其他事件不算活跃。"""
    from sqlalchemy import select
    db = SessionLocal()
    try:
        base = datetime(2026, 1, 1, tzinfo=timezone.utc)
        seen = _new_user(db, created_at=base)      # home_visible → 活跃
        _add_event(db, seen, "home_visible", datetime(2026, 1, 10, tzinfo=timezone.utc))
        other = _new_user(db, created_at=base)     # 其他事件 → 不算活跃
        _add_event(db, other, "show", datetime(2026, 1, 10, tzinfo=timezone.utc), page="coupon")
        db.commit()

        ev_sub, eng_sub = activity.last_active_subqueries(db)
        dialect = db.get_bind().dialect.name
        expr = activity.last_active_expr(User.created_at, ev_sub, eng_sub, dialect)

        def last_active(uid):
            stmt = (select(expr).select_from(User)
                    .outerjoin(ev_sub, ev_sub.c.user_id == User.id)
                    .outerjoin(eng_sub, eng_sub.c.user_id == User.id)
                    .where(User.id == uid))
            return activity.norm_utc(db.execute(stmt).scalar_one())

        assert last_active(seen) == datetime(2026, 1, 10, tzinfo=timezone.utc)  # home_visible 算
        assert last_active(other) == base                                       # 其他事件不算
    finally:
        db.rollback()
        db.close()


def test_inactivity_warn_stages_parsing() -> None:
    from app.core.config import Settings
    s = Settings(INACTIVITY_WARN_DAYS_BEFORE="7,2", INACTIVITY_RESET_DAYS=15)
    assert s.inactivity_warn_stages == [7, 2]        # 降序去重
    s2 = Settings(INACTIVITY_WARN_DAYS_BEFORE="", INACTIVITY_RESET_DAYS=15)
    assert s2.inactivity_warn_stages == []           # 空=不推
    s3 = Settings(INACTIVITY_WARN_DAYS_BEFORE="2,20,7,2", INACTIVITY_RESET_DAYS=15)
    assert s3.inactivity_warn_stages == [7, 2]        # 去重 + 丢弃 >=RESET_DAYS(20)


def test_log_notifier_returns_placeholder(caplog) -> None:
    from app.integrations.notifier import LogNotifier, get_notifier
    n = get_notifier("log")
    assert isinstance(n, LogNotifier) and n.channel == "log"
    status = n.warn(user_id=1, coin=10, cash_cents=20, stage=7, days_until_reset=8)
    assert status == "placeholder"
    # 未实现通道回退 LogNotifier(占位)
    assert get_notifier("jpush").channel == "log"


def test_run_reset_clears_coin_and_cash_but_preserves_invite_cash() -> None:
    from sqlalchemy import select
    from app.models.wallet import CoinAccount, CoinTransaction, CashTransaction, InviteCashTransaction
    from app.repositories import inactivity

    db = SessionLocal()
    try:
        today = date(2026, 2, 1)
        # 末次活跃 = created_at 基线 = 1/10(距 today 22 天 → 应清)
        old = _new_user(db, created_at=datetime(2026, 1, 10, tzinfo=timezone.utc),
                        coin=100, cash=200, invite=300)
        # 活跃用户:昨天有 home_visible → 不清
        fresh = _new_user(db, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), coin=50)
        _add_event(db, fresh, "home_visible", datetime(2026, 1, 31, tzinfo=timezone.utc))
        db.commit()

        stats = inactivity.run_reset_once(db, reset_days=15, today=today)
        assert stats["cleared"] == 1 and stats["failed"] == 0

        acc = db.get(CoinAccount, old)
        # 金币 + 折算现金清零;邀请现金是产品红线,原封不动(见 wallet.CoinAccount 注释)
        assert (acc.coin_balance, acc.cash_balance_cents) == (0, 0)
        assert acc.invite_cash_balance_cents == 300
        assert acc.total_coin_earned == 100  # 历史累计不动

        log = db.execute(select(InactivityResetLog).where(InactivityResetLog.user_id == old)).scalar_one()
        # 审计仍快照三桶余额(邀请现金记为"清零时仍保留"的余额,便于纠纷排查)
        assert (log.coin_balance_before, log.cash_balance_cents_before,
                log.invite_cash_balance_cents_before) == (100, 200, 300)
        assert log.inactive_days == 22 and log.reason == "inactive_15d"

        ct = db.execute(select(CoinTransaction).where(
            CoinTransaction.user_id == old, CoinTransaction.biz_type == "inactivity_reset")).scalar_one()
        assert ct.amount == -100 and ct.balance_after == 0 and ct.ref_id == str(log.id)
        assert db.execute(select(CashTransaction).where(
            CashTransaction.user_id == old, CashTransaction.biz_type == "inactivity_reset")).scalar_one().amount_cents == -200
        # 关键:不写邀请现金流水(邀请现金不清)
        assert db.execute(select(InviteCashTransaction).where(
            InviteCashTransaction.user_id == old,
            InviteCashTransaction.biz_type == "inactivity_reset")).first() is None

        # 活跃用户不动;再跑一次幂等(coin+cash 已 0、邀请现金不算候选 → 不再匹配)
        assert db.get(CoinAccount, fresh).coin_balance == 50
        assert inactivity.run_reset_once(db, reset_days=15, today=today)["cleared"] == 0
    finally:
        db.rollback()
        db.close()


def test_user_with_only_invite_cash_is_not_cleared() -> None:
    """只有邀请现金余额的久不活跃用户:邀请现金是产品红线,不清 → 根本不该被选中。"""
    from app.models.wallet import CoinAccount
    from app.repositories import inactivity

    db = SessionLocal()
    try:
        today = date(2026, 2, 1)
        uid = _new_user(db, created_at=datetime(2026, 1, 10, tzinfo=timezone.utc),
                        coin=0, cash=0, invite=500)
        db.commit()
        stats = inactivity.run_reset_once(db, reset_days=15, today=today)
        assert stats["cleared"] == 0
        assert db.get(CoinAccount, uid).invite_cash_balance_cents == 500  # 原封不动
    finally:
        db.rollback()
        db.close()


def test_run_warn_picks_stage_and_dedups_within_streak() -> None:
    from app.integrations.notifier import LogNotifier
    from app.repositories import inactivity

    db = SessionLocal()
    try:
        today = date(2026, 2, 1)
        # 末次活跃 1/22(距 today 10 天)→ 档 7 命中(idays>=8),档 2 未到(需>=13)
        uid = _new_user(db, created_at=datetime(2026, 1, 22, tzinfo=timezone.utc), coin=100)
        db.commit()

        stats = inactivity.run_warn_once(db, LogNotifier(), reset_days=15, warn_stages=[7, 2], today=today)
        assert stats["warned"] == 1
        from sqlalchemy import select
        rows = db.execute(select(InactivityNotificationLog).where(
            InactivityNotificationLog.user_id == uid)).scalars().all()
        assert len(rows) == 1 and rows[0].stage == 7 and rows[0].status == "placeholder"
        assert rows[0].inactive_days == 10 and rows[0].coin_balance == 100

        # 同一 streak 再跑 → 不重推
        assert inactivity.run_warn_once(db, LogNotifier(), reset_days=15, warn_stages=[7, 2], today=today)["warned"] == 0

        # 无余额用户不预警
        _new_user(db, created_at=datetime(2026, 1, 22, tzinfo=timezone.utc), coin=0)
        db.commit()
        assert inactivity.run_warn_once(db, LogNotifier(), reset_days=15, warn_stages=[7, 2], today=today)["warned"] == 0
    finally:
        db.rollback()
        db.close()


def test_run_once_warns_then_resets() -> None:
    from app.integrations.notifier import LogNotifier
    from app.models.wallet import CoinAccount
    from app.repositories import inactivity

    db = SessionLocal()
    try:
        today = date(2026, 2, 1)
        warn_uid = _new_user(db, created_at=datetime(2026, 1, 22, tzinfo=timezone.utc), coin=10)   # 10天→预警
        clear_uid = _new_user(db, created_at=datetime(2026, 1, 5, tzinfo=timezone.utc), coin=10)   # 27天→清零
        db.commit()
        stats = inactivity.run_once(db, notifier=LogNotifier(), reset_days=15, warn_stages=[7, 2], today=today)
        assert stats["warned"] == 1 and stats["cleared"] == 1
        assert db.get(CoinAccount, clear_uid).coin_balance == 0
        assert db.get(CoinAccount, warn_uid).coin_balance == 10   # 预警不动钱
    finally:
        db.rollback()
        db.close()


def test_worker_run_once_entry_dry_run(monkeypatch) -> None:
    """ENABLED=false(默认语义)→ worker 常驻但只记审计不清(dry_run = not ENABLED)。"""
    from sqlalchemy import select

    from app.core import inactivity_reset_worker as w
    from app.core.config import settings
    from app.models.wallet import CoinAccount

    monkeypatch.setattr(settings, "INACTIVITY_RESET_ENABLED", False)  # false = 只记审计
    monkeypatch.setattr(settings, "INACTIVITY_RESET_DAYS", 15)
    monkeypatch.setattr(settings, "INACTIVITY_WARN_DAYS_BEFORE", "")
    monkeypatch.setattr(w, "_cn_today", lambda: date(2026, 2, 1))

    db = SessionLocal()
    try:
        uid = _new_user(db, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), coin=100)
        db.commit()
    finally:
        db.close()

    w._run_once_entry()

    db = SessionLocal()
    try:
        assert db.get(CoinAccount, uid).coin_balance == 100  # 没清
        log = db.execute(select(InactivityResetLog).where(InactivityResetLog.user_id == uid)).scalar_one()
        assert log.reason.endswith("dryrun")  # 记了审计
    finally:
        db.close()


def test_worker_run_once_entry_executes(monkeypatch) -> None:
    """_run_once_entry 用真实 SessionLocal 跑一轮,总闸开时能清掉一个不活跃用户。"""
    from app.core import inactivity_reset_worker as w
    from app.core.config import settings
    from app.models.wallet import CoinAccount

    monkeypatch.setattr(settings, "INACTIVITY_RESET_ENABLED", True)
    monkeypatch.setattr(settings, "INACTIVITY_RESET_DAYS", 15)
    monkeypatch.setattr(settings, "INACTIVITY_WARN_DAYS_BEFORE", "")  # 只测清零
    # 固定"今天"避免依赖真实时钟
    monkeypatch.setattr(w, "_cn_today", lambda: date(2026, 2, 1))

    db = SessionLocal()
    try:
        uid = _new_user(db, created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), coin=100)
        db.commit()
    finally:
        db.close()

    stats = w._run_once_entry()
    assert stats["cleared"] >= 1

    db = SessionLocal()
    try:
        assert db.get(CoinAccount, uid).coin_balance == 0
    finally:
        db.close()


def test_run_once_dry_run_records_audit_but_does_not_clear() -> None:
    """dry-run:只写审计(标 dryrun)、不动钱、不预警;重复跑不重复记(streak dedup)。"""
    from sqlalchemy import select

    from app.integrations.notifier import LogNotifier
    from app.models.wallet import CoinAccount, CoinTransaction
    from app.repositories import inactivity

    db = SessionLocal()
    try:
        today = date(2026, 2, 1)
        old = _new_user(db, created_at=datetime(2026, 1, 10, tzinfo=timezone.utc), coin=100, cash=200, invite=300)
        warn_uid = _new_user(db, created_at=datetime(2026, 1, 22, tzinfo=timezone.utc), coin=50)  # 预警窗
        db.commit()

        stats = inactivity.run_once(db, notifier=LogNotifier(), reset_days=15,
                                    warn_stages=[7, 2], today=today, dry_run=True)
        acc = db.get(CoinAccount, old)
        assert (acc.coin_balance, acc.cash_balance_cents, acc.invite_cash_balance_cents) == (100, 200, 300)  # 原封
        log = db.execute(select(InactivityResetLog).where(InactivityResetLog.user_id == old)).scalar_one()
        assert log.coin_balance_before == 100 and log.reason.endswith("dryrun")   # 审计标 dryrun
        assert db.execute(select(CoinTransaction).where(
            CoinTransaction.user_id == old, CoinTransaction.biz_type == "inactivity_reset")).first() is None  # 无流水
        assert stats["warned"] == 0                                              # dry-run 不预警
        assert db.execute(select(InactivityNotificationLog).where(
            InactivityNotificationLog.user_id == warn_uid)).first() is None
        assert stats["cleared"] == 1                                             # dry-run:cleared=记了几条

        # 再跑一次 → 不重复记(dedup),余额仍原封
        inactivity.run_once(db, notifier=LogNotifier(), reset_days=15, warn_stages=[7, 2], today=today, dry_run=True)
        assert len(db.execute(select(InactivityResetLog).where(
            InactivityResetLog.user_id == old)).scalars().all()) == 1
        assert db.get(CoinAccount, old).coin_balance == 100
    finally:
        db.rollback()
        db.close()


def test_admin_list_users_last_active_ignores_login() -> None:
    """admin 用户列表 last_active_at 改用共享口径:登录不算活跃(baseline=created_at)、只认活跃事件。"""
    from app.admin.repositories import queries

    db = SessionLocal()
    try:
        created = datetime(2026, 1, 1, tzinfo=timezone.utc)
        uid = _new_user(db, created_at=created)
        u = db.get(User, uid)
        u.last_login_at = datetime(2026, 6, 1, tzinfo=timezone.utc)  # 登录很新、但无任何活跃事件
        db.commit()
        phone = db.get(User, uid).phone
        users, _cursor, _total = queries.list_users(db, phone=phone)
        item = next(x for x in users if x.id == uid)
        assert activity.norm_utc(item.last_active_at) == created  # 登录不算 → last_active=created_at
    finally:
        db.rollback()
        db.close()


def test_run_warn_isolates_notifier_failure_and_does_not_block_reset() -> None:
    """单用户通知器抛错:预警计 warn_failed、不外抛,且清零(reset)照常执行。"""
    from app.models.wallet import CoinAccount
    from app.repositories import inactivity

    class BoomNotifier:
        channel = "log"

        def warn(self, *, user_id, coin, cash_cents, stage, days_until_reset) -> str:
            raise RuntimeError("push service down")

    db = SessionLocal()
    try:
        today = date(2026, 2, 1)
        warn_uid = _new_user(db, created_at=datetime(2026, 1, 22, tzinfo=timezone.utc), coin=10)   # 10天→预警
        clear_uid = _new_user(db, created_at=datetime(2026, 1, 5, tzinfo=timezone.utc), coin=10)   # 27天→清零
        db.commit()

        stats = inactivity.run_once(db, notifier=BoomNotifier(), reset_days=15,
                                    warn_stages=[7, 2], today=today)
        assert stats["warned"] == 0 and stats["warn_failed"] >= 1   # 预警失败被隔离
        assert stats["cleared"] == 1                                # 关键:清零没被阻塞
        assert db.get(CoinAccount, clear_uid).coin_balance == 0
        assert db.get(CoinAccount, warn_uid).coin_balance == 10     # 预警用户不动钱
        # 预警失败已回滚,不留半条 notification_log
        from sqlalchemy import select
        assert db.execute(select(InactivityNotificationLog).where(
            InactivityNotificationLog.user_id == warn_uid)).first() is None
    finally:
        db.rollback()
        db.close()


def test_run_once_reset_runs_even_if_warn_phase_throws(monkeypatch) -> None:
    """预警整段异常(如候选查询失败)也绝不阻塞清零。"""
    from app.integrations.notifier import LogNotifier
    from app.models.wallet import CoinAccount
    from app.repositories import inactivity

    def boom(*a, **k):
        raise RuntimeError("warn phase blew up")

    monkeypatch.setattr(inactivity, "run_warn_once", boom)

    db = SessionLocal()
    try:
        today = date(2026, 2, 1)
        clear_uid = _new_user(db, created_at=datetime(2026, 1, 5, tzinfo=timezone.utc), coin=10)
        db.commit()
        stats = inactivity.run_once(db, notifier=LogNotifier(), reset_days=15,
                                    warn_stages=[7, 2], today=today)
        assert stats["cleared"] == 1
        assert db.get(CoinAccount, clear_uid).coin_balance == 0
    finally:
        db.rollback()
        db.close()
