"""消息通知中心 3 接口(落库版)。

覆盖:空列表(虚拟数据已清除)、列表字段/派生/排序/分页、未读角标、标记已读(ids / all /
幂等 / 参数校验)、鉴权与用户隔离、去重键部分唯一索引。数据直接写 notification 表
(repositories/notification),不再有内存 mock。
"""
from __future__ import annotations

from datetime import datetime, timedelta, timezone

import pytest
from fastapi.testclient import TestClient
from sqlalchemy.exc import IntegrityError

from app.core.security import decode_token
from app.db.session import SessionLocal
from app.repositories import notification as notif_repo

_CST = timezone(timedelta(hours=8))


def _login(client: TestClient, 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 _uid(token: str) -> int:
    return int(decode_token(token, expected_type="access")["sub"])


def _seed_sample(token: str, type_key: str):
    """按类型插一条样例通知(复用 repo 的样例卡片内容),返回落库行。"""
    with SessionLocal() as db:
        return notif_repo.insert_sample(db, _uid(token), type_key)


def _seed(token: str, type_key: str, **kw):
    """按显式内容插一条通知(排序/分页用,可指定 sent_at)。"""
    with SessionLocal() as db:
        return notif_repo.create_notification(db, user_id=_uid(token), type_key=type_key, **kw)


def _fetch_all(client: TestClient, token: str) -> dict:
    r = client.get("/api/v1/notifications?pageSize=100", headers=_auth(token))
    assert r.status_code == 200, r.text
    return r.json()


def test_requires_auth(client: TestClient) -> None:
    assert client.get("/api/v1/notifications").status_code == 401
    assert client.get("/api/v1/notifications/unread-count").status_code == 401
    assert client.post("/api/v1/notifications/read", json={"all": True}).status_code == 401


def test_fresh_user_has_no_notifications(client: TestClient) -> None:
    """虚拟数据已清除:新用户初始为空列表、未读 0、角标隐藏。"""
    token = _login(client, "13900010000")
    data = _fetch_all(client, token)
    assert data["items"] == []
    assert data["total"] == 0
    assert data["unreadCount"] == 0
    assert data["hasMore"] is False

    r = client.get("/api/v1/notifications/unread-count", headers=_auth(token))
    assert r.json() == {"count": 0, "badgeText": None}


def test_list_item_fields_camel_case_and_derived(client: TestClient) -> None:
    token = _login(client, "13900010001")
    _seed_sample(token, "reward_expiring")
    _seed_sample(token, "withdraw_success")
    _seed_sample(token, "perm_accessibility")
    _seed_sample(token, "feedback_reward")

    items = _fetch_all(client, token)["items"]
    assert len(items) == 4

    # 字段按 PRD 契约 camelCase,卡片要素齐全
    first = items[0]
    for key in (
        "id", "category", "categoryLabel", "type", "cardStyle", "title",
        "coins", "cashCents", "cashYuan", "infoRows", "actionText",
        "extra", "sentAt", "isRead",
    ):
        assert key in first, f"missing field {key}"
    assert "+08:00" in first["sentAt"]  # 落库后仍恒带 +08:00

    by_type = {i["type"]: i for i in items}

    # 双金额卡:金币整数 + 现金两位小数;category/cardStyle/title/actionText 均由 catalog 派生
    expiring = by_type["reward_expiring"]
    assert expiring["category"] == "withdraw_assistant"
    assert expiring["categoryLabel"] == "提现助手"
    assert expiring["cardStyle"] == "dual_amount"
    assert expiring["title"] == "金币现金奖励即将失效"
    assert expiring["actionText"] == "立即激活您的收益"
    assert isinstance(expiring["coins"], int)
    assert expiring["cashCents"] == 1280
    assert expiring["cashYuan"] == "12.80"
    assert [row["label"] for row in expiring["infoRows"]] == ["过期说明", "过期时间"]

    # 提现成功卡:无操作行、无金币
    ws = by_type["withdraw_success"]
    assert ws["actionText"] is None
    assert ws["coins"] is None

    # 权限异常卡带 permission 参数(客户端点击时实时检测用)
    assert by_type["perm_accessibility"]["extra"] == {"permission": "accessibility"}

    # 反馈奖励卡:官方留言必填(PRD §3)
    reward = by_type["feedback_reward"]
    assert any(row["label"] == "官方留言" and row["value"] for row in reward["infoRows"])

    # 新插入默认未读
    assert all(i["isRead"] is False for i in items)


def test_list_sorted_by_time_desc(client: TestClient) -> None:
    """全列表 sent_at 倒序(最新在前),不分组。"""
    token = _login(client, "13900010002")
    base = datetime(2026, 7, 1, 12, 0, tzinfo=_CST)
    _seed(token, "withdraw_success", sent_at=base - timedelta(days=2))
    newest = _seed(token, "invite_order_reward", sent_at=base)
    _seed(token, "feedback_reply", sent_at=base - timedelta(days=1))

    items = _fetch_all(client, token)["items"]
    sent_ats = [i["sentAt"] for i in items]
    assert sent_ats == sorted(sent_ats, reverse=True), "最新在前"
    assert items[0]["id"] == newest.id
    assert items[0]["type"] == "invite_order_reward"


def test_pagination(client: TestClient) -> None:
    token = _login(client, "13900010003")
    base = datetime(2026, 7, 1, 12, 0, tzinfo=_CST)
    n = 12
    for i in range(n):
        _seed(token, "withdraw_success", sent_at=base - timedelta(minutes=i))

    total = _fetch_all(client, token)["total"]
    assert total == n

    page_size = 5
    seen_ids: list[int] = []
    page = 1
    while True:
        r = client.get(
            f"/api/v1/notifications?page={page}&pageSize={page_size}", headers=_auth(token)
        )
        assert r.status_code == 200
        data = r.json()
        assert data["page"] == page
        assert data["pageSize"] == page_size
        assert data["total"] == total
        seen_ids.extend(i["id"] for i in data["items"])
        if not data["hasMore"]:
            assert len(data["items"]) <= page_size
            break
        assert len(data["items"]) == page_size
        page += 1

    assert len(seen_ids) == total
    assert len(set(seen_ids)) == total, "翻页不重不漏"

    # 超出末页 → 空页而非报错
    r = client.get("/api/v1/notifications?page=99&pageSize=50", headers=_auth(token))
    assert r.status_code == 200
    assert r.json()["items"] == []
    assert r.json()["hasMore"] is False


def test_unread_count_and_badge(client: TestClient) -> None:
    token = _login(client, "13900010004")
    for _ in range(3):
        _seed_sample(token, "withdraw_success")

    r = client.get("/api/v1/notifications/unread-count", headers=_auth(token))
    assert r.json() == {"count": 3, "badgeText": "3"}

    # 全部读完 → count=0,badgeText=null(整个角标隐藏)
    client.post("/api/v1/notifications/read", json={"all": True}, headers=_auth(token))
    r = client.get("/api/v1/notifications/unread-count", headers=_auth(token))
    assert r.json() == {"count": 0, "badgeText": None}


def test_mark_read_by_ids_idempotent(client: TestClient) -> None:
    token = _login(client, "13900010005")
    ids = [_seed_sample(token, "withdraw_success").id for _ in range(3)]
    picked = ids[:2]

    r = client.post("/api/v1/notifications/read", json={"ids": picked}, headers=_auth(token))
    assert r.status_code == 200
    assert r.json() == {"ok": True, "markedCount": 2, "unreadCount": 1}

    # 列表状态同步翻转
    items = {i["id"]: i for i in _fetch_all(client, token)["items"]}
    assert all(items[i]["isRead"] for i in picked)
    assert items[ids[2]]["isRead"] is False

    # 重复置读 + 不存在的 id → 幂等,不报错
    r = client.post(
        "/api/v1/notifications/read", json={"ids": [*picked, 123456789]}, headers=_auth(token)
    )
    assert r.status_code == 200
    assert r.json()["markedCount"] == 0
    assert r.json()["unreadCount"] == 1


def test_mark_read_requires_ids_or_all(client: TestClient) -> None:
    token = _login(client, "13900010006")
    r = client.post("/api/v1/notifications/read", json={}, headers=_auth(token))
    assert r.status_code == 400
    r = client.post("/api/v1/notifications/read", json={"ids": []}, headers=_auth(token))
    assert r.status_code == 400


def test_isolated_between_users(client: TestClient) -> None:
    token_a = _login(client, "13900010007")
    token_b = _login(client, "13900010008")
    _seed_sample(token_a, "withdraw_success")
    _seed_sample(token_b, "withdraw_success")

    client.post("/api/v1/notifications/read", json={"all": True}, headers=_auth(token_a))
    assert client.get("/api/v1/notifications/unread-count", headers=_auth(token_a)).json()["count"] == 0
    assert (
        client.get("/api/v1/notifications/unread-count", headers=_auth(token_b)).json()["count"] == 1
    ), "A 清零不影响 B"


def test_dedup_key_blocks_duplicate_unread(client: TestClient) -> None:
    """同一 (user, type, dedup_key) 未读期间只允许一条(部分唯一索引拦重复未读)。"""
    token = _login(client, "13900010009")
    uid = _uid(token)
    with SessionLocal() as db:
        notif_repo.create_notification(
            db, user_id=uid, type_key="perm_accessibility", dedup_key="accessibility"
        )
    # 同键第二条(仍未读)→ 唯一索引拦截
    with pytest.raises(IntegrityError):
        with SessionLocal() as db:
            notif_repo.create_notification(
                db, user_id=uid, type_key="perm_accessibility", dedup_key="accessibility"
            )
    # 只落了一条
    assert _fetch_all(client, token)["total"] == 1
