"""Support for the Withings API.

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

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
