"""photo.py 单测（FEAT-008 avatar / FEAT-009 album）。

mock OSS bucket 避免真调阿里云。实际 OSS 集成在 M6 drill / 人工 smoke 验证。
"""

from __future__ import annotations

import sqlite3
from pathlib import Path
from typing import Any

import pytest

from xiangqin import auth, photo, profile


# ─── mock OSS bucket ──────────────────────────────────


class FakeBucket:
    """模仿 oss2.Bucket 的 put_object / sign_url / delete_object。"""

    def __init__(self) -> None:
        self.stored: dict[str, bytes] = {}
        self.put_calls: list[tuple[str, bytes, dict[str, str]]] = []
        self.delete_calls: list[str] = []

    def put_object(self, key: str, data: bytes, headers: dict[str, str] | None = None) -> Any:
        self.put_calls.append((key, data, headers or {}))
        self.stored[key] = data
        return object()  # 简单返个非 None

    def sign_url(self, method: str, key: str, expires: int) -> str:
        return f"https://fake-oss.example.com/{key}?expires={expires}"

    def delete_object(self, key: str) -> Any:
        self.delete_calls.append(key)
        self.stored.pop(key, None)
        return object()


@pytest.fixture
def fake_bucket(monkeypatch: pytest.MonkeyPatch) -> FakeBucket:
    bucket = FakeBucket()
    monkeypatch.setattr(photo, "_get_oss_bucket", lambda: bucket)
    return bucket


@pytest.fixture
def a_user() -> str:
    """建一个测试用户 + profile，返回 user_id。依赖 conftest autouse tmp_db。"""
    r = auth.register("13800001111")
    v = auth.verify(r.request_id, "123456")
    profile.update(v.user_id, {"gender": "m", "age": 30, "city": "hangzhou"})
    return v.user_id


# ─── upload ───────────────────────────────────────────


def test_upload_avatar_happy(fake_bucket: FakeBucket, tmp_db, a_user: str) -> None:
    data = b"\xff\xd8\xff\xe0fake-jpeg-data"
    p = photo.upload(a_user, data, "image/jpeg", "avatar")

    assert p.user_id == a_user
    assert p.purpose == "avatar"
    assert p.size_bytes == len(data)
    assert p.moderation_status == "passed"  # stub
    assert p.oss_key.startswith(f"users/{a_user}/avatar/")
    assert p.oss_key.endswith(".jpg")

    # OSS 确实被调用
    assert len(fake_bucket.put_calls) == 1
    key, bytes_, headers = fake_bucket.put_calls[0]
    assert key == p.oss_key
    assert bytes_ == data
    assert headers["Content-Type"] == "image/jpeg"

    # db 里有记录
    with sqlite3.connect(tmp_db) as conn:
        row = conn.execute(
            "SELECT id, moderation_status FROM user_photos WHERE id=?", (p.id,)
        ).fetchone()
    assert row is not None and row[1] == "passed"

    # profile.avatar_photo_id 被更新
    with sqlite3.connect(tmp_db) as conn:
        row = conn.execute(
            "SELECT avatar_photo_id FROM profiles WHERE user_id=?", (a_user,)
        ).fetchone()
    assert row[0] == p.id


def test_upload_album_does_not_touch_profile_avatar(
    fake_bucket: FakeBucket, tmp_db, a_user: str
) -> None:
    p = photo.upload(a_user, b"png-data", "image/png", "album")
    assert p.oss_key.endswith(".png")

    with sqlite3.connect(tmp_db) as conn:
        row = conn.execute(
            "SELECT avatar_photo_id FROM profiles WHERE user_id=?", (a_user,)
        ).fetchone()
    # album 上传不该碰 avatar
    assert row[0] is None


def test_upload_replaces_old_avatar(fake_bucket: FakeBucket, tmp_db, a_user: str) -> None:
    p1 = photo.upload(a_user, b"first-jpeg", "image/jpeg", "avatar")
    p2 = photo.upload(a_user, b"second-jpeg", "image/jpeg", "avatar")
    assert p1.id != p2.id
    with sqlite3.connect(tmp_db) as conn:
        row = conn.execute(
            "SELECT avatar_photo_id FROM profiles WHERE user_id=?", (a_user,)
        ).fetchone()
    assert row[0] == p2.id  # 指向最新


def test_upload_rejects_unsupported_content_type(
    fake_bucket: FakeBucket, tmp_db, a_user: str
) -> None:
    with pytest.raises(photo.PhotoError) as ei:
        photo.upload(a_user, b"gif-data", "image/gif", "avatar")
    assert ei.value.code == "unsupported_type"


