"""测试账号(app/core/test_account.py)行为测试。

覆盖四件事:
  - 免验证码登录(mock 与 real 模式都跳过校验);
  - 每次登录都重走新手引导(onboarding_completed 恒 false,即便完成表已有记录);
  - 每日登录上限(超过即 429),且只算 login、不算 send;
  - 功能关闭(TEST_ACCOUNT_PHONE 留空)时对任何号都不生效,回到原逻辑。
"""
from __future__ import annotations

import pytest

from app.core import test_account
from app.integrations import sms

# 测试自注入的固定号:各用例用 monkeypatch 把它塞进 settings.TEST_ACCOUNT_PHONE,
# 再断言行为。它与 .env 配的值【相互独立】——测试不读 .env(保证确定性 / CI 无 .env 也能跑),
# 写成和 .env 同值只为直观;换任意 11 位号测试照样全过,二者无需保持一致。
TEST_PHONE = "11111111111"


@pytest.fixture()
def enabled(monkeypatch):
    """启用测试账号功能并清空当日计数;monkeypatch 与 _reset 保证用例间隔离。"""
    monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_PHONE", TEST_PHONE)
    test_account._reset_for_test()
    yield
    test_account._reset_for_test()


# ============================ 开关 / 识别 ============================

def test_disabled_when_phone_unset(monkeypatch) -> None:
    """TEST_ACCOUNT_PHONE 留空 = 整功能关闭,对测试号也不特殊处理。"""
    monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_PHONE", "")
    assert test_account.is_enabled() is False
    assert test_account.is_test_account(TEST_PHONE) is False


def test_only_configured_phone_is_test_account(enabled) -> None:
    assert test_account.is_test_account(TEST_PHONE) is True
    assert test_account.is_test_account("13800138000") is False


def test_phone_is_whitespace_trimmed(monkeypatch) -> None:
    """.env 里手机号前后带空格也能正确识别。"""
    monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_PHONE", f"  {TEST_PHONE}  ")
    assert test_account.is_test_account(TEST_PHONE) is True


# ============================ 免验证码 + 新手引导 ============================

def test_login_skips_code_and_returns_tokens(client, enabled) -> None:
    """测试号任意验证码即可登入(不用等真短信),拿到 JWT,onboarding 为 false。"""
    r = client.post(
        "/api/v1/auth/sms/login",
        json={"phone": TEST_PHONE, "code": "000000", "device_id": "dev-a"},
    )
    assert r.status_code == 200, r.text
    data = r.json()
    assert data["user"]["phone"] == TEST_PHONE
    assert data["user"]["register_channel"] == "sms"
    assert data["onboarding_completed"] is False
    assert data["force_onboarding"] is True   # 测试号:应用内重登也强制重走引导
    assert "access_token" in data and "refresh_token" in data


def test_onboarding_always_false_even_if_completed(client, enabled) -> None:
    """即便 onboarding_completion 表已有 (该用户, 该设备) 记录,测试号再登仍返回 false。"""
    from app.db.session import SessionLocal
    from app.repositories import onboarding as onboarding_repo

    device = "dev-onb"
    r = client.post(
        "/api/v1/auth/sms/login",
        json={"phone": TEST_PHONE, "code": "123456", "device_id": device},
    )
    uid = r.json()["user"]["id"]

    db = SessionLocal()
    try:
        onboarding_repo.mark_completed(db, user_id=uid, device_id=device)
        assert onboarding_repo.is_completed(db, user_id=uid, device_id=device) is True
    finally:
        db.close()

    r2 = client.post(
        "/api/v1/auth/sms/login",
        json={"phone": TEST_PHONE, "code": "123456", "device_id": device},
    )
    assert r2.json()["onboarding_completed"] is False  # 仍重走引导


# ============================ real 模式跳过 ============================

def test_login_skips_verify_in_real_mode(client, enabled, monkeypatch) -> None:
    """real 模式(SMS_MOCK=false)普通号要真校验;测试号必须跳过 → 任意码仍 200。"""
    monkeypatch.setattr(sms.settings, "SMS_MOCK", False)
    r = client.post(
        "/api/v1/auth/sms/login",
        json={"phone": TEST_PHONE, "code": "9999"},
    )
    assert r.status_code == 200, r.text


def test_send_short_circuits_in_real_mode(client, enabled, monkeypatch) -> None:
    """real 模式下测试号 /sms/send 不真打极光(否则缺号/缺 key 会 503),直接 200 放行进填码界面。"""
    monkeypatch.setattr(sms.settings, "SMS_MOCK", False)
    r = client.post("/api/v1/auth/sms/send", json={"phone": TEST_PHONE})
    assert r.status_code == 200, r.text
    body = r.json()
    assert body["sent"] is True
    assert body["cooldown_sec"] == 0


# ============================ 每日上限 ============================

def test_daily_limit_blocks_after_threshold(client, enabled, monkeypatch) -> None:
    """当日登录数到上限后再登 → 429。"""
    monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_DAILY_LIMIT", 2)
    test_account._reset_for_test()

    for _ in range(2):
        r = client.post("/api/v1/auth/sms/login", json={"phone": TEST_PHONE, "code": "123456"})
        assert r.status_code == 200, r.text

    r = client.post("/api/v1/auth/sms/login", json={"phone": TEST_PHONE, "code": "123456"})
    assert r.status_code == 429
    assert "上限" in r.json()["detail"]


