"""The metron module.

This module provides the following classes:
- Metron
"""

__all__ = ["Metron"]

import json
import logging
import platform
from datetime import datetime
from email.utils import format_datetime, parsedate_to_datetime
from typing import Any, ClassVar, Final
from urllib.parse import urlencode

from httpx import BasicAuth, Client, HTTPStatusError, RequestError, TimeoutException, codes
from pydantic import TypeAdapter, ValidationError
from pyrate_limiter import Duration, Limiter, Rate, SQLiteBucket

from seagrin import __version__
from seagrin.cache import CachePolicy, EndpointType, SQLiteCache
from seagrin.errors import AuthenticationError, RateLimitError, ServiceError
from seagrin.schemas import BaseResource, Publisher

# Constants
LOGGER = logging.getLogger(__name__)
MINUTE_RATE: Final[int] = 30
DAY_RATE: Final[int] = 10_000
SECONDS_PER_MINUTE: Final[int] = 60
SECONDS_PER_HOUR: Final[int] = 3_600


def rfc7231_format(dt: datetime | None) -> str | None:
    return format_datetime(dt, usegmt=True) if dt else None


def rfc7231_parse(dt: str | None) -> datetime | None:
    return parsedate_to_datetime(dt) if dt else None


def format_time(seconds: str | float) -> str:
    total_seconds = int(seconds)
    if total_seconds < 0:
        return "0 seconds"
    hours = total_seconds // SECONDS_PER_HOUR
    minutes = (total_seconds % SECONDS_PER_HOUR) // SECONDS_PER_MINUTE
    remaining_seconds = total_seconds % SECONDS_PER_MINUTE
    parts = []
    if hours > 0:
        parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
    if minutes > 0:
        parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
    if remaining_seconds > 0 or not parts:
        parts.append(f"{remaining_seconds} second{'s' if remaining_seconds != 1 else ''}")
    return ", ".join(parts)


def rate_mapping(*arg: Any, **kwargs: Any) -> tuple[str, int]:
    return "metron", 1


