"""PixivClient tests — parsing + iteration against canned pages, no network.

The fixture mirrors a real `/v1/user/illusts` page (multi-page work, single
page, ugoira, sanity-limited placeholder, deleted-author ghost). HTTP is
stubbed at the requests-session seam (oauth POST + API GET), so the exact
gallery-dl-parity request profile — headers, oauth form, pagination params —
is asserted rather than assumed.
"""

import json
from pathlib import Path

import pytest

from backend.app.services.pixiv_client import (
    PIXIV_APP_HEADERS,
    MediaItem,
    PixivAPIError,
    PixivAuthError,
    PixivClient,
    PixivDriftError,
    rating_label,
    user_id_from_url,
)

_FIXTURE = Path(__file__).parent / "fixtures" / "pixiv_user_illusts_page1.json"


class FakeResponse:
    def __init__(self, status_code=200, json_data=None, headers=None):
        self.status_code = status_code
        self._json = json_data
        self.headers = headers or {}

    def json(self):
        if self._json is None:
            raise ValueError("no JSON")
        return self._json


class FakeSession:
    """Minimal requests.Session stand-in: canned responses per (method, url
    fragment), recording every call for profile assertions."""

    def __init__(self):
        self.headers = dict(PIXIV_APP_HEADERS)
        self.responses = []
        self.calls = []

    def queue(self, response):
        self.responses.append(response)
        return self

    def _next(self):
        if not self.responses:
            raise AssertionError("FakeSession ran out of queued responses")
        return self.responses.pop(0)

    def post(self, url, data=None, headers=None, timeout=None):
        self.calls.append(("POST", url, data, headers))
        return self._next()

    def get(self, url, params=None, timeout=None, headers=None):
        self.calls.append(("GET", url, params, headers))
        return self._next()


def _oauth_ok():
    return FakeResponse(200, {
        "response": {
            "access_token": "acc-token",
            "expires_in": 3600,
            "user": {"id": "77", "account": "operator", "name": "Op"},
        }
    })


@pytest.fixture
def page1():
    return json.loads(_FIXTURE.read_text())


@pytest.fixture
def client():
    # Never issues a request in pure-parsing tests.
    return PixivClient("refresh-tok", session=FakeSession())


def _post_for(client, page1, work_id):
    for work in page1["illusts"]:
        if work["id"] == work_id:
            return client._normalize(work)
    raise AssertionError(f"no work {work_id} in fixture")


# -- URL → user id ----------------------------------------------------------

def test_user_id_from_url_variants():
    assert user_id_from_url("https://www.pixiv.net/users/12345678") == "12345678"
    assert user_id_from_url("https://www.pixiv.net/en/users/42") == "42"
    assert user_id_from_url("https://pixiv.net/users/7/artworks") == "7"
    assert user_id_from_url("https://www.pixiv.net/member.php?id=99") == "99"


def test_user_id_from_url_rejects_non_matches():
    assert user_id_from_url("https://www.pixiv.net/artworks/111") is None
    assert user_id_from_url("https://www.pixiv.net/users/notdigits") is None
    assert user_id_from_url("https://example.com/users/5") is None
    assert user_id_from_url("") is None


# -- normalization ------------------------------------------------------------

def test_normalize_maps_attributes(client, page1):
    post = _post_for(client, page1, 111)
    attrs = post["attributes"]
    assert post["id"] == 111
    assert attrs["title"] == "Multi Page Adventure"
    assert "Two-page set." in attrs["content"]
    assert attrs["published_at"] == "2026-06-20T18:00:00+09:00"
    assert attrs["post_type"] == "illust"
    assert post["_work"]["total_bookmarks"] == 250


def test_post_record_key_and_meta(client, page1):
    post = _post_for(client, page1, 222)
    assert client.post_record_key(post) == ("post:222", "222")
    meta = client.post_meta(post)
    assert meta["title"] == "Single Piece"
    assert meta["date"] == "2026-06-18T12:30:00+09:00"
    assert client.post_record_key({"id": None}) is None


# -- gating -------------------------------------------------------------------

def test_post_is_gated_limit_placeholder(client, page1):
    assert client.post_is_gated(_post_for(client, page1, 444)) is True


def test_post_is_gated_deleted_author(client, page1):
    assert client.post_is_gated(_post_for(client, page1, 555)) is True


def test_post_is_gated_normal_works(client, page1):
    assert client.post_is_gated(_post_for(client, page1, 111)) is False
    assert client.post_is_gated(_post_for(client, page1, 222)) is False


# -- extract_media --------------------------------------------------------------

def test_extract_media_multi_page(client, page1):
    items = client.extract_media(_post_for(client, page1, 111), {})
    assert len(items) == 2
    assert all(isinstance(m, MediaItem) for m in items)
    assert [m.media_id for m in items] == ["p0", "p1"]
    assert items[0].url.endswith("/111_p0.png")
    assert items[1].url.endswith("/111_p1.png")
    # gallery-dl filename parity: {id}_{title[:50]}_{num:>02}.{extension}
    assert items[0].filename == "111_Multi Page Adventure_00.png"
    assert items[1].filename == "111_Multi Page Adventure_01.png"
    assert all(m.post_id == "111" for m in items)


