"""Home Assistant event parser — HA WebSocket event dict -> ServiceEvent.

The HA event bus delivers events with this envelope:

.. code-block:: json

    {
        "id": 7,
        "type": "event",
        "event": {
            "event_type": "state_changed",
            "data": { ... event-type-specific ... },
            "origin": "LOCAL",
            "time_fired": "2026-05-13T12:34:56.789Z",
            "context": { ... }
        }
    }

The parser accepts the inner ``event`` dict (the WS client strips the outer
envelope before calling us) and emits a :class:`ServiceEvent` ready for the
existing dispatch path. Areas are looked up via an optional ``area_lookup``
mapping so the parser stays pure — the WS client maintains the registry
cache and passes its current snapshot on each call.
"""

from __future__ import annotations

import json
import logging
from datetime import datetime, timezone
from typing import Any

from notify_bridge_core.models.events import EventType, ServiceEvent
from notify_bridge_core.providers.base import ServiceProviderType

_LOGGER = logging.getLogger(__name__)

# Defensive caps for fields that get persisted to the event_log row. Home
# Assistant's own constraints keep entity ids well under 70 chars, but a
# misbehaving custom integration could emit kilobyte-sized strings that
# would bloat the JSON details column.
_MAX_ENTITY_ID_LEN = 255
_MAX_EVENT_DATA_BYTES = 4096


def _parse_time_fired(raw: Any) -> datetime:
    """Parse HA's ``time_fired`` ISO string, falling back to now() on garbage.

    HA always sends UTC with a ``Z`` suffix or explicit ``+00:00``. Datetime
    parsing is wrapped because a malformed payload should not break the
    pipeline — better to dispatch with a slightly-off timestamp than drop.
    """
    if isinstance(raw, str):
        try:
            # ``datetime.fromisoformat`` accepts ``+00:00`` natively; rewrite
            # the trailing ``Z`` since pre-3.11 stdlib rejects it.
            cleaned = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
            return datetime.fromisoformat(cleaned)
        except ValueError:
            _LOGGER.debug("Unparseable HA time_fired %r, using now()", raw)
    return datetime.now(timezone.utc)


def _domain_of(entity_id: str) -> str:
    """Return the HA domain prefix (``light.kitchen`` -> ``light``)."""
    if "." in entity_id:
        return entity_id.split(".", 1)[0]
    return ""


def _friendly_name(state_obj: dict[str, Any] | None, entity_id: str) -> str:
    """Pull ``friendly_name`` from attributes or fall back to entity_id."""
    if not state_obj:
        return entity_id
    attrs = state_obj.get("attributes") or {}
    name = attrs.get("friendly_name")
    return str(name) if name else entity_id


def parse_event(
    ha_event: dict[str, Any],
    provider_name: str,
    area_lookup: dict[str, str] | None = None,
) -> ServiceEvent | None:
    """Parse one HA event dict into a :class:`ServiceEvent`.

    Returns None for malformed payloads (missing ``event_type`` etc.) so the
    caller can drop without raising. Genuine network/parsing exceptions
    bubble up — only known-bad payload shapes return None.
    """
    if not isinstance(ha_event, dict):
        return None
    event_type_raw = ha_event.get("event_type")
    if not isinstance(event_type_raw, str):
        return None

    data = ha_event.get("data") or {}
    timestamp = _parse_time_fired(ha_event.get("time_fired"))
    area_lookup = area_lookup or {}

    if event_type_raw == "state_changed":
        return _parse_state_changed(data, timestamp, provider_name, area_lookup)
    if event_type_raw == "automation_triggered":
        return _parse_automation_triggered(data, timestamp, provider_name)
    if event_type_raw == "call_service":
        return _parse_call_service(data, timestamp, provider_name)
    # Everything else maps to the generic "event_fired" slot. Tracking
    # configs decide whether to enable this loud catch-all.
    return _parse_generic_event(event_type_raw, data, timestamp, provider_name)


