"""http.py の HttpClient とページネーション関数をテストするモジュール。"""

from datetime import UTC

import pytest
import responses

from gfo.exceptions import (
    AuthenticationError,
    HttpError,
    NetworkError,
    NotFoundError,
    RateLimitError,
    ServerError,
)
from gfo.http import (
    HttpClient,
    _extract_next_link,
    _validate_same_origin,
    paginate_link_header,
    paginate_offset,
    paginate_page_param,
    paginate_response_body,
    paginate_top_skip,
)

BASE = "https://api.example.com"


# ── HttpClient 初期化 ──


class TestHttpClientInit:
    def test_base_url_trailing_slash_stripped(self):
        c = HttpClient("https://api.example.com/")
        assert c.base_url == "https://api.example.com"

    def test_auth_header_set(self):
        c = HttpClient(BASE, auth_header={"Authorization": "Bearer tok"})
        assert c._session.headers["Authorization"] == "Bearer tok"

    def test_basic_auth_set(self):
        c = HttpClient(BASE, basic_auth=("user", "pass"))
        assert c._session.auth == ("user", "pass")

    def test_extra_headers_set(self):
        c = HttpClient(BASE, extra_headers={"X-Custom": "val"})
        assert c._session.headers["X-Custom"] == "val"

    def test_mutually_exclusive_auth_raises(self):
        with pytest.raises(ValueError, match="mutually exclusive"):
            HttpClient(
                BASE,
                auth_header={"Authorization": "Bearer tok"},
                basic_auth=("u", "p"),
            )

    def test_mutually_exclusive_three_raises(self):
        with pytest.raises(ValueError, match="mutually exclusive"):
            HttpClient(
                BASE,
                auth_header={"Authorization": "Bearer tok"},
                auth_params={"apiKey": "k"},
                basic_auth=("u", "p"),
            )


# ── 基本リクエスト ──


class TestBasicRequests:
    @responses.activate
    def test_get(self):
        responses.add(responses.GET, f"{BASE}/items", json={"ok": True})
        c = HttpClient(BASE)
        resp = c.get("/items")
        assert resp.json() == {"ok": True}

    @responses.activate
    def test_post(self):
        responses.add(responses.POST, f"{BASE}/items", json={"id": 1}, status=201)
        c = HttpClient(BASE)
        resp = c.post("/items", json={"name": "x"})
        assert resp.status_code == 201

    @responses.activate
    def test_put(self):
        responses.add(responses.PUT, f"{BASE}/items/1", json={"ok": True})
        c = HttpClient(BASE)
        resp = c.put("/items/1", json={"name": "y"})
        assert resp.json() == {"ok": True}

    @responses.activate
    def test_patch(self):
        responses.add(responses.PATCH, f"{BASE}/items/1", json={"ok": True})
        c = HttpClient(BASE)
        resp = c.patch("/items/1", json={"name": "z"})
        assert resp.json() == {"ok": True}

    @responses.activate
    def test_delete(self):
        responses.add(responses.DELETE, f"{BASE}/items/1", status=204)
        c = HttpClient(BASE)
        resp = c.delete("/items/1")
        assert resp.status_code == 204


# ── パラメータマージ ──


class TestParamsMerge:
    @responses.activate
    def test_default_and_auth_and_request_params_merged(self):
        responses.add(responses.GET, f"{BASE}/x", json=[])
        c = HttpClient(
            BASE,
            auth_params={"apiKey": "secret"},
            default_params={"api-version": "7.1"},
        )
        c.get("/x", params={"state": "open"})
        assert responses.calls[0].request.params == {
            "api-version": "7.1",
            "apiKey": "secret",
            "state": "open",
        }

    @responses.activate
    def test_request_params_override(self):
        responses.add(responses.GET, f"{BASE}/x", json=[])
        c = HttpClient(BASE, default_params={"page": "1"})
        c.get("/x", params={"page": "2"})
        assert responses.calls[0].request.params["page"] == "2"