class Metron:
    """Client for the Metron comic-book database API.

    Handles authentication, rate limiting, caching, and response validation for all requests made
        to the Metron REST API.

    Args:
        username: Metron account username used for HTTP Basic authentication.
        password: Metron account password used for HTTP Basic authentication.
        cache: Cache object to store and retrieve responses.
        base_url: Root URL of the Metron API.
        user_agent: Value sent in the `User-Agent` request header.
        timeout: Number of seconds to wait for a server response before timing out.
        policy: Cache-freshness policy applied to every request.
            Pass `None` to disable freshness checks and always return cached data when available.
    """

    _minute_rate = Rate(MINUTE_RATE, Duration.MINUTE)
    _daily_rate = Rate(DAY_RATE, Duration.DAY)
    _rates: ClassVar[list[Rate]] = [_minute_rate, _daily_rate]
    _bucket = SQLiteBucket.init_from_file(_rates)
    _limiter = Limiter(_bucket, raise_when_fail=False, max_delay=Duration.DAY)
    decorator = _limiter.as_decorator()

    def __init__(
        self,
        username: str,
        password: str,
        cache: SQLiteCache,
        base_url: str = "https://metron.cloud/api",
        user_agent: str = f"Seagrin/{__version__} ({platform.system()}: {platform.release()}; Python v{platform.python_version()})",  # noqa: E501
        timeout: float = 20,
        policy: CachePolicy | None = CachePolicy(),  # noqa: B008
    ):
        self._base_url = base_url
        self._client = Client(
            base_url=self._base_url,
            headers={"Accept": "application/json", "User-Agent": user_agent},
            auth=BasicAuth(username=username, password=password),
            timeout=timeout,
        )
        self._cache = cache
        self._policy = policy

    @decorator(rate_mapping)
    def _perform_get_request(
        self,
        endpoint: str,
        params: dict[str, str] | None = None,
        last_modified: datetime | None = None,
    ) -> tuple[dict[str, Any] | None, datetime | None]:
        params = params or {}
        headers = {"If-Modified-Since": rfc7231_format(dt=last_modified)} if last_modified else {}
        try:
            response = self._client.get(endpoint, params=params, headers=headers)
            response.raise_for_status()
            return response.json(), rfc7231_parse(dt=response.headers.get("Last-Modified"))
        except RequestError as err:
            raise ServiceError(f"Unable to connect to '{self._base_url}{endpoint}'") from err
        except HTTPStatusError as err:
            try:
                if err.response.status_code == codes.NOT_MODIFIED:
                    return None, rfc7231_parse(dt=err.response.headers.get("Last-Modified"))
                if err.response.status_code == codes.UNAUTHORIZED:
                    raise AuthenticationError(err.response.json()["detail"]) from err
                if err.response.status_code == codes.NOT_FOUND:
                    raise ServiceError(err.response.json()["detail"]) from err
                if err.response.status_code == codes.TOO_MANY_REQUESTS:
                    raise RateLimitError(
                        f"Too Many API Requests: Need to wait {format_time(err.response.headers.get('Retry-After', 0))}."  # noqa: E501
                    ) from err
                raise ServiceError(err) from err
            except json.JSONDecodeError as err:
                raise ServiceError(
                    f"Unable to parse response from '{self._base_url}{endpoint}' as Json"
                ) from err
        except json.JSONDecodeError as err:
            raise ServiceError(
                "Unable to parse response from '{self._base_url}{endpoint}' as Json"
            ) from err
        except TimeoutException as err:
            raise ServiceError("Service took too long to respond") from err

    def _get_request(
        self, endpoint: str, endpoint_type: EndpointType, params: dict[str, str] | None = None
    ) -> dict[str, Any]:
        params = params or {}
        url = self._base_url + endpoint
        cache_params = f"?{urlencode({k: params[k] for k in sorted(params)})}" if params else ""
        cache_key = url + cache_params
        cache_data = self._cache.select(url=cache_key)
        if cache_data and cache_data.is_fresh(policy=self._policy, endpoint_type=endpoint_type):
            try:
                return json.loads(cache_data.response)
            except json.JSONDecodeError as err:
                LOGGER.warning(err)
                self._cache.delete(url=cache_key)
                cache_data = None
        body, last_modified = self._perform_get_request(
            endpoint=endpoint,
            params=params,
            last_modified=cache_data.last_modified if cache_data else None,
        )
        response = None
        if body:
            response = body
        elif cache_data:
            try:
                response = json.loads(cache_data.response)
            except json.JSONDecodeError as err:
                LOGGER.warning(err)
                self._cache.delete(url=cache_key)
                cache_data = None
        if response:
            self._cache.insert(
                url=cache_key, response=json.dumps(response), last_modified=last_modified
            )
            return response
        self._cache.delete(url=cache_key)
        raise ServiceError(f"Unable to retrieve request for '{url}'")

    def _fetch_item(self, endpoint: str) -> dict[str, Any]:
        return self._get_request(endpoint=endpoint, endpoint_type=EndpointType.ITEM)

    def _fetch_list(
        self, endpoint: str, params: dict[str, str] | None = None
    ) -> list[dict[str, Any]]:
        params = params or {}
        results = []
        page = int(params.get("page", "1"))
        while True:
            response = self._get_request(
                endpoint=endpoint,
                params={**params, "page": str(page)},
                endpoint_type=EndpointType.LIST,
            )
            results.extend(response["results"])
            page += 1
            if response["next"] is None:
                break
        return results

    def list_publishers(self, params: dict[str, str] | None = None) -> list[BaseResource]:
        """Retrieve a list of publishers with optional filtering.

        Args:
            params: Optional dictionary of query parameters for filtering results.
                   Common parameters include 'name', 'modified_gt', 'modified_lt'.

        Returns:
            A list of BaseResource objects representing publishers.

        Raises:
            ServiceError: If the API returns an unexpected response or validation of response fails.
            AuthenticationError: If the request has invalid credentials
            RateLimitError: If the API returns a `Too Many Requests` response.
                This should be an unlikely error due to seagrin's builtin rate-limiting.
        """
        try:
            results = self._fetch_list(endpoint="/publisher/", params=params)
            return TypeAdapter(list[BaseResource]).validate_python(results)
        except ValidationError as err:
            raise ServiceError(err) from err

    def get_publisher(self, publisher_id: int) -> Publisher | None:
        """Retrieve detailed information about a publisher by ID.

        Args:
            publisher_id: The unique identifier for the publisher.

        Returns:
            A Publisher object, or `None` if the resource has not been modified.

        Raises:
            ServiceError: If the API returns an unexpected response or validation of response fails.
            AuthenticationError: If the request has invalid credentials
            RateLimitError: If the API returns a `Too Many Requests` response.
                This should be an unlikely error due to seagrin's builtin rate-limiting.
        """
        try:
            result = self._fetch_item(endpoint=f"/publisher/{publisher_id}/")
            return TypeAdapter(Publisher).validate_python(result)
        except ValidationError as err:
            raise ServiceError(err) from err
