"""Sorting helpers for ISY device classifications."""
from __future__ import annotations

from typing import cast

from pyisy.constants import (
    BACKLIGHT_SUPPORT,
    CMD_BACKLIGHT,
    ISY_VALUE_UNKNOWN,
    PROP_BUSY,
    PROP_COMMS_ERROR,
    PROP_ON_LEVEL,
    PROP_RAMP_RATE,
    PROP_STATUS,
    PROTO_GROUP,
    PROTO_INSTEON,
    PROTO_PROGRAM,
    PROTO_ZWAVE,
    TAG_ENABLED,
    TAG_FOLDER,
    UOM_INDEX,
)
from pyisy.nodes import Group, Node, Nodes
from pyisy.programs import Programs

from homeassistant.const import ATTR_MANUFACTURER, ATTR_MODEL, Platform
from homeassistant.helpers.device_registry import DeviceInfo

from .const import (
    _LOGGER,
    DEFAULT_PROGRAM_STRING,
    DOMAIN,
    FILTER_INSTEON_TYPE,
    FILTER_NODE_DEF_ID,
    FILTER_STATES,
    FILTER_UOM,
    FILTER_ZWAVE_CAT,
    ISY_GROUP_PLATFORM,
    KEY_ACTIONS,
    KEY_STATUS,
    NODE_AUX_FILTERS,
    NODE_FILTERS,
    NODE_PLATFORMS,
    PROGRAM_PLATFORMS,
    SUBNODE_CLIMATE_COOL,
    SUBNODE_CLIMATE_HEAT,
    SUBNODE_EZIO2X4_SENSORS,
    SUBNODE_FANLINC_LIGHT,
    SUBNODE_IOLINC_RELAY,
    TYPE_CATEGORY_SENSOR_ACTUATORS,
    TYPE_EZIO2X4,
    UOM_DOUBLE_TEMP,
    UOM_ISYV4_DEGREES,
)
from .models import IsyData

BINARY_SENSOR_UOMS = ["2", "78"]
BINARY_SENSOR_ISY_STATES = ["on", "off"]
ROOT_AUX_CONTROLS = {
    PROP_ON_LEVEL,
    PROP_RAMP_RATE,
}
SKIP_AUX_PROPS = {PROP_BUSY, PROP_COMMS_ERROR, PROP_STATUS, *ROOT_AUX_CONTROLS}


def _check_for_node_def(
    isy_data: IsyData, node: Group | Node, single_platform: Platform | None = None
) -> bool:
    """Check if the node matches the node_def_id for any platforms.

    This is only present on the 5.0 ISY firmware, and is the most reliable
    way to determine a device's type.
    """
    if not hasattr(node, "node_def_id") or node.node_def_id is None:
        # Node doesn't have a node_def (pre 5.0 firmware most likely)
        return False

    node_def_id = node.node_def_id

    platforms = NODE_PLATFORMS if not single_platform else [single_platform]
    for platform in platforms:
        if node_def_id in NODE_FILTERS[platform][FILTER_NODE_DEF_ID]:
            isy_data.nodes[platform].append(node)
            return True

    return False


