"""Support for the Withings API.

For more details about this platform, please refer to the documentation at
"""

from __future__ import annotations

import asyncio
from collections.abc import Awaitable, Callable
import contextlib
from dataclasses import dataclass, field
from datetime import timedelta
from typing import TYPE_CHECKING, Any

from aiohttp import ClientError
from aiohttp.hdrs import METH_POST
from aiohttp.web import Request, Response
from aiowithings import NotificationCategory, WithingsClient
from aiowithings.exceptions import (
    WithingsAuthenticationFailedError,
    WithingsError,
    WithingsInvalidParamsError,
    WithingsTooManyRequestsError,
    WithingsUnauthorizedError,
)
from aiowithings.util import to_enum
from yarl import URL

from homeassistant.components import cloud
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.webhook import (
    async_generate_id as webhook_generate_id,
    async_generate_url as webhook_generate_url,
    async_register as webhook_register,
    async_unregister as webhook_unregister,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
    CONF_ACCESS_TOKEN,
    CONF_TOKEN,
    CONF_WEBHOOK_ID,
    EVENT_HOMEASSISTANT_STOP,
    Platform,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
    ImplementationUnavailableError,
    OAuth2Session,
    async_get_config_entry_implementation,
)
from homeassistant.helpers.event import async_call_later

from .const import DEFAULT_TITLE, DOMAIN, LOGGER
from .coordinator import (
    WithingsActivityDataUpdateCoordinator,
    WithingsBedPresenceDataUpdateCoordinator,
    WithingsDataUpdateCoordinator,
    WithingsDeviceDataUpdateCoordinator,
    WithingsGoalsDataUpdateCoordinator,
    WithingsMeasurementDataUpdateCoordinator,
    WithingsSleepDataUpdateCoordinator,
    WithingsWorkoutDataUpdateCoordinator,
)

PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SENSOR]

SUBSCRIBE_DELAY = timedelta(seconds=5)
UNSUBSCRIBE_DELAY = timedelta(seconds=1)
WEBHOOK_REGISTER_DELAY = 1
MAX_WEBHOOK_RETRY_INTERVAL = 1800
CONF_CLOUDHOOK_URL = "cloudhook_url"
type WithingsConfigEntry = ConfigEntry[WithingsData]


@dataclass(slots=True)
class WithingsData:
    """Dataclass to hold withings domain data."""

    client: WithingsClient
    measurement_coordinator: WithingsMeasurementDataUpdateCoordinator
    sleep_coordinator: WithingsSleepDataUpdateCoordinator
    bed_presence_coordinator: WithingsBedPresenceDataUpdateCoordinator
    goals_coordinator: WithingsGoalsDataUpdateCoordinator
    activity_coordinator: WithingsActivityDataUpdateCoordinator
    workout_coordinator: WithingsWorkoutDataUpdateCoordinator
    device_coordinator: WithingsDeviceDataUpdateCoordinator
    coordinators: set[WithingsDataUpdateCoordinator] = field(default_factory=set)

    def __post_init__(self) -> None:
        """Collect all coordinators in a set."""
        self.coordinators = {
            self.measurement_coordinator,
            self.sleep_coordinator,
            self.bed_presence_coordinator,
            self.goals_coordinator,
            self.activity_coordinator,
            self.workout_coordinator,
            self.device_coordinator,
        }


