"""Functions used to migrate unique IDs for Z-Wave JS entities."""
from __future__ import annotations

from dataclasses import dataclass, field
import logging
from typing import TypedDict, cast

from zwave_js_server.client import Client as ZwaveClient
from zwave_js_server.model.value import Value as ZwaveValue

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import (
    DeviceEntry,
    async_get as async_get_device_registry,
)
from homeassistant.helpers.entity_registry import (
    EntityRegistry,
    RegistryEntry,
    async_entries_for_device,
    async_get as async_get_entity_registry,
)
from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.storage import Store

from .const import DOMAIN
from .discovery import ZwaveDiscoveryInfo
from .helpers import get_device_id, get_unique_id

_LOGGER = logging.getLogger(__name__)

LEGACY_ZWAVE_MIGRATION = f"{DOMAIN}_legacy_zwave_migration"
MIGRATED = "migrated"
STORAGE_WRITE_DELAY = 30
STORAGE_KEY = f"{DOMAIN}.legacy_zwave_migration"
STORAGE_VERSION = 1

NOTIFICATION_CC_LABEL_TO_PROPERTY_NAME = {
    "Smoke": "Smoke Alarm",
    "Carbon Monoxide": "CO Alarm",
    "Carbon Dioxide": "CO2 Alarm",
    "Heat": "Heat Alarm",
    "Flood": "Water Alarm",
    "Access Control": "Access Control",
    "Burglar": "Home Security",
    "Power Management": "Power Management",
    "System": "System",
    "Emergency": "Siren",
    "Clock": "Clock",
    "Appliance": "Appliance",
    "HomeHealth": "Home Health",
}

SENSOR_MULTILEVEL_CC_LABEL_TO_PROPERTY_NAME = {
    "Temperature": "Air temperature",
    "General": "General purpose",
    "Luminance": "Illuminance",
    "Power": "Power",
    "Relative Humidity": "Humidity",
    "Velocity": "Velocity",
    "Direction": "Direction",
    "Atmospheric Pressure": "Atmospheric pressure",
    "Barometric Pressure": "Barometric pressure",
    "Solar Radiation": "Solar radiation",
    "Dew Point": "Dew point",
    "Rain Rate": "Rain rate",
    "Tide Level": "Tide level",
    "Weight": "Weight",
    "Voltage": "Voltage",
    "Current": "Current",
    "CO2 Level": "Carbon dioxide (CO₂) level",
    "Air Flow": "Air flow",
    "Tank Capacity": "Tank capacity",
    "Distance": "Distance",
    "Angle Position": "Angle position",
    "Rotation": "Rotation",
    "Water Temperature": "Water temperature",
    "Soil Temperature": "Soil temperature",
    "Seismic Intensity": "Seismic Intensity",
    "Seismic Magnitude": "Seismic magnitude",
    "Ultraviolet": "Ultraviolet",
    "Electrical Resistivity": "Electrical resistivity",
    "Electrical Conductivity": "Electrical conductivity",
    "Loudness": "Loudness",
    "Moisture": "Moisture",
}

CC_ID_LABEL_TO_PROPERTY = {
    49: SENSOR_MULTILEVEL_CC_LABEL_TO_PROPERTY_NAME,
    113: NOTIFICATION_CC_LABEL_TO_PROPERTY_NAME,
}


class ZWaveMigrationData(TypedDict):
    """Represent the Z-Wave migration data dict."""

    node_id: int
    node_instance: int
    command_class: int
    command_class_label: str
    value_index: int
    device_id: str
    domain: str
    entity_id: str
    unique_id: str
    unit_of_measurement: str | None


class ZWaveJSMigrationData(TypedDict):
    """Represent the Z-Wave JS migration data dict."""

    node_id: int
    endpoint_index: int
    command_class: int
    value_property_name: str
    value_property_key_name: str | None
    value_id: str
    device_id: str
    domain: str
    entity_id: str
    unique_id: str
    unit_of_measurement: str | None


@dataclass
class LegacyZWaveMappedData:
    """Represent the mapped data between Z-Wave and Z-Wave JS."""

    entity_entries: dict[str, ZWaveMigrationData] = field(default_factory=dict)
    device_entries: dict[str, str] = field(default_factory=dict)


async def async_add_migration_entity_value(
    hass: HomeAssistant,
    config_entry: ConfigEntry,
    entity_id: str,
    discovery_info: ZwaveDiscoveryInfo,
) -> None:
    """Add Z-Wave JS entity value for legacy Z-Wave migration."""
    migration_handler: LegacyZWaveMigration = await get_legacy_zwave_migration(hass)
    migration_handler.add_entity_value(config_entry, entity_id, discovery_info)