# ── エラーハンドリング ──


class TestErrorHandling:
    @responses.activate
    def test_401_raises_authentication_error(self):
        responses.add(responses.GET, f"{BASE}/x", status=401)
        c = HttpClient(BASE)
        with pytest.raises(AuthenticationError) as exc_info:
            c.get("/x")
        assert exc_info.value.status_code == 401

    @responses.activate
    def test_403_raises_authentication_error(self):
        responses.add(responses.GET, f"{BASE}/x", status=403)
        c = HttpClient(BASE)
        with pytest.raises(AuthenticationError) as exc_info:
            c.get("/x")
        assert exc_info.value.status_code == 403

    @responses.activate
    def test_404_raises_not_found(self):
        responses.add(responses.GET, f"{BASE}/x", status=404)
        c = HttpClient(BASE)
        with pytest.raises(NotFoundError):
            c.get("/x")

    @responses.activate
    def test_500_raises_server_error(self):
        responses.add(responses.GET, f"{BASE}/x", status=500)
        c = HttpClient(BASE)
        with pytest.raises(ServerError) as exc_info:
            c.get("/x")
        assert exc_info.value.status_code == 500

    @responses.activate
    def test_502_raises_server_error(self):
        responses.add(responses.GET, f"{BASE}/x", status=502)
        c = HttpClient(BASE)
        with pytest.raises(ServerError):
            c.get("/x")

    @responses.activate
    def test_418_raises_http_error(self):
        responses.add(responses.GET, f"{BASE}/x", status=418, body="teapot")
        c = HttpClient(BASE)
        with pytest.raises(HttpError) as exc_info:
            c.get("/x")
        assert exc_info.value.status_code == 418

    @responses.activate
    def test_connection_error_raises_network_error(self):
        import requests as req

        responses.add(
            responses.GET,
            f"{BASE}/x",
            body=req.ConnectionError("connection refused"),
        )
        c = HttpClient(BASE)
        with pytest.raises(NetworkError):
            c.get("/x")

    @responses.activate
    def test_timeout_raises_network_error(self):
        from requests.exceptions import ReadTimeout

        responses.add(responses.GET, f"{BASE}/x", body=ReadTimeout("timed out"))
        c = HttpClient(BASE)
        with pytest.raises(NetworkError):
            c.get("/x")

    @responses.activate
    def test_ssl_error_raises_network_error(self):
        """SSLError 等の RequestException サブクラスも NetworkError に変換される。"""
        from requests.exceptions import SSLError

        responses.add(responses.GET, f"{BASE}/x", body=SSLError("certificate verify failed"))
        c = HttpClient(BASE)
        with pytest.raises(NetworkError):
            c.get("/x")


# ── 429 リトライ ──


