"""风控监控：三类规则、明细及处置闭环。"""
from __future__ import annotations

from datetime import datetime, timedelta
from uuid import uuid4

import pytest
from fastapi.testclient import TestClient

from app.admin.main import admin_app
from app.admin.repositories import admin_user as admin_repo
from app.api.v1 import compare as compare_api
from app.core.config_schema import (
    RISK_COMPARE_DAILY_THRESHOLD_KEY,
    RISK_ONECLICK_DAILY_THRESHOLD_KEY,
    RISK_SMS_HOURLY_THRESHOLD_KEY,
)
from app.core.security import issue_token_pair
from app.db.session import SessionLocal
from app.models.app_config import AppConfig
from app.models.comparison import ComparisonRecord
from app.models.device import DeviceLiveness
from app.models.risk import RiskIncident
from app.repositories import risk as risk_repo
from app.repositories import user as user_repo


@pytest.fixture(autouse=True)
def _reset_risk_rule_config():
    keys = (
        RISK_SMS_HOURLY_THRESHOLD_KEY,
        RISK_ONECLICK_DAILY_THRESHOLD_KEY,
        RISK_COMPARE_DAILY_THRESHOLD_KEY,
        risk_repo.RISK_RESET_BASELINES_KEY,
    )
    with SessionLocal() as db:
        db.query(AppConfig).filter(AppConfig.key.in_(keys)).delete(
            synchronize_session=False
        )
        db.commit()
    yield
    with SessionLocal() as db:
        db.query(AppConfig).filter(AppConfig.key.in_(keys)).delete(
            synchronize_session=False
        )
        db.commit()


def _admin_token() -> str:
    username = "risk_monitor_admin"
    with SessionLocal() as db:
        if admin_repo.get_by_username(db, username) is None:
            admin_repo.create_admin(
                db, username=username, password="risk-pass-123", role="super_admin"
            )
    with TestClient(admin_app) as client:
        response = client.post(
            "/admin/api/auth/login",
            json={"username": username, "password": "risk-pass-123"},
        )
    assert response.status_code == 200
    return response.json()["access_token"]


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


def _seed_incidents() -> tuple[int, int, int, int, str]:
    suffix = uuid4().hex[:8]
    sms_subject = f"risk_sms_{suffix}"
    oneclick_subject = f"risk_oneclick_{suffix}"
    now = (risk_repo.utcnow() - timedelta(minutes=5)).replace(microsecond=0)
    with SessionLocal() as db:
        user = user_repo.upsert_user_for_login(
            db,
            phone=f"139{int(suffix, 16) % 100_000_000:08d}",
            register_channel="sms",
        )
        second_user = user_repo.upsert_user_for_login(
            db,
            phone=f"137{int(suffix, 16) % 100_000_000:08d}",
            register_channel="sms",
        )
        db.add(
            DeviceLiveness(
                user_id=user.id,
                device_id=sms_subject,
                created_at=now - timedelta(days=10),
            )
        )
        for index in range(5):
            risk_repo.record_behavior_event(
                db,
                event_type=risk_repo.EVENT_SMS_SEND,
                subject_type="device",
                subject_id=sms_subject,
                device_id=sms_subject,
                device_model="OPPO A5",
                phone=f"13877771{index:03d}",
                outcome="success",
                occurred_at=now + timedelta(seconds=index),
                evaluate_rule=risk_repo.RULE_SMS_HOURLY,
            )
        risk_repo.record_behavior_event(
            db,
            event_type=risk_repo.EVENT_SMS_LOGIN,
            subject_type="device",
            subject_id=sms_subject,
            user_id=user.id,
            device_id=sms_subject,
            device_model="OPPO A5",
            phone="13877771000",
            outcome="success",
            occurred_at=now + timedelta(seconds=30),
        )
        risk_repo.record_behavior_event(
            db,
            event_type=risk_repo.EVENT_SMS_LOGIN,
            subject_type="device",
            subject_id=sms_subject,
            user_id=second_user.id,
            device_id=sms_subject,
            device_model="OPPO A5",
            phone="13877771001",
            outcome="success",
            occurred_at=now + timedelta(seconds=31),
        )

        for index in range(21):
            risk_repo.record_behavior_event(
                db,
                event_type=risk_repo.EVENT_ONECLICK_LOGIN,
                subject_type="device",
                subject_id=oneclick_subject,
                user_id=second_user.id if index == 20 else user.id,
                device_id=oneclick_subject,
                device_model="OPPO Reno",
                phone=user.phone,
                outcome="success" if index % 3 else "failed",
                reason="mock provider failure" if index % 3 == 0 else None,
                occurred_at=now + timedelta(seconds=index),
                evaluate_rule=risk_repo.RULE_ONECLICK_DAILY,
            )

        local_now = now.astimezone(risk_repo.CN_TZ).replace(tzinfo=None)
        for index in range(100):
            db.add(
                ComparisonRecord(
                    user_id=user.id,
                    device_id="risk_compare_device",
                    trace_id=f"risk-monitor-{suffix}-{index}",
                    store_name="测试门店",
                    product_names="测试菜品",
                    status="success",
                    items=[{"name": "测试菜品", "qty": 1}],
                    comparison_results=[
                        {"platform_id": "meituan", "platform_name": "美团", "price": 12.5},
                        {"platform_id": "taobao", "platform_name": "淘宝", "price": 11.8},
                    ],
                    skipped_dish_names=[],
                    saved_amount_cents=70,
                    created_at=local_now + timedelta(seconds=index),
                )
            )
        db.commit()
        risk_repo.sync_compare_incident(db, user_id=user.id, at=local_now)

        incidents = {
            incident.rule_code: incident.id
            for incident in db.query(RiskIncident)
            .filter(
                RiskIncident.subject_id.in_(
                    (sms_subject, oneclick_subject, str(user.id))
                )
            )
            .all()
        }
        return (
            incidents[risk_repo.RULE_SMS_HOURLY],
            incidents[risk_repo.RULE_ONECLICK_DAILY],
            incidents[risk_repo.RULE_COMPARE_DAILY],
            user.id,
            sms_subject,
        )