def test_upload_rejects_invalid_purpose(fake_bucket: FakeBucket, tmp_db, a_user: str) -> None:
    with pytest.raises(photo.PhotoError) as ei:
        photo.upload(a_user, b"jpg", "image/jpeg", "wtf")
    assert ei.value.code == "invalid_purpose"


def test_upload_rejects_empty_file(fake_bucket: FakeBucket, tmp_db, a_user: str) -> None:
    with pytest.raises(photo.PhotoError) as ei:
        photo.upload(a_user, b"", "image/jpeg", "avatar")
    assert ei.value.code == "empty_file"


def test_upload_rejects_too_large(fake_bucket: FakeBucket, tmp_db, a_user: str) -> None:
    too_big = b"x" * (photo.MAX_BYTES + 1)
    with pytest.raises(photo.PhotoError) as ei:
        photo.upload(a_user, too_big, "image/jpeg", "avatar")
    assert ei.value.code == "file_too_large"


# ─── signed_url / avatar_url / list_album ─────────────


def test_signed_url_returns_fake_url(fake_bucket: FakeBucket, tmp_db, a_user: str) -> None:
    p = photo.upload(a_user, b"jpeg", "image/jpeg", "avatar")
    url = photo.signed_url(p.id)
    assert url is not None
    assert p.oss_key in url


def test_signed_url_unknown_id_returns_none(fake_bucket: FakeBucket, tmp_db) -> None:
    assert photo.signed_url("NOPE") is None


def test_avatar_url_no_avatar_returns_none(fake_bucket: FakeBucket, tmp_db, a_user: str) -> None:
    assert photo.avatar_url(a_user) is None


def test_avatar_url_after_upload(fake_bucket: FakeBucket, tmp_db, a_user: str) -> None:
    photo.upload(a_user, b"jpeg", "image/jpeg", "avatar")
    assert photo.avatar_url(a_user) is not None


def test_list_album_only_returns_album_purpose(
    fake_bucket: FakeBucket, tmp_db, a_user: str
) -> None:
    photo.upload(a_user, b"avatar-data", "image/jpeg", "avatar")
    photo.upload(a_user, b"album-1", "image/png", "album")
    photo.upload(a_user, b"album-2", "image/webp", "album")

    photos = photo.list_album(a_user)
    assert len(photos) == 2
    assert all(p.purpose == "album" for p in photos)


# ─── delete ───────────────────────────────────────────


def test_delete_own_photo(fake_bucket: FakeBucket, tmp_db, a_user: str) -> None:
    p = photo.upload(a_user, b"album-jpeg", "image/jpeg", "album")
    photo.delete(a_user, p.id)

    with sqlite3.connect(tmp_db) as conn:
        row = conn.execute("SELECT id FROM user_photos WHERE id=?", (p.id,)).fetchone()
    assert row is None
    # OSS 对象也被删
    assert p.oss_key in fake_bucket.delete_calls


def test_delete_own_avatar_clears_profile_ref(fake_bucket: FakeBucket, tmp_db, a_user: str) -> None:
    p = photo.upload(a_user, b"a-jpeg", "image/jpeg", "avatar")
    photo.delete(a_user, p.id)
    with sqlite3.connect(tmp_db) as conn:
        row = conn.execute(
            "SELECT avatar_photo_id FROM profiles WHERE user_id=?", (a_user,)
        ).fetchone()
    assert row[0] is None  # avatar 清了


def test_delete_someone_elses_photo_forbidden(fake_bucket: FakeBucket, tmp_db, a_user: str) -> None:
    p = photo.upload(a_user, b"x", "image/jpeg", "album")
    with pytest.raises(photo.PhotoError) as ei:
        photo.delete("other-user-id", p.id)
    assert ei.value.code == "forbidden"
    assert ei.value.http_status == 403


def test_delete_nonexistent_returns_not_found(fake_bucket: FakeBucket, tmp_db, a_user: str) -> None:
    with pytest.raises(photo.PhotoError) as ei:
        photo.delete(a_user, "NOPE")
    assert ei.value.code == "not_found"
    assert ei.value.http_status == 404


# ─── OSS not configured ───────────────────────────────


def test_oss_not_configured_raises_503(
    monkeypatch: pytest.MonkeyPatch, tmp_db: Path, a_user: str
) -> None:
    """没配 AK/SK 时，upload 应返 503 而不是隐蔽挂掉。"""
    monkeypatch.delenv("ALIYUN_ACCESS_KEY_ID", raising=False)
    monkeypatch.delenv("ALIYUN_ACCESS_KEY_SECRET", raising=False)
    with pytest.raises(photo.PhotoError) as ei:
        photo.upload(a_user, b"jpeg", "image/jpeg", "avatar")
    assert ei.value.code == "oss_not_configured"
    assert ei.value.http_status == 503