class TestRateLimit:
    @responses.activate
    def test_429_retry_success(self, monkeypatch):
        monkeypatch.setattr("gfo.http.time.sleep", lambda _: None)
        responses.add(
            responses.GET,
            f"{BASE}/x",
            status=429,
            headers={"Retry-After": "1"},
        )
        responses.add(responses.GET, f"{BASE}/x", json={"ok": True})
        c = HttpClient(BASE)
        resp = c.get("/x")
        assert resp.json() == {"ok": True}
        assert len(responses.calls) == 2

    @responses.activate
    def test_429_retry_default_wait(self, monkeypatch):
        sleep_values = []
        monkeypatch.setattr("gfo.http.time.sleep", lambda v: sleep_values.append(v))
        responses.add(responses.GET, f"{BASE}/x", status=429)
        responses.add(responses.GET, f"{BASE}/x", json={"ok": True})
        c = HttpClient(BASE)
        c.get("/x")
        assert sleep_values == [60]

    @responses.activate
    def test_429_retry_also_429_raises(self, monkeypatch):
        monkeypatch.setattr("gfo.http.time.sleep", lambda _: None)
        responses.add(
            responses.GET,
            f"{BASE}/x",
            status=429,
            headers={"Retry-After": "1"},
        )
        responses.add(responses.GET, f"{BASE}/x", status=429)
        c = HttpClient(BASE)
        with pytest.raises(RateLimitError):
            c.get("/x")

    @responses.activate
    def test_429_retry_then_connection_error(self, monkeypatch):
        import requests as req

        monkeypatch.setattr("gfo.http.time.sleep", lambda _: None)
        responses.add(
            responses.GET,
            f"{BASE}/x",
            status=429,
            headers={"Retry-After": "1"},
        )
        responses.add(
            responses.GET,
            f"{BASE}/x",
            body=req.ConnectionError("disconnected"),
        )
        c = HttpClient(BASE)
        with pytest.raises(NetworkError):
            c.get("/x")

    @responses.activate
    def test_429_retry_then_timeout(self, monkeypatch):
        from requests.exceptions import ReadTimeout

        monkeypatch.setattr("gfo.http.time.sleep", lambda _: None)
        responses.add(
            responses.GET,
            f"{BASE}/x",
            status=429,
            headers={"Retry-After": "1"},
        )
        responses.add(responses.GET, f"{BASE}/x", body=ReadTimeout("timeout"))
        c = HttpClient(BASE)
        with pytest.raises(NetworkError):
            c.get("/x")


# ── _parse_retry_after ──


class TestParseRetryAfter:
    def test_integer_seconds(self):
        assert HttpClient._parse_retry_after("30") == 30

    def test_zero_clamps_to_one(self):
        assert HttpClient._parse_retry_after("0") == 1

    def test_negative_clamps_to_one(self):
        assert HttpClient._parse_retry_after("-5") == 1

    def test_none_returns_default(self):
        assert HttpClient._parse_retry_after(None) == 60
        assert HttpClient._parse_retry_after(None, default=10) == 10

    def test_invalid_string_returns_default(self):
        assert HttpClient._parse_retry_after("invalid") == 60

    def test_http_date_future(self, monkeypatch):
        """HTTP-date 形式（未来の日時）が秒数に変換される。"""
        from datetime import datetime

        future = datetime(2099, 1, 1, 0, 0, 0, tzinfo=UTC)
        monkeypatch.setattr(
            "gfo.http.datetime",
            type(
                "_MockDatetime",
                (),
                {
                    "now": staticmethod(
                        lambda tz=None: future - __import__("datetime").timedelta(seconds=120)
                    )
                },
            )(),
        )
        # HTTP-date で 120 秒後を指定
        import email.utils

        date_str = email.utils.format_datetime(future)
        result = HttpClient._parse_retry_after(date_str)
        assert result == 120

    def test_http_date_past_clamps_to_one(self):
        """HTTP-date が過去の日時を示す場合は 1 秒にクランプされる。"""
        # 過去の日時を指定
        past_date = "Mon, 01 Jan 2000 00:00:00 GMT"
        assert HttpClient._parse_retry_after(past_date) == 1


# ── _mask_api_key ──


class TestMaskApiKey:
    def test_masks_api_key(self):
        url = "https://example.com/api?apiKey=secret123&other=val"
        assert HttpClient._mask_api_key(url) == ("https://example.com/api?apiKey=***&other=val")

    def test_no_api_key_unchanged(self):
        url = "https://example.com/api?foo=bar"
        assert HttpClient._mask_api_key(url) == url


# ── paginate_link_header ──


