"""Reolink integration for HomeAssistant."""

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

from reolink_aio.api import DUAL_LENS_DUAL_MOTION_MODELS, RETRY_ATTEMPTS
from reolink_aio.const import UNKNOWN
from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError

from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
    ConfigEntryAuthFailed,
    ConfigEntryError,
    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.util import dt as dt_util

from .const import (
    BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL,
    CONF_BC_CONNECT,
    CONF_BC_ONLY,
    CONF_BC_PORT,
    CONF_FIRMWARE_CHECK_TIME,
    CONF_SUPPORTS_PRIVACY_MODE,
    CONF_UID,
    CONF_USE_HTTPS,
    DOMAIN,
)
from .coordinator import ReolinkDeviceCoordinator, ReolinkFirmwareCoordinator
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.TIME,
    Platform.UPDATE,
]
FIRMWARE_UPDATE_INTERVAL = timedelta(hours=24)

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()
        # pylint: disable-next=home-assistant-exception-not-translated
        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)
    )

    # do not allow changes to the UID
    if (
        config_entry.data.get(CONF_UID, host.api.uid) != host.api.uid
        and config_entry.data.get(CONF_UID) != UNKNOWN
    ):
        await host.stop()
        raise ConfigEntryError(
            translation_domain=DOMAIN,
            translation_key="uid_mismatch",
            translation_placeholders={
                "name": host.api.nvr_name,
                "conf_uid": config_entry.data.get(CONF_UID, ""),
                "uid": host.api.uid,
            },
        )

    # 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)
        or host.api.baichuan.connection_type.value
        != config_entry.data.get(CONF_BC_CONNECT)
        or host.api.uid != config_entry.data.get(CONF_UID)
    ):
        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_BC_CONNECT: host.api.baichuan.connection_type.value,
            CONF_UID: host.api.uid,
            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)

    device_coordinator = ReolinkDeviceCoordinator(
        hass,
        config_entry,
        host,
        min_timeout=min_timeout,
    )

    firmware_coordinator = ReolinkFirmwareCoordinator(
        hass,
        config_entry,
        host,
        min_timeout=min_timeout,
    )
    device_coordinator.firmware_coordinator = firmware_coordinator

    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 = dt_util.utcnow()
    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)
    host_device = 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)},
    )

    if host.api.is_nvr and host.api.model in DUAL_LENS_DUAL_MOTION_MODELS:
        # ensure the camera devices are setup before
        # the lens sub-devices that use via_device
        for channel in host.api.stream_channels:
            if host.api.supported(channel, "UID"):
                camera_dev_id = f"{host.unique_id}_{host.api.camera_uid(channel)}"
            else:
                camera_dev_id = f"{host.unique_id}_ch{channel}"
            device_registry.async_get_or_create(
                config_entry_id=config_entry.entry_id,
                identifiers={(DOMAIN, camera_dev_id)},
                via_device_id=host_device.id,
            )

    # ensure the camera devices that chimes connect through are setup
    # before the chime sub-devices that use via_device
    for chime in host.api.chime_list:
        if chime.channel is None or not host.api.is_nvr:
            continue  # chime connected directly to the host device
        if host.api.supported(chime.channel, "UID"):
            camera_dev_id = f"{host.unique_id}_{host.api.camera_uid(chime.channel)}"
        else:
            camera_dev_id = f"{host.unique_id}_ch{chime.channel}"
        device_registry.async_get_or_create(
            config_entry_id=config_entry.entry_id,
            identifiers={(DOMAIN, camera_dev_id)},
            via_device_id=host_device.id,
        )

    await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)

    return True


async def register_callbacks(
    host: ReolinkHost,
    device_coordinator: ReolinkDeviceCoordinator,
    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_623", async_privacy_mode_change, 623
    )
    host.api.baichuan.register_callback(
        "privacy_mode_change_574", async_privacy_mode_change, 574
    )
    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_623")
    host.api.baichuan.unregister_callback("privacy_mode_change_574")
    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 or device_uid[1].startswith("lens"):
            continue  # Do not consider the NVR itself, chimes or lens sub-devices

        # 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_by_identifier(
                (DOMAIN, new_device_id), config_entry_id
            )
            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,
            )
            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 entity with unique_id %s",
                    new_id,
                    entity.unique_id,
                )
                entity_reg.async_remove(entity.entity_id)
                continue

        # Can be removed in HA 2027.2
        if (
            host.api.is_dual_lens
            and host.api.supported(1, "zoom_basic")
            and not host.api.supported(0, "zoom_basic")
        ):
            id_parts = entity.unique_id.split("_", 2)
            if len(id_parts) < 3:
                continue
            if id_parts[1] == "0" and id_parts[2] in {
                "zoom",
                "ptz_zoom_in",
                "ptz_zoom_out",
            }:
                new_id = f"{host.unique_id}_1_{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 not None:
                    entity_reg.async_remove(existing_entity)
                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 entity with unique_id %s",
                        new_id,
                        entity.unique_id,
                    )
                    entity_reg.async_remove(entity.entity_id)