async def async_get_migration_data(
    hass: HomeAssistant, config_entry: ConfigEntry
) -> dict[str, ZWaveJSMigrationData]:
    """Return Z-Wave JS migration data."""
    migration_handler: LegacyZWaveMigration = await get_legacy_zwave_migration(hass)
    return await migration_handler.get_data(config_entry)


@singleton(LEGACY_ZWAVE_MIGRATION)
async def get_legacy_zwave_migration(hass: HomeAssistant) -> LegacyZWaveMigration:
    """Return legacy Z-Wave migration handler."""
    migration_handler = LegacyZWaveMigration(hass)
    await migration_handler.load_data()
    return migration_handler


class LegacyZWaveMigration:
    """Handle the migration from zwave to zwave_js."""

    def __init__(self, hass: HomeAssistant) -> None:
        """Set up migration instance."""
        self._hass = hass
        self._store = Store(hass, STORAGE_VERSION, STORAGE_KEY)
        self._data: dict[str, dict[str, ZWaveJSMigrationData]] = {}

    async def load_data(self) -> None:
        """Load Z-Wave JS migration data."""
        stored = cast(dict, await self._store.async_load())
        if stored:
            self._data = stored

    @callback
    def save_data(
        self, config_entry_id: str, entity_id: str, data: ZWaveJSMigrationData
    ) -> None:
        """Save Z-Wave JS migration data."""
        if config_entry_id not in self._data:
            self._data[config_entry_id] = {}
        self._data[config_entry_id][entity_id] = data
        self._store.async_delay_save(self._data_to_save, STORAGE_WRITE_DELAY)

    @callback
    def _data_to_save(self) -> dict[str, dict[str, ZWaveJSMigrationData]]:
        """Return data to save."""
        return self._data

    @callback
    def add_entity_value(
        self,
        config_entry: ConfigEntry,
        entity_id: str,
        discovery_info: ZwaveDiscoveryInfo,
    ) -> None:
        """Add info for one entity and Z-Wave JS value."""
        ent_reg = async_get_entity_registry(self._hass)
        dev_reg = async_get_device_registry(self._hass)

        node = discovery_info.node
        primary_value = discovery_info.primary_value
        entity_entry = ent_reg.async_get(entity_id)
        assert entity_entry
        device_identifier = get_device_id(node.client, node)
        device_entry = dev_reg.async_get_device({device_identifier}, set())
        assert device_entry

        # Normalize unit of measurement.
        if unit := entity_entry.unit_of_measurement:
            unit = unit.lower()
        if unit == "":
            unit = None

        data: ZWaveJSMigrationData = {
            "node_id": node.node_id,
            "endpoint_index": node.index,
            "command_class": primary_value.command_class,
            "value_property_name": primary_value.property_name,
            "value_property_key_name": primary_value.property_key_name,
            "value_id": primary_value.value_id,
            "device_id": device_entry.id,
            "domain": entity_entry.domain,
            "entity_id": entity_id,
            "unique_id": entity_entry.unique_id,
            "unit_of_measurement": unit,
        }

        self.save_data(config_entry.entry_id, entity_id, data)

    async def get_data(
        self, config_entry: ConfigEntry
    ) -> dict[str, ZWaveJSMigrationData]:
        """Return Z-Wave JS migration data for a config entry."""
        await self.load_data()
        data = self._data.get(config_entry.entry_id)
        return data or {}


@callback
def async_map_legacy_zwave_values(
    zwave_data: dict[str, ZWaveMigrationData],
    zwave_js_data: dict[str, ZWaveJSMigrationData],
) -> LegacyZWaveMappedData:
    """Map Z-Wave node values onto Z-Wave JS node values."""
    migration_map = LegacyZWaveMappedData()
    zwave_proc_data: dict[
        tuple[int, int, int, str, str | None, str | None],
        ZWaveMigrationData | None,
    ] = {}
    zwave_js_proc_data: dict[
        tuple[int, int, int, str, str | None, str | None],
        ZWaveJSMigrationData | None,
    ] = {}

    for zwave_item in zwave_data.values():
        zwave_js_property_name = CC_ID_LABEL_TO_PROPERTY.get(
            zwave_item["command_class"], {}
        ).get(zwave_item["command_class_label"])
        item_id = (
            zwave_item["node_id"],
            zwave_item["command_class"],
            zwave_item["node_instance"] - 1,
            zwave_item["domain"],
            zwave_item["unit_of_measurement"],
            zwave_js_property_name,
        )

        # Filter out duplicates that are not resolvable.
        if item_id in zwave_proc_data:
            zwave_proc_data[item_id] = None
            continue

        zwave_proc_data[item_id] = zwave_item

    for zwave_js_item in zwave_js_data.values():
        # Only identify with property name if there is a command class label map.
        if zwave_js_item["command_class"] in CC_ID_LABEL_TO_PROPERTY:
            zwave_js_property_name = zwave_js_item["value_property_name"]
        else:
            zwave_js_property_name = None
        item_id = (
            zwave_js_item["node_id"],
            zwave_js_item["command_class"],
            zwave_js_item["endpoint_index"],
            zwave_js_item["domain"],
            zwave_js_item["unit_of_measurement"],
            zwave_js_property_name,
        )

        # Filter out duplicates that are not resolvable.
        if item_id in zwave_js_proc_data:
            zwave_js_proc_data[item_id] = None
            continue

        zwave_js_proc_data[item_id] = zwave_js_item

    for item_id, zwave_entry in zwave_proc_data.items():
        zwave_js_entry = zwave_js_proc_data.pop(item_id, None)

        if zwave_entry is None or zwave_js_entry is None:
            continue

        migration_map.entity_entries[zwave_js_entry["entity_id"]] = zwave_entry
        migration_map.device_entries[zwave_js_entry["device_id"]] = zwave_entry[
            "device_id"
        ]

    return migration_map


