"""The asynchronous AccuWeather weather client (httpx-based).

Mirrors :class:`accuweather_client.clients.WeatherClient` method-for-method
so switching between sync and async code is a mechanical change. Requires
the ``async`` extra::

    pip install 'accuweather-client[async]'

Example:
    >>> import asyncio
    >>> from accuweather_client import AsyncWeatherClient
    >>>
    >>> async def main():
    ...     async with AsyncWeatherClient(token=API_KEY, city="sydney") as client:
    ...         conditions = await client.get_current_conditions()
    ...         print(conditions.current_conditions)
    >>>
    >>> asyncio.run(main())
"""

from collections.abc import Mapping
from typing import Any

from accuweather_client._http import (
    BASE_URL,
    DEFAULT_RETRIES,
    DEFAULT_TIMEOUT,
    build_params,
    raise_for_response,
    validate_token,
)
from accuweather_client.cache import TTLCache
from accuweather_client.exceptions import (
    ApiRequestError,
    LocationNotFoundError,
)
from accuweather_client.models import (
    AlertsModel,
    CurrentConditionsModel,
    ForecastModel5Days,
    HistoricalConditionsModel,
    HourlyForecastModel,
    IndicesModel,
    LocationModel,
    LocationModelItem,
)


def _require_httpx():
    """Import httpx lazily, with a helpful error if it is missing."""
    try:
        import httpx
    except ImportError as exc:  # pragma: no cover
        raise ImportError(
            "httpx is required for the async client. Install it with: "
            "pip install 'accuweather-client[async]'"
        ) from exc
    return httpx