def _check_for_insteon_type(
    isy_data: IsyData, node: Group | Node, single_platform: Platform | None = None
) -> bool:
    """Check if the node matches the Insteon type for any platforms.

    This is for (presumably) every version of the ISY firmware, but only
    works for Insteon device. "Node Server" (v5+) and Z-Wave and others will
    not have a type.
    """
    if node.protocol != PROTO_INSTEON:
        return False
    if not hasattr(node, "type") or node.type is None:
        # Node doesn't have a type (non-Insteon device most likely)
        return False

    device_type = node.type
    platforms = NODE_PLATFORMS if not single_platform else [single_platform]
    for platform in platforms:
        if any(
            device_type.startswith(t)
            for t in set(NODE_FILTERS[platform][FILTER_INSTEON_TYPE])
        ):
            # Hacky special-cases for certain devices with different platforms
            # included as subnodes. Note that special-cases are not necessary
            # on ISY 5.x firmware as it uses the superior NodeDefs method
            subnode_id = int(node.address.split(" ")[-1], 16)

            # FanLinc, which has a light module as one of its nodes.
            if platform == Platform.FAN and subnode_id == SUBNODE_FANLINC_LIGHT:
                isy_data.nodes[Platform.LIGHT].append(node)
                return True

            # Thermostats, which has a "Heat" and "Cool" sub-node on address 2 and 3
            if platform == Platform.CLIMATE and subnode_id in (
                SUBNODE_CLIMATE_COOL,
                SUBNODE_CLIMATE_HEAT,
            ):
                isy_data.nodes[Platform.BINARY_SENSOR].append(node)
                return True

            # IOLincs which have a sensor and relay on 2 different nodes
            if (
                platform == Platform.BINARY_SENSOR
                and device_type.startswith(TYPE_CATEGORY_SENSOR_ACTUATORS)
                and subnode_id == SUBNODE_IOLINC_RELAY
            ):
                isy_data.nodes[Platform.SWITCH].append(node)
                return True

            # Smartenit EZIO2X4
            if (
                platform == Platform.SWITCH
                and device_type.startswith(TYPE_EZIO2X4)
                and subnode_id in SUBNODE_EZIO2X4_SENSORS
            ):
                isy_data.nodes[Platform.BINARY_SENSOR].append(node)
                return True

            isy_data.nodes[platform].append(node)
            return True

    return False


def _check_for_zwave_cat(
    isy_data: IsyData, node: Group | Node, single_platform: Platform | None = None
) -> bool:
    """Check if the node matches the ISY Z-Wave Category for any platforms.

    This is for (presumably) every version of the ISY firmware, but only
    works for Z-Wave Devices with the devtype.cat property.
    """
    if node.protocol != PROTO_ZWAVE:
        return False

    if not hasattr(node, "zwave_props") or node.zwave_props is None:
        # Node doesn't have a device type category (non-Z-Wave device)
        return False

    device_type = node.zwave_props.category
    platforms = NODE_PLATFORMS if not single_platform else [single_platform]
    for platform in platforms:
        if any(
            device_type.startswith(t)
            for t in set(NODE_FILTERS[platform][FILTER_ZWAVE_CAT])
        ):
            isy_data.nodes[platform].append(node)
            return True

    return False


def _check_for_uom_id(
    isy_data: IsyData,
    node: Group | Node,
    single_platform: Platform | None = None,
    uom_list: list[str] | None = None,
) -> bool:
    """Check if a node's uom matches any of the platforms uom filter.

    This is used for versions of the ISY firmware that report uoms as a single
    ID. We can often infer what type of device it is by that ID.
    """
    if not hasattr(node, "uom") or node.uom in (None, ""):
        # Node doesn't have a uom (Scenes for example)
        return False

    # Backwards compatibility for ISYv4 Firmware:
    node_uom = node.uom
    if isinstance(node.uom, list):
        node_uom = node.uom[0]

    if uom_list and single_platform:
        if node_uom in uom_list:
            isy_data.nodes[single_platform].append(node)
            return True
        return False

    platforms = NODE_PLATFORMS if not single_platform else [single_platform]
    for platform in platforms:
        if node_uom in NODE_FILTERS[platform][FILTER_UOM]:
            isy_data.nodes[platform].append(node)
            return True

    return False


def _check_for_states_in_uom(
    isy_data: IsyData,
    node: Group | Node,
    single_platform: Platform | None = None,
    states_list: list[str] | None = None,
) -> bool:
    """Check if a list of uoms matches two possible filters.

    This is for versions of the ISY firmware that report uoms as a list of all
    possible "human readable" states. This filter passes if all of the possible
    states fit inside the given filter.
    """
    if not hasattr(node, "uom") or node.uom in (None, ""):
        # Node doesn't have a uom (Scenes for example)
        return False

    # This only works for ISYv4 Firmware where uom is a list of states:
    if not isinstance(node.uom, list):
        return False

    node_uom = set(map(str.lower, node.uom))

    if states_list and single_platform:
        if node_uom == set(states_list):
            isy_data.nodes[single_platform].append(node)
            return True
        return False

    platforms = NODE_PLATFORMS if not single_platform else [single_platform]
    for platform in platforms:
        if node_uom == set(NODE_FILTERS[platform][FILTER_STATES]):
            isy_data.nodes[platform].append(node)
            return True

    return False