def test_extract_media_single_page(client, page1):
    items = client.extract_media(_post_for(client, page1, 222), {})
    assert len(items) == 1
    assert items[0].media_id == "p0"
    assert items[0].url.endswith("/222_p0.jpg")
    assert items[0].filename == "222_Single Piece_00.jpg"


def test_extract_media_gated_yields_nothing(client, page1):
    assert client.extract_media(_post_for(client, page1, 444), {}) == []
    assert client.extract_media(_post_for(client, page1, 555), {}) == []


def test_extract_media_ugoira_zip_swap(client, page1, monkeypatch):
    frames = [{"file": "000000.jpg", "delay": 90}, {"file": "000001.jpg", "delay": 90}]

    def fake_call(endpoint, params):
        assert endpoint == "/v1/ugoira/metadata"
        assert params == {"illust_id": "333"}
        return {"ugoira_metadata": {
            "zip_urls": {"medium": (
                "https://i.pximg.net/img-zip-ugoira/img/2026/06/15/09/00/00/"
                "333_ugoira600x600.zip"
            )},
            "frames": frames,
        }}

    monkeypatch.setattr(client, "_call", fake_call)
    post = _post_for(client, page1, 333)
    items = client.extract_media(post, {})
    assert len(items) == 1
    assert items[0].media_id == "ugoira"
    assert items[0].kind == "ugoira"
    assert items[0].url.endswith("333_ugoira1920x1080.zip")
    assert items[0].filename == "333_Wiggle Loop_00.zip"
    # Frame delays memoized for the post record (future video conversion).
    assert post["_work"]["_ugoira_frames"] == frames


def test_fetch_ugoira_frames_memoizes_and_shares_one_call(client, page1, monkeypatch):
    # fetch_ugoira_frames (called by write_post_record, which the core runs
    # BEFORE extract_media) populates frames; extract_media then REUSES the
    # memoized metadata — exactly one /v1/ugoira/metadata call total.
    frames = [{"file": "000000.jpg", "delay": 90}]
    calls = []

    def fake_call(endpoint, params):
        calls.append(endpoint)
        return {"ugoira_metadata": {
            "zip_urls": {"medium": (
                "https://i.pximg.net/img-zip-ugoira/x/333_ugoira600x600.zip"
            )},
            "frames": frames,
        }}

    monkeypatch.setattr(client, "_call", fake_call)
    post = _post_for(client, page1, 333)
    client.fetch_ugoira_frames(post)
    assert post["_work"]["_ugoira_frames"] == frames
    items = client.extract_media(post, {})          # reuses memoized meta
    assert items[0].media_id == "ugoira"
    assert calls == ["/v1/ugoira/metadata"]          # ONE fetch, not two


def test_fetch_ugoira_frames_noop_for_non_ugoira(client, page1, monkeypatch):
    monkeypatch.setattr(client, "_call", lambda e, p: pytest.fail("should not fetch"))
    post = _post_for(client, page1, 222)             # a single-page illust
    client.fetch_ugoira_frames(post)
    assert "_ugoira_frames" not in post["_work"]


def test_extract_media_ugoira_metadata_failure_downgrades(
    client, page1, monkeypatch
):
    def fake_call(endpoint, params):
        raise PixivAPIError("boom", status_code=500)

    monkeypatch.setattr(client, "_call", fake_call)
    assert client.extract_media(_post_for(client, page1, 333), {}) == []


def test_extract_media_ugoira_auth_failure_stays_loud(
    client, page1, monkeypatch
):
    def fake_call(endpoint, params):
        raise PixivAuthError("token dead", status_code=400)

    monkeypatch.setattr(client, "_call", fake_call)
    with pytest.raises(PixivAuthError):
        client.extract_media(_post_for(client, page1, 333), {})


# -- iteration ------------------------------------------------------------------

def test_iter_posts_paginates_and_carries_cursor(page1, monkeypatch):
    client = PixivClient("refresh-tok", session=FakeSession())
    page2 = {
        "illusts": [dict(page1["illusts"][1], id=666, title="Older")],
        "next_url": None,
    }
    seen_params = []

    def fake_call(endpoint, params):
        assert endpoint == "/v1/user/illusts"
        seen_params.append(dict(params))
        return page1 if len(seen_params) == 1 else page2

    monkeypatch.setattr(client, "_call", fake_call)
    rows = list(client.iter_posts("99"))

    assert seen_params[0] == {"user_id": "99"}
    # Page 2 params come verbatim from next_url's query string.
    assert seen_params[1] == {"user_id": "99", "offset": "30"}
    # Page-1 posts carry cursor None; page-2 posts carry the fetching cursor.
    assert [c for _, _, c in rows[:5]] == [None] * 5
    assert rows[5][2] == "user_id=99&offset=30"
    assert rows[5][0]["id"] == 666