class AsyncWeatherClient:
    """Async twin of :class:`~accuweather_client.clients.WeatherClient`.

    Accepts the same arguments and exposes the same methods (awaitable).
    Use it as an async context manager so the underlying HTTP connection
    pool is closed cleanly, or call :meth:`aclose` yourself.

    Args:
        token: A valid AccuWeather API key.
        city: City name, e.g. ``"sydney"``.
        country: Country name to disambiguate the city.
        poi: Point of interest, e.g. ``"Eiffel tower"``.
        lat: Latitude in decimal degrees (used together with ``lon``).
        lon: Longitude in decimal degrees (used together with ``lat``).
        metric: Request metric units (default) or imperial.
        language: BCP-47 language code for localized text.
        timeout: Per-request timeout in seconds.
        retries: Automatic retries for transient connection errors.
        cache_ttl: When > 0, cache weather responses for this many
            seconds.

    Raises:
        InvalidTokenError: If the token does not look valid.
        ValueError: If the location arguments are missing or conflicting.
        ImportError: If httpx is not installed.
    """

    def __init__(
        self,
        token: str,
        city: str | None = None,
        country: str | None = None,
        poi: str | None = None,
        lat: float | None = None,
        lon: float | None = None,
        *,
        metric: bool = True,
        language: str | None = None,
        timeout: float = DEFAULT_TIMEOUT,
        retries: int = DEFAULT_RETRIES,
        cache_ttl: float = 0,
        base_url: str = BASE_URL,
    ):
        httpx = _require_httpx()
        self.token = validate_token(token)
        self.metric = metric
        self.language = language
        self.city = city
        self.country = country
        self.poi = poi
        self.lat = lat
        self.lon = lon
        if city and poi:
            raise ValueError(
                'Only one of "city" or "poi" can be provided, not both.'
            )
        if not any((city, poi)) and (lat is None or lon is None):
            raise ValueError(
                'You must provide either a "city", a "poi" or a '
                '"lat lon" combination.'
            )
        from accuweather_client._http import _user_agent

        self._client = httpx.AsyncClient(
            base_url=base_url,
            timeout=timeout,
            transport=httpx.AsyncHTTPTransport(retries=retries),
            headers={"User-Agent": _user_agent()},
        )
        self._location: LocationModelItem | None = None
        self._cache = TTLCache(ttl=cache_ttl)

    async def __aenter__(self) -> "AsyncWeatherClient":
        return self

    async def __aexit__(self, *exc_info: Any) -> None:
        await self.aclose()

    async def aclose(self) -> None:
        """Close the underlying HTTP connection pool."""
        await self._client.aclose()

    # -- internal --------------------------------------------------------

    async def _get(
        self,
        endpoint: str,
        *,
        details: bool = True,
        metric: bool | None = None,
        extra: Mapping[str, Any] | None = None,
    ) -> Any:
        """Perform a cached GET request and return parsed JSON."""
        httpx = _require_httpx()
        cache_key = (endpoint, metric, tuple(sorted((extra or {}).items())))
        cached = self._cache.get(cache_key)
        if cached is not None:
            return cached
        params = build_params(
            self.token,
            details=details,
            metric=metric,
            language=self.language,
            extra=extra,
        )
        try:
            response = await self._client.get(endpoint, params=params)
        except httpx.HTTPError as exc:
            raise ApiRequestError(
                f"Failed to reach the AccuWeather API at {endpoint}: {exc}"
            ) from exc
        raise_for_response(
            response.status_code,
            response.text,
            response.headers,
            str(response.url),
        )
        payload = response.json()
        self._cache.set(cache_key, payload)
        return payload

    async def _resolve_location(self) -> LocationModelItem:
        """Resolve the configured location input to a location item."""
        if self._location is not None:
            return self._location
        params: dict[str, Any] = {}
        if self.city and self.country:
            endpoint = "locations/v1/search"
            params["q"] = f"{self.city} {self.country}"
        elif self.city:
            endpoint = "locations/v1/cities/search"
            params["q"] = self.city
        elif self.poi:
            endpoint = "locations/v1/poi/search"
            params["q"] = self.poi
        else:
            endpoint = "locations/v1/cities/geoposition/search"
            params["q"] = f"{self.lat},{self.lon}"
        payload = await self._get(endpoint, extra=params)
        if payload in (None, [], {}):
            raise LocationNotFoundError(
                f"No AccuWeather location matched {params.get('q')!r}."
            )
        self._location = LocationModel(response=payload).first
        return self._location

    async def get_location(self) -> LocationModelItem:
        """Return the resolved location (fetching it on first call)."""
        return await self._resolve_location()

    async def get_location_key(self) -> str:
        """Return the AccuWeather location key for the location."""
        return (await self._resolve_location()).Key

    # -- endpoints (async mirrors of WeatherClient) ----------------------

    async def get_current_conditions(self) -> CurrentConditionsModel:
        """Retrieve current conditions. See ``WeatherClient``."""
        key = await self.get_location_key()
        return CurrentConditionsModel(
            output=await self._get(f"currentconditions/v1/{key}")
        )

    async def get_historical_conditions(
        self, hours: int = 24
    ) -> HistoricalConditionsModel:
        """Retrieve past conditions (6 or 24 h). See ``WeatherClient``."""
        if hours not in (6, 24):
            raise ValueError(f"hours must be 6 or 24, got {hours}.")
        key = await self.get_location_key()
        suffix = "/historical" if hours == 6 else "/historical/24"
        return HistoricalConditionsModel(
            output=await self._get(f"currentconditions/v1/{key}{suffix}")
        )

    async def get_daily_forecast(self, days: int = 5) -> ForecastModel5Days:
        """Retrieve a 1/5/10/15-day forecast. See ``WeatherClient``."""
        if days not in (1, 5, 10, 15):
            raise ValueError(f"days must be 1, 5, 10 or 15, got {days}.")
        key = await self.get_location_key()
        return ForecastModel5Days.from_api_response(
            await self._get(
                f"forecasts/v1/daily/{days}day/{key}", metric=self.metric
            )
        )

    async def get_5day_forecast(self) -> ForecastModel5Days:
        """Retrieve the 5-day forecast."""
        return await self.get_daily_forecast(5)

    async def get_hourly_forecast(
        self, hours: int = 12
    ) -> HourlyForecastModel:
        """Retrieve a 1/12/24/72/120-hour forecast. See ``WeatherClient``."""
        if hours not in (1, 12, 24, 72, 120):
            raise ValueError(
                f"hours must be 1, 12, 24, 72 or 120, got {hours}."
            )
        key = await self.get_location_key()
        return HourlyForecastModel(
            output=await self._get(
                f"forecasts/v1/hourly/{hours}hour/{key}", metric=self.metric
            )
        )

    async def get_hourly_forecast_12h(self) -> HourlyForecastModel:
        """Retrieve the 12-hour hourly forecast (sync-API-compatible alias)."""
        return await self.get_hourly_forecast(12)

    async def get_alerts(self) -> AlertsModel:
        """Retrieve active severe-weather alerts. See ``WeatherClient``."""
        key = await self.get_location_key()
        return AlertsModel(output=await self._get(f"alerts/v1/{key}"))

    async def get_indices(self, index_id: int | None = None) -> IndicesModel:
        """Retrieve today's lifestyle indices. See ``WeatherClient``."""
        key = await self.get_location_key()
        endpoint = f"indices/v1/daily/1day/{key}"
        if index_id is not None:
            endpoint += f"/{index_id}"
        return IndicesModel(output=await self._get(endpoint))