async def async_setup_entry(hass: HomeAssistant, entry: WithingsConfigEntry) -> bool:
    """Set up Withings from a config entry."""
    if CONF_WEBHOOK_ID not in entry.data or entry.unique_id is None:
        new_data = entry.data.copy()
        unique_id = str(entry.data[CONF_TOKEN]["userid"])
        if CONF_WEBHOOK_ID not in new_data:
            new_data[CONF_WEBHOOK_ID] = webhook_generate_id()

        hass.config_entries.async_update_entry(
            entry, data=new_data, unique_id=unique_id
        )
    session = async_get_clientsession(hass)
    client = WithingsClient(session=session)
    try:
        implementation = await async_get_config_entry_implementation(hass, entry)
    except ImplementationUnavailableError as err:
        raise ConfigEntryNotReady(
            translation_domain=DOMAIN,
            translation_key="oauth2_implementation_unavailable",
        ) from err
    oauth_session = OAuth2Session(hass, entry, implementation)

    refresh_lock = asyncio.Lock()

    async def _refresh_token() -> str:
        async with refresh_lock:
            await oauth_session.async_ensure_token_valid()
            token = oauth_session.token[CONF_ACCESS_TOKEN]
            if TYPE_CHECKING:
                assert isinstance(token, str)
            return token

    client.refresh_token_function = _refresh_token
    withings_data = WithingsData(
        client=client,
        measurement_coordinator=WithingsMeasurementDataUpdateCoordinator(
            hass, entry, client
        ),
        sleep_coordinator=WithingsSleepDataUpdateCoordinator(hass, entry, client),
        bed_presence_coordinator=WithingsBedPresenceDataUpdateCoordinator(
            hass, entry, client
        ),
        goals_coordinator=WithingsGoalsDataUpdateCoordinator(hass, entry, client),
        activity_coordinator=WithingsActivityDataUpdateCoordinator(hass, entry, client),
        workout_coordinator=WithingsWorkoutDataUpdateCoordinator(hass, entry, client),
        device_coordinator=WithingsDeviceDataUpdateCoordinator(hass, entry, client),
    )

    for coordinator in withings_data.coordinators:
        await coordinator.async_config_entry_first_refresh()

    device_registry = dr.async_get(hass)
    device_registry.async_get_or_create(
        config_entry_id=entry.entry_id,
        identifiers={(DOMAIN, str(entry.unique_id))},
        manufacturer="Withings",
    )
    entry.runtime_data = withings_data

    webhook_manager = WithingsWebhookManager(hass, entry)

    async def manage_cloudhook(state: cloud.CloudConnectionState) -> None:
        LOGGER.debug("Cloudconnection state changed to %s", state)
        if state is cloud.CloudConnectionState.CLOUD_CONNECTED:
            await webhook_manager.register_webhook(None)

        if state is cloud.CloudConnectionState.CLOUD_DISCONNECTED:
            await webhook_manager.unregister_webhook(None)
            entry.async_on_unload(
                async_call_later(hass, 30, webhook_manager.register_webhook)
            )

    if cloud.async_active_subscription(hass):
        if cloud.async_is_connected(hass):
            entry.async_on_unload(
                async_call_later(
                    hass, WEBHOOK_REGISTER_DELAY, webhook_manager.register_webhook
                )
            )
        entry.async_on_unload(
            cloud.async_listen_connection_change(hass, manage_cloudhook)
        )
    else:
        entry.async_on_unload(
            async_call_later(
                hass, WEBHOOK_REGISTER_DELAY, webhook_manager.register_webhook
            )
        )

    await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

    return True


async def async_unload_entry(hass: HomeAssistant, entry: WithingsConfigEntry) -> bool:
    """Unload Withings config entry."""
    webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID])

    return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)


async def async_subscribe_webhooks(client: WithingsClient, webhook_url: str) -> None:
    """Subscribe to Withings webhooks."""
    await async_unsubscribe_webhooks(client)

    notification_to_subscribe = {
        NotificationCategory.WEIGHT,
        NotificationCategory.PRESSURE,
        NotificationCategory.ACTIVITY,
        NotificationCategory.SLEEP,
        NotificationCategory.IN_BED,
        NotificationCategory.OUT_BED,
    }

    for notification in notification_to_subscribe:
        LOGGER.debug(
            "Subscribing %s for %s in %s seconds",
            webhook_url,
            notification,
            SUBSCRIBE_DELAY.total_seconds(),
        )
        # Withings will HTTP HEAD the callback_url and needs some downtime
        # between each call or there is a higher chance of failure.
        await asyncio.sleep(SUBSCRIBE_DELAY.total_seconds())
        await client.subscribe_notification(webhook_url, notification)