def test_iter_posts_resumes_from_cursor(page1, monkeypatch):
    client = PixivClient("refresh-tok", session=FakeSession())
    captured = {}

    def fake_call(endpoint, params):
        captured["params"] = dict(params)
        return {"illusts": [], "next_url": None}

    monkeypatch.setattr(client, "_call", fake_call)
    list(client.iter_posts("99", cursor="user_id=99&offset=60"))
    assert captured["params"] == {"user_id": "99", "offset": "60"}


def test_iter_posts_rejects_non_numeric_campaign():
    client = PixivClient("refresh-tok", session=FakeSession())
    with pytest.raises(PixivDriftError):
        next(client.iter_posts("https://www.pixiv.net/users/99"))


def test_iter_posts_drift_on_missing_illusts(monkeypatch):
    client = PixivClient("refresh-tok", session=FakeSession())
    monkeypatch.setattr(client, "_call", lambda e, p: {"error": None, "body": 1})
    with pytest.raises(PixivDriftError):
        next(client.iter_posts("99"))


# -- auth ------------------------------------------------------------------------

def test_login_sends_gallery_dl_profile_and_sets_bearer():
    session = FakeSession().queue(_oauth_ok())
    client = PixivClient("refresh-tok", session=session)
    client._login()

    method, url, data, headers = session.calls[0]
    assert method == "POST"
    assert url == "https://oauth.secure.pixiv.net/auth/token"
    assert data["grant_type"] == "refresh_token"
    assert data["refresh_token"] == "refresh-tok"
    assert data["client_id"] == "MOBrBDS8blbauoSck0ZfDbtuzpyT"
    assert data["get_secure_url"] == "1"
    # X-Client-Time/-Hash pair: ISO + literal +00:00, md5 hex digest.
    assert headers["X-Client-Time"].endswith("+00:00")
    assert len(headers["X-Client-Hash"]) == 32
    assert session.headers["Authorization"] == "Bearer acc-token"
    # Token is fresh → a second login is a no-op (no extra POST).
    client._login()
    assert len(session.calls) == 1


def test_login_maps_rejection_to_auth_error():
    session = FakeSession().queue(FakeResponse(400, {"has_error": True}))
    client = PixivClient("refresh-tok", session=session)
    with pytest.raises(PixivAuthError):
        client._login()


def test_login_without_token_is_auth_error():
    client = PixivClient(None, session=FakeSession())
    with pytest.raises(PixivAuthError):
        client._login()


def test_call_maps_rate_limit_message():
    session = FakeSession().queue(_oauth_ok()).queue(FakeResponse(403, {
        "error": {"message": "Rate Limit", "user_message": ""},
    }))
    client = PixivClient("refresh-tok", session=session)
    with pytest.raises(PixivAPIError) as exc_info:
        client._call("/v1/user/illusts", {"user_id": "1"})
    err = exc_info.value
    assert not isinstance(err, PixivAuthError)
    assert err.status_code == 429
    assert err.retry_after == 300.0


def test_call_maps_403_to_auth_error():
    session = FakeSession().queue(_oauth_ok()).queue(FakeResponse(403, {
        "error": {"message": "invalid access token", "user_message": ""},
    }))
    client = PixivClient("refresh-tok", session=session)
    with pytest.raises(PixivAuthError):
        client._call("/v1/user/illusts", {"user_id": "1"})


def test_call_maps_404_status():
    session = FakeSession().queue(_oauth_ok()).queue(FakeResponse(404, {
        "error": {"message": "Not Found", "user_message": ""},
    }))
    client = PixivClient("refresh-tok", session=session)
    with pytest.raises(PixivAPIError) as exc_info:
        client._call("/v1/user/illusts", {"user_id": "1"})
    assert exc_info.value.status_code == 404


def test_verify_auth_reports_account():
    session = FakeSession().queue(_oauth_ok())
    client = PixivClient("refresh-tok", session=session)
    ok, message = client.verify_auth()
    assert ok is True
    assert "operator" in message


def test_verify_auth_bad_token():
    session = FakeSession().queue(FakeResponse(400, {"has_error": True}))
    client = PixivClient("bad", session=session)
    ok, message = client.verify_auth()
    assert ok is False
    assert "rotate" in message.lower() or "rejected" in message.lower()


def test_resolve_display_name(client, monkeypatch):
    monkeypatch.setattr(
        client, "_call",
        lambda e, p: {"user": {"name": "Kurotsuchi Machi", "id": p["user_id"]}},
    )
    assert client.resolve_display_name("99") == "Kurotsuchi Machi"


def test_resolve_display_name_none_on_failure(client, monkeypatch):
    def boom(endpoint, params):
        raise PixivAPIError("nope", status_code=404)
    monkeypatch.setattr(client, "_call", boom)
    assert client.resolve_display_name("99") is None
    # Empty/whitespace name → None (caller falls back to the id).
    monkeypatch.setattr(client, "_call", lambda e, p: {"user": {"name": "  "}})
    assert client.resolve_display_name("99") is None


# -- rating ------------------------------------------------------------------------

def test_rating_label():
    assert rating_label(0) == "General"
    assert rating_label(1) == "R-18"
    assert rating_label(2) == "R-18G"
    assert rating_label(None) is None
    assert rating_label(True) is None
    assert rating_label(9) is None
