"""Reolink integration for HomeAssistant."""

from __future__ import annotations

import asyncio
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
import logging
from random import uniform
from time import time
from typing import Any

from reolink_aio.api import RETRY_ATTEMPTS
from reolink_aio.exceptions import (
    CredentialsInvalidError,
    LoginPrivacyModeError,
    ReolinkError,
)

from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import (
    config_validation as cv,
    device_registry as dr,
    entity_registry as er,
)
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from .const import (
    BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL,
    CONF_BC_ONLY,
    CONF_BC_PORT,
    CONF_FIRMWARE_CHECK_TIME,
    CONF_SUPPORTS_PRIVACY_MODE,
    CONF_USE_HTTPS,
    DOMAIN,
)
from .exceptions import PasswordIncompatible, ReolinkException, UserNotAdmin
from .host import ReolinkHost
from .services import async_setup_services
from .util import ReolinkConfigEntry, ReolinkData, get_device_uid_and_ch, get_store
from .views import PlaybackProxyView

_LOGGER = logging.getLogger(__name__)

PLATFORMS = [
    Platform.BINARY_SENSOR,
    Platform.BUTTON,
    Platform.CAMERA,
    Platform.LIGHT,
    Platform.NUMBER,
    Platform.SELECT,
    Platform.SENSOR,
    Platform.SIREN,
    Platform.SWITCH,
    Platform.UPDATE,
]
DEVICE_UPDATE_INTERVAL_MIN = timedelta(seconds=60)
DEVICE_UPDATE_INTERVAL_PER_CAM = timedelta(seconds=10)
FIRMWARE_UPDATE_INTERVAL = timedelta(hours=24)
NUM_CRED_ERRORS = 3

CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)


async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
    """Set up Reolink shared code."""

    async_setup_services(hass)
    return True


async def async_setup_entry(
    hass: HomeAssistant, config_entry: ReolinkConfigEntry
) -> bool:
    """Set up Reolink from a config entry."""
    host = ReolinkHost(hass, config_entry.data, config_entry.options, config_entry)

    try:
        await host.async_init()
    except (UserNotAdmin, CredentialsInvalidError, PasswordIncompatible) as err:
        await host.stop()
        raise ConfigEntryAuthFailed(err) from err
    except (
        ReolinkException,
        ReolinkError,
    ) as err:
        await host.stop()
        raise ConfigEntryNotReady(
            translation_domain=DOMAIN,
            translation_key="config_entry_not_ready",
            translation_placeholders={"host": host.api.host, "err": str(err)},
        ) from err
    except BaseException:
        await host.stop()
        raise

    config_entry.async_on_unload(
        hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, host.stop)
    )

    # update the config info if needed for the next time
    if (
        host.api.port != config_entry.data[CONF_PORT]
        or host.api.use_https != config_entry.data[CONF_USE_HTTPS]
        or host.api.supported(None, "privacy_mode")
        != config_entry.data.get(CONF_SUPPORTS_PRIVACY_MODE)
        or host.api.baichuan.port != config_entry.data.get(CONF_BC_PORT)
        or host.api.baichuan_only != config_entry.data.get(CONF_BC_ONLY)
    ):
        if host.api.port != config_entry.data[CONF_PORT]:
            _LOGGER.warning(
                "HTTP(s) port of Reolink %s, changed from %s to %s",
                host.api.nvr_name,
                config_entry.data[CONF_PORT],
                host.api.port,
            )
        if (
            config_entry.data.get(CONF_BC_PORT, host.api.baichuan.port)
            != host.api.baichuan.port
        ):
            _LOGGER.warning(
                "Baichuan port of Reolink %s, changed from %s to %s",
                host.api.nvr_name,
                config_entry.data.get(CONF_BC_PORT),
                host.api.baichuan.port,
            )
        data = {
            **config_entry.data,
            CONF_PORT: host.api.port,
            CONF_USE_HTTPS: host.api.use_https,
            CONF_BC_PORT: host.api.baichuan.port,
            CONF_BC_ONLY: host.api.baichuan_only,
            CONF_SUPPORTS_PRIVACY_MODE: host.api.supported(None, "privacy_mode"),
        }
        hass.config_entries.async_update_entry(config_entry, data=data)

    min_timeout = host.api.timeout * (RETRY_ATTEMPTS + 2)
    update_timeout = max(min_timeout, min_timeout * host.api.num_cameras / 10)

    async def async_device_config_update() -> None:
        """Update the host state cache and renew the ONVIF-subscription."""
        async with asyncio.timeout(update_timeout):
            try:
                await host.update_states()
            except CredentialsInvalidError as err:
                host.credential_errors += 1
                if host.credential_errors >= NUM_CRED_ERRORS:
                    await host.stop()
                    raise ConfigEntryAuthFailed(err) from err
                raise UpdateFailed(str(err)) from err
            except LoginPrivacyModeError:
                pass  # HTTP API is shutdown when privacy mode is active
            except ReolinkError as err:
                host.credential_errors = 0
                raise UpdateFailed(str(err)) from err

        host.credential_errors = 0

        async with asyncio.timeout(min_timeout):
            await host.renew()

        if host.api.new_devices and config_entry.state == ConfigEntryState.LOADED:
            # Their are new cameras/chimes connected, reload to add them.
            _LOGGER.debug(
                "Reloading Reolink %s to add new device (capabilities)",
                host.api.nvr_name,
            )
            hass.async_create_task(
                hass.config_entries.async_reload(config_entry.entry_id)
            )

    async def async_check_firmware_update() -> None:
        """Check for firmware updates."""
        async with asyncio.timeout(min_timeout):
            try:
                await host.api.check_new_firmware(host.firmware_ch_list)
            except ReolinkError as err:
                if host.starting:
                    _LOGGER.debug(
                        "Error checking Reolink firmware update at startup "
                        "from %s, possibly internet access is blocked",
                        host.api.nvr_name,
                    )
                    return

                raise UpdateFailed(
                    f"Error checking Reolink firmware update from {host.api.nvr_name}, "
                    "if the camera is blocked from accessing the internet, "
                    "disable the update entity"
                ) from err
            finally:
                host.starting = False

    device_coordinator = DataUpdateCoordinator(
        hass,
        _LOGGER,
        config_entry=config_entry,
        name=f"reolink.{host.api.nvr_name}",
        update_method=async_device_config_update,
        update_interval=max(
            DEVICE_UPDATE_INTERVAL_MIN,
            DEVICE_UPDATE_INTERVAL_PER_CAM * host.api.num_cameras,
        ),
    )
    firmware_coordinator = DataUpdateCoordinator(
        hass,
        _LOGGER,
        config_entry=config_entry,
        name=f"reolink.{host.api.nvr_name}.firmware",
        update_method=async_check_firmware_update,
        update_interval=None,  # Do not fetch data automatically, resume 24h schedule
    )

    async def first_firmware_check(*args: Any) -> None:
        """Start first firmware check delayed to continue 24h schedule."""
        firmware_coordinator.update_interval = FIRMWARE_UPDATE_INTERVAL
        await firmware_coordinator.async_refresh()
        host.cancel_first_firmware_check = None

    # get update time from config entry
    check_time_sec = config_entry.data.get(CONF_FIRMWARE_CHECK_TIME)
    if check_time_sec is None:
        check_time_sec = uniform(0, 86400)
        data = {
            **config_entry.data,
            CONF_FIRMWARE_CHECK_TIME: check_time_sec,
        }
        hass.config_entries.async_update_entry(config_entry, data=data)

    # If camera WAN blocked, firmware check fails and takes long, do not prevent setup
    now = datetime.now(UTC)
    check_time = timedelta(seconds=check_time_sec)
    delta_midnight = now - now.replace(hour=0, minute=0, second=0, microsecond=0)
    firmware_check_delay = check_time - delta_midnight
    if firmware_check_delay < timedelta(0):
        firmware_check_delay += timedelta(days=1)
    _LOGGER.debug(
        "Scheduling first Reolink %s firmware check in %s",
        host.api.nvr_name,
        firmware_check_delay,
    )
    host.cancel_first_firmware_check = async_call_later(
        hass, firmware_check_delay, first_firmware_check
    )

    # Fetch initial data so we have data when entities subscribe
    try:
        await device_coordinator.async_config_entry_first_refresh()
    except BaseException:
        await host.stop()
        raise

    config_entry.runtime_data = ReolinkData(
        host=host,
        device_coordinator=device_coordinator,
        firmware_coordinator=firmware_coordinator,
    )

    migrate_entity_ids(hass, config_entry.entry_id, host)

    hass.http.register_view(PlaybackProxyView(hass))

    await register_callbacks(host, device_coordinator, hass)

    # ensure host device is setup before connected camera devices that use via_device
    device_registry = dr.async_get(hass)
    device_registry.async_get_or_create(
        config_entry_id=config_entry.entry_id,
        identifiers={(DOMAIN, host.unique_id)},
        connections={(dr.CONNECTION_NETWORK_MAC, host.api.mac_address)},
    )

    await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)

    return True


async def register_callbacks(
    host: ReolinkHost,
    device_coordinator: DataUpdateCoordinator[None],
    hass: HomeAssistant,
) -> None:
    """Register update callbacks."""

    async def refresh(*args: Any) -> None:
        """Request refresh of coordinator."""
        await device_coordinator.async_request_refresh()
        host.cancel_refresh_privacy_mode = None

    def async_privacy_mode_change() -> None:
        """Request update when privacy mode is turned off."""
        if host.privacy_mode and not host.api.baichuan.privacy_mode():
            # The privacy mode just turned off, give the API 2 seconds to start
            if host.cancel_refresh_privacy_mode is None:
                host.cancel_refresh_privacy_mode = async_call_later(hass, 2, refresh)
        host.privacy_mode = host.api.baichuan.privacy_mode()

    def generate_async_camera_wake(channel: int) -> Callable[[], None]:
        def async_camera_wake() -> None:
            """Request update when a battery camera wakes up."""
            if (
                not host.api.sleeping(channel)
                and time() - host.last_wake[channel]
                > BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL
            ):
                hass.loop.create_task(device_coordinator.async_request_refresh())

        return async_camera_wake

    host.api.baichuan.register_callback(
        "privacy_mode_change", async_privacy_mode_change, 623
    )
    for channel in host.api.channels:
        if host.api.supported(channel, "battery"):
            host.api.baichuan.register_callback(
                f"camera_{channel}_wake",
                generate_async_camera_wake(channel),
                145,
                channel,
            )


async def async_unload_entry(
    hass: HomeAssistant, config_entry: ReolinkConfigEntry
) -> bool:
    """Unload a config entry."""
    host: ReolinkHost = config_entry.runtime_data.host

    await host.stop()

    host.api.baichuan.unregister_callback("privacy_mode_change")
    for channel in host.api.channels:
        if host.api.supported(channel, "battery"):
            host.api.baichuan.unregister_callback(f"camera_{channel}_wake")
    if host.cancel_refresh_privacy_mode is not None:
        host.cancel_refresh_privacy_mode()
    if host.cancel_first_firmware_check is not None:
        host.cancel_first_firmware_check()

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


async def async_remove_entry(
    hass: HomeAssistant, config_entry: ReolinkConfigEntry
) -> None:
    """Handle removal of an entry."""
    store = get_store(hass, config_entry.entry_id)
    await store.async_remove()


async def async_remove_config_entry_device(
    hass: HomeAssistant, config_entry: ReolinkConfigEntry, device: dr.DeviceEntry
) -> bool:
    """Remove a device from a config entry."""
    host: ReolinkHost = config_entry.runtime_data.host
    (_device_uid, ch, is_chime) = get_device_uid_and_ch(device, host)

    if is_chime:
        await host.api.get_state(cmd="GetDingDongList")
        chime = host.api.chime(ch)
        if (
            chime is None
            or chime.connect_state is None
            or chime.connect_state < 0
            or chime.channel not in host.api.channels
        ):
            _LOGGER.debug(
                "Removing Reolink chime %s with id %s, "
                "since it is not coupled to %s anymore",
                device.name,
                ch,
                host.api.nvr_name,
            )
            return True

        # remove the chime from the host
        await chime.remove()
        await host.api.get_state(cmd="GetDingDongList")
        if chime.connect_state < 0:
            _LOGGER.debug(
                "Removed Reolink chime %s with id %s from %s",
                device.name,
                ch,
                host.api.nvr_name,
            )
            return True

        _LOGGER.warning(
            "Cannot remove Reolink chime %s with id %s, because it is still connected "
            "to %s, please first remove the chime "
            "in the reolink app",
            device.name,
            ch,
            host.api.nvr_name,
        )
        return False

    if not host.api.is_nvr or ch is None:
        _LOGGER.warning(
            "Cannot remove Reolink device %s, because it is not a camera connected "
            "to a NVR/Hub, please remove the integration entry instead",
            device.name,
        )
        return False  # Do not remove the host/NVR itself

    if ch not in host.api.channels:
        _LOGGER.debug(
            "Removing Reolink device %s, "
            "since no camera is connected to NVR channel %s anymore",
            device.name,
            ch,
        )
        return True

    await host.api.get_state(cmd="GetChannelstatus")  # update the camera_online status
    if not host.api.camera_online(ch):
        _LOGGER.debug(
            "Removing Reolink device %s, "
            "since the camera connected to channel %s is offline",
            device.name,
            ch,
        )
        return True

    _LOGGER.warning(
        "Cannot remove Reolink device %s on channel %s, because it is still connected "
        "to the NVR/Hub, please first remove the camera from the NVR/Hub "
        "in the reolink app",
        device.name,
        ch,
    )
    return False


def migrate_entity_ids(
    hass: HomeAssistant, config_entry_id: str, host: ReolinkHost
) -> None:
    """Migrate entity IDs if needed."""
    device_reg = dr.async_get(hass)
    devices = dr.async_entries_for_config_entry(device_reg, config_entry_id)
    ch_device_ids = {}
    for device in devices:
        (device_uid, ch, is_chime) = get_device_uid_and_ch(device, host)

        if host.api.supported(None, "UID") and device_uid[0] != host.unique_id:
            if ch is None:
                new_device_id = f"{host.unique_id}"
            else:
                new_device_id = f"{host.unique_id}_{device_uid[1]}"
            _LOGGER.debug(
                "Updating Reolink device UID from %s to %s",
                device_uid,
                new_device_id,
            )
            new_identifiers = {(DOMAIN, new_device_id)}
            device_reg.async_update_device(device.id, new_identifiers=new_identifiers)

        # Check for wrongfully combined entities in one device
        # Can be removed in HA 2025.12
        new_identifiers = device.identifiers.copy()
        remove_ids = False
        if (DOMAIN, host.unique_id) in device.identifiers:
            remove_ids = True  # NVR/Hub in identifiers, keep that one, remove others
        for old_id in device.identifiers:
            (old_device_uid, _old_ch, _old_is_chime) = get_device_uid_and_ch(
                old_id, host
            )
            if (
                not old_device_uid
                or old_device_uid[0] != host.unique_id
                or old_id[1] == host.unique_id
            ):
                continue
            if remove_ids:
                new_identifiers.remove(old_id)
            remove_ids = True  # after the first identifier, remove the others
        if new_identifiers != device.identifiers:
            _LOGGER.debug(
                "Updating Reolink device identifiers from %s to %s",
                device.identifiers,
                new_identifiers,
            )
            device_reg.async_update_device(device.id, new_identifiers=new_identifiers)
            break

        if ch is None or is_chime:
            continue  # Do not consider the NVR itself or chimes

        # Check for wrongfully added MAC of the NVR/Hub to the camera
        # Can be removed in HA 2025.12
        host_connnection = (CONNECTION_NETWORK_MAC, host.api.mac_address)
        if host_connnection in device.connections:
            new_connections = device.connections.copy()
            new_connections.remove(host_connnection)
            _LOGGER.debug(
                "Updating Reolink device connections from %s to %s",
                device.connections,
                new_connections,
            )
            device_reg.async_update_device(device.id, new_connections=new_connections)

        ch_device_ids[device.id] = ch
        if host.api.supported(ch, "UID") and device_uid[1] != host.api.camera_uid(ch):
            if host.api.supported(None, "UID"):
                new_device_id = f"{host.unique_id}_{host.api.camera_uid(ch)}"
            else:
                new_device_id = f"{device_uid[0]}_{host.api.camera_uid(ch)}"
            _LOGGER.debug(
                "Updating Reolink device UID from %s to %s",
                device_uid,
                new_device_id,
            )
            new_identifiers = {(DOMAIN, new_device_id)}
            existing_device = device_reg.async_get_device(identifiers=new_identifiers)
            if existing_device is None:
                device_reg.async_update_device(
                    device.id, new_identifiers=new_identifiers
                )
            else:
                _LOGGER.warning(
                    "Reolink device with uid %s already exists, "
                    "removing device with uid %s",
                    new_device_id,
                    device_uid,
                )
                device_reg.async_remove_device(device.id)

    entity_reg = er.async_get(hass)
    entities = er.async_entries_for_config_entry(entity_reg, config_entry_id)
    for entity in entities:
        if host.api.supported(None, "UID") and not entity.unique_id.startswith(
            host.unique_id
        ):
            new_id = f"{host.unique_id}_{entity.unique_id.split('_', 1)[1]}"
            _LOGGER.debug(
                "Updating Reolink entity unique_id from %s to %s",
                entity.unique_id,
                new_id,
            )
            entity_reg.async_update_entity(entity.entity_id, new_unique_id=new_id)

        if entity.device_id in ch_device_ids:
            ch = ch_device_ids[entity.device_id]
            id_parts = entity.unique_id.split("_", 2)
            if len(id_parts) < 3:
                _LOGGER.warning(
                    "Reolink channel %s entity has unexpected unique_id format %s, with device id %s",
                    ch,
                    entity.unique_id,
                    entity.device_id,
                )
                continue
            if host.api.supported(ch, "UID") and id_parts[1] != host.api.camera_uid(ch):
                new_id = f"{host.unique_id}_{host.api.camera_uid(ch)}_{id_parts[2]}"
                _LOGGER.debug(
                    "Updating Reolink entity unique_id from %s to %s",
                    entity.unique_id,
                    new_id,
                )
                existing_entity = entity_reg.async_get_entity_id(
                    entity.domain, entity.platform, new_id
                )
                if existing_entity is None:
                    entity_reg.async_update_entity(
                        entity.entity_id, new_unique_id=new_id
                    )
                else:
                    _LOGGER.warning(
                        "Reolink entity with unique_id %s already exists, "
                        "removing device with unique_id %s",
                        new_id,
                        entity.unique_id,
                    )
                    entity_reg.async_remove(entity.entity_id)
