"""Narrow read-only Strava API client; request bodies and credentials are never logged."""

from __future__ import annotations

import json
from dataclasses import dataclass
from typing import Callable, Protocol
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request

from mountain_twin.offline import urlopen  # AV-045: offline mode for tests

from .oauth import REVOKE_ENDPOINT, StravaToken

API_ROOT = "https://www.strava.com/api/v3"
STREAM_KEYS = (
    "time",
    "distance",
    "latlng",
    "altitude",
    "heartrate",
    "velocity_smooth",
    "cadence",
    "temp",
)


@dataclass(frozen=True)
class HttpResponse:
    status: int
    headers: dict[str, str]
    body: object


@dataclass(frozen=True)
class RateLimitMetadata:
    usage: str | None
    limit: str | None
    retry_after_seconds: int | None


class StravaApiError(RuntimeError):
    def __init__(self, code: str, rate_limit: RateLimitMetadata | None = None):
        super().__init__(code)
        self.rate_limit = rate_limit


class StravaAuthenticationError(StravaApiError):
    pass


class StravaInsufficientScopeError(StravaApiError):
    pass


class StravaRateLimitError(StravaApiError):
    pass


class StravaNetworkError(StravaApiError):
    pass


class HttpTransport(Protocol):
    def __call__(
        self, method: str, url: str, headers: dict[str, str], body: bytes | None = None
    ) -> HttpResponse: ...


def urllib_transport(
    method: str, url: str, headers: dict[str, str], body: bytes | None = None
) -> HttpResponse:
    try:
        with urlopen(
            Request(url, data=body, headers=headers, method=method), timeout=30
        ) as response:
            return HttpResponse(
                response.status, dict(response.headers.items()), json.loads(response.read())
            )
    except HTTPError as error:
        return HttpResponse(
            error.code, dict(error.headers.items()), json.loads(error.read() or b"{}")
        )
    except (URLError, OSError, json.JSONDecodeError) as error:
        raise StravaNetworkError("STRAVA_NETWORK_FAILURE") from error


class StravaClient:
    def __init__(
        self,
        token: StravaToken,
        transport: HttpTransport = urllib_transport,
        refresher: Callable[[StravaToken], StravaToken] | None = None,
    ):
        self.token, self.transport, self.refresher = token, transport, refresher
        self.last_rate_limit: RateLimitMetadata | None = None

    def ensure_access(self, required_scope: str = "activity:read") -> None:
        if required_scope not in self.token.granted_scopes:
            raise StravaInsufficientScopeError("STRAVA_SCOPE_INSUFFICIENT")
        if self.token.requires_refresh():
            if self.refresher is None:
                raise StravaAuthenticationError("STRAVA_TOKEN_REFRESH_REQUIRED")
            self.token = self.refresher(self.token)

    def athlete(self) -> dict:
        return self._get("/athlete")

    def list_activities(
        self,
        *,
        after: int | None = None,
        before: int | None = None,
        per_page: int = 30,
        max_pages: int = 1,
    ) -> tuple[dict, ...]:
        self.ensure_access()
        result = []
        for page in range(1, max_pages + 1):
            query = {"page": page, "per_page": min(max(per_page, 1), 200)}
            if after is not None:
                query["after"] = after
            if before is not None:
                query["before"] = before
            values = self._get("/athlete/activities", query)
            if not isinstance(values, list):
                raise StravaApiError("STRAVA_ACTIVITY_LIST_INVALID")
            result.extend(values)
            if len(values) < query["per_page"]:
                break
        return tuple(result)

    def activity_page(
        self, *, page: int, per_page: int, after: int | None = None, before: int | None = None
    ) -> tuple[dict, ...]:
        self.ensure_access()
        query = {"page": max(page, 1), "per_page": min(max(per_page, 1), 200)}
        if after is not None:
            query["after"] = after
        if before is not None:
            query["before"] = before
        values = self._get("/athlete/activities", query)
        if not isinstance(values, list):
            raise StravaApiError("STRAVA_ACTIVITY_LIST_INVALID")
        return tuple(values)

    def activity(self, activity_id: str) -> dict:
        self.ensure_access()
        return self._get(f"/activities/{activity_id}")

    def streams(self, activity_id: str) -> dict:
        self.ensure_access()
        return self._get(
            f"/activities/{activity_id}/streams",
            {"keys": ",".join(STREAM_KEYS), "key_by_type": "true"},
        )

    def deauthorize(self) -> None:
        # Strava documents access_token as this endpoint's query parameter. It is never logged.
        query = urlencode({"access_token": self.token.access_token})
        response = self.transport("POST", f"{REVOKE_ENDPOINT}?{query}", {})
        self._raise_for_response(response)

    def _get(self, path: str, query: dict | None = None):
        suffix = f"?{urlencode(query)}" if query else ""
        return self._request("GET", f"{API_ROOT}{path}{suffix}")

    def _request(self, method: str, url: str, _: dict | None = None):
        response = self.transport(
            method, url, {"Authorization": f"Bearer {self.token.access_token}"}
        )
        self._raise_for_response(response)
        return response.body

    def _raise_for_response(self, response: HttpResponse) -> None:
        metadata = RateLimitMetadata(
            response.headers.get("X-RateLimit-Usage"),
            response.headers.get("X-RateLimit-Limit"),
            _integer_header(response.headers.get("Retry-After")),
        )
        self.last_rate_limit = metadata
        if response.status == 401:
            raise StravaAuthenticationError("STRAVA_AUTHENTICATION_FAILED", metadata)
        if response.status == 429:
            raise StravaRateLimitError("STRAVA_RATE_LIMITED", metadata)
        if response.status in (403,):
            raise StravaInsufficientScopeError("STRAVA_SCOPE_INSUFFICIENT", metadata)
        if response.status < 200 or response.status >= 300:
            raise StravaApiError(f"STRAVA_API_HTTP_{response.status}", metadata)


def _integer_header(value: str | None) -> int | None:
    try:
        return int(value) if value is not None else None
    except ValueError:
        return None