class TestPaginateNegativeLimit:
    def test_link_header_negative_limit_raises(self):
        c = HttpClient(BASE)
        with pytest.raises(ValueError, match="non-negative"):
            paginate_link_header(c, "/items", limit=-1)

    def test_page_param_negative_limit_raises(self):
        c = HttpClient(BASE)
        with pytest.raises(ValueError, match="non-negative"):
            paginate_page_param(c, "/items", limit=-1)

    def test_response_body_negative_limit_raises(self):
        c = HttpClient(BASE)
        with pytest.raises(ValueError, match="non-negative"):
            paginate_response_body(c, "/items", limit=-1)

    def test_offset_negative_limit_raises(self):
        c = HttpClient(BASE)
        with pytest.raises(ValueError, match="non-negative"):
            paginate_offset(c, "/items", limit=-1)

    def test_top_skip_negative_limit_raises(self):
        c = HttpClient(BASE)
        with pytest.raises(ValueError, match="non-negative"):
            paginate_top_skip(c, "/items", limit=-1)


class TestPaginateLinkHeader:
    @responses.activate
    def test_single_page(self):
        responses.add(responses.GET, f"{BASE}/items", json=[{"id": 1}])
        c = HttpClient(BASE)
        result = paginate_link_header(c, "/items", limit=10)
        assert result == [{"id": 1}]

    @responses.activate
    def test_multi_page(self):
        import json as json_mod

        next_url = f"{BASE}/items?page=2&per_page=2"
        call_count = {"n": 0}

        def callback(request):
            call_count["n"] += 1
            if call_count["n"] == 1:
                headers = {"Link": f'<{next_url}>; rel="next"'}
                return (200, headers, json_mod.dumps([{"id": 1}, {"id": 2}]))
            return (200, {}, json_mod.dumps([{"id": 3}]))

        responses.add_callback(responses.GET, f"{BASE}/items", callback=callback)
        c = HttpClient(BASE)
        result = paginate_link_header(c, "/items", per_page=2, limit=10)
        assert len(result) == 3
        assert call_count["n"] == 2

    @responses.activate
    def test_limit_truncates(self):
        responses.add(
            responses.GET,
            f"{BASE}/items",
            json=[{"id": 1}, {"id": 2}, {"id": 3}],
        )
        c = HttpClient(BASE)
        result = paginate_link_header(c, "/items", limit=2)
        assert len(result) == 2

    @responses.activate
    def test_empty_response(self):
        responses.add(responses.GET, f"{BASE}/items", json=[])
        c = HttpClient(BASE)
        result = paginate_link_header(c, "/items", limit=10)
        assert result == []

    @responses.activate
    def test_limit_zero_unlimited(self):
        import json as json_mod

        next_url = f"{BASE}/items?page=2&per_page=30"
        call_count = {"n": 0}

        def callback(request):
            call_count["n"] += 1
            if call_count["n"] == 1:
                headers = {"Link": f'<{next_url}>; rel="next"'}
                return (200, headers, json_mod.dumps([{"id": 1}, {"id": 2}]))
            return (200, {}, json_mod.dumps([{"id": 3}, {"id": 4}]))

        responses.add_callback(responses.GET, f"{BASE}/items", callback=callback)
        c = HttpClient(BASE)
        result = paginate_link_header(c, "/items", limit=0)
        assert len(result) == 4
        assert call_count["n"] == 2

    @responses.activate
    def test_custom_per_page_key(self):
        """per_page_key="limit" 指定時にリクエスト URL に limit= が使われる。"""
        responses.add(responses.GET, f"{BASE}/items", json=[{"id": 1}])
        c = HttpClient(BASE)
        paginate_link_header(c, "/items", per_page_key="limit", per_page=20, limit=30)
        assert "limit=20" in responses.calls[0].request.url
        assert "per_page=" not in responses.calls[0].request.url

    @responses.activate
    def test_second_page_connection_error(self):
        import json as json_mod

        import requests as req_lib

        next_url = f"{BASE}/items?page=2"

        def callback(request):
            headers = {"Link": f'<{next_url}>; rel="next"'}
            return (200, headers, json_mod.dumps([{"id": 1}]))

        responses.add_callback(responses.GET, f"{BASE}/items", callback=callback)
        responses.add(responses.GET, next_url, body=req_lib.ConnectionError("refused"))
        c = HttpClient(BASE)
        with pytest.raises(NetworkError):
            paginate_link_header(c, "/items", limit=10)

    @responses.activate
    def test_per_page_limited_to_limit(self):
        """limit=2 のとき per_page が 2 に切り詰められる。"""
        responses.add(responses.GET, f"{BASE}/items", json=[{"id": 1}, {"id": 2}])
        c = HttpClient(BASE)
        paginate_link_header(c, "/items", per_page=30, limit=2)
        assert "per_page=2" in responses.calls[0].request.url

    @responses.activate
    def test_three_pages(self):
        """3 ページ以上の連鎖ページネーションが正しく結合される。"""
        import json as json_mod

        page2_url = f"{BASE}/items?page=2"
        page3_url = f"{BASE}/items?page=3"
        call_count = {"n": 0}

        def callback(request):
            call_count["n"] += 1
            if call_count["n"] == 1:
                headers = {"Link": f'<{page2_url}>; rel="next"'}
                return (200, headers, json_mod.dumps([{"id": 1}]))
            if call_count["n"] == 2:
                headers = {"Link": f'<{page3_url}>; rel="next"'}
                return (200, headers, json_mod.dumps([{"id": 2}]))
            return (200, {}, json_mod.dumps([{"id": 3}]))

        responses.add_callback(responses.GET, f"{BASE}/items", callback=callback)
        c = HttpClient(BASE)
        result = paginate_link_header(c, "/items", limit=0)
        assert len(result) == 3
        assert call_count["n"] == 3

    @responses.activate
    def test_non_json_response_stops_pagination(self):
        """200 で非 JSON レスポンスを返した場合、ループを終了して空リストを返す。"""
        responses.add(
            responses.GET,
            f"{BASE}/items",
            body="<html>not json</html>",
            status=200,
            content_type="text/html",
        )
        c = HttpClient(BASE)
        result = paginate_link_header(c, "/items", limit=10)
        assert result == []

    @responses.activate
    def test_cross_origin_next_link_stops_pagination(self):
        """Link ヘッダーの next URL が異なるオリジンの場合はページネーションを停止する。"""
        import json as json_mod

        cross_origin_url = "https://evil.com/items?page=2"

        def callback(request):
            headers = {"Link": f'<{cross_origin_url}>; rel="next"'}
            return (200, headers, json_mod.dumps([{"id": 1}]))

        responses.add_callback(responses.GET, f"{BASE}/items", callback=callback)
        c = HttpClient(BASE)
        result = paginate_link_header(c, "/items", limit=10)
        assert result == [{"id": 1}]