async def async_migrate_legacy_zwave(
    hass: HomeAssistant,
    zwave_config_entry: ConfigEntry,
    zwave_js_config_entry: ConfigEntry,
    migration_map: LegacyZWaveMappedData,
) -> None:
    """Perform Z-Wave to Z-Wave JS migration."""
    dev_reg = async_get_device_registry(hass)
    for zwave_js_device_id, zwave_device_id in migration_map.device_entries.items():
        zwave_device_entry = dev_reg.async_get(zwave_device_id)
        if not zwave_device_entry:
            continue
        dev_reg.async_update_device(
            zwave_js_device_id,
            area_id=zwave_device_entry.area_id,
            name_by_user=zwave_device_entry.name_by_user,
        )

    ent_reg = async_get_entity_registry(hass)
    for zwave_js_entity_id, zwave_entry in migration_map.entity_entries.items():
        zwave_entity_id = zwave_entry["entity_id"]
        if not (entity_entry := ent_reg.async_get(zwave_entity_id)):
            continue
        ent_reg.async_remove(zwave_entity_id)
        ent_reg.async_update_entity(
            zwave_js_entity_id,
            new_entity_id=entity_entry.entity_id,
            name=entity_entry.name,
            icon=entity_entry.icon,
        )

    await hass.config_entries.async_remove(zwave_config_entry.entry_id)

    updates = {
        **zwave_js_config_entry.data,
        MIGRATED: True,
    }
    hass.config_entries.async_update_entry(zwave_js_config_entry, data=updates)


@dataclass
class ValueID:
    """Class to represent a Value ID."""

    command_class: str
    endpoint: str
    property_: str
    property_key: str | None = None

    @staticmethod
    def from_unique_id(unique_id: str) -> ValueID:
        """
        Get a ValueID from a unique ID.

        This also works for Notification CC Binary Sensors which have their own unique ID
        format.
        """
        return ValueID.from_string_id(unique_id.split(".")[1])

    @staticmethod
    def from_string_id(value_id_str: str) -> ValueID:
        """Get a ValueID from a string representation of the value ID."""
        parts = value_id_str.split("-")
        property_key = parts[4] if len(parts) > 4 else None
        return ValueID(parts[1], parts[2], parts[3], property_key=property_key)

    def is_same_value_different_endpoints(self, other: ValueID) -> bool:
        """Return whether two value IDs are the same excluding endpoint."""
        return (
            self.command_class == other.command_class
            and self.property_ == other.property_
            and self.property_key == other.property_key
            and self.endpoint != other.endpoint
        )


@callback
def async_migrate_old_entity(
    hass: HomeAssistant,
    ent_reg: EntityRegistry,
    registered_unique_ids: set[str],
    platform: str,
    device: DeviceEntry,
    unique_id: str,
) -> None:
    """Migrate existing entity if current one can't be found and an old one exists."""
    # If we can find an existing entity with this unique ID, there's nothing to migrate
    if ent_reg.async_get_entity_id(platform, DOMAIN, unique_id):
        return

    value_id = ValueID.from_unique_id(unique_id)

    # Look for existing entities in the registry that could be the same value but on
    # a different endpoint
    existing_entity_entries: list[RegistryEntry] = []
    for entry in async_entries_for_device(ent_reg, device.id):
        # If entity is not in the domain for this discovery info or entity has already
        # been processed, skip it
        if entry.domain != platform or entry.unique_id in registered_unique_ids:
            continue

        try:
            old_ent_value_id = ValueID.from_unique_id(entry.unique_id)
        # Skip non value ID based unique ID's (e.g. node status sensor)
        except IndexError:
            continue

        if value_id.is_same_value_different_endpoints(old_ent_value_id):
            existing_entity_entries.append(entry)
            # We can return early if we get more than one result
            if len(existing_entity_entries) > 1:
                return

    # If we couldn't find any results, return early
    if not existing_entity_entries:
        return

    entry = existing_entity_entries[0]
    state = hass.states.get(entry.entity_id)

    if not state or state.state == STATE_UNAVAILABLE:
        async_migrate_unique_id(ent_reg, platform, entry.unique_id, unique_id)