def _parse_state_changed(
    data: dict[str, Any],
    timestamp: datetime,
    provider_name: str,
    area_lookup: dict[str, str],
) -> ServiceEvent | None:
    entity_id = data.get("entity_id")
    if not isinstance(entity_id, str):
        return None
    entity_id = entity_id[:_MAX_ENTITY_ID_LEN]

    old_state_obj = data.get("old_state") if isinstance(data.get("old_state"), dict) else None
    new_state_obj = data.get("new_state") if isinstance(data.get("new_state"), dict) else None

    # ``new_state`` is None when an entity is removed — surface it as a
    # transition to the literal string "removed" so templates can branch.
    old_state_val = old_state_obj.get("state") if old_state_obj else None
    new_state_val = new_state_obj.get("state") if new_state_obj else "removed"

    attributes = (new_state_obj or {}).get("attributes") or {}
    friendly_name = _friendly_name(new_state_obj or old_state_obj, entity_id)
    domain = _domain_of(entity_id)

    extra: dict[str, Any] = {
        "entity_id": entity_id,
        "friendly_name": friendly_name,
        "domain": domain,
        "old_state": old_state_val,
        "new_state": new_state_val,
        "attributes": attributes,
        "device_class": attributes.get("device_class"),
        "unit_of_measurement": attributes.get("unit_of_measurement"),
        "area": area_lookup.get(entity_id),
        "ha_event_type": "state_changed",
    }
    if new_state_obj and "last_changed" in new_state_obj:
        extra["last_changed"] = new_state_obj["last_changed"]
    if new_state_obj and "last_updated" in new_state_obj:
        extra["last_updated"] = new_state_obj["last_updated"]

    return ServiceEvent(
        event_type=EventType.HA_STATE_CHANGED,
        provider_type=ServiceProviderType.HOME_ASSISTANT,
        provider_name=provider_name,
        collection_id=entity_id,
        collection_name=friendly_name,
        timestamp=timestamp,
        extra=extra,
    )


def _parse_automation_triggered(
    data: dict[str, Any],
    timestamp: datetime,
    provider_name: str,
) -> ServiceEvent | None:
    entity_id = data.get("entity_id")
    if isinstance(entity_id, str):
        entity_id = entity_id[:_MAX_ENTITY_ID_LEN]
    automation_name = data.get("name") or (entity_id if isinstance(entity_id, str) else "automation")
    source = data.get("source") or ""

    collection_id = entity_id if isinstance(entity_id, str) else f"automation.{automation_name}"
    collection_id = collection_id[:_MAX_ENTITY_ID_LEN]
    return ServiceEvent(
        event_type=EventType.HA_AUTOMATION_TRIGGERED,
        provider_type=ServiceProviderType.HOME_ASSISTANT,
        provider_name=provider_name,
        collection_id=collection_id,
        collection_name=str(automation_name),
        timestamp=timestamp,
        extra={
            "entity_id": entity_id,
            "automation_name": str(automation_name),
            "trigger_source": str(source),
            "ha_event_type": "automation_triggered",
        },
    )


def _parse_call_service(
    data: dict[str, Any],
    timestamp: datetime,
    provider_name: str,
) -> ServiceEvent | None:
    domain = data.get("domain")
    service = data.get("service")
    if not isinstance(domain, str) or not isinstance(service, str):
        return None
    domain = domain[:_MAX_ENTITY_ID_LEN]
    service = service[:_MAX_ENTITY_ID_LEN]
    service_data = data.get("service_data") if isinstance(data.get("service_data"), dict) else {}
    qualified = f"{domain}.{service}"
    target_entity = None
    if isinstance(service_data, dict):
        raw_target = service_data.get("entity_id")
        if isinstance(raw_target, str):
            target_entity = raw_target
        elif isinstance(raw_target, list) and raw_target:
            target_entity = ", ".join(str(x) for x in raw_target)

    return ServiceEvent(
        event_type=EventType.HA_SERVICE_CALLED,
        provider_type=ServiceProviderType.HOME_ASSISTANT,
        provider_name=provider_name,
        collection_id=qualified,
        collection_name=qualified,
        timestamp=timestamp,
        extra={
            "service_domain": domain,
            "service_name": service,
            "service_called": qualified,
            "service_data": service_data,
            "target_entity": target_entity,
            "ha_event_type": "call_service",
        },
    )


def _parse_generic_event(
    event_type_raw: str,
    data: dict[str, Any],
    timestamp: datetime,
    provider_name: str,
) -> ServiceEvent | None:
    event_type_raw = event_type_raw[:_MAX_ENTITY_ID_LEN]
    # Cap the serialized payload so a custom HA integration that emits
    # a multi-megabyte event_data dict doesn't blow up the event_log JSON
    # column. Templates can still reference fields up to the cap; beyond it
    # the dict is replaced with a marker so the limit is visible to authors.
    capped_data: Any = data
    try:
        serialized = json.dumps(data, default=str)
    except (TypeError, ValueError):
        # Unserializable payload — keep the dict in-memory so templates can
        # still read scalar fields, but flag the size as 0 to avoid surprises.
        serialized = ""
    if len(serialized.encode("utf-8")) > _MAX_EVENT_DATA_BYTES:
        capped_data = {
            "_truncated": True,
            "_original_size_bytes": len(serialized.encode("utf-8")),
            "_note": f"event_data exceeded {_MAX_EVENT_DATA_BYTES}B and was dropped",
        }

    return ServiceEvent(
        event_type=EventType.HA_EVENT_FIRED,
        provider_type=ServiceProviderType.HOME_ASSISTANT,
        provider_name=provider_name,
        collection_id=event_type_raw,
        collection_name=event_type_raw,
        timestamp=timestamp,
        extra={
            "ha_event_type": event_type_raw,
            "event_data": capped_data,
        },
    )