# ── paginate_page_param ──


class TestPaginatePageParam:
    @responses.activate
    def test_single_page(self):
        responses.add(responses.GET, f"{BASE}/items", json=[{"id": 1}])
        c = HttpClient(BASE)
        result = paginate_page_param(c, "/items", limit=10)
        assert result == [{"id": 1}]

    @responses.activate
    def test_multi_page(self):
        responses.add(
            responses.GET,
            f"{BASE}/items",
            json=[{"id": 1}],
            headers={"X-Next-Page": "2"},
        )
        responses.add(responses.GET, f"{BASE}/items", json=[{"id": 2}])
        c = HttpClient(BASE)
        result = paginate_page_param(c, "/items", limit=10)
        assert len(result) == 2

    @responses.activate
    def test_limit(self):
        responses.add(
            responses.GET,
            f"{BASE}/items",
            json=[{"id": 1}, {"id": 2}, {"id": 3}],
        )
        c = HttpClient(BASE)
        result = paginate_page_param(c, "/items", limit=2)
        assert len(result) == 2

    @responses.activate
    def test_empty_response(self):
        responses.add(responses.GET, f"{BASE}/items", json=[])
        c = HttpClient(BASE)
        result = paginate_page_param(c, "/items", limit=10)
        assert result == []

    @responses.activate
    def test_limit_zero_unlimited(self):
        responses.add(
            responses.GET,
            f"{BASE}/items",
            json=[{"id": 1}, {"id": 2}],
            headers={"X-Next-Page": "2"},
        )
        responses.add(
            responses.GET,
            f"{BASE}/items",
            json=[{"id": 3}, {"id": 4}],
        )
        c = HttpClient(BASE)
        result = paginate_page_param(c, "/items", limit=0)
        assert len(result) == 4

    @responses.activate
    def test_per_page_limited_to_limit(self):
        """limit=2 のとき per_page が 2 に切り詰められる。"""
        responses.add(responses.GET, f"{BASE}/items", json=[{"id": 1}, {"id": 2}])
        c = HttpClient(BASE)
        paginate_page_param(c, "/items", per_page=20, limit=2)
        assert "per_page=2" in responses.calls[0].request.url

    @responses.activate
    def test_next_page_header_non_integer_stops_pagination(self):
        """X-Next-Page が整数でない場合はページネーションを停止する。"""
        responses.add(
            responses.GET,
            f"{BASE}/items",
            json=[{"id": 1}],
            headers={"X-Next-Page": "invalid"},
        )
        c = HttpClient(BASE)
        result = paginate_page_param(c, "/items", limit=10)
        assert result == [{"id": 1}]

    @responses.activate
    def test_non_json_response_stops_pagination(self):
        """200 で非 JSON レスポンスが返ったときページネーションを停止して空リストを返す。"""
        responses.add(
            responses.GET,
            f"{BASE}/items",
            body="<html>not json</html>",
            content_type="text/html",
            status=200,
        )
        c = HttpClient(BASE)
        result = paginate_page_param(c, "/items", limit=10)
        assert result == []