class WithingsWebhookManager:
    """Manager that manages the Withings webhooks."""

    def __init__(self, hass: HomeAssistant, entry: WithingsConfigEntry) -> None:
        """Initialize webhook manager."""
        self.hass = hass
        self.entry = entry
        self._subscribe_attempt: int = 0
        self._webhook_url: str | None = None
        self._webhooks_registered: bool = False
        self._webhook_url_invalid: bool = False
        self._ha_webhook_registered: bool = False
        self._cancel_retry: CALLBACK_TYPE | None = None
        self._register_lock = asyncio.Lock()

    @property
    def withings_data(self) -> WithingsData:
        """Return Withings data."""
        return self.entry.runtime_data

    def _cancel_pending_retry(self) -> None:
        """Cancel any pending retry."""
        if self._cancel_retry is not None:
            self._cancel_retry()
            self._cancel_retry = None

    def _schedule_retry(self, delay: int) -> None:
        """Schedule a webhook registration retry."""
        self._cancel_pending_retry()
        self._cancel_retry = async_call_later(
            self.hass, delay, self._async_subscribe_webhook
        )

    async def unregister_webhook(
        self,
        _: Any,
    ) -> None:
        """Unregister webhooks at Withings."""
        async with self._register_lock:
            LOGGER.debug(
                "Unregister Withings webhook (%s)", self.entry.data[CONF_WEBHOOK_ID]
            )
            self._cancel_pending_retry()
            webhook_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID])
            self._ha_webhook_registered = False
            self._webhook_url = None
            for coordinator in self.withings_data.coordinators:
                coordinator.webhook_subscription_listener(False)
            self._webhooks_registered = False
            self._subscribe_attempt = 0
            try:
                await async_unsubscribe_webhooks(self.withings_data.client)
            except WithingsError as ex:
                LOGGER.warning("Failed to unsubscribe from Withings webhook: %s", ex)

    async def register_webhook(
        self,
        _: Any,
    ) -> None:
        """Register webhooks at Withings."""
        async with self._register_lock:
            if self._webhooks_registered:
                return
            self._cancel_pending_retry()
            if cloud.async_active_subscription(self.hass):
                webhook_url = await _async_cloudhook_generate_url(self.hass, self.entry)
            else:
                webhook_url = webhook_generate_url(
                    self.hass, self.entry.data[CONF_WEBHOOK_ID]
                )
            url = URL(webhook_url)
            if url.scheme != "https":
                if not self._webhook_url_invalid:
                    LOGGER.warning(
                        "Webhook not registered - HTTPS is required. "
                        "See https://www.home-assistant.io/integrations/withings/#webhook-requirements"
                    )
                    self._webhook_url_invalid = True
                return
            if url.port != 443:
                if not self._webhook_url_invalid:
                    LOGGER.warning(
                        "Webhook not registered - port 443 is required. "
                        "See https://www.home-assistant.io/integrations/withings/#webhook-requirements"
                    )
                    self._webhook_url_invalid = True
                return

            if not self._ha_webhook_registered:
                webhook_name = "Withings"
                if self.entry.title != DEFAULT_TITLE:
                    webhook_name = f"{DEFAULT_TITLE} {self.entry.title}"

                webhook_register(
                    self.hass,
                    DOMAIN,
                    webhook_name,
                    self.entry.data[CONF_WEBHOOK_ID],
                    get_webhook_handler(self.withings_data),
                    allowed_methods=[METH_POST],
                )
                self._ha_webhook_registered = True
                LOGGER.debug("Registered Withings webhook at hass: %s", webhook_url)

            self._webhook_url = webhook_url
            self._subscribe_attempt = 0

        await self._async_subscribe_webhook()

    async def _async_subscribe_webhook(self, _: Any = None) -> None:
        """Attempt to subscribe to Withings webhooks."""

        async with self._register_lock:
            if self._webhooks_registered or self._webhook_url is None:
                return

            try:
                await async_subscribe_webhooks(
                    self.withings_data.client, self._webhook_url
                )
            except (
                WithingsUnauthorizedError,
                WithingsAuthenticationFailedError,
            ) as err:
                LOGGER.error(
                    "Authentication failed while subscribing to webhooks: %s",
                    err,
                )
                self.entry.async_start_reauth(self.hass)
                return
            except WithingsInvalidParamsError as err:
                LOGGER.error(
                    "Webhook URL rejected by Withings: %s",
                    err,
                )
                return
            except WithingsTooManyRequestsError as err:
                delay = min(
                    300 * (self._subscribe_attempt + 1), MAX_WEBHOOK_RETRY_INTERVAL
                )
                LOGGER.warning(
                    "Rate limited by Withings API (attempt %d): %s. "
                    "Retrying in %d seconds",
                    self._subscribe_attempt + 1,
                    err,
                    delay,
                )
            except WithingsError as err:
                base_delay = 30
                delay = min(
                    base_delay * (2**self._subscribe_attempt),
                    MAX_WEBHOOK_RETRY_INTERVAL,
                )
                LOGGER.warning(
                    "Failed to subscribe to Withings webhooks "
                    "(attempt %d): %s. Retrying in %d seconds",
                    self._subscribe_attempt + 1,
                    err,
                    delay,
                )
            else:
                for coordinator in self.withings_data.coordinators:
                    coordinator.webhook_subscription_listener(True)
                LOGGER.debug(
                    "Registered Withings webhook at Withings: %s", self._webhook_url
                )
                self.entry.async_on_unload(
                    self.hass.bus.async_listen_once(
                        EVENT_HOMEASSISTANT_STOP, self.unregister_webhook
                    )
                )
                self._webhooks_registered = True
                return

            self._subscribe_attempt += 1
            self._schedule_retry(delay)


