"""The Z-Wave JS integration."""
from __future__ import annotations

import asyncio
from collections import defaultdict

from async_timeout import timeout
from zwave_js_server.client import Client as ZwaveClient
from zwave_js_server.exceptions import BaseZwaveJSServerError, InvalidServerVersion
from zwave_js_server.model.node import Node as ZwaveNode
from zwave_js_server.model.notification import (
    EntryControlNotification,
    NotificationNotification,
)
from zwave_js_server.model.value import Value, ValueNotification

from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
    ATTR_DEVICE_ID,
    ATTR_DOMAIN,
    ATTR_ENTITY_ID,
    CONF_URL,
    EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry, entity_registry
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import ConfigType

from .addon import AddonError, AddonManager, AddonState, get_addon_manager
from .api import async_register_api
from .const import (
    ATTR_COMMAND_CLASS,
    ATTR_COMMAND_CLASS_NAME,
    ATTR_DATA_TYPE,
    ATTR_ENDPOINT,
    ATTR_EVENT,
    ATTR_EVENT_DATA,
    ATTR_EVENT_LABEL,
    ATTR_EVENT_TYPE,
    ATTR_HOME_ID,
    ATTR_LABEL,
    ATTR_NODE_ID,
    ATTR_PARAMETERS,
    ATTR_PROPERTY,
    ATTR_PROPERTY_KEY,
    ATTR_PROPERTY_KEY_NAME,
    ATTR_PROPERTY_NAME,
    ATTR_TYPE,
    ATTR_VALUE,
    ATTR_VALUE_RAW,
    CONF_ADDON_DEVICE,
    CONF_ADDON_NETWORK_KEY,
    CONF_DATA_COLLECTION_OPTED_IN,
    CONF_INTEGRATION_CREATED_ADDON,
    CONF_NETWORK_KEY,
    CONF_USB_PATH,
    CONF_USE_ADDON,
    DATA_CLIENT,
    DATA_PLATFORM_SETUP,
    DOMAIN,
    EVENT_DEVICE_ADDED_TO_REGISTRY,
    LOGGER,
    ZWAVE_JS_NOTIFICATION_EVENT,
    ZWAVE_JS_VALUE_NOTIFICATION_EVENT,
    ZWAVE_JS_VALUE_UPDATED_EVENT,
)
from .discovery import (
    ZwaveDiscoveryInfo,
    async_discover_node_values,
    async_discover_single_value,
)
from .helpers import async_enable_statistics, get_device_id, get_unique_id
from .migrate import async_migrate_discovered_value
from .services import ZWaveServices

CONNECT_TIMEOUT = 10
DATA_CLIENT_LISTEN_TASK = "client_listen_task"
DATA_START_PLATFORM_TASK = "start_platform_task"
DATA_CONNECT_FAILED_LOGGED = "connect_failed_logged"
DATA_INVALID_SERVER_VERSION_LOGGED = "invalid_server_version_logged"


async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
    """Set up the Z-Wave JS component."""
    hass.data[DOMAIN] = {}
    return True


@callback
def register_node_in_dev_reg(
    hass: HomeAssistant,
    entry: ConfigEntry,
    dev_reg: device_registry.DeviceRegistry,
    client: ZwaveClient,
    node: ZwaveNode,
) -> device_registry.DeviceEntry:
    """Register node in dev reg."""
    params = {
        "config_entry_id": entry.entry_id,
        "identifiers": {get_device_id(client, node)},
        "sw_version": node.firmware_version,
        "name": node.name or node.device_config.description or f"Node {node.node_id}",
        "model": node.device_config.label,
        "manufacturer": node.device_config.manufacturer,
    }
    if node.location:
        params["suggested_area"] = node.location
    device = dev_reg.async_get_or_create(**params)

    async_dispatcher_send(hass, EVENT_DEVICE_ADDED_TO_REGISTRY, device)

    return device