# ── paginate_response_body ──


class TestPaginateResponseBody:
    @responses.activate
    def test_single_page(self):
        responses.add(responses.GET, f"{BASE}/items", json={"values": [{"id": 1}]})
        c = HttpClient(BASE)
        result = paginate_response_body(c, "/items", limit=10)
        assert result == [{"id": 1}]

    @responses.activate
    def test_multi_page(self):
        responses.add(
            responses.GET,
            f"{BASE}/items",
            json={
                "values": [{"id": 1}],
                "next": f"{BASE}/items?page=2",
            },
        )
        responses.add(
            responses.GET,
            f"{BASE}/items?page=2",
            json={"values": [{"id": 2}]},
        )
        c = HttpClient(BASE)
        result = paginate_response_body(c, "/items", limit=10)
        assert len(result) == 2

    @responses.activate
    def test_empty(self):
        responses.add(responses.GET, f"{BASE}/items", json={"values": []})
        c = HttpClient(BASE)
        result = paginate_response_body(c, "/items", limit=10)
        assert result == []

    @responses.activate
    def test_limit_zero_unlimited(self):
        next_url = f"{BASE}/items?page=2"
        responses.add(
            responses.GET,
            f"{BASE}/items",
            json={"values": [{"id": 1}, {"id": 2}], "next": next_url},
        )
        responses.add(
            responses.GET,
            next_url,
            json={"values": [{"id": 3}, {"id": 4}]},
        )
        c = HttpClient(BASE)
        result = paginate_response_body(c, "/items", limit=0)
        assert len(result) == 4

    @responses.activate
    def test_non_dict_body_stops_pagination(self):
        """レスポンスボディが dict でない場合はページネーションを停止する。"""
        responses.add(responses.GET, f"{BASE}/items", json=[1, 2, 3])
        c = HttpClient(BASE)
        result = paginate_response_body(c, "/items", limit=10)
        assert result == []

    @responses.activate
    def test_limit_truncates_results(self):
        """結果が limit を超える場合に切り詰められる。"""
        next_url = f"{BASE}/items?page=2"
        responses.add(
            responses.GET,
            f"{BASE}/items",
            json={"values": [{"id": 1}, {"id": 2}, {"id": 3}], "next": next_url},
        )
        c = HttpClient(BASE)
        result = paginate_response_body(c, "/items", limit=2)
        assert len(result) == 2

    @responses.activate
    def test_cross_origin_next_url_stops_pagination(self):
        """next URL が異なるオリジンの場合はページネーションを停止する。"""
        cross_origin_url = "https://evil.com/items?page=2"
        responses.add(
            responses.GET,
            f"{BASE}/items",
            json={"values": [{"id": 1}], "next": cross_origin_url},
        )
        c = HttpClient(BASE)
        result = paginate_response_body(c, "/items", limit=10)
        assert result == [{"id": 1}]

    @responses.activate
    def test_non_json_response_stops_pagination(self):
        """200 で非 JSON レスポンスが返ったときページネーションを停止して空リストを返す。"""
        responses.add(
            responses.GET,
            f"{BASE}/items",
            body="<html>not json</html>",
            content_type="text/html",
            status=200,
        )
        c = HttpClient(BASE)
        result = paginate_response_body(c, "/items", limit=10)
        assert result == []

    @responses.activate
    def test_non_string_next_url_stops_pagination(self):
        """next フィールドが文字列以外（整数等）の場合はページネーションを停止する（R29修正確認）。"""
        responses.add(
            responses.GET,
            f"{BASE}/items",
            json={"values": [{"id": 1}], "next": 42},
        )
        c = HttpClient(BASE)
        result = paginate_response_body(c, "/items", limit=10)
        assert result == [{"id": 1}]


# ── paginate_offset ──


class TestPaginateOffset:
    @responses.activate
    def test_single_page(self):
        responses.add(responses.GET, f"{BASE}/items", json=[{"id": 1}])
        c = HttpClient(BASE)
        result = paginate_offset(c, "/items", count=20, limit=10)
        assert result == [{"id": 1}]

    @responses.activate
    def test_multi_page(self):
        responses.add(responses.GET, f"{BASE}/items", json=[{"id": i} for i in range(20)])
        responses.add(responses.GET, f"{BASE}/items", json=[{"id": 20}])
        c = HttpClient(BASE)
        result = paginate_offset(c, "/items", count=20, limit=0)
        assert len(result) == 21

    @responses.activate
    def test_limit(self):
        responses.add(responses.GET, f"{BASE}/items", json=[{"id": i} for i in range(20)])
        c = HttpClient(BASE)
        result = paginate_offset(c, "/items", count=20, limit=5)
        assert len(result) == 5

    @responses.activate
    def test_empty_response(self):
        responses.add(responses.GET, f"{BASE}/items", json=[])
        c = HttpClient(BASE)
        result = paginate_offset(c, "/items", count=20, limit=10)
        assert result == []

    @responses.activate
    def test_non_json_response_stops_pagination(self):
        """200 で非 JSON レスポンスが返ったときページネーションを停止して空リストを返す。"""
        responses.add(
            responses.GET,
            f"{BASE}/items",
            body="<html>not json</html>",
            content_type="text/html",
            status=200,
        )
        c = HttpClient(BASE)
        result = paginate_offset(c, "/items", count=20, limit=10)
        assert result == []


# ── paginate_top_skip ──