@callback
def async_migrate_unique_id(
    ent_reg: EntityRegistry, platform: str, old_unique_id: str, new_unique_id: str
) -> None:
    """Check if entity with old unique ID exists, and if so migrate it to new ID."""
    if entity_id := ent_reg.async_get_entity_id(platform, DOMAIN, old_unique_id):
        _LOGGER.debug(
            "Migrating entity %s from old unique ID '%s' to new unique ID '%s'",
            entity_id,
            old_unique_id,
            new_unique_id,
        )
        try:
            ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id)
        except ValueError:
            _LOGGER.debug(
                (
                    "Entity %s can't be migrated because the unique ID is taken; "
                    "Cleaning it up since it is likely no longer valid"
                ),
                entity_id,
            )
            ent_reg.async_remove(entity_id)


@callback
def async_migrate_discovered_value(
    hass: HomeAssistant,
    ent_reg: EntityRegistry,
    registered_unique_ids: set[str],
    device: DeviceEntry,
    client: ZwaveClient,
    disc_info: ZwaveDiscoveryInfo,
) -> None:
    """Migrate unique ID for entity/entities tied to discovered value."""

    new_unique_id = get_unique_id(
        client.driver.controller.home_id,
        disc_info.primary_value.value_id,
    )

    # On reinterviews, there is no point in going through this logic again for already
    # discovered values
    if new_unique_id in registered_unique_ids:
        return

    # Migration logic was added in 2021.3 to handle a breaking change to the value_id
    # format. Some time in the future, the logic to migrate unique IDs can be removed.

    # 2021.2.*, 2021.3.0b0, and 2021.3.0 formats
    old_unique_ids = [
        get_unique_id(
            client.driver.controller.home_id,
            value_id,
        )
        for value_id in get_old_value_ids(disc_info.primary_value)
    ]

    if (
        disc_info.platform == "binary_sensor"
        and disc_info.platform_hint == "notification"
    ):
        for state_key in disc_info.primary_value.metadata.states:
            # ignore idle key (0)
            if state_key == "0":
                continue

            new_bin_sensor_unique_id = f"{new_unique_id}.{state_key}"

            # On reinterviews, there is no point in going through this logic again
            # for already discovered values
            if new_bin_sensor_unique_id in registered_unique_ids:
                continue

            # Unique ID migration
            for old_unique_id in old_unique_ids:
                async_migrate_unique_id(
                    ent_reg,
                    disc_info.platform,
                    f"{old_unique_id}.{state_key}",
                    new_bin_sensor_unique_id,
                )

            # Migrate entities in case upstream changes cause endpoint change
            async_migrate_old_entity(
                hass,
                ent_reg,
                registered_unique_ids,
                disc_info.platform,
                device,
                new_bin_sensor_unique_id,
            )
            registered_unique_ids.add(new_bin_sensor_unique_id)

        # Once we've iterated through all state keys, we are done
        return

    # Unique ID migration
    for old_unique_id in old_unique_ids:
        async_migrate_unique_id(
            ent_reg, disc_info.platform, old_unique_id, new_unique_id
        )

    # Migrate entities in case upstream changes cause endpoint change
    async_migrate_old_entity(
        hass, ent_reg, registered_unique_ids, disc_info.platform, device, new_unique_id
    )
    registered_unique_ids.add(new_unique_id)


@callback
def get_old_value_ids(value: ZwaveValue) -> list[str]:
    """Get old value IDs so we can migrate entity unique ID."""
    value_ids = []

    # Pre 2021.3.0 value ID
    command_class = value.command_class
    endpoint = value.endpoint or "00"
    property_ = value.property_
    property_key_name = value.property_key_name or "00"
    value_ids.append(
        f"{value.node.node_id}.{value.node.node_id}-{command_class}-{endpoint}-"
        f"{property_}-{property_key_name}"
    )

    endpoint = "00" if value.endpoint is None else value.endpoint
    property_key = "00" if value.property_key is None else value.property_key
    property_key_name = value.property_key_name or "00"

    value_id = (
        f"{value.node.node_id}-{command_class}-{endpoint}-"
        f"{property_}-{property_key}-{property_key_name}"
    )
    # 2021.3.0b0 and 2021.3.0 value IDs
    value_ids.extend([f"{value.node.node_id}.{value_id}", value_id])

    return value_ids