"""Support for Ring Doorbell/Chimes."""
from __future__ import annotations

import asyncio
from collections.abc import Callable
from datetime import timedelta
from functools import partial
import logging
from pathlib import Path
from typing import Any

from oauthlib.oauth2 import AccessDeniedError
import requests
from ring_doorbell import Auth, Ring

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform, __version__
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.async_ import run_callback_threadsafe

_LOGGER = logging.getLogger(__name__)

ATTRIBUTION = "Data provided by Ring.com"

NOTIFICATION_ID = "ring_notification"
NOTIFICATION_TITLE = "Ring Setup"

DOMAIN = "ring"
DEFAULT_ENTITY_NAMESPACE = "ring"

PLATFORMS = [
    Platform.BINARY_SENSOR,
    Platform.LIGHT,
    Platform.SENSOR,
    Platform.SWITCH,
    Platform.CAMERA,
]


async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
    """Set up the Ring component."""
    if DOMAIN not in config:
        return True

    def legacy_cleanup():
        """Clean up old tokens."""
        old_cache = Path(hass.config.path(".ring_cache.pickle"))
        if old_cache.is_file():
            old_cache.unlink()

    await hass.async_add_executor_job(legacy_cleanup)

    return True


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
    """Set up a config entry."""

    def token_updater(token):
        """Handle from sync context when token is updated."""
        run_callback_threadsafe(
            hass.loop,
            partial(
                hass.config_entries.async_update_entry,
                entry,
                data={**entry.data, "token": token},
            ),
        ).result()

    auth = Auth(f"HomeAssistant/{__version__}", entry.data["token"], token_updater)
    ring = Ring(auth)

    try:
        await hass.async_add_executor_job(ring.update_data)
    except AccessDeniedError:
        _LOGGER.error("Access token is no longer valid. Please set up Ring again")
        return False

    hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
        "api": ring,
        "devices": ring.devices(),
        "device_data": GlobalDataUpdater(
            hass, "device", entry.entry_id, ring, "update_devices", timedelta(minutes=1)
        ),
        "dings_data": GlobalDataUpdater(
            hass,
            "active dings",
            entry.entry_id,
            ring,
            "update_dings",
            timedelta(seconds=5),
        ),
        "history_data": DeviceDataUpdater(
            hass,
            "history",
            entry.entry_id,
            ring,
            lambda device: device.history(limit=10),
            timedelta(minutes=1),
        ),
        "health_data": DeviceDataUpdater(
            hass,
            "health",
            entry.entry_id,
            ring,
            lambda device: device.update_health_data(),
            timedelta(minutes=1),
        ),
    }

    hass.config_entries.async_setup_platforms(entry, PLATFORMS)

    if hass.services.has_service(DOMAIN, "update"):
        return True

    async def async_refresh_all(_: ServiceCall) -> None:
        """Refresh all ring data."""
        for info in hass.data[DOMAIN].values():
            await info["device_data"].async_refresh_all()
            await info["dings_data"].async_refresh_all()
            await hass.async_add_executor_job(info["history_data"].refresh_all)
            await hass.async_add_executor_job(info["health_data"].refresh_all)

    # register service
    hass.services.async_register(DOMAIN, "update", async_refresh_all)

    return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
    """Unload Ring entry."""
    if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
        return False

    hass.data[DOMAIN].pop(entry.entry_id)

    if len(hass.data[DOMAIN]) != 0:
        return True

    # Last entry unloaded, clean up service
    hass.services.async_remove(DOMAIN, "update")

    return True