class TestPaginateTopSkip:
    @responses.activate
    def test_single_page(self):
        responses.add(responses.GET, f"{BASE}/items", json={"value": [{"id": 1}]})
        c = HttpClient(BASE)
        result = paginate_top_skip(c, "/items", top=30, limit=10)
        assert result == [{"id": 1}]

    @responses.activate
    def test_multi_page(self):
        responses.add(
            responses.GET,
            f"{BASE}/items",
            json={"value": [{"id": i} for i in range(3)]},
        )
        responses.add(
            responses.GET,
            f"{BASE}/items",
            json={"value": [{"id": 3}]},
        )
        c = HttpClient(BASE)
        result = paginate_top_skip(c, "/items", top=3, limit=0)
        assert len(result) == 4

    @responses.activate
    def test_limit(self):
        responses.add(
            responses.GET,
            f"{BASE}/items",
            json={"value": [{"id": i} for i in range(10)]},
        )
        c = HttpClient(BASE)
        result = paginate_top_skip(c, "/items", top=10, limit=5)
        assert len(result) == 5

    @responses.activate
    def test_empty(self):
        responses.add(responses.GET, f"{BASE}/items", json={"value": []})
        c = HttpClient(BASE)
        result = paginate_top_skip(c, "/items", top=30, limit=10)
        assert result == []

    @responses.activate
    def test_non_dict_body_stops_pagination(self):
        """レスポンスボディが dict でない場合はページネーションを停止する。"""
        responses.add(responses.GET, f"{BASE}/items", json=[1, 2, 3])
        c = HttpClient(BASE)
        result = paginate_top_skip(c, "/items", top=30, limit=10)
        assert result == []

    @responses.activate
    def test_non_json_response_stops_pagination(self):
        """200 で非 JSON レスポンスが返ったときページネーションを停止して空リストを返す。"""
        responses.add(
            responses.GET,
            f"{BASE}/items",
            body="<html>not json</html>",
            content_type="text/html",
            status=200,
        )
        c = HttpClient(BASE)
        result = paginate_top_skip(c, "/items", top=30, limit=10)
        assert result == []


# ── _validate_same_origin ──


class TestValidateSameOrigin:
    def test_same_origin(self):
        assert (
            _validate_same_origin(
                "https://api.example.com/v1/items",
                "https://api.example.com/v1/items?page=2",
            )
            is True
        )

    def test_different_host_blocked(self):
        assert (
            _validate_same_origin(
                "https://api.example.com/v1",
                "https://api.evil.com/v1",
            )
            is False
        )

    def test_different_scheme_blocked(self):
        assert (
            _validate_same_origin(
                "https://api.example.com/v1",
                "http://api.example.com/v1",
            )
            is False
        )

    def test_different_port_blocked(self):
        assert (
            _validate_same_origin(
                "https://api.example.com:8080/v1",
                "https://api.example.com:9000/v1",
            )
            is False
        )

    def test_subdomain_blocked(self):
        assert (
            _validate_same_origin(
                "https://api.example.com/v1",
                "https://evil.example.com/v1",
            )
            is False
        )

    def test_implicit_vs_explicit_default_port(self):
        """https://host と https://host:443 は同一オリジン。"""
        assert (
            _validate_same_origin(
                "https://api.example.com/v1",
                "https://api.example.com:443/v1",
            )
            is True
        )

    def test_http_default_port(self):
        """http://host と http://host:80 は同一オリジン。"""
        assert (
            _validate_same_origin(
                "http://api.example.com/v1",
                "http://api.example.com:80/v1",
            )
            is True
        )


# ── _extract_next_link ──


class TestExtractNextLink:
    def test_basic(self):
        link = '<https://api.example.com/items?page=2>; rel="next"'
        assert _extract_next_link(link) == "https://api.example.com/items?page=2"

    def test_with_prev(self):
        link = '<https://api.example.com/items?page=1>; rel="prev", <https://api.example.com/items?page=3>; rel="next"'
        assert _extract_next_link(link) == "https://api.example.com/items?page=3"

    def test_param_order_variation(self):
        """rel が URL の直後でなくても正しく抽出する（RFC 5988 順序非依存）。"""
        link = '<https://api.example.com/items?page=2>; title="Page 2"; rel="next"'
        assert _extract_next_link(link) == "https://api.example.com/items?page=2"

    def test_no_next(self):
        link = '<https://api.example.com/items?page=1>; rel="prev"'
        assert _extract_next_link(link) is None

    def test_empty_string(self):
        assert _extract_next_link("") is None