def test_risk_monitor_summary_lists_and_details() -> None:
    sms_id, oneclick_id, compare_id, _, _ = _seed_incidents()
    headers = _headers()
    with TestClient(admin_app) as client:
        summary = client.get("/admin/api/risk-monitor/summary", headers=headers)
        assert summary.status_code == 200
        cards = {card["kind"]: card for card in summary.json()["cards"]}
        assert cards["sms"]["alert_subject_count"] >= 1
        assert cards["sms"]["today_total"] >= 5
        assert cards["oneclick"]["alert_subject_count"] >= 1
        assert cards["oneclick"]["today_total"] >= 20
        assert cards["compare"]["alert_subject_count"] >= 1
        assert cards["compare"]["today_total"] >= 100

        sms_list = client.get("/admin/api/risk-monitor/incidents/sms", headers=headers)
        assert sms_list.status_code == 200
        sms_item = next(
            item for item in sms_list.json()["items"] if item["incident_id"] == sms_id
        )
        assert sms_item["device_model"] == "OPPO A5"
        assert sms_item["event_count"] == 5
        assert sms_item["first_used_at"].startswith(
            (risk_repo.utcnow() - timedelta(days=10)).date().isoformat()
        )

        sms_detail = client.get(
            f"/admin/api/risk-monitor/incidents/sms/{sms_id}/details",
            headers=headers,
        )
        assert sms_detail.status_code == 200
        assert sms_detail.json()["total"] == 5
        assert sms_detail.json()["distinct_accounts"] == 2
        assert any(item["verified"] for item in sms_detail.json()["items"])

        oneclick_detail = client.get(
            f"/admin/api/risk-monitor/incidents/oneclick/{oneclick_id}/details?limit=1",
            headers=headers,
        )
        assert oneclick_detail.status_code == 200
        assert oneclick_detail.json()["total"] == 21
        assert oneclick_detail.json()["distinct_accounts"] == 2

        compare_detail = client.get(
            f"/admin/api/risk-monitor/incidents/compare/{compare_id}/details",
            headers=headers,
        )
        assert compare_detail.status_code == 200
        assert compare_detail.json()["total"] == 100
        assert compare_detail.json()["items"][0]["prices"]["美团"] == 12.5