def test_send_does_not_consume_quota(client, enabled, monkeypatch) -> None:
    """额度只算 login、不算 send:狂发 send 后那唯一一次 login 仍能成功。"""
    monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_DAILY_LIMIT", 1)
    test_account._reset_for_test()

    for _ in range(3):
        assert client.post("/api/v1/auth/sms/send", json={"phone": TEST_PHONE}).status_code == 200

    assert client.post("/api/v1/auth/sms/login", json={"phone": TEST_PHONE, "code": "1234"}).status_code == 200
    # 第二次 login 才超限
    assert client.post("/api/v1/auth/sms/login", json={"phone": TEST_PHONE, "code": "1234"}).status_code == 429


# ============================ 登录限流豁免 ============================

def test_exempt_from_device_ip_login_rate_limit(client, enabled, monkeypatch) -> None:
    """测试号豁免 (设备+IP) 每小时登录限流。

    普通设备同设备同 IP 到 SMS_LOGIN_MAX_PER_HOUR 次即被拦(见 test_auth.test_sms_login_device_ip_rate_limit);
    测试号走自己的每日额度、不受这道限流——这里临时打开 RATE_LIMIT_ENABLED,同一设备连登远超该上限仍全 200。
    回归用:防有人把 enforce_rate_limit 挪到测试账号 early-return 之前而破坏豁免。"""
    from app.api.v1 import auth
    from app.core import ratelimit

    monkeypatch.setattr(ratelimit.settings, "RATE_LIMIT_ENABLED", True)
    ratelimit._buckets.clear()  # 隔离:清掉跨用例累加的内存计数

    # 同一设备连登 (每小时上限 + 3) 次,远超普通设备会被拦的阈值;测试号豁免 → 全部放行
    for i in range(auth.SMS_LOGIN_MAX_PER_HOUR + 3):
        r = client.post(
            "/api/v1/auth/sms/login",
            json={"phone": TEST_PHONE, "code": "000000", "device_id": "dev-test-exempt"},
        )
        assert r.status_code == 200, (
            f"测试号第 {i + 1} 次登录应放行(豁免每小时限流),实际 {r.status_code}: {r.text}"
        )


def test_exempt_from_device_ip_send_rate_limit(client, enabled, monkeypatch) -> None:
    """测试号豁免 (设备+IP) 每小时**发码**限流(与登录那道同样靠早返回豁免)。

    普通设备同设备到 SMS_SEND_MAX_PER_HOUR_PER_DEVICE 次发码即被拦(见 test_auth.test_sms_send_device_ip_rate_limit);
    测试号发码本就短路(不真发)、且在限流之前早返回 → 同一设备连发远超上限仍全 200。
    回归用:防有人把发码限流挪到测试账号 early-return 之前而把 QA 联调也风控了。"""
    from app.api.v1 import auth
    from app.core import ratelimit

    monkeypatch.setattr(ratelimit.settings, "RATE_LIMIT_ENABLED", True)
    monkeypatch.setattr(auth, "SMS_SEND_MAX_PER_HOUR_PER_DEVICE", 2)  # 调小:不豁免的话第 3 次就该被拦
    ratelimit._buckets.clear()

    for i in range(auth.SMS_SEND_MAX_PER_HOUR_PER_DEVICE + 2):
        r = client.post(
            "/api/v1/auth/sms/send",
            json={"phone": TEST_PHONE, "device_id": "dev-test-send"},
        )
        assert r.status_code == 200, (
            f"测试号第 {i + 1} 次发码应放行(豁免每小时限流),实际 {r.status_code}: {r.text}"
        )


def test_send_is_audit_only_and_does_not_trigger_risk_alert(client, enabled) -> None:
    """测试号不真发短信：可留审计流水，但不能算正式短信下发或触发设备风控。"""
    from app.db.session import SessionLocal
    from app.models.risk import BehaviorEvent, RiskIncident
    from app.repositories import risk as risk_repo

    device_id = "qa-test-account-device"
    for _ in range(6):
        response = client.post(
            "/api/v1/auth/sms/send",
            json={"phone": TEST_PHONE, "device_id": device_id},
        )
        assert response.status_code == 200

    with SessionLocal() as db:
        events = (
            db.query(BehaviorEvent)
            .filter(
                BehaviorEvent.event_type == risk_repo.EVENT_SMS_SEND,
                BehaviorEvent.subject_id == device_id,
            )
            .all()
        )
        assert len(events) == 6
        assert {event.outcome for event in events} == {"test"}
        assert (
            db.query(RiskIncident)
            .filter(
                RiskIncident.rule_code == risk_repo.RULE_SMS_HOURLY,
                RiskIncident.subject_id == device_id,
            )
            .count()
            == 0
        )


# ============================ 计数单元逻辑 ============================

def test_quota_resets_across_day(enabled, monkeypatch) -> None:
    """跨天自动归零。"""
    monkeypatch.setattr(test_account.settings, "TEST_ACCOUNT_DAILY_LIMIT", 1)
    monkeypatch.setattr(test_account, "_today", lambda: "2026-06-23")
    assert test_account.try_consume_quota() is True
    assert test_account.try_consume_quota() is False  # 当天超限

    monkeypatch.setattr(test_account, "_today", lambda: "2026-06-24")
    assert test_account.try_consume_quota() is True   # 次日归零放行


def test_non_test_phone_unaffected_when_enabled(client, enabled) -> None:
    """功能开启时,普通号走原 mock 流程(任意 6 位通过),不受测试号逻辑影响。"""
    phone = "13812341234"
    client.post("/api/v1/auth/sms/send", json={"phone": phone})
    r = client.post("/api/v1/auth/sms/login", json={"phone": phone, "code": "123456", "device_id": "d1"})
    assert r.status_code == 200, r.text
    assert r.json()["user"]["phone"] == phone
    assert r.json()["force_onboarding"] is False   # 普通号不强制重走,按原逻辑
