"""Event parser and human readable log generator."""
from __future__ import annotations

from collections.abc import Callable
from typing import Any

from homeassistant.components.sensor import ATTR_STATE_CLASS
from homeassistant.const import (
    ATTR_DEVICE_ID,
    ATTR_DOMAIN,
    ATTR_ENTITY_ID,
    ATTR_UNIT_OF_MEASUREMENT,
    EVENT_LOGBOOK_ENTRY,
    EVENT_STATE_CHANGED,
)
from homeassistant.core import (
    CALLBACK_TYPE,
    Event,
    HomeAssistant,
    State,
    callback,
    is_callback,
    split_entity_id,
)
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.entityfilter import EntityFilter
from homeassistant.helpers.event import async_track_state_change_event

from .const import ALWAYS_CONTINUOUS_DOMAINS, AUTOMATION_EVENTS, BUILT_IN_EVENTS, DOMAIN
from .models import LazyEventPartialState


def async_filter_entities(hass: HomeAssistant, entity_ids: list[str]) -> list[str]:
    """Filter out any entities that logbook will not produce results for."""
    ent_reg = er.async_get(hass)
    return [
        entity_id
        for entity_id in entity_ids
        if not _is_entity_id_filtered(hass, ent_reg, entity_id)
    ]


@callback
def _async_config_entries_for_ids(
    hass: HomeAssistant, entity_ids: list[str] | None, device_ids: list[str] | None
) -> set[str]:
    """Find the config entry ids for a set of entities or devices."""
    config_entry_ids: set[str] = set()
    if entity_ids:
        eng_reg = er.async_get(hass)
        for entity_id in entity_ids:
            if (entry := eng_reg.async_get(entity_id)) and entry.config_entry_id:
                config_entry_ids.add(entry.config_entry_id)
    if device_ids:
        dev_reg = dr.async_get(hass)
        for device_id in device_ids:
            if (device := dev_reg.async_get(device_id)) and device.config_entries:
                config_entry_ids |= device.config_entries
    return config_entry_ids


def async_determine_event_types(
    hass: HomeAssistant, entity_ids: list[str] | None, device_ids: list[str] | None
) -> tuple[str, ...]:
    """Reduce the event types based on the entity ids and device ids."""
    external_events: dict[
        str, tuple[str, Callable[[LazyEventPartialState], dict[str, Any]]]
    ] = hass.data.get(DOMAIN, {})
    if not entity_ids and not device_ids:
        return (*BUILT_IN_EVENTS, *external_events)

    interested_domains: set[str] = set()
    for entry_id in _async_config_entries_for_ids(hass, entity_ids, device_ids):
        if entry := hass.config_entries.async_get_entry(entry_id):
            interested_domains.add(entry.domain)

    #
    # automations and scripts can refer to entities or devices
    # but they do not have a config entry so we need
    # to add them since we have historically included
    # them when matching only on entities
    #
    intrested_event_types: set[str] = {
        external_event
        for external_event, domain_call in external_events.items()
        if domain_call[0] in interested_domains
    } | AUTOMATION_EVENTS
    if entity_ids:
        # We also allow entity_ids to be recorded via manual logbook entries.
        intrested_event_types.add(EVENT_LOGBOOK_ENTRY)

    return tuple(intrested_event_types)


@callback
def extract_attr(source: dict[str, Any], attr: str) -> list[str]:
    """Extract an attribute as a list or string."""
    if (value := source.get(attr)) is None:
        return []
    if isinstance(value, list):
        return value
    return str(value).split(",")