class GlobalDataUpdater:
    """Data storage for single API endpoint."""

    def __init__(
        self,
        hass: HomeAssistant,
        data_type: str,
        config_entry_id: str,
        ring: Ring,
        update_method: str,
        update_interval: timedelta,
    ) -> None:
        """Initialize global data updater."""
        self.hass = hass
        self.data_type = data_type
        self.config_entry_id = config_entry_id
        self.ring = ring
        self.update_method = update_method
        self.update_interval = update_interval
        self.listeners: list[Callable[[], None]] = []
        self._unsub_interval = None

    @callback
    def async_add_listener(self, update_callback):
        """Listen for data updates."""
        # This is the first listener, set up interval.
        if not self.listeners:
            self._unsub_interval = async_track_time_interval(
                self.hass, self.async_refresh_all, self.update_interval
            )

        self.listeners.append(update_callback)

    @callback
    def async_remove_listener(self, update_callback):
        """Remove data update."""
        self.listeners.remove(update_callback)

        if not self.listeners:
            self._unsub_interval()
            self._unsub_interval = None

    async def async_refresh_all(self, _now: int | None = None) -> None:
        """Time to update."""
        if not self.listeners:
            return

        try:
            await self.hass.async_add_executor_job(
                getattr(self.ring, self.update_method)
            )
        except AccessDeniedError:
            _LOGGER.error("Ring access token is no longer valid. Set up Ring again")
            await self.hass.config_entries.async_unload(self.config_entry_id)
            return
        except requests.Timeout:
            _LOGGER.warning(
                "Time out fetching Ring %s data",
                self.data_type,
            )
            return
        except requests.RequestException as err:
            _LOGGER.warning(
                "Error fetching Ring %s data: %s",
                self.data_type,
                err,
            )
            return

        for update_callback in self.listeners:
            update_callback()


class DeviceDataUpdater:
    """Data storage for device data."""

    def __init__(
        self,
        hass: HomeAssistant,
        data_type: str,
        config_entry_id: str,
        ring: Ring,
        update_method: Callable[[Ring], Any],
        update_interval: timedelta,
    ) -> None:
        """Initialize device data updater."""
        self.data_type = data_type
        self.hass = hass
        self.config_entry_id = config_entry_id
        self.ring = ring
        self.update_method = update_method
        self.update_interval = update_interval
        self.devices: dict = {}
        self._unsub_interval = None

    async def async_track_device(self, device, update_callback):
        """Track a device."""
        if not self.devices:
            self._unsub_interval = async_track_time_interval(
                self.hass, self.refresh_all, self.update_interval
            )

        if device.device_id not in self.devices:
            self.devices[device.device_id] = {
                "device": device,
                "update_callbacks": [update_callback],
                "data": None,
            }
            # Store task so that other concurrent requests can wait for us to finish and
            # data be available.
            self.devices[device.device_id]["task"] = asyncio.current_task()
            self.devices[device.device_id][
                "data"
            ] = await self.hass.async_add_executor_job(self.update_method, device)
            self.devices[device.device_id].pop("task")
        else:
            self.devices[device.device_id]["update_callbacks"].append(update_callback)
            # If someone is currently fetching data as part of the initialization, wait for them
            if "task" in self.devices[device.device_id]:
                await self.devices[device.device_id]["task"]

        update_callback(self.devices[device.device_id]["data"])

    @callback
    def async_untrack_device(self, device, update_callback):
        """Untrack a device."""
        self.devices[device.device_id]["update_callbacks"].remove(update_callback)

        if not self.devices[device.device_id]["update_callbacks"]:
            self.devices.pop(device.device_id)

        if not self.devices:
            self._unsub_interval()
            self._unsub_interval = None

    def refresh_all(self, _=None):
        """Refresh all registered devices."""
        for device_id, info in self.devices.items():
            try:
                data = info["data"] = self.update_method(info["device"])
            except AccessDeniedError:
                _LOGGER.error("Ring access token is no longer valid. Set up Ring again")
                self.hass.add_job(
                    self.hass.config_entries.async_unload(self.config_entry_id)
                )
                return
            except requests.Timeout:
                _LOGGER.warning(
                    "Time out fetching Ring %s data for device %s",
                    self.data_type,
                    device_id,
                )
                continue
            except requests.RequestException as err:
                _LOGGER.warning(
                    "Error fetching Ring %s data for device %s: %s",
                    self.data_type,
                    device_id,
                    err,
                )
                continue

            for update_callback in info["update_callbacks"]:
                self.hass.loop.call_soon_threadsafe(update_callback, data)
