"""The synchronous AccuWeather weather client.

This is the package's main entry point. Its signature feature is that you
address locations the human way - by city name (optionally with a
country), point of interest, or coordinates - and the client resolves the
AccuWeather location key for you.

Example:
    >>> from accuweather_client import WeatherClient
    >>> client = WeatherClient(token=API_KEY, city="sydney")
    >>> client.location            # resolved Sydney, Australia
    >>> client.get_current_conditions().current_conditions
    'At the moment: sunny, with a temperature of 22.0C. ...'
    >>> client.get_5day_forecast().forecast_tomorrow
    'Partly sunny, max temp is 18.9C and 25% chance of rain.'
"""

from collections.abc import Mapping
from typing import Any

from accuweather_client._http import (
    DEFAULT_RETRIES,
    DEFAULT_TIMEOUT,
    Transport,
)
from accuweather_client.cache import TTLCache
from accuweather_client.clients.location import LocationClient
from accuweather_client.models import (
    AlertsModel,
    CurrentConditionsModel,
    ForecastModel5Days,
    HistoricalConditionsModel,
    HourlyForecastModel,
    IndicesModel,
    LocationModelItem,
)

_HOURLY_CHOICES = (1, 12, 24, 72, 120)
_DAILY_CHOICES = (1, 5, 10, 15)
_HISTORICAL_CHOICES = (6, 24)