def test_risk_monitor_ignore_and_block_are_enforced(client: TestClient) -> None:
    sms_id, oneclick_id, compare_id, user_id, sms_subject = _seed_incidents()
    headers = _headers()
    with TestClient(admin_app) as admin_client:
        ignored = admin_client.post(
            f"/admin/api/risk-monitor/incidents/{oneclick_id}/ignore",
            json={"reason": "测试确认正常"},
            headers=headers,
        )
        assert ignored.status_code == 200
        assert ignored.json()["status"] == "ignored"

        blocked_device = admin_client.post(
            f"/admin/api/risk-monitor/incidents/{sms_id}/block",
            json={"reason": "测试设备异常"},
            headers=headers,
        )
        assert blocked_device.status_code == 200
        assert blocked_device.json()["restriction_id"]

        blocked_user = admin_client.post(
            f"/admin/api/risk-monitor/incidents/{compare_id}/block",
            json={"reason": "测试账号异常"},
            headers=headers,
        )
        assert blocked_user.status_code == 200
        device_restriction_id = blocked_device.json()["restriction_id"]
        user_restriction_id = blocked_user.json()["restriction_id"]

        blocked_sms = admin_client.get(
            "/admin/api/risk-monitor/incidents/sms?status=blocked",
            headers=headers,
        )
        assert blocked_sms.status_code == 200
        assert blocked_sms.json()["items"][0]["restriction_id"] == device_restriction_id
        assert blocked_sms.json()["items"][0]["restricted"] is True

        blocked_compare = admin_client.get(
            "/admin/api/risk-monitor/incidents/compare?status=blocked",
            headers=headers,
        )
        assert blocked_compare.status_code == 200
        assert blocked_compare.json()["items"][0]["restriction_id"] == user_restriction_id
        assert blocked_compare.json()["items"][0]["restricted"] is True

    denied_sms = client.post(
        "/api/v1/auth/sms/send",
        json={
            "phone": "13877779999",
            "device_id": sms_subject,
            "device_model": "OPPO A5",
        },
    )
    assert denied_sms.status_code == 403
    assert denied_sms.json()["detail"] == "当前设备环境异常，暂无法发送验证码"

    tokens = issue_token_pair(user_id)
    denied_compare = client.post(
        "/api/v1/compare/start",
        json={"trace_id": "risk-blocked-new-trace", "device_id": "risk_compare_device"},
        headers={"Authorization": f"Bearer {tokens['access_token']}"},
    )
    assert denied_compare.status_code == 403
    assert denied_compare.json()["detail"] == "账号存在异常，该功能暂不可用"

    denied_compare_step = client.post(
        "/api/v1/intent/recognize",
        json={},
        headers={"Authorization": f"Bearer {tokens['access_token']}"},
    )
    assert denied_compare_step.status_code == 403
    assert denied_compare_step.json()["detail"] == "账号存在异常，该功能暂不可用"

    with TestClient(admin_app) as admin_client:
        assert (
            admin_client.post(
                f"/admin/api/risk-monitor/restrictions/{device_restriction_id}/revoke",
                headers=headers,
            ).status_code
            == 200
        )
        assert (
            admin_client.post(
                f"/admin/api/risk-monitor/restrictions/{user_restriction_id}/revoke",
                headers=headers,
            ).status_code
            == 200
        )

    allowed_sms = client.post(
        "/api/v1/auth/sms/send",
        json={
            "phone": "13877779998",
            "device_id": sms_subject,
            "device_model": "OPPO A5",
        },
    )
    assert allowed_sms.status_code == 200

    with SessionLocal() as db:
        assert not risk_repo.is_restricted(
            db,
            subject_type="user",
            subject_id=str(user_id),
            scope=risk_repo.SCOPE_ECONOMIC_ACCOUNT,
        )
        assert db.get(RiskIncident, compare_id).status == "resolved"


def test_admin_can_reset_all_alerts_and_sms_restarts_from_zero() -> None:
    sms_id, oneclick_id, compare_id, _, sms_subject = _seed_incidents()
    headers = _headers()

    with TestClient(admin_app) as client:
        reset = client.post("/admin/api/risk-monitor/reset", headers=headers)
        assert reset.status_code == 200
        payload = reset.json()
        assert payload["reset_incident_count"] >= 3
        assert payload["reset_counts"]["sms"] >= 1
        assert payload["reset_counts"]["oneclick"] >= 1
        assert payload["reset_counts"]["compare"] >= 1

        summary = client.get("/admin/api/risk-monitor/summary", headers=headers)
        assert summary.status_code == 200
        cards = {card["kind"]: card for card in summary.json()["cards"]}
        assert all(card["alert_subject_count"] == 0 for card in cards.values())
        # 重置只清待处理报警，不删除今日事实总量。
        assert cards["sms"]["today_total"] >= 5
        assert cards["oneclick"]["today_total"] >= 20
        assert cards["compare"]["today_total"] >= 100

    reset_at = datetime.fromisoformat(payload["reset_at"])
    with SessionLocal() as db:
        for incident_id in (sms_id, oneclick_id, compare_id):
            incident = db.get(RiskIncident, incident_id)
            assert incident.status == "resolved"
            assert incident.action_reason == risk_repo.MANUAL_RESET_REASON

        for index in range(4):
            risk_repo.record_behavior_event(
                db,
                event_type=risk_repo.EVENT_SMS_SEND,
                subject_type="device",
                subject_id=sms_subject,
                device_id=sms_subject,
                phone=f"13688880{index:03d}",
                outcome="success",
                occurred_at=reset_at + timedelta(seconds=index + 1),
                evaluate_rule=risk_repo.RULE_SMS_HOURLY,
            )
        assert db.get(RiskIncident, sms_id).status == "resolved"

        risk_repo.record_behavior_event(
            db,
            event_type=risk_repo.EVENT_SMS_SEND,
            subject_type="device",
            subject_id=sms_subject,
            device_id=sms_subject,
            phone="13688880999",
            outcome="success",
            occurred_at=reset_at + timedelta(seconds=5),
            evaluate_rule=risk_repo.RULE_SMS_HOURLY,
        )
        incident = db.get(RiskIncident, sms_id)
        assert incident.status == "open"
        assert incident.event_count == 5
        assert incident.window_start == reset_at.replace(tzinfo=None)


