"""Tibber Data API client for REST endpoints."""

from __future__ import annotations

import asyncio
import datetime as dt
import logging
import random
from http import HTTPStatus
from typing import Any, NoReturn, TypeAlias

import aiohttp

from .const import DATA_API_ENDPOINT, DEFAULT_TIMEOUT, USERINFO_ENDPOINT, __version__
from .exceptions import (
    FatalHttpExceptionError,
    InvalidLoginError,
    RateLimitExceededError,
    RetryableHttpExceptionError,
    UserAgentMissingError,
)
from .response_handler import extract_response_data

_LOGGER = logging.getLogger(__name__)

MAX_RATE_LIMIT_ATTEMPTS = 2

SensorValue: TypeAlias = bool | int | float | str | None


class TibberDataAPI:
    """Client for Tibber Data API REST endpoints."""

    def __init__(
        self,
        access_token: str,
        timeout: int = DEFAULT_TIMEOUT,
        websession: aiohttp.ClientSession | None = None,
        user_agent: str | None = None,
    ) -> None:
        """Initialize the Tibber Data API client.

        :param access_token: The access token to access the Tibber Data API with.
        :param timeout: The timeout in seconds to use when communicating with the API.
        :param websession: The websession to use when communicating with the API.
        :param user_agent: Optional user agent string attached to outgoing requests.
        """
        owns_session = websession is None
        if websession is None:
            if user_agent is None:
                raise UserAgentMissingError("Please provide value for HTTP user agent")
            websession = aiohttp.ClientSession()
        elif user_agent is None:
            user_agent = websession.headers.get(aiohttp.hdrs.USER_AGENT)

        if user_agent is None:
            raise UserAgentMissingError("Please provide value for HTTP user agent")

        self.websession = websession
        self._owns_session = owns_session
        self.timeout = timeout
        self._access_token = access_token
        self._user_agent = f"{user_agent} pyTibber/{__version__} "
        self._devices: dict[str, TibberDevice] = {}
        self._rate_limit_attempt = 0

    def set_access_token(self, access_token: str) -> None:
        """Set the access token."""
        self._access_token = access_token

    async def close_connection(self) -> None:
        """Close the Data API connection."""
        if self._owns_session and not self.websession.closed:
            await self.websession.close()

    async def _make_request(
        self,
        method: str,
        endpoint: str,
        params: dict[str, Any] | None = None,
        retry: int = 3,
    ) -> dict[str, Any]:
        """Make a request to the Data API.

        :param method: HTTP method (GET, POST, etc.).
        :param endpoint: API endpoint path.
        :param params: Query parameters.
        :param retry: Number of retries on failure.
        """
        url = f"{DATA_API_ENDPOINT}{endpoint}"

        headers = {
            "Authorization": f"Bearer {self._access_token}",
            "Accept": "application/json",
            "Content-Type": "application/json",
        }
        if self._user_agent:
            headers[aiohttp.hdrs.USER_AGENT] = self._user_agent

        response: aiohttp.ClientResponse | None = None
        try:
            response = await self.websession.request(
                method,
                url,
                headers=headers,
                params=params,
                timeout=aiohttp.ClientTimeout(total=self.timeout),
            )
            status = response.status
            if status == HTTPStatus.OK:
                self._rate_limit_attempt = 0
                data = await extract_response_data(response)
                response.close()
                return data

            if status == HTTPStatus.TOO_MANY_REQUESTS:
                if self._rate_limit_attempt >= MAX_RATE_LIMIT_ATTEMPTS:
                    _LOGGER.error("Rate limit exceeded: max attempts (%d) reached", MAX_RATE_LIMIT_ATTEMPTS)
                    await self._handle_error_response(response)
                retry_after = response.headers.get("Retry-After")
                wait_time = self._calculate_429_wait_time(retry_after)
                wait_time = min(wait_time, 10)

                _LOGGER.warning(
                    "Rate limited (429), waiting %.2f seconds before retry (attempt %d/%d)",
                    wait_time,
                    self._rate_limit_attempt + 1,
                    MAX_RATE_LIMIT_ATTEMPTS,
                )
                await asyncio.sleep(wait_time)

                self._rate_limit_attempt += 1
                return await self._make_request(method, endpoint, params, retry)

            await self._handle_error_response(response)

        except (aiohttp.ClientError, TimeoutError):
            if retry > 0:
                _LOGGER.warning("Request failed, retrying... (%d attempts left)", retry, exc_info=True)
                return await self._make_request(method, endpoint, params, retry - 1)
            _LOGGER.exception("Error connecting to Tibber Data API")
            raise
        except (InvalidLoginError, FatalHttpExceptionError) as err:
            _LOGGER.error(
                "Fatal error interacting with Tibber Data API, HTTP status: %s. API error: %s / %s",
                err.status,
                err.extension_code,
                err.message,
            )
            raise
        except RetryableHttpExceptionError as err:
            _LOGGER.warning(
                "Temporary failure interacting with Tibber Data API, HTTP status: %s. API error: %s / %s",
                err.status,
                err.extension_code,
                err.message,
            )
            raise
        finally:
            if response is not None and not response.closed:
                response.close()

    def _calculate_429_wait_time(self, retry_after: str | None) -> float:
        """Calculate wait time for 429 rate limiting.

        :param retry_after: Retry-After header value (seconds or HTTP-date).
        :return: Wait time in seconds.
        """
        if retry_after:
            wait_seconds: int | float | None
            try:
                wait_seconds = int(float(retry_after))
            except ValueError:
                try:
                    retry_time = dt.datetime.fromisoformat(retry_after)
                    if retry_time.tzinfo is None:
                        retry_time = retry_time.replace(tzinfo=dt.UTC)
                    else:
                        retry_time = retry_time.astimezone(dt.UTC)
                    now = dt.datetime.now(dt.UTC)
                    wait_seconds = max(0, (retry_time - now).total_seconds())
                except ValueError:
                    wait_seconds = None

            if wait_seconds is not None:
                wait_seconds = min(wait_seconds, 10 * 60)
                jitter = random.uniform(0, 1)  # noqa: S311
                return wait_seconds + jitter

        base = 1.0
        max_wait = base * (2**self._rate_limit_attempt)
        max_wait = min(max_wait, 10 * 60)
        return random.uniform(0, 30) + max_wait  # noqa: S311

    async def _handle_error_response(self, response: aiohttp.ClientResponse) -> NoReturn:
        """Handle non-OK HTTP responses from the Data API."""
        status = response.status
        if status == HTTPStatus.UNAUTHORIZED:
            response.close()
            raise InvalidLoginError(status, "Invalid token")

        detail, extension_code = await self._read_error_response(response)
        response.close()

        fatal_codes: dict[int, str] = {
            HTTPStatus.BAD_REQUEST.value: "BAD_REQUEST",
            HTTPStatus.NOT_FOUND.value: "NOT_FOUND",
        }
        if status in fatal_codes:
            raise FatalHttpExceptionError(
                status,
                detail,
                extension_code or fatal_codes[status],
            )
        if status == HTTPStatus.TOO_MANY_REQUESTS:
            retry_after = response.headers.get("Retry-After")
            wait_time = self._calculate_429_wait_time(retry_after)
            raise RateLimitExceededError(
                status,
                detail or "Rate limited",
                extension_code or "RATE_LIMITED",
                wait_time,
            )
        if status == HTTPStatus.PRECONDITION_FAILED:
            raise RetryableHttpExceptionError(
                status,
                detail or "Rate limited",
                extension_code or "RATE_LIMITED",
            )
        if status >= HTTPStatus.INTERNAL_SERVER_ERROR:
            raise RetryableHttpExceptionError(status, detail, extension_code)
        if status >= HTTPStatus.BAD_REQUEST and status < HTTPStatus.INTERNAL_SERVER_ERROR:
            raise FatalHttpExceptionError(status, detail, extension_code)

        _LOGGER.error("Unexpected HTTP status: %s", status)
        raise FatalHttpExceptionError(status, detail, extension_code)

    async def _read_error_response(self, response: aiohttp.ClientResponse) -> tuple[str, str]:
        """Extract detail and extension code from an error HTTP response."""
        extension_code = f"HTTP_{response.status}"
        detail: str | None = response.reason

        if response.content_type == "application/json":
            try:
                error_data = await response.json()
            except (aiohttp.ContentTypeError, ValueError):
                pass
            else:
                preferred_detail = (
                    error_data.get("detail") or error_data.get("error_description") or error_data.get("error")
                )
                if preferred_detail is not None:
                    detail = preferred_detail
                extension_code = error_data.get("type") or extension_code
                return detail or "HTTP error", extension_code

        try:
            text = await response.text()
        except (aiohttp.ClientError, UnicodeDecodeError):
            text = None
        if text:
            detail = text
        return detail or "HTTP error", extension_code

    async def get_homes(self) -> list[dict[str, Any]]:
        """Get all homes for the user."""
        response = await self._make_request("GET", "/v1/homes")
        return response.get("homes", [])

    async def get_devices_for_home(self, home_id: str) -> list[dict[str, Any]]:
        """Get all devices for a specific home."""
        response = await self._make_request("GET", f"/v1/homes/{home_id}/devices")
        return response.get("devices", [])

    async def get_device(self, home_id: str, device_id: str) -> TibberDevice | None:
        """Get detailed information about a specific device."""
        try:
            response = await self._make_request("GET", f"/v1/homes/{home_id}/devices/{device_id}")
        except FatalHttpExceptionError:
            _LOGGER.error("Error getting device %s for home %s", device_id, home_id)
            return None
        return TibberDevice(response, home_id)

    async def get_all_devices(self) -> dict[str, TibberDevice]:
        """Get all devices for the user."""
        homes = await self.get_homes()
        if not homes:
            return {}

        devices: dict[str, TibberDevice] = {}
        for home in homes:
            _LOGGER.debug("home data: %s", home)
            raw_devices = await self.get_devices_for_home(home["id"])
            _LOGGER.debug("raw devices data: %s", raw_devices)
            if not raw_devices:
                continue
            for raw_device in raw_devices:
                device = await self.get_device(home["id"], raw_device["id"])
                if device is None:
                    _LOGGER.error("Error getting device %s", raw_device)
                    continue
                devices[device.id] = device
        self._devices = devices
        return devices

    async def update_devices(self) -> dict[str, TibberDevice]:
        """Update the devices."""
        tasks = [self.get_device(device.home_id, device.id) for device in self._devices.values()]
        for result in await asyncio.gather(*tasks, return_exceptions=True):
            if isinstance(result, BaseException):
                _LOGGER.error("Error getting device %s", result)
                raise result
            if result is not None:
                self._devices[result.id] = result
        return self._devices

    async def get_userinfo(self) -> dict[str, Any]:
        """Return OpenID Connect user info for the current access token."""
        headers = {
            "Authorization": f"Bearer {self._access_token}",
            "Accept": "application/json",
        }

        try:
            async with self.websession.get(
                USERINFO_ENDPOINT,
                headers=headers,
                timeout=aiohttp.ClientTimeout(total=self.timeout),
            ) as response:
                if response.status == HTTPStatus.OK:
                    return await response.json()
                if response.status == HTTPStatus.UNAUTHORIZED:
                    raise InvalidLoginError(response.status, "Invalid token")

                detail: str | None = None
                if response.content_type == "application/json":
                    error_data = await response.json()
                    detail = error_data.get("error_description")
                    if detail is None:
                        detail = error_data.get("detail")
                    if detail is None:
                        detail = error_data.get("error")
                else:
                    detail = await response.text()

                raise FatalHttpExceptionError(
                    response.status,
                    detail or "Failed to retrieve user info",
                    "USERINFO_HTTP_ERROR",
                )
        except (aiohttp.ClientError, TimeoutError):
            _LOGGER.exception("Error connecting to Tibber user info endpoint")
            raise