class WeatherClient:
    """AccuWeather API client addressing locations the human-friendly way.

    Provide exactly one location input: a ``city`` (optionally with a
    ``country`` to disambiguate), a ``poi`` (point of interest), or a
    ``lat``/``lon`` pair. The location is resolved lazily on first use and
    cached, so constructing the client performs no network requests.

    Args:
        token: A valid AccuWeather API key
            (https://developer.accuweather.com).
        city: City name, e.g. ``"sydney"``.
        country: Country name to disambiguate the city, e.g. ``"canada"``.
        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 (``True``, default) or imperial
            (``False``) where the endpoint supports it.
        language: BCP-47 language code for localized text, e.g.
            ``"nl-nl"``. Defaults to the API's ``"en-us"``.
        timeout: Per-request timeout in seconds.
        retries: Automatic retries (with backoff) for transient failures.
        cache_ttl: When > 0, weather responses are cached in memory for
            this many seconds, conserving the 50-calls/day free quota.

    Raises:
        InvalidTokenError: If the token does not look like an AccuWeather
            API key.
        ValueError: If the location arguments are missing or conflicting.
    """

    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,
    ):
        self.transport = Transport(token, timeout=timeout, retries=retries)
        self.metric = metric
        self.language = language
        self.city = city
        self.country = country
        self.poi = poi
        self.lat = lat
        self.lon = lon
        self.location_client = LocationClient(
            token, language=language, transport=self.transport
        )
        # Validate the location arguments eagerly (no network involved) so
        # bad input fails at construction time, like it did in v1.
        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.'
            )
        self._location: LocationModelItem | None = None
        self._cache = TTLCache(ttl=cache_ttl)

    # -- location --------------------------------------------------------

    @property
    def location(self) -> LocationModelItem:
        """The resolved location (fetched on first access, then cached)."""
        if self._location is None:
            model = self.location_client.resolve(
                city=self.city,
                country=self.country,
                poi=self.poi,
                lat=self.lat,
                lon=self.lon,
            )
            self._location = model.first
        return self._location

    @property
    def location_key(self) -> str:
        """The AccuWeather location key used by all weather endpoints."""
        return self.location.Key

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

    def _get(
        self,
        endpoint: str,
        *,
        metric: bool | None = None,
        extra: Mapping[str, Any] | None = None,
    ) -> Any:
        """Perform a cached GET for a weather endpoint of this location."""
        cache_key = (endpoint, metric, tuple(sorted((extra or {}).items())))
        cached = self._cache.get(cache_key)
        if cached is not None:
            return cached
        payload = self.transport.get(
            endpoint,
            metric=metric,
            language=self.language,
            extra=extra,
        )
        self._cache.set(cache_key, payload)
        return payload

    # -- current & historical conditions ---------------------------------

    def get_current_conditions(self) -> CurrentConditionsModel:
        """Retrieve the current weather conditions.

        Returns:
            CurrentConditionsModel: Includes the human-readable
            ``.current_conditions`` summary and ``.to_pandas_df()``.
        """
        return CurrentConditionsModel(
            output=self._get(f"currentconditions/v1/{self.location_key}")
        )

    def get_historical_conditions(
        self, hours: int = 24
    ) -> HistoricalConditionsModel:
        """Retrieve past observed conditions for the last 6 or 24 hours.

        Requires a paid AccuWeather plan; free-tier keys receive HTTP 403
        (raised as :class:`~accuweather_client.exceptions.ApiRequestError`).

        Args:
            hours: Either ``6`` or ``24``.

        Returns:
            HistoricalConditionsModel: One observation per hour.

        Raises:
            ValueError: If ``hours`` is not 6 or 24.
        """
        if hours not in _HISTORICAL_CHOICES:
            raise ValueError(
                f"hours must be one of {_HISTORICAL_CHOICES}, got {hours}."
            )
        suffix = "/historical" if hours == 6 else "/historical/24"
        return HistoricalConditionsModel(
            output=self._get(
                f"currentconditions/v1/{self.location_key}{suffix}"
            )
        )

    # -- daily forecasts -------------------------------------------------

    def get_daily_forecast(self, days: int = 5) -> ForecastModel5Days:
        """Retrieve a daily forecast for 1, 5, 10 or 15 days.

        Note that 10- and 15-day forecasts require a paid AccuWeather
        plan; the free tier covers 1 and 5 days.

        Args:
            days: Forecast length: 1, 5, 10 or 15.

        Returns:
            ForecastModel5Days: With ``.forecast_tomorrow``, ``.text``
            and ``.to_pandas_df()`` conveniences.

        Raises:
            ValueError: If ``days`` is not a supported length.
        """
        if days not in _DAILY_CHOICES:
            raise ValueError(
                f"days must be one of {_DAILY_CHOICES}, got {days}."
            )
        return ForecastModel5Days.from_api_response(
            self._get(
                f"forecasts/v1/daily/{days}day/{self.location_key}",
                metric=self.metric,
            )
        )

    def get_1day_forecast(self) -> ForecastModel5Days:
        """Retrieve the 1-day forecast. See :meth:`get_daily_forecast`."""
        return self.get_daily_forecast(1)

    def get_5day_forecast(self) -> ForecastModel5Days:
        """Retrieve the 5-day forecast. See :meth:`get_daily_forecast`."""
        return self.get_daily_forecast(5)

    def get_10day_forecast(self) -> ForecastModel5Days:
        """Retrieve the 10-day forecast (paid plans only)."""
        return self.get_daily_forecast(10)

    def get_15day_forecast(self) -> ForecastModel5Days:
        """Retrieve the 15-day forecast (paid plans only)."""
        return self.get_daily_forecast(15)

    # -- hourly forecasts ------------------------------------------------

    def get_hourly_forecast(self, hours: int = 12) -> HourlyForecastModel:
        """Retrieve an hourly forecast for 1, 12, 24, 72 or 120 hours.

        24 hours and beyond require a paid AccuWeather plan; the free
        tier covers 1 and 12 hours.

        Args:
            hours: Forecast length: 1, 12, 24, 72 or 120.

        Returns:
            HourlyForecastModel: One entry per hour, with
            ``.to_pandas_df()`` for analysis.

        Raises:
            ValueError: If ``hours`` is not a supported length.
        """
        if hours not in _HOURLY_CHOICES:
            raise ValueError(
                f"hours must be one of {_HOURLY_CHOICES}, got {hours}."
            )
        return HourlyForecastModel(
            output=self._get(
                f"forecasts/v1/hourly/{hours}hour/{self.location_key}",
                metric=self.metric,
            )
        )

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

    # -- alerts & indices ------------------------------------------------

    def get_alerts(self) -> AlertsModel:
        """Retrieve active severe-weather alerts for the location.

        Requires a paid AccuWeather plan; free-tier keys receive HTTP 403
        (raised as :class:`~accuweather_client.exceptions.ApiRequestError`).

        Returns:
            AlertsModel: ``.count`` and ``.descriptions`` summarize the
            active alerts; the list is empty when there are none.
        """
        return AlertsModel(output=self._get(f"alerts/v1/{self.location_key}"))

    def get_indices(self, index_id: int | None = None) -> IndicesModel:
        """Retrieve today's lifestyle indices (pollen, UV, running, ...).

        Requires a paid AccuWeather plan; free-tier keys receive HTTP 403
        (raised as :class:`~accuweather_client.exceptions.ApiRequestError`).

        Args:
            index_id: Optional AccuWeather index group ID to restrict the
                response to a single index.

        Returns:
            IndicesModel: Filterable via ``.get("running")`` and
            exportable via ``.to_pandas_df()``.
        """
        endpoint = f"indices/v1/daily/1day/{self.location_key}"
        if index_id is not None:
            endpoint += f"/{index_id}"
        return IndicesModel(output=self._get(endpoint))