def test_real_compare_harvest_creates_incident_at_threshold() -> None:
    suffix = uuid4().hex[:8]
    local_now = risk_repo.utcnow().astimezone(risk_repo.CN_TZ).replace(tzinfo=None)
    with SessionLocal() as db:
        user = user_repo.upsert_user_for_login(
            db,
            phone=f"136{int(suffix, 16) % 100_000_000:08d}",
            register_channel="sms",
        )
        for index in range(99):
            db.add(
                ComparisonRecord(
                    user_id=user.id,
                    device_id="real-harvest-device",
                    trace_id=f"risk-real-harvest-{suffix}-{index}",
                    status="running",
                    created_at=local_now + timedelta(milliseconds=index),
                )
            )
        db.commit()
        user_id = user.id

    compare_api._harvest_running_blocking(
        f"risk-real-harvest-{suffix}-99",
        user_id,
        "food",
        "real-harvest-device",
        {"model": "OPPO A5"},
        None,
    )

    with SessionLocal() as db:
        incident = db.query(RiskIncident).filter(
            RiskIncident.rule_code == risk_repo.RULE_COMPARE_DAILY,
            RiskIncident.subject_id == str(user_id),
            RiskIncident.window_key == local_now.strftime("%Y-%m-%d"),
        ).one()
        assert incident.event_count == 100


def test_admin_can_edit_rules_and_current_window_is_reconciled() -> None:
    suffix = uuid4().hex[:8]
    subject = f"risk_dynamic_sms_{suffix}"
    now = risk_repo.utcnow().replace(microsecond=0)
    with SessionLocal() as db:
        for index in range(3):
            risk_repo.record_behavior_event(
                db,
                event_type=risk_repo.EVENT_SMS_SEND,
                subject_type="device",
                subject_id=subject,
                device_id=subject,
                device_model="动态规则测试机",
                phone=f"13800001{index:03d}",
                outcome="success",
                occurred_at=now + timedelta(seconds=index),
            )

    headers = _headers()
    with TestClient(admin_app) as client:
        defaults = client.get("/admin/api/risk-monitor/rules", headers=headers)
        assert defaults.status_code == 200
        assert defaults.json() == {
            "sms_hourly_threshold": 5,
            "oneclick_daily_threshold": 20,
            "compare_daily_threshold": 100,
        }

        lowered = client.patch(
            "/admin/api/risk-monitor/rules",
            headers=headers,
            json={
                "sms_hourly_threshold": 3,
                "oneclick_daily_threshold": 20,
                "compare_daily_threshold": 100,
            },
        )
        assert lowered.status_code == 200
        with SessionLocal() as db:
            incident = db.query(RiskIncident).filter(
                RiskIncident.rule_code == risk_repo.RULE_SMS_HOURLY,
                RiskIncident.subject_id == subject,
            ).one()
            assert incident.status == "open"
            assert incident.event_count == 3

        raised = client.patch(
            "/admin/api/risk-monitor/rules",
            headers=headers,
            json={
                "sms_hourly_threshold": 4,
                "oneclick_daily_threshold": 20,
                "compare_daily_threshold": 100,
            },
        )
        assert raised.status_code == 200
        with SessionLocal() as db:
            incident = db.query(RiskIncident).filter(
                RiskIncident.rule_code == risk_repo.RULE_SMS_HOURLY,
                RiskIncident.subject_id == subject,
            ).one()
            assert incident.status == "resolved"
            assert incident.action_reason == risk_repo.AUTO_RESOLVED_REASON

        reopened = client.patch(
            "/admin/api/risk-monitor/rules",
            headers=headers,
            json={
                "sms_hourly_threshold": 3,
                "oneclick_daily_threshold": 20,
                "compare_daily_threshold": 100,
            },
        )
        assert reopened.status_code == 200
        with SessionLocal() as db:
            incident = db.query(RiskIncident).filter(
                RiskIncident.rule_code == risk_repo.RULE_SMS_HOURLY,
                RiskIncident.subject_id == subject,
            ).one()
            assert incident.status == "open"
            assert incident.triggered_at == (
                now + timedelta(seconds=2)
            ).replace(tzinfo=None)

        invalid = client.patch(
            "/admin/api/risk-monitor/rules",
            headers=headers,
            json={
                "sms_hourly_threshold": 21,
                "oneclick_daily_threshold": 20,
                "compare_daily_threshold": 100,
            },
        )
        assert invalid.status_code == 422