async def async_unsubscribe_webhooks(client: WithingsClient) -> None:
    """Unsubscribe to all Withings webhooks."""
    try:
        current_webhooks = await client.list_notification_configurations()
    except ClientError:
        LOGGER.exception("Error when unsubscribing webhooks")
        return

    for webhook_configuration in current_webhooks:
        LOGGER.debug(
            "Unsubscribing %s for %s in %s seconds",
            webhook_configuration.callback_url,
            webhook_configuration.notification_category,
            UNSUBSCRIBE_DELAY.total_seconds(),
        )
        # Quick calls to Withings can result in the service returning errors.
        # Give them some time to cool down.
        await asyncio.sleep(UNSUBSCRIBE_DELAY.total_seconds())
        await client.revoke_notification_configurations(
            webhook_configuration.callback_url,
            webhook_configuration.notification_category,
        )


async def _async_cloudhook_generate_url(
    hass: HomeAssistant, entry: WithingsConfigEntry
) -> str:
    """Generate the full URL for a webhook_id."""
    if CONF_CLOUDHOOK_URL not in entry.data:
        webhook_id = entry.data[CONF_WEBHOOK_ID]
        # Some users already have their webhook as cloudhook.
        # We remove them to be sure we can create a new one.
        with contextlib.suppress(ValueError):
            await cloud.async_delete_cloudhook(hass, webhook_id)
        webhook_url = await cloud.async_create_cloudhook(hass, webhook_id)
        data = {**entry.data, CONF_CLOUDHOOK_URL: webhook_url}
        hass.config_entries.async_update_entry(entry, data=data)
        return webhook_url
    return str(entry.data[CONF_CLOUDHOOK_URL])


async def async_remove_entry(hass: HomeAssistant, entry: WithingsConfigEntry) -> None:
    """Cleanup when entry is removed."""
    if cloud.async_active_subscription(hass):
        try:
            LOGGER.debug(
                "Removing Withings cloudhook (%s)", entry.data[CONF_WEBHOOK_ID]
            )
            await cloud.async_delete_cloudhook(hass, entry.data[CONF_WEBHOOK_ID])
        except cloud.CloudNotAvailable:
            pass


def json_message_response(message: str, message_code: int) -> Response:
    """Produce common json output."""
    return HomeAssistantView.json({"message": message, "code": message_code})


def get_webhook_handler(
    withings_data: WithingsData,
) -> Callable[[HomeAssistant, str, Request], Awaitable[Response | None]]:
    """Return webhook handler."""

    async def async_webhook_handler(
        hass: HomeAssistant, webhook_id: str, request: Request
    ) -> Response | None:
        # Handle http post calls to the path.
        if not request.body_exists:
            return json_message_response("No request body", message_code=12)

        params = await request.post()

        if "appli" not in params:
            return json_message_response(
                "Parameter appli not provided", message_code=20
            )

        notification_category = to_enum(
            NotificationCategory,
            int(params.getone("appli")),  # type: ignore[arg-type]
            NotificationCategory.UNKNOWN,
        )

        for coordinator in withings_data.coordinators:
            if notification_category in coordinator.notification_categories:
                await coordinator.async_webhook_data_updated(notification_category)

        return json_message_response("Success", message_code=0)

    return async_webhook_handler