class TibberDevice:
    """Represents a Tibber device from the Data API."""

    def __init__(self, device_data: dict[str, Any], home_id: str) -> None:
        """Initialize the device."""
        self._data = device_data
        self._sensors = [Sensor(capability) for capability in device_data.get("capabilities", [])]
        attributes = device_data.get("attributes", [])
        for attribute in attributes:
            if "id" not in attribute:
                continue
            attr_value = attribute.get("value") if "value" in attribute else attribute.get("status")
            if attr_value is None:
                continue
            sensor_data = {
                "id": attribute["id"],
                "value": attr_value,
            }
            if "description" in attribute:
                sensor_data["description"] = attribute["description"]
            if "unit" in attribute:
                sensor_data["unit"] = attribute["unit"]
            self._sensors.append(Sensor(sensor_data))
        self._home_id = home_id

    @property
    def home_id(self) -> str:
        """Return the home ID."""
        return self._home_id

    @property
    def id(self) -> str:
        """Return the device ID."""
        return self._data["id"]

    @property
    def external_id(self) -> str:
        """Return the device external ID."""
        return self._data["externalId"]

    @property
    def name(self) -> str:
        """Return the device name."""
        return self._data["info"]["name"]

    @property
    def sensors(self) -> list[Sensor]:
        """Return the device sensors from both capabilities and attributes."""
        return self._sensors

    @property
    def brand(self) -> str:
        """Return the device brand."""
        return self._data["info"]["brand"]

    @property
    def model(self) -> str:
        """Return the device model."""
        return self._data["info"]["model"]

    def __repr__(self) -> str:
        """Return the representation of the device."""
        return f"TibberDevice(id={self.id}, name={self.name}, brand={self.brand}, model={self.model})"


class Sensor:
    """Represents a Tibber device sensor from capabilities or attributes in the Data API."""

    def __init__(self, capability_data: dict[str, Any]) -> None:
        """Initialize the sensor from capability or attribute data."""
        self._data = capability_data

    @property
    def id(self) -> str:
        """Return the sensor ID."""
        return self._data["id"]

    @property
    def unit(self) -> str:
        """Return the device capability unit."""
        return self._data.get("unit")

    @property
    def value(self) -> SensorValue:
        """Return the device capability value."""
        return self._data.get("value")

    @property
    def description(self) -> str:
        """Return the device capability description."""
        description = self._data.get("description")
        if not description:
            return ""
        return description.capitalize()

    def __repr__(self) -> str:
        """Return the representation of the sensor."""
        return f"Sensor(id={self.id}, unit={self.unit}, value={self.value}, description={self.description})"