@callback
def event_forwarder_filtered(
    target: Callable[[Event], None],
    entities_filter: EntityFilter | None,
    entity_ids: list[str] | None,
    device_ids: list[str] | None,
) -> Callable[[Event], None]:
    """Make a callable to filter events."""
    if not entities_filter and not entity_ids and not device_ids:
        # No filter
        # - Script Trace (context ids)
        # - Automation Trace (context ids)
        return target

    if entities_filter:
        # We have an entity filter:
        # - Logbook panel

        @callback
        def _forward_events_filtered_by_entities_filter(event: Event) -> None:
            assert entities_filter is not None
            event_data = event.data
            entity_ids = extract_attr(event_data, ATTR_ENTITY_ID)
            if entity_ids and not any(
                entities_filter(entity_id) for entity_id in entity_ids
            ):
                return
            domain = event_data.get(ATTR_DOMAIN)
            if domain and not entities_filter(f"{domain}._"):
                return
            target(event)

        return _forward_events_filtered_by_entities_filter

    # We are filtering on entity_ids and/or device_ids:
    # - Areas
    # - Devices
    # - Logbook Card
    entity_ids_set = set(entity_ids) if entity_ids else set()
    device_ids_set = set(device_ids) if device_ids else set()

    @callback
    def _forward_events_filtered_by_device_entity_ids(event: Event) -> None:
        event_data = event.data
        if entity_ids_set.intersection(
            extract_attr(event_data, ATTR_ENTITY_ID)
        ) or device_ids_set.intersection(extract_attr(event_data, ATTR_DEVICE_ID)):
            target(event)

    return _forward_events_filtered_by_device_entity_ids


@callback
def async_subscribe_events(
    hass: HomeAssistant,
    subscriptions: list[CALLBACK_TYPE],
    target: Callable[[Event], None],
    event_types: tuple[str, ...],
    entities_filter: EntityFilter | None,
    entity_ids: list[str] | None,
    device_ids: list[str] | None,
) -> None:
    """Subscribe to events for the entities and devices or all.

    These are the events we need to listen for to do
    the live logbook stream.
    """
    ent_reg = er.async_get(hass)
    assert is_callback(target), "target must be a callback"
    event_forwarder = event_forwarder_filtered(
        target, entities_filter, entity_ids, device_ids
    )
    for event_type in event_types:
        subscriptions.append(
            hass.bus.async_listen(event_type, event_forwarder, run_immediately=True)
        )

    if device_ids and not entity_ids:
        # No entities to subscribe to but we are filtering
        # on device ids so we do not want to get any state
        # changed events
        return

    @callback
    def _forward_state_events_filtered(event: Event) -> None:
        if event.data.get("old_state") is None or event.data.get("new_state") is None:
            return
        new_state: State = event.data["new_state"]
        old_state: State = event.data["old_state"]
        if _is_state_filtered(ent_reg, new_state, old_state) or (
            entities_filter and not entities_filter(new_state.entity_id)
        ):
            return
        target(event)

    if entity_ids:
        subscriptions.append(
            async_track_state_change_event(
                hass, entity_ids, _forward_state_events_filtered
            )
        )
        return

    # We want the firehose
    subscriptions.append(
        hass.bus.async_listen(
            EVENT_STATE_CHANGED,
            _forward_state_events_filtered,
            run_immediately=True,
        )
    )


def is_sensor_continuous(ent_reg: er.EntityRegistry, entity_id: str) -> bool:
    """Determine if a sensor is continuous by checking its state class.

    Sensors with a unit_of_measurement are also considered continuous, but are filtered
    already by the SQL query generated by _get_events
    """
    if not (entry := ent_reg.async_get(entity_id)):
        # Entity not registered, so can't have a state class
        return False
    return (
        entry.capabilities is not None
        and entry.capabilities.get(ATTR_STATE_CLASS) is not None
    )


def _is_state_filtered(
    ent_reg: er.EntityRegistry, new_state: State, old_state: State
) -> bool:
    """Check if the logbook should filter a state.

    Used when we are in live mode to ensure
    we only get significant changes (state.last_changed != state.last_updated)
    """
    return bool(
        new_state.state == old_state.state
        or split_entity_id(new_state.entity_id)[0] in ALWAYS_CONTINUOUS_DOMAINS
        or new_state.last_changed != new_state.last_updated
        or ATTR_UNIT_OF_MEASUREMENT in new_state.attributes
        or is_sensor_continuous(ent_reg, new_state.entity_id)
    )


def _is_entity_id_filtered(
    hass: HomeAssistant, ent_reg: er.EntityRegistry, entity_id: str
) -> bool:
    """Check if the logbook should filter an entity.

    Used to setup listeners and which entities to select
    from the database when a list of entities is requested.
    """
    return bool(
        split_entity_id(entity_id)[0] in ALWAYS_CONTINUOUS_DOMAINS
        or (state := hass.states.get(entity_id))
        and (ATTR_UNIT_OF_MEASUREMENT in state.attributes)
        or is_sensor_continuous(ent_reg, entity_id)
    )