def _is_sensor_a_binary_sensor(isy_data: IsyData, node: Group | Node) -> bool:
    """Determine if the given sensor node should be a binary_sensor."""
    if _check_for_node_def(isy_data, node, single_platform=Platform.BINARY_SENSOR):
        return True
    if _check_for_insteon_type(isy_data, node, single_platform=Platform.BINARY_SENSOR):
        return True

    # For the next two checks, we're providing our own set of uoms that
    # represent on/off devices. This is because we can only depend on these
    # checks in the context of already knowing that this is definitely a
    # sensor device.
    if _check_for_uom_id(
        isy_data,
        node,
        single_platform=Platform.BINARY_SENSOR,
        uom_list=BINARY_SENSOR_UOMS,
    ):
        return True
    if _check_for_states_in_uom(
        isy_data,
        node,
        single_platform=Platform.BINARY_SENSOR,
        states_list=BINARY_SENSOR_ISY_STATES,
    ):
        return True

    return False


def _add_backlight_if_supported(isy_data: IsyData, node: Node) -> None:
    """Check if a node supports setting a backlight and add entity."""
    if not getattr(node, "is_backlight_supported", False):
        return
    if BACKLIGHT_SUPPORT[node.node_def_id] == UOM_INDEX:
        isy_data.aux_properties[Platform.SELECT].append((node, CMD_BACKLIGHT))
        return
    isy_data.aux_properties[Platform.NUMBER].append((node, CMD_BACKLIGHT))


def _generate_device_info(node: Node) -> DeviceInfo:
    """Generate the device info for a root node device."""
    isy = node.isy
    device_info = DeviceInfo(
        identifiers={(DOMAIN, f"{isy.uuid}_{node.address}")},
        manufacturer=node.protocol.title(),
        name=node.name,
        via_device=(DOMAIN, isy.uuid),
        configuration_url=isy.conn.url,
        suggested_area=node.folder,
    )

    # ISYv5 Device Types can provide model and manufacturer
    model: str = str(node.address).rpartition(" ")[0] or node.address
    if node.node_def_id is not None:
        model += f": {node.node_def_id}"

    # Numerical Device Type
    if node.type is not None:
        model += f" ({node.type})"

    # Get extra information for Z-Wave Devices
    if (
        node.protocol == PROTO_ZWAVE
        and node.zwave_props
        and node.zwave_props.mfr_id != "0"
    ):
        device_info[
            ATTR_MANUFACTURER
        ] = f"Z-Wave MfrID:{int(node.zwave_props.mfr_id):#0{6}x}"
        model += (
            f"Type:{int(node.zwave_props.prod_type_id):#0{6}x} "
            f"Product:{int(node.zwave_props.product_id):#0{6}x}"
        )
    device_info[ATTR_MODEL] = model

    return device_info


def _categorize_nodes(
    isy_data: IsyData, nodes: Nodes, ignore_identifier: str, sensor_identifier: str
) -> None:
    """Sort the nodes to their proper platforms."""
    for path, node in nodes:
        ignored = ignore_identifier in path or ignore_identifier in node.name
        if ignored:
            # Don't import this node as a device at all
            continue

        if hasattr(node, "parent_node") and node.parent_node is None:
            # This is a physical device / parent node
            isy_data.devices[node.address] = _generate_device_info(node)
            isy_data.root_nodes[Platform.BUTTON].append(node)
            # Any parent node can have communication errors:
            isy_data.aux_properties[Platform.SENSOR].append((node, PROP_COMMS_ERROR))
            # Add Ramp Rate and On Levels for Dimmable Load devices
            if getattr(node, "is_dimmable", False):
                aux_controls = ROOT_AUX_CONTROLS.intersection(node.aux_properties)
                for control in aux_controls:
                    platform = NODE_AUX_FILTERS[control]
                    isy_data.aux_properties[platform].append((node, control))
            if hasattr(node, TAG_ENABLED):
                isy_data.aux_properties[Platform.SWITCH].append((node, TAG_ENABLED))
            _add_backlight_if_supported(isy_data, node)

        if node.protocol == PROTO_GROUP:
            isy_data.nodes[ISY_GROUP_PLATFORM].append(node)
            continue

        if node.protocol == PROTO_INSTEON:
            for control in node.aux_properties:
                if control in SKIP_AUX_PROPS:
                    continue
                isy_data.aux_properties[Platform.SENSOR].append((node, control))

        if sensor_identifier in path or sensor_identifier in node.name:
            # User has specified to treat this as a sensor. First we need to
            # determine if it should be a binary_sensor.
            if _is_sensor_a_binary_sensor(isy_data, node):
                continue
            isy_data.nodes[Platform.SENSOR].append(node)
            continue

        # We have a bunch of different methods for determining the device type,
        # each of which works with different ISY firmware versions or device
        # family. The order here is important, from most reliable to least.
        if _check_for_node_def(isy_data, node):
            continue
        if _check_for_insteon_type(isy_data, node):
            continue
        if _check_for_zwave_cat(isy_data, node):
            continue
        if _check_for_uom_id(isy_data, node):
            continue
        if _check_for_states_in_uom(isy_data, node):
            continue

        # Fallback as as sensor, e.g. for un-sortable items like NodeServer nodes.
        isy_data.nodes[Platform.SENSOR].append(node)


def _categorize_programs(isy_data: IsyData, programs: Programs) -> None:
    """Categorize the ISY programs."""
    for platform in PROGRAM_PLATFORMS:
        folder = programs.get_by_name(f"{DEFAULT_PROGRAM_STRING}{platform}")
        if not folder:
            continue

        for dtype, _, node_id in folder.children:
            if dtype != TAG_FOLDER:
                continue
            entity_folder = folder[node_id]

            actions = None
            status = entity_folder.get_by_name(KEY_STATUS)
            if not status or status.protocol != PROTO_PROGRAM:
                _LOGGER.warning(
                    "Program %s entity '%s' not loaded, invalid/missing status program",
                    platform,
                    entity_folder.name,
                )
                continue

            if platform != Platform.BINARY_SENSOR:
                actions = entity_folder.get_by_name(KEY_ACTIONS)
                if not actions or actions.protocol != PROTO_PROGRAM:
                    _LOGGER.warning(
                        (
                            "Program %s entity '%s' not loaded, invalid/missing actions"
                            " program"
                        ),
                        platform,
                        entity_folder.name,
                    )
                    continue

            entity = (entity_folder.name, status, actions)
            isy_data.programs[platform].append(entity)


def convert_isy_value_to_hass(
    value: int | float | None,
    uom: str | None,
    precision: int | str,
    fallback_precision: int | None = None,
) -> float | int | None:
    """Fix ISY Reported Values.

    ISY provides float values as an integer and precision component.
    Correct by shifting the decimal place left by the value of precision.
    (e.g. value=2345, prec="2" == 23.45)

    Insteon Thermostats report temperature in 0.5-deg precision as an int
    by sending a value of 2 times the Temp. Correct by dividing by 2 here.
    """
    if value is None or value == ISY_VALUE_UNKNOWN:
        return None
    if uom in (UOM_DOUBLE_TEMP, UOM_ISYV4_DEGREES):
        return round(float(value) / 2.0, 1)
    if precision not in ("0", 0):
        return cast(float, round(float(value) / 10 ** int(precision), int(precision)))
    if fallback_precision:
        return round(float(value), fallback_precision)
    return value