async def async_setup_entry(  # noqa: C901
    hass: HomeAssistant, entry: ConfigEntry
) -> bool:
    """Set up Z-Wave JS from a config entry."""
    use_addon = entry.data.get(CONF_USE_ADDON)
    if use_addon:
        await async_ensure_addon_running(hass, entry)

    client = ZwaveClient(entry.data[CONF_URL], async_get_clientsession(hass))
    dev_reg = device_registry.async_get(hass)
    ent_reg = entity_registry.async_get(hass)
    entry_hass_data: dict = hass.data[DOMAIN].setdefault(entry.entry_id, {})

    entry_hass_data[DATA_CLIENT] = client
    entry_hass_data[DATA_PLATFORM_SETUP] = {}

    registered_unique_ids: dict[str, dict[str, set[str]]] = defaultdict(dict)
    discovered_value_ids: dict[str, set[str]] = defaultdict(set)

    async def async_handle_discovery_info(
        device: device_registry.DeviceEntry,
        disc_info: ZwaveDiscoveryInfo,
        value_updates_disc_info: dict[str, ZwaveDiscoveryInfo],
    ) -> None:
        """Handle discovery info and all dependent tasks."""
        # This migration logic was added in 2021.3 to handle a breaking change to
        # the value_id format. Some time in the future, this call (as well as the
        # helper functions) can be removed.
        async_migrate_discovered_value(
            hass,
            ent_reg,
            registered_unique_ids[device.id][disc_info.platform],
            device,
            client,
            disc_info,
        )

        platform_setup_tasks = entry_hass_data[DATA_PLATFORM_SETUP]
        platform = disc_info.platform
        if platform not in platform_setup_tasks:
            platform_setup_tasks[platform] = hass.async_create_task(
                hass.config_entries.async_forward_entry_setup(entry, platform)
            )
        await platform_setup_tasks[platform]

        LOGGER.debug("Discovered entity: %s", disc_info)
        async_dispatcher_send(
            hass, f"{DOMAIN}_{entry.entry_id}_add_{platform}", disc_info
        )

        # If we don't need to watch for updates return early
        if not disc_info.assumed_state:
            return
        value_updates_disc_info[disc_info.primary_value.value_id] = disc_info
        # If this is the first time we found a value we want to watch for updates,
        # return early
        if len(value_updates_disc_info) != 1:
            return
        # add listener for value updated events
        entry.async_on_unload(
            disc_info.node.on(
                "value updated",
                lambda event: async_on_value_updated_fire_event(
                    value_updates_disc_info, event["value"]
                ),
            )
        )

    async def async_on_node_ready(node: ZwaveNode) -> None:
        """Handle node ready event."""
        LOGGER.debug("Processing node %s", node)
        # register (or update) node in device registry
        device = register_node_in_dev_reg(hass, entry, dev_reg, client, node)
        # We only want to create the defaultdict once, even on reinterviews
        if device.id not in registered_unique_ids:
            registered_unique_ids[device.id] = defaultdict(set)

        value_updates_disc_info: dict[str, ZwaveDiscoveryInfo] = {}

        # run discovery on all node values and create/update entities
        await asyncio.gather(
            *(
                async_handle_discovery_info(device, disc_info, value_updates_disc_info)
                for disc_info in async_discover_node_values(
                    node, device, discovered_value_ids
                )
            )
        )

        # add listeners to handle new values that get added later
        for event in ("value added", "value updated", "metadata updated"):
            entry.async_on_unload(
                node.on(
                    event,
                    lambda event: hass.async_create_task(
                        async_on_value_added(value_updates_disc_info, event["value"])
                    ),
                )
            )

        # add listener for stateless node value notification events
        entry.async_on_unload(
            node.on(
                "value notification",
                lambda event: async_on_value_notification(event["value_notification"]),
            )
        )
        # add listener for stateless node notification events
        entry.async_on_unload(
            node.on(
                "notification",
                lambda event: async_on_notification(event["notification"]),
            )
        )

    async def async_on_node_added(node: ZwaveNode) -> None:
        """Handle node added event."""
        platform_setup_tasks = entry_hass_data[DATA_PLATFORM_SETUP]

        # We need to set up the sensor platform if it hasn't already been setup in
        # order to create the node status sensor
        if SENSOR_DOMAIN not in platform_setup_tasks:
            platform_setup_tasks[SENSOR_DOMAIN] = hass.async_create_task(
                hass.config_entries.async_forward_entry_setup(entry, SENSOR_DOMAIN)
            )

        # This guard ensures that concurrent runs of this function all await the
        # platform setup task
        if not platform_setup_tasks[SENSOR_DOMAIN].done():
            await platform_setup_tasks[SENSOR_DOMAIN]

        # Create a node status sensor for each device
        async_dispatcher_send(
            hass, f"{DOMAIN}_{entry.entry_id}_add_node_status_sensor", node
        )

        # we only want to run discovery when the node has reached ready state,
        # otherwise we'll have all kinds of missing info issues.
        if node.ready:
            await async_on_node_ready(node)
            return
        # if node is not yet ready, register one-time callback for ready state
        LOGGER.debug("Node added: %s - waiting for it to become ready", node.node_id)
        node.once(
            "ready",
            lambda event: hass.async_create_task(async_on_node_ready(event["node"])),
        )
        # we do submit the node to device registry so user has
        # some visual feedback that something is (in the process of) being added
        register_node_in_dev_reg(hass, entry, dev_reg, client, node)

    async def async_on_value_added(
        value_updates_disc_info: dict[str, ZwaveDiscoveryInfo], value: Value
    ) -> None:
        """Fire value updated event."""
        # If node isn't ready or a device for this node doesn't already exist, we can
        # let the node ready event handler perform discovery. If a value has already
        # been processed, we don't need to do it again
        device_id = get_device_id(client, value.node)
        if (
            not value.node.ready
            or not (device := dev_reg.async_get_device({device_id}))
            or value.value_id in discovered_value_ids[device.id]
        ):
            return

        LOGGER.debug("Processing node %s added value %s", value.node, value)
        await asyncio.gather(
            *(
                async_handle_discovery_info(device, disc_info, value_updates_disc_info)
                for disc_info in async_discover_single_value(
                    value, device, discovered_value_ids
                )
            )
        )

    @callback
    def async_on_node_removed(node: ZwaveNode) -> None:
        """Handle node removed event."""
        # grab device in device registry attached to this node
        dev_id = get_device_id(client, node)
        device = dev_reg.async_get_device({dev_id})
        # note: removal of entity registry entry is handled by core
        dev_reg.async_remove_device(device.id)  # type: ignore
        registered_unique_ids.pop(device.id, None)  # type: ignore
        discovered_value_ids.pop(device.id, None)  # type: ignore

    @callback
    def async_on_value_notification(notification: ValueNotification) -> None:
        """Relay stateless value notification events from Z-Wave nodes to hass."""
        device = dev_reg.async_get_device({get_device_id(client, notification.node)})
        raw_value = value = notification.value
        if notification.metadata.states:
            value = notification.metadata.states.get(str(value), value)
        hass.bus.async_fire(
            ZWAVE_JS_VALUE_NOTIFICATION_EVENT,
            {
                ATTR_DOMAIN: DOMAIN,
                ATTR_NODE_ID: notification.node.node_id,
                ATTR_HOME_ID: client.driver.controller.home_id,
                ATTR_ENDPOINT: notification.endpoint,
                ATTR_DEVICE_ID: device.id,  # type: ignore
                ATTR_COMMAND_CLASS: notification.command_class,
                ATTR_COMMAND_CLASS_NAME: notification.command_class_name,
                ATTR_LABEL: notification.metadata.label,
                ATTR_PROPERTY: notification.property_,
                ATTR_PROPERTY_NAME: notification.property_name,
                ATTR_PROPERTY_KEY: notification.property_key,
                ATTR_PROPERTY_KEY_NAME: notification.property_key_name,
                ATTR_VALUE: value,
                ATTR_VALUE_RAW: raw_value,
            },
        )

    @callback
    def async_on_notification(
        notification: EntryControlNotification | NotificationNotification,
    ) -> None:
        """Relay stateless notification events from Z-Wave nodes to hass."""
        device = dev_reg.async_get_device({get_device_id(client, notification.node)})
        event_data = {
            ATTR_DOMAIN: DOMAIN,
            ATTR_NODE_ID: notification.node.node_id,
            ATTR_HOME_ID: client.driver.controller.home_id,
            ATTR_DEVICE_ID: device.id,  # type: ignore
            ATTR_COMMAND_CLASS: notification.command_class,
        }

        if isinstance(notification, EntryControlNotification):
            event_data.update(
                {
                    ATTR_COMMAND_CLASS_NAME: "Entry Control",
                    ATTR_EVENT_TYPE: notification.event_type,
                    ATTR_DATA_TYPE: notification.data_type,
                    ATTR_EVENT_DATA: notification.event_data,
                }
            )
        else:
            event_data.update(
                {
                    ATTR_COMMAND_CLASS_NAME: "Notification",
                    ATTR_LABEL: notification.label,
                    ATTR_TYPE: notification.type_,
                    ATTR_EVENT: notification.event,
                    ATTR_EVENT_LABEL: notification.event_label,
                    ATTR_PARAMETERS: notification.parameters,
                }
            )

        hass.bus.async_fire(ZWAVE_JS_NOTIFICATION_EVENT, event_data)

    @callback
    def async_on_value_updated_fire_event(
        value_updates_disc_info: dict[str, ZwaveDiscoveryInfo], value: Value
    ) -> None:
        """Fire value updated event."""
        # Get the discovery info for the value that was updated. If there is
        # no discovery info for this value, we don't need to fire an event
        if value.value_id not in value_updates_disc_info:
            return
        disc_info = value_updates_disc_info[value.value_id]

        device = dev_reg.async_get_device({get_device_id(client, value.node)})

        unique_id = get_unique_id(
            client.driver.controller.home_id, disc_info.primary_value.value_id
        )
        entity_id = ent_reg.async_get_entity_id(disc_info.platform, DOMAIN, unique_id)

        raw_value = value_ = value.value
        if value.metadata.states:
            value_ = value.metadata.states.get(str(value), value_)

        hass.bus.async_fire(
            ZWAVE_JS_VALUE_UPDATED_EVENT,
            {
                ATTR_NODE_ID: value.node.node_id,
                ATTR_HOME_ID: client.driver.controller.home_id,
                ATTR_DEVICE_ID: device.id,  # type: ignore
                ATTR_ENTITY_ID: entity_id,
                ATTR_COMMAND_CLASS: value.command_class,
                ATTR_COMMAND_CLASS_NAME: value.command_class_name,
                ATTR_ENDPOINT: value.endpoint,
                ATTR_PROPERTY: value.property_,
                ATTR_PROPERTY_NAME: value.property_name,
                ATTR_PROPERTY_KEY: value.property_key,
                ATTR_PROPERTY_KEY_NAME: value.property_key_name,
                ATTR_VALUE: value_,
                ATTR_VALUE_RAW: raw_value,
            },
        )

    # connect and throw error if connection failed
    try:
        async with timeout(CONNECT_TIMEOUT):
            await client.connect()
    except InvalidServerVersion as err:
        if not entry_hass_data.get(DATA_INVALID_SERVER_VERSION_LOGGED):
            LOGGER.error("Invalid server version: %s", err)
            entry_hass_data[DATA_INVALID_SERVER_VERSION_LOGGED] = True
        if use_addon: