"""
Alexa Devices Entities.

SPDX-License-Identifier: Apache-2.0

For more details about this platform, please refer to the documentation at
https://community.home-assistant.io/t/echo-devices-alexa-as-media-player-testers-needed/58639
"""

from __future__ import annotations

from datetime import datetime, timedelta, timezone
import json
import logging
import re
from typing import Any, Optional, TypedDict

from alexapy import AlexaAPI, AlexaLogin
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator

from .helpers import safe_get

_LOGGER = logging.getLogger(__name__)

# How long we keep "requested state" protected from stale coordinator values
# when capabilityStates do not include a usable timeOfSample.
_REQUESTED_STATE_TTL = timedelta(seconds=15)


def has_capability(
    appliance: dict[str, Any], interface_name: str, property_name: str
) -> bool:
    """Determine if an appliance from the Alexa network details offers a particular interface with enough support that is worth adding to Home Assistant.

    Args:
        appliance(dict[str, Any]): An appliance from a call to AlexaAPI.get_network_details
        interface_name(str): One of the interfaces documented by the Alexa Smart Home Skills API
        property_name(str): The property that matches the interface name.

    """
    for cap in appliance["capabilities"]:
        props = cap.get("properties")
        if (
            cap["interfaceName"] == interface_name
            and props
            and (props["retrievable"] or props["proactivelyReported"])
        ):
            for prop in props["supported"]:
                if prop["name"] == property_name:
                    return True
    return False


def is_hue_v1(appliance: dict[str, Any]) -> bool:
    """Determine if an appliance is managed via the Philips Hue v1 Hub.

    This check catches old Philips Hue bulbs and hubs, but critically, it also catches things pretending to be older
    Philips Hue bulbs and hubs. This includes things exposed by HA to Alexa using the emulated_hue integration.
    """
    return appliance.get("manufacturerName") == "Royal Philips Electronics"


def is_skill(appliance: dict[str, Any]) -> bool:
    namespace = safe_get(appliance, ["driverIdentity", "namespace"], "")
    return namespace and namespace == "SKILL"


def is_known_ha_bridge(appliance: dict[str, Any] | None) -> bool:
    """Test whether a bridge appliance is a known HA bridge to avoid creating loops."""

    if appliance is None:
        return False

    if appliance.get("manufacturerName") in ("t0bst4r", "Matterbridge"):
        return True

    # Identify Matter bridge hubs regardless of manufacturerName
    if "HUB" in appliance.get("applianceTypes", []):
        driver_ns = safe_get(appliance, ["driverIdentity", "namespace"], "")
        driver_id = safe_get(appliance, ["driverIdentity", "identifier"], "")
        if driver_ns == "AAA" and driver_id == "SonarCloudService":
            interfaces = {
                cap.get("interfaceName") for cap in appliance.get("capabilities", [])
            }
            if (
                "Alexa.Matter.NodeOperationalCredentials.FabricManagement" in interfaces
                or "Alexa.Commissionable" in interfaces
            ):
                return True

    return False


def is_local(appliance: dict[str, Any]) -> bool:
    """Test whether locally connected.

    This is mainly present to prevent loops with the official Alexa integration.
    There is probably a better way to prevent that, but this works.
    """

    if appliance.get("connectedVia"):
        # connectedVia is a flag that determines which Echo devices holds the connection. Its blank for
        # skill derived devices and includes an Echo name for zigbee and local devices.
        return True

    # This catches the Echo/AVS devices. connectedVia isn't reliable in this case.
    # Only the first appears to get that set.
    if "ALEXA_VOICE_ENABLED" in appliance.get("applianceTypes", []):
        return not is_skill(appliance)

    # Ledvance/Sengled bulbs connected via bluetooth are hard to detect as locally connected
    # Amazon devices are not local but bypassing the local check allows for control by the integration
    # There is probably a better way, but this works for now.
    manufacturerNames = ["Ledvance", "Sengled", "Amazon"]
    if appliance.get("manufacturerName") in manufacturerNames:
        return not is_skill(appliance)

    # Zigbee devices are guaranteed to be local and have a particular pattern of id
    zigbee_pattern = re.compile(
        "AAA_SonarCloudService_([0-9A-F][0-9A-F]:){7}[0-9A-F][0-9A-F]", flags=re.I
    )
    return zigbee_pattern.fullmatch(appliance.get("applianceId", "")) is not None


def is_alexa_guard(appliance: dict[str, Any]) -> bool:
    """Is the given appliance the guard alarm system of an echo."""
    return appliance["modelName"] == "REDROCK_GUARD_PANEL" and has_capability(
        appliance, "Alexa.SecurityPanelController", "armState"
    )


def is_temperature_sensor(appliance: dict[str, Any]) -> bool:
    """Is the given appliance the temperature sensor of an Echo."""
    return (
        is_local(appliance)
        and has_capability(appliance, "Alexa.TemperatureSensor", "temperature")
        and appliance["friendlyDescription"] != "Amazon Indoor Air Quality Monitor"
    )


# Checks if air quality sensor
def is_air_quality_sensor(appliance: dict[str, Any]) -> bool:
    """Is the given appliance the Amazon Indoor Air Quality Monitor (AIAQM)."""
    return (
        appliance.get("friendlyDescription") == "Amazon Indoor Air Quality Monitor"
        and "AIR_QUALITY_MONITOR" in appliance.get("applianceTypes", [])
        and has_capability(appliance, "Alexa.RangeController", "rangeValue")
    )


def is_light(appliance: dict[str, Any]) -> bool:
    """Is the given appliance a light controlled locally by an Echo."""
    return (
        is_local(appliance)
        and (
            "LIGHT" in appliance.get("applianceTypes", [])
            or (
                "SMARTPLUG" in appliance.get("applianceTypes", [])
                and appliance.get("customerDefinedDeviceType") == "LIGHT"
            )
        )
        and has_capability(appliance, "Alexa.PowerController", "powerState")
    )


def is_contact_sensor(appliance: dict[str, Any]) -> bool:
    """Is the given appliance a contact sensor controlled locally by an Echo."""
    return (
        is_local(appliance)
        and "CONTACT_SENSOR" in appliance.get("applianceTypes", [])
        and has_capability(appliance, "Alexa.ContactSensor", "detectionState")
    )


def is_switch(appliance: dict[str, Any]) -> bool:
    """Is the given appliance a switch controlled locally by an Echo, which is not redeclared as a light."""
    return (
        is_local(appliance)
        and (
            "SMARTPLUG" in appliance.get("applianceTypes", [])
            or "SWITCH" in appliance.get("applianceTypes", [])
        )
        and appliance.get("customerDefinedDeviceType") != "LIGHT"
        and has_capability(appliance, "Alexa.PowerController", "powerState")
    )


def get_friendliest_name(appliance: dict[str, Any]) -> str:
    """Find the best friendly name. Alexa seems to store manual renames in aliases. Prefer that one."""
    aliases = appliance.get("aliases", [])
    for alias in aliases:
        friendly = alias.get("friendlyName")
        if friendly:
            return friendly
    return appliance["friendlyName"]


def get_device_serial(appliance: dict[str, Any]) -> str | None:
    """Find the device serial id if it is present."""
    alexa_device_id_list = appliance.get("alexaDeviceIdentifierList", [])
    for alexa_device_id in alexa_device_id_list:
        if isinstance(alexa_device_id, dict):
            return alexa_device_id.get("dmsDeviceSerialNumber")
    return None


def get_device_bridge(
    appliance: dict[str, Any], appliances: dict[str, dict[str, Any]]
) -> dict[str, Any] | None:
    """Find the bridge device for an appliance connected through e.g. a Matter bridge."""

    appliance_id = appliance.get("applianceId")
    if not isinstance(appliance_id, str) or "#" not in appliance_id:
        return None

    # HA Matter Hub bridged endpoints are identified by applianceId prefixes
    # of the form AAA_SonarCloudService_<bridgeId>#<childId>.
    bridge_id, _sep, _child = appliance_id.partition("#")

    if not bridge_id.startswith("AAA_SonarCloudService_"):
        return None

    bridge = appliances.get(bridge_id)
    return bridge if isinstance(bridge, dict) else None


AlexaEntityData = dict[str, list["AlexaCapabilityState"]]


class AlexaEntity(TypedDict):
    """Class for Alexaentity."""

    id: str
    appliance_id: str
    name: str
    is_hue_v1: bool


class AlexaLightEntity(AlexaEntity):
    """Class for AlexaLightEntity."""

    brightness: bool
    color: bool
    color_temperature: bool


class AlexaTemperatureEntity(TypedDict, total=False):
    device_serial: str
    is_aiaqm: bool


class AlexaAirQualityEntity(AlexaEntity):
    """Class for AlexaAirQualityEntity."""

    device_serial: str


class AlexaAIAQMEntity(AlexaEntity):
    """Entity-backed "device" representing an Amazon Indoor Air Quality Monitor."""

    device_serial: str
    sensors: list[dict[str, str]]


class AlexaBinaryEntity(AlexaEntity):
    """Class for AlexaBinaryEntity."""

    battery_level: bool


class AlexaEntities(TypedDict):
    """Class for Alexa Entities."""

    light: list[AlexaLightEntity]
    guard: list[AlexaEntity]
    temperature: list[AlexaTemperatureEntity]
    air_quality: list[AlexaAirQualityEntity]
    aiaqm: list[AlexaAIAQMEntity]
    binary_sensor: list[AlexaBinaryEntity]
    smart_switch: list[AlexaEntity]


class AlexaCapabilityState(TypedDict, total=False):
    """Class for AlexaCapabilityState."""

    name: str
    namespace: str
    value: int | float | str | dict[str, Any]
    instance: str
    timeOfSample: str
    uncertaintyInMilliseconds: int


def parse_alexa_entities(
    network_details: list[dict[str, Any]] | None,
    debug: bool = False,
) -> AlexaEntities:
    # pylint: disable=too-many-locals
    """Turn the network details into a list of useful entities with the important details extracted."""
    temperature_sensors: list[AlexaTemperatureEntity] = []
    air_quality_sensors: list[AlexaAirQualityEntity] = []
    aiaqm_entities: list[AlexaAIAQMEntity] = []
    contact_sensors: list[AlexaBinaryEntity] = []
    switches: list[AlexaEntity] = []
    guards: list[AlexaEntity] = []
    lights: list[AlexaLightEntity] = []

    function_name = "parse_alexa_entities()"

    if not network_details:
        return {
            "light": lights,
            "guard": guards,
            "temperature": temperature_sensors,
            "air_quality": air_quality_sensors,
            "aiaqm": aiaqm_entities,
            "binary_sensor": contact_sensors,
            "smart_switch": switches,
        }

    network_dict: dict[str, dict[str, Any]] = {}
    if debug:
        _LOGGER.debug("Processing network_details")

    # Build an applianceId → appliance map first so bridged devices
    # can resolve their bridge regardless of list ordering.
    for appliance in network_details:
        appliance_id = appliance.get("applianceId")
        if appliance_id:
            network_dict[appliance_id] = appliance

    for appliance in network_details:
        device_bridge = get_device_bridge(appliance, network_dict)

        bridge_label = (
            device_bridge.get("friendlyName") or device_bridge.get("manufacturerName")
            if device_bridge
            else None
        )

        appliance_id = str(appliance.get("applianceId", ""))

        # Only log a bridge check when:
        # - we found a bridge, OR
        # - ADV debug is enabled AND the appliance looks like a bridge candidate
        if bridge_label is not None or (debug and "#" in appliance_id):
            _LOGGER.debug(
                "%s: Checking device bridge: %s",
                appliance.get("friendlyName"),
                bridge_label or "<none>",
            )

        # ADV-only: only log resolution for cases where it might apply
        if debug and "#" in appliance_id:
            bridge_id = device_bridge.get("applianceId") if device_bridge else None
            _LOGGER.debug(
                "[%s] [ADV] Matter bridge resolution: appliance=%s → bridge=%s (connectedVia=%s, bridge=%s)",
                function_name,
                appliance_id,
                bridge_id,
                appliance.get("connectedVia"),
                bridge_label,
            )

        if is_known_ha_bridge(device_bridge):
            if debug:
                _LOGGER.debug(
                    '[%s] [ADV] Skipping bridged Matter device "%s" (%s) via known bridge: %s (%s)',
                    function_name,
                    appliance.get("friendlyName"),
                    appliance.get("applianceId"),
                    bridge_label,
                    device_bridge.get("applianceId") if device_bridge else None,
                )
            else:
                _LOGGER.debug(
                    'Skipping bridged Matter device "%s" via known bridge "%s"',
                    appliance.get("friendlyName"),
                    bridge_label or "<unknown>",
                )
            continue

        processed_appliance: AlexaEntity = {
            "id": appliance["entityId"],
            "appliance_id": appliance["applianceId"],
            "name": get_friendliest_name(appliance),
            "is_hue_v1": is_hue_v1(appliance),
        }

        if is_alexa_guard(appliance):
            _LOGGER.debug("Added Alexa Guard: %s", processed_appliance["name"])
            guards.append(processed_appliance)

        elif is_temperature_sensor(appliance):
            if debug:
                _LOGGER.debug(
                    "Added temperature sensor: %s", processed_appliance["name"]
                )
            serial = get_device_serial(appliance)
            temp_entity: AlexaTemperatureEntity = {
                **processed_appliance,
                "device_serial": serial if serial else appliance["entityId"],
            }
            temperature_sensors.append(temp_entity)

        elif is_air_quality_sensor(appliance):
            if debug:
                _LOGGER.debug("Added AIAQM sensor: %s", processed_appliance["name"])

            serial = get_device_serial(appliance)
            device_serial = serial if serial else appliance["entityId"]

            # Build a list of sub-sensors we can read via AlexaAPI.get_entity_state.
            # AIAQM metrics are exposed via Alexa.RangeController(rangeValue) with an
            # instance per metric. Some accounts/devices use numeric instances, so
            # we derive the sensor type from the friendlyName assetId/text.
            sensors: list[dict[str, str]] = []
            for cap in appliance.get("capabilities", []):
                if cap.get("interfaceName") != "Alexa.RangeController":
                    continue

                # Must support numeric rangeValue to be a sensor.
                supported = safe_get(cap, ["properties", "supported"], [])
                if not isinstance(supported, list) or not any(
                    isinstance(p, dict) and p.get("name") == "rangeValue"
                    for p in supported
                ):
                    continue

                instance = cap.get("instance")
                if instance is None or instance == "":
                    continue
                if not isinstance(instance, str):
                    if isinstance(instance, (int, float)):
                        instance = str(instance)
                    else:
                        continue

                unit = safe_get(cap, ["configuration", "unitOfMeasure"], "") or ""

                resources = (
                    cap.get("resources", {})
                    if isinstance(cap.get("resources"), dict)
                    else {}
                )
                friendly = (
                    resources.get("friendlyNames", [])
                    if isinstance(resources.get("friendlyNames"), list)
                    else []
                )

                sensor_type: str | None = None
                for entry in friendly:
                    if not isinstance(entry, dict):
                        continue
                    value_obj = entry.get("value")
                    asset_id = None
                    if isinstance(value_obj, dict):
                        asset_id = value_obj.get("assetId")
                    else:
                        asset_id = entry.get("assetId")

                    # Only treat Alexa.AirQuality assetIds as real AIAQM sensors.
                    # Text-only friendlyNames (e.g. @type "text") must be ignored to avoid
                    # creating extra sensors such as PM10.
                    if isinstance(asset_id, str) and asset_id.startswith(
                        "Alexa.AirQuality."
                    ):
                        sensor_type = asset_id
                        break

                if not sensor_type:
                    continue
                sensors.append(
                    {
                        "sensorType": str(sensor_type),
                        "instance": instance,
                        "unit": str(unit),
                    }
                )

            # Always register the AIAQM device (even if no sub-sensors are exposed).
            aiaqm_entity: AlexaAIAQMEntity = {
                **processed_appliance,
                "device_serial": device_serial,
                "sensors": sensors,
            }
            aiaqm_entities.append(aiaqm_entity)

            # Backwards compatibility: also expose as air_quality for existing paths.
            aq_entity: AlexaAirQualityEntity = {
                **processed_appliance,
                "device_serial": device_serial,
            }
            air_quality_sensors.append(aq_entity)

            # AIAQM also has temperature; ensure it gets created and grouped with AIAQM.
            temp_entity: AlexaTemperatureEntity = {
                **processed_appliance,
                "device_serial": device_serial,
                "is_aiaqm": True,
            }
            temperature_sensors.append(temp_entity)
        elif is_switch(appliance):
            if debug:
                _LOGGER.debug("Added switch: %s", processed_appliance["name"])
            switches.append(processed_appliance)

        elif is_light(appliance):
            if debug:
                _LOGGER.debug("Added light %s", processed_appliance["name"])
            processed_appliance["brightness"] = has_capability(
                appliance, "Alexa.BrightnessController", "brightness"
            )
            processed_appliance["color"] = has_capability(
                appliance, "Alexa.ColorController", "color"
            )
            processed_appliance["color_temperature"] = has_capability(
                appliance,
                "Alexa.ColorTemperatureController",
                "colorTemperatureInKelvin",
            )
            light_entity: AlexaLightEntity = {
                **processed_appliance,
                "brightness": processed_appliance["brightness"],
                "color": processed_appliance["color"],
                "color_temperature": processed_appliance["color_temperature"],
            }
            lights.append(light_entity)

        elif is_contact_sensor(appliance):
            if debug:
                _LOGGER.debug("Added contact sensor: %s", processed_appliance["name"])
            processed_appliance["battery_level"] = has_capability(
                appliance, "Alexa.BatteryLevelSensor", "batteryLevel"
            )
            binary_entity: AlexaBinaryEntity = {
                **processed_appliance,
                "battery_level": processed_appliance["battery_level"],
            }
            contact_sensors.append(binary_entity)

        else:
            if debug:
                _LOGGER.debug("Unsupported entity: %s", processed_appliance["name"])

    return {
        "light": lights,
        "guard": guards,
        "temperature": temperature_sensors,
        "air_quality": air_quality_sensors,
        "aiaqm": aiaqm_entities,
        "binary_sensor": contact_sensors,
        "smart_switch": switches,
    }


async def get_entity_data(
    login_obj: AlexaLogin, entity_ids: list[str]
) -> AlexaEntityData:
    """Get and process the entity data into a more usable format."""

    entities = {}
    if entity_ids:
        raw = await AlexaAPI.get_entity_state(login_obj, entity_ids=entity_ids)
        device_states = raw.get("deviceStates", []) if isinstance(raw, dict) else None
        if device_states:
            for device_state in device_states:
                entity_id = safe_get(device_state, ["entity", "entityId"])
                if entity_id:
                    entities[entity_id] = []
                    cap_states = device_state.get("capabilityStates", [])
                    for cap_state in cap_states:
                        entities[entity_id].append(json.loads(cap_state))
    return entities


def parse_temperature_from_coordinator(
    coordinator: DataUpdateCoordinator,
    entity_id: str,
    debug: bool = False,
) -> dict[str, Any] | None:
    """Get the temperature of an entity from the coordinator data."""
    temperature = parse_value_from_coordinator(
        coordinator,
        entity_id,
        "Alexa.TemperatureSensor",
        "temperature",
        debug=debug,
    )
    if debug:
        _LOGGER.debug("parse_temperature_from_coordinator: %s", temperature)
    return temperature


def parse_air_quality_from_coordinator(
    coordinator: DataUpdateCoordinator,
    entity_id: str,
    instance_id: str,
    debug: bool = False,
) -> int | float | str | None:
    """Get the air quality of an entity from the coordinator data."""
    value = parse_value_from_coordinator(
        coordinator,
        entity_id,
        "Alexa.RangeController",
        "rangeValue",
        instance=instance_id,
        debug=debug,
    )
    return value


def parse_brightness_from_coordinator(
    coordinator: DataUpdateCoordinator, entity_id: str, since: datetime | None
) -> int | None:
    """Get the brightness in the range 0-100."""
    return parse_value_from_coordinator(
        coordinator,
        entity_id,
        "Alexa.BrightnessController",
        "brightness",
        since=since,
    )


def parse_color_temp_from_coordinator(
    coordinator: DataUpdateCoordinator, entity_id: str, since: datetime | None
) -> int | None:
    """Get the color temperature in kelvin."""
    return parse_value_from_coordinator(
        coordinator,
        entity_id,
        "Alexa.ColorTemperatureController",
        "colorTemperatureInKelvin",
        since=since,
    )


def parse_color_from_coordinator(
    coordinator: DataUpdateCoordinator, entity_id: str, since: datetime | None
) -> tuple[float, float, float] | None:
    """Get the color as a tuple of (hue, saturation, brightness)."""
    value = parse_value_from_coordinator(
        coordinator, entity_id, "Alexa.ColorController", "color", since
    )
    if value is not None:
        hue = value.get("hue", 0)
        saturation = value.get("saturation", 0)
        return hue, saturation, 1
    return None


def parse_power_from_coordinator(
    coordinator: DataUpdateCoordinator, entity_id: str, since: datetime | None
) -> str | None:
    """Get the power state of the entity."""
    return parse_value_from_coordinator(
        coordinator,
        entity_id,
        "Alexa.PowerController",
        "powerState",
        since=since,
    )


def parse_guard_state_from_coordinator(
    coordinator: DataUpdateCoordinator, entity_id: str
) -> str | None:
    """Get the guard state from the coordinator data."""
    return parse_value_from_coordinator(
        coordinator, entity_id, "Alexa.SecurityPanelController", "armState"
    )


def parse_detection_state_from_coordinator(
    coordinator: DataUpdateCoordinator, entity_id: str
) -> bool | None:
    """Get the detection state from the coordinator data."""
    return parse_value_from_coordinator(
        coordinator, entity_id, "Alexa.ContactSensor", "detectionState"
    )


def parse_value_from_coordinator(
    coordinator: DataUpdateCoordinator,
    entity_id: str,
    namespace: str,
    name: str,
    since: datetime | None = None,
    instance: str | None = None,
    *,
    debug: bool = False,
) -> Any:
    """Parse out values from coordinator for Alexa Entities."""
    if coordinator.data and entity_id in coordinator.data:
        found_match = False
        for cap_state in coordinator.data[entity_id]:
            cap_instance = cap_state.get("instance")
            instance_match = instance is None or (
                cap_instance is not None and str(cap_instance) == str(instance)
            )
            if (
                cap_state.get("namespace") == namespace
                and cap_state.get("name") == name
                and instance_match
            ):
                found_match = True
                if is_cap_state_still_acceptable(cap_state, since):
                    return cap_state.get("value")
                if debug:
                    _LOGGER.debug(
                        "Coordinator data for %s (%s/%s instance=%s) is too old; checking other matches.",
                        entity_id,
                        namespace,
                        name,
                        instance,
                    )
                # Keep searching in case a newer matching cap_state exists later.
                continue
        if debug and found_match:
            _LOGGER.debug(
                "No acceptable coordinator data found for %s (%s/%s instance=%s).",
                entity_id,
                namespace,
                name,
                instance,
            )
    else:
        if debug:
            _LOGGER.debug(
                "Coordinator has no data yet for %s, %s, %s, %s",
                entity_id,
                namespace,
                name,
                instance,
            )
    return None


def is_cap_state_still_acceptable(
    cap_state: dict[str, Any], since: datetime | None
) -> bool:
    """Determine if a particular capability state is still usable given its age."""
    if since is None:
        return True

    # Don't protect requested state forever; after TTL fall back to coordinator
    # even if timeOfSample is missing/unparsable.
    if datetime.now(timezone.utc) - since > _REQUESTED_STATE_TTL:
        return True

    formatted_time_of_sample = cap_state.get("timeOfSample")
    if not formatted_time_of_sample:
        # If we can't prove the sample is newer than the requested state,
        # do not allow it to override optimistic/requested values.
        return False

    try:
        time_of_sample = datetime.fromisoformat(formatted_time_of_sample)
    except ValueError:
        return False

    return time_of_sample >= since
