"""Automation engine — background loop that evaluates rules and activates scenes."""

import asyncio
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Callable, Dict, Set

from ledgrab.core.automations.platform_detector import PlatformDetector
from ledgrab.storage.automation import (
    ApplicationRule,
    Automation,
    DisplayStateRule,
    HomeAssistantRule,
    HTTPPollRule,
    MQTTRule,
    Rule,
    StartupRule,
    SystemIdleRule,
    TimeOfDayRule,
    WebhookRule,
)
from ledgrab.storage.automation_store import AutomationStore
from ledgrab.storage.scene_preset import ScenePreset
from ledgrab.utils import get_logger

logger = get_logger(__name__)


@dataclass(frozen=True)
class _RuleEvalContext:
    """Per-tick environment passed to every rule handler.

    Bundles all the cross-cutting state the various ``_evaluate_*``
    handlers need so adding a new handler does not require widening
    ``_evaluate_rule``'s parameter list. ``frozen=True`` guards against
    a handler mutating its inputs.
    """

    running_procs: Set[str]
    topmost_proc: str | None
    topmost_fullscreen: bool
    fullscreen_procs: Set[str]
    idle_seconds: float | None
    display_state: str | None


def _apply_operator(operator: str, extracted, expected: str) -> bool:
    """Compare *extracted* against *expected* using *operator*.

    String operators (equals, not_equals, contains, regex) coerce the
    extracted value to str. Numeric operators (gt, lt) coerce both sides
    to float and return False on parse failure.
    """
    if operator == "equals":
        return str(extracted) == expected
    if operator == "not_equals":
        return str(extracted) != expected
    if operator == "contains":
        return expected in str(extracted)
    if operator == "regex":
        try:
            return bool(re.search(expected, str(extracted)))
        except re.error as exc:
            logger.debug("HTTP poll rule regex error: %s", exc)
            return False
    if operator in ("gt", "lt"):
        try:
            lhs = float(extracted)
            rhs = float(expected)
        except (TypeError, ValueError):
            return False
        return lhs > rhs if operator == "gt" else lhs < rhs
    return False


class AutomationEngine:
    """Evaluates automation rules and activates/deactivates scene presets."""

    def __init__(
        self,
        automation_store: AutomationStore,
        processor_manager,
        poll_interval: float = 1.0,
        scene_preset_store=None,
        target_store=None,
        device_store=None,
        ha_manager=None,
        mqtt_manager=None,
        value_stream_manager=None,
        value_source_store=None,
    ):
        self._store = automation_store
        self._manager = processor_manager
        self._poll_interval = poll_interval
        self._detector = PlatformDetector()
        self._mqtt_manager = mqtt_manager
        self._value_stream_manager = value_stream_manager
        self._value_source_store = value_source_store
        self._scene_preset_store = scene_preset_store
        self._target_store = target_store
        self._device_store = device_store
        self._ha_manager = ha_manager
        self._task: asyncio.Task | None = None
        self._eval_lock = asyncio.Lock()

        # Runtime state (not persisted)
        # automation_id → True when automation is currently active
        self._active_automations: Dict[str, bool] = {}
        # automation_id → snapshot captured before activation (for "revert" mode)
        self._pre_activation_snapshots: Dict[str, ScenePreset] = {}
        # automation_id → datetime of last activation / deactivation
        self._last_activated: Dict[str, datetime] = {}
        self._last_deactivated: Dict[str, datetime] = {}
        # webhook_token → bool (volatile state set by webhook calls)
        self._webhook_states: Dict[str, bool] = {}
        # HA source IDs currently acquired by the engine
        self._ha_acquired: Set[str] = set()
        # MQTT source IDs currently acquired by the engine
        self._mqtt_acquired: Set[str] = set()
        # Value source IDs currently acquired by the engine (for HTTPPollRule)
        self._value_sources_acquired: Set[str] = set()

    async def start(self) -> None:
        if self._task is not None:
            return
        await self._sync_ha_runtimes()
        await self._sync_mqtt_runtimes()
        self._sync_value_stream_refs()
        self._task = asyncio.create_task(self._poll_loop())
        logger.info("Automation engine started")

    async def stop(self) -> None:
        if self._task is None:
            return

        self._task.cancel()
        try:
            await self._task
        except asyncio.CancelledError:
            logger.debug("Automation engine task cancelled")
            pass
        self._task = None

        # Deactivate all automations
        for automation_id in list(self._active_automations.keys()):
            await self._deactivate_automation(automation_id)

        # Release all HA runtimes
        await self._release_all_ha_runtimes()
        # Release all MQTT runtimes
        await self._release_all_mqtt_runtimes()
        # Release all value-stream refs held for HTTPPollRule evaluation
        self._release_all_value_stream_refs()

        logger.info("Automation engine stopped")

    def _get_needed_ha_sources(self) -> Set[str]:
        """Collect HA source IDs referenced by enabled automations."""
        needed: Set[str] = set()
        if self._ha_manager is None:
            return needed
        for a in self._store.get_all_automations():
            if a.enabled:
                for r in a.rules:
                    if isinstance(r, HomeAssistantRule) and r.ha_source_id:
                        needed.add(r.ha_source_id)
        return needed

    async def _sync_ha_runtimes(self) -> None:
        """Acquire/release HA runtimes to match current automation rules."""
        if self._ha_manager is None:
            return
        needed = self._get_needed_ha_sources()
        # Release sources no longer needed
        for source_id in self._ha_acquired - needed:
            try:
                await self._ha_manager.release(source_id)
                logger.debug("Released HA runtime for automation: %s", source_id)
            except Exception as e:
                logger.warning("Failed to release HA runtime %s: %s", source_id, e)
        # Acquire newly needed sources
        for source_id in needed - self._ha_acquired:
            try:
                await self._ha_manager.acquire(source_id)
                logger.debug("Acquired HA runtime for automation: %s", source_id)
            except Exception as e:
                logger.warning("Failed to acquire HA runtime %s: %s", source_id, e)
        self._ha_acquired = needed

    async def _release_all_ha_runtimes(self) -> None:
        """Release all HA runtimes held by the engine."""
        if self._ha_manager is None:
            return
        for source_id in self._ha_acquired:
            try:
                await self._ha_manager.release(source_id)
            except Exception as e:
                logger.warning("Failed to release HA runtime %s: %s", source_id, e)
        self._ha_acquired = set()

    def _get_needed_mqtt_sources(self) -> Set[str]:
        """Collect MQTT source IDs referenced by enabled automations."""
        needed: Set[str] = set()
        if self._mqtt_manager is None:
            return needed
        for a in self._store.get_all_automations():
            if a.enabled:
                for r in a.rules:
                    if isinstance(r, MQTTRule) and r.mqtt_source_id:
                        needed.add(r.mqtt_source_id)
        return needed

    async def _sync_mqtt_runtimes(self) -> None:
        """Acquire/release MQTT runtimes to match current automation rules."""
        if self._mqtt_manager is None:
            return
        needed = self._get_needed_mqtt_sources()
        for source_id in self._mqtt_acquired - needed:
            try:
                await self._mqtt_manager.release(source_id)
                logger.debug("Released MQTT runtime for automation: %s", source_id)
            except Exception as e:
                logger.warning("Failed to release MQTT runtime %s: %s", source_id, e)
        for source_id in needed - self._mqtt_acquired:
            try:
                await self._mqtt_manager.acquire(source_id)
                logger.debug("Acquired MQTT runtime for automation: %s", source_id)
            except Exception as e:
                logger.warning("Failed to acquire MQTT runtime %s: %s", source_id, e)
        self._mqtt_acquired = needed

    async def _release_all_mqtt_runtimes(self) -> None:
        """Release all MQTT runtimes held by the engine."""
        if self._mqtt_manager is None:
            return
        for source_id in self._mqtt_acquired:
            try:
                await self._mqtt_manager.release(source_id)
            except Exception as e:
                logger.warning("Failed to release MQTT runtime %s: %s", source_id, e)
        self._mqtt_acquired = set()

    def _get_needed_value_sources(self) -> Set[str]:
        """Collect value source IDs referenced by enabled HTTPPollRule rules."""
        needed: Set[str] = set()
        if self._value_stream_manager is None:
            return needed
        for a in self._store.get_all_automations():
            if a.enabled:
                for r in a.rules:
                    if isinstance(r, HTTPPollRule) and r.value_source_id:
                        needed.add(r.value_source_id)
        return needed

    def _sync_value_stream_refs(self) -> None:
        """Acquire/release ValueStreams to keep HTTPPollRule sources polling.

        Mirrors the HA/MQTT sync pattern, but talks to ``ValueStreamManager``
        (which is sync). Acquiring a stream both starts its background poll
        task and pins the ref count; releasing decrements.
        """
        if self._value_stream_manager is None:
            return
        needed = self._get_needed_value_sources()
        for vs_id in self._value_sources_acquired - needed:
            try:
                self._value_stream_manager.release(vs_id)
                logger.debug("Released value stream for automation: %s", vs_id)
            except Exception as e:
                logger.warning("Failed to release value stream %s: %s", vs_id, e)
        for vs_id in needed - self._value_sources_acquired:
            try:
                self._value_stream_manager.acquire(vs_id)
                logger.debug("Acquired value stream for automation: %s", vs_id)
            except Exception as e:
                logger.warning("Failed to acquire value stream %s: %s", vs_id, e)
        self._value_sources_acquired = needed

    def _release_all_value_stream_refs(self) -> None:
        """Release all ValueStreams held for HTTPPollRule evaluation."""
        if self._value_stream_manager is None:
            return
        for vs_id in self._value_sources_acquired:
            try:
                self._value_stream_manager.release(vs_id)
            except Exception as e:
                logger.warning("Failed to release value stream %s: %s", vs_id, e)
        self._value_sources_acquired = set()

    async def _poll_loop(self) -> None:
        try:
            while True:
                try:
                    await self._evaluate_all()
                except Exception as e:
                    logger.error(f"Automation evaluation error: {e}", exc_info=True)
                await asyncio.sleep(self._poll_interval)
        except asyncio.CancelledError:
            logger.debug("Automation poll loop cancelled")
            pass

    async def _evaluate_all(self) -> None:
        await self._sync_ha_runtimes()
        await self._sync_mqtt_runtimes()
        self._sync_value_stream_refs()
        async with self._eval_lock:
            await self._evaluate_all_locked()

    def _detect_all_sync(
        self,
        needs_running: bool,
        needs_topmost: bool,
        needs_fullscreen: bool,
        needs_idle: bool,
        needs_display_state: bool,
    ) -> tuple:
        """Run all platform detection in a single thread call.

        Batching detection calls into one executor submission reduces
        event-loop wake-ups, minimising asyncio.sleep() jitter in
        latency-sensitive processing loops.
        """
        running_procs = self._detector._get_running_processes_sync() if needs_running else set()
        if needs_topmost:
            topmost_proc, topmost_fullscreen = self._detector._get_topmost_process_sync()
        else:
            topmost_proc, topmost_fullscreen = None, False
        fullscreen_procs = (
            self._detector._get_fullscreen_processes_sync() if needs_fullscreen else set()
        )
        idle_seconds = self._detector._get_idle_seconds_sync() if needs_idle else None
        display_state = (
            self._detector._get_display_power_state_sync() if needs_display_state else None
        )
        return (
            running_procs,
            topmost_proc,
            topmost_fullscreen,
            fullscreen_procs,
            idle_seconds,
            display_state,
        )

    async def _evaluate_all_locked(self) -> None:
        automations = self._store.get_all_automations()
        if not automations:
            # No automations — deactivate any stale state
            for aid in list(self._active_automations.keys()):
                await self._deactivate_automation(aid)
            return

        # Determine which detection methods are actually needed
        match_types_used: set = set()
        needs_idle = False
        needs_display_state = False
        for a in automations:
            if a.enabled:
                for r in a.rules:
                    if isinstance(r, ApplicationRule):
                        match_types_used.add(r.match_type)
                    elif isinstance(r, SystemIdleRule):
                        needs_idle = True
                    elif isinstance(r, DisplayStateRule):
                        needs_display_state = True

        needs_running = "running" in match_types_used
        needs_topmost = bool(match_types_used & {"topmost", "topmost_fullscreen"})
        needs_fullscreen = "fullscreen" in match_types_used

        # Single executor call for all platform detection
        (
            running_procs,
            topmost_proc,
            topmost_fullscreen,
            fullscreen_procs,
            idle_seconds,
            display_state,
        ) = await asyncio.to_thread(
            self._detect_all_sync,
            needs_running,
            needs_topmost,
            needs_fullscreen,
            needs_idle,
            needs_display_state,
        )

        active_automation_ids = set()

        for automation in automations:
            should_be_active = automation.enabled and (
                len(automation.rules) == 0
                or self._evaluate_rules(
                    automation,
                    running_procs,
                    topmost_proc,
                    topmost_fullscreen,
                    fullscreen_procs,
                    idle_seconds,
                    display_state,
                )
            )

            is_active = automation.id in self._active_automations

            if should_be_active and not is_active:
                await self._activate_automation(automation)
                active_automation_ids.add(automation.id)
            elif should_be_active and is_active:
                active_automation_ids.add(automation.id)
            elif not should_be_active and is_active:
                await self._deactivate_automation(automation.id)

        # Deactivate automations that were removed from store while active
        for aid in list(self._active_automations.keys()):
            if aid not in active_automation_ids:
                await self._deactivate_automation(aid)

    def _evaluate_rules(
        self,
        automation: Automation,
        running_procs: Set[str],
        topmost_proc: str | None,
        topmost_fullscreen: bool,
        fullscreen_procs: Set[str],
        idle_seconds: float | None,
        display_state: str | None,
    ) -> bool:
        results = [
            self._evaluate_rule(
                r,
                running_procs,
                topmost_proc,
                topmost_fullscreen,
                fullscreen_procs,
                idle_seconds,
                display_state,
            )
            for r in automation.rules
        ]

        if automation.rule_logic == "and":
            return all(results)
        return any(results)  # "or" is default

    # Per-rule-type handlers. Built once at class-definition time (see
    # ``_RULE_HANDLERS`` below) so the dispatch dict is not rebuilt on every
    # tick the way the old inline body used to. Each handler signature is
    # ``(self, rule, ctx: _RuleEvalContext) -> bool``.
    _RULE_HANDLERS: "Dict[type, Callable[..., bool]]"

    def _evaluate_rule(
        self,
        rule: Rule,
        running_procs: Set[str],
        topmost_proc: str | None,
        topmost_fullscreen: bool,
        fullscreen_procs: Set[str],
        idle_seconds: float | None,
        display_state: str | None,
    ) -> bool:
        ctx = _RuleEvalContext(
            running_procs=running_procs,
            topmost_proc=topmost_proc,
            topmost_fullscreen=topmost_fullscreen,
            fullscreen_procs=fullscreen_procs,
            idle_seconds=idle_seconds,
            display_state=display_state,
        )
        handler = self._RULE_HANDLERS.get(type(rule))
        if handler is None:
            # Coverage of ``_RULE_HANDLERS`` is asserted at module import,
            # so reaching this branch means a Rule subclass slipped past
            # the assertion (e.g. a hand-built test instance). Log loudly
            # and fall back to the previous "treat as inactive" semantics.
            logger.warning(
                "No handler registered for rule type %s — treating as inactive",
                type(rule).__name__,
            )
            return False
        return handler(self, rule, ctx)

    # -- Per-rule-type handlers --
    # Bound to ``self`` via ``_RULE_HANDLERS`` lookup; each signature is
    # ``(self, rule, ctx: _RuleEvalContext) -> bool``.

    def _handle_startup(self, rule: StartupRule, ctx: _RuleEvalContext) -> bool:
        return True

    def _handle_application(self, rule: ApplicationRule, ctx: _RuleEvalContext) -> bool:
        return self._evaluate_app_rule(
            rule,
            ctx.running_procs,
            ctx.topmost_proc,
            ctx.topmost_fullscreen,
            ctx.fullscreen_procs,
        )

    def _handle_time_of_day(self, rule: TimeOfDayRule, ctx: _RuleEvalContext) -> bool:
        return self._evaluate_time_of_day(rule)

    def _handle_system_idle(self, rule: SystemIdleRule, ctx: _RuleEvalContext) -> bool:
        return self._evaluate_idle(rule, ctx.idle_seconds)

    def _handle_display_state(self, rule: DisplayStateRule, ctx: _RuleEvalContext) -> bool:
        return self._evaluate_display_state(rule, ctx.display_state)

    def _handle_mqtt(self, rule: MQTTRule, ctx: _RuleEvalContext) -> bool:
        return self._evaluate_mqtt(rule)

    def _handle_webhook(self, rule: WebhookRule, ctx: _RuleEvalContext) -> bool:
        return self._webhook_states.get(rule.token, False)

    def _handle_home_assistant(self, rule: HomeAssistantRule, ctx: _RuleEvalContext) -> bool:
        return self._evaluate_home_assistant(rule)

    def _handle_http_poll(self, rule: HTTPPollRule, ctx: _RuleEvalContext) -> bool:
        return self._evaluate_http_poll(rule)

    @staticmethod
    def _evaluate_time_of_day(rule: TimeOfDayRule) -> bool:
        now = datetime.now()
        current = now.hour * 60 + now.minute
        parts_s = rule.start_time.split(":")
        parts_e = rule.end_time.split(":")
        start = int(parts_s[0]) * 60 + int(parts_s[1])
        end = int(parts_e[0]) * 60 + int(parts_e[1])
        if start <= end:
            return start <= current <= end
        # Overnight range (e.g. 22:00 → 06:00)
        return current >= start or current <= end

    @staticmethod
    def _evaluate_idle(rule: SystemIdleRule, idle_seconds: float | None) -> bool:
        if idle_seconds is None:
            return False
        is_idle = idle_seconds >= (rule.idle_minutes * 60)
        return is_idle if rule.when_idle else not is_idle

    @staticmethod
    def _evaluate_display_state(rule: DisplayStateRule, display_state: str | None) -> bool:
        if display_state is None:
            return False
        return display_state == rule.state

    def _evaluate_mqtt(self, rule: MQTTRule) -> bool:
        # Multi-broker model: the rule references a specific MQTTSource.
        # Rules without one are no-ops (UI should enforce a source on save).
        if self._mqtt_manager is None or not rule.mqtt_source_id:
            return False
        runtime = self._mqtt_manager.get_runtime(rule.mqtt_source_id)
        if runtime is None or not runtime.is_connected:
            return False
        value = runtime.get_last_value(rule.topic)
        if value is None:
            return False
        matchers = {
            "exact": lambda: value == rule.payload,
            "contains": lambda: rule.payload in value,
            "regex": lambda: bool(re.search(rule.payload, value)),
        }
        matcher = matchers.get(rule.match_mode)
        if matcher is None:
            return False
        try:
            return matcher()
        except re.error as e:
            logger.debug("MQTT rule regex error: %s", e)
            return False

    def _evaluate_home_assistant(self, rule: HomeAssistantRule) -> bool:
        if self._ha_manager is None:
            return False
        entity_state = self._ha_manager.get_state(rule.ha_source_id, rule.entity_id)
        if entity_state is None:
            return False
        value = entity_state.state
        matchers = {
            "exact": lambda: value == rule.state,
            "contains": lambda: rule.state in value,
            "regex": lambda: bool(re.search(rule.state, value)),
        }
        matcher = matchers.get(rule.match_mode)
        if matcher is None:
            return False
        try:
            return matcher()
        except re.error as e:
            logger.debug("HA rule regex error: %s", e)
            return False

    def _evaluate_http_poll(self, rule: HTTPPollRule) -> bool:
        """Evaluate an HTTPPollRule by reading the referenced value source.

        The value source (HTTPValueSource → HTTPValueStream) handles the
        actual polling + JSON extraction; the rule only compares the
        already-extracted raw value to ``rule.value`` via ``operator``.
        """
        if self._value_stream_manager is None or not rule.value_source_id:
            return False
        stream = self._value_stream_manager.peek(rule.value_source_id)
        if stream is None or not hasattr(stream, "get_raw_value"):
            return False
        raw = stream.get_raw_value()
        if rule.operator == "exists":
            return raw is not None
        if raw is None:
            return False
        return _apply_operator(rule.operator, raw, rule.value)

    def _evaluate_app_rule(
        self,
        rule: ApplicationRule,
        running_procs: Set[str],
        topmost_proc: str | None,
        topmost_fullscreen: bool,
        fullscreen_procs: Set[str],
    ) -> bool:
        if not rule.apps:
            return False

        apps_lower = [a.lower() for a in rule.apps]

        match_handlers = {
            "fullscreen": lambda: any(app in fullscreen_procs for app in apps_lower),
            "topmost_fullscreen": lambda: (
                topmost_proc is not None
                and topmost_fullscreen
                and any(app == topmost_proc for app in apps_lower)
            ),
            "topmost": lambda: (
                topmost_proc is not None and any(app == topmost_proc for app in apps_lower)
            ),
        }
        handler = match_handlers.get(rule.match_type)
        if handler is not None:
            return handler()
        # Default: "running"
        return any(app in running_procs for app in apps_lower)

    async def _activate_automation(self, automation: Automation) -> None:
        if not automation.scene_preset_id:
            # No scene configured — just mark active (rules matched but nothing to do)
            self._active_automations[automation.id] = True
            self._last_activated[automation.id] = datetime.now(timezone.utc)
            self._fire_event(automation.id, "activated")
            logger.info(f"Automation '{automation.name}' activated (no scene configured)")
            return

        if not self._scene_preset_store or not self._target_store or not self._device_store:
            logger.warning(f"Automation '{automation.name}' matched but scene stores not available")
            return

        # Load the scene preset
        try:
            preset = self._scene_preset_store.get_preset(automation.scene_preset_id)
        except ValueError:
            logger.warning(
                f"Automation '{automation.name}': scene preset {automation.scene_preset_id} not found"
            )
            return

        # For "revert" mode, capture current state before activating
        if automation.deactivation_mode == "revert":
            from ledgrab.core.scenes.scene_activator import capture_current_snapshot

            targets = capture_current_snapshot(self._target_store, self._manager)
            self._pre_activation_snapshots[automation.id] = ScenePreset(
                id=f"_revert_{automation.id}",
                name=f"Pre-activation snapshot for {automation.name}",
                targets=targets,
            )

        # Apply the scene
        from ledgrab.core.scenes.scene_activator import apply_scene_state

        status, errors = await apply_scene_state(
            preset,
            self._target_store,
            self._manager,
        )

        self._active_automations[automation.id] = True
        self._last_activated[automation.id] = datetime.now(timezone.utc)
        self._fire_event(automation.id, "activated")

        if errors:
            logger.warning(f"Automation '{automation.name}' activated with errors: {errors}")
        else:
            logger.info(f"Automation '{automation.name}' activated (scene '{preset.name}' applied)")

    async def _deactivate_automation(self, automation_id: str) -> None:
        was_active = self._active_automations.pop(automation_id, False)
        if not was_active:
            return

        # Look up the automation for deactivation settings
        try:
            automation = self._store.get_automation(automation_id)
        except ValueError:
            automation = None

        deactivation_mode = automation.deactivation_mode if automation else "none"

        if deactivation_mode == "revert":
            await self._deactivate_revert(automation_id)
        elif deactivation_mode == "fallback_scene":
            await self._deactivate_fallback(automation_id, automation)
        else:
            logger.info(f"Automation {automation_id} deactivated")

        self._last_deactivated[automation_id] = datetime.now(timezone.utc)
        self._fire_event(automation_id, "deactivated")
        # Clean up any leftover snapshot
        self._pre_activation_snapshots.pop(automation_id, None)

    async def _deactivate_revert(self, automation_id: str) -> None:
        """Revert to pre-activation snapshot."""
        snapshot = self._pre_activation_snapshots.pop(automation_id, None)
        if snapshot and self._target_store:
            from ledgrab.core.scenes.scene_activator import apply_scene_state

            status, errors = await apply_scene_state(
                snapshot,
                self._target_store,
                self._manager,
            )
            if errors:
                logger.warning(f"Automation {automation_id} revert errors: {errors}")
            else:
                logger.info(f"Automation {automation_id} deactivated (reverted to previous state)")
        else:
            logger.warning(f"Automation {automation_id}: no snapshot available for revert")

    async def _deactivate_fallback(self, automation_id: str, automation) -> None:
        """Activate fallback scene on deactivation."""
        fallback_id = automation.deactivation_scene_preset_id if automation else None
        if fallback_id and self._scene_preset_store and self._target_store:
            try:
                fallback = self._scene_preset_store.get_preset(fallback_id)
                from ledgrab.core.scenes.scene_activator import apply_scene_state

                status, errors = await apply_scene_state(
                    fallback,
                    self._target_store,
                    self._manager,
                )
                if errors:
                    logger.warning(f"Automation {automation_id} fallback errors: {errors}")
                else:
                    logger.info(
                        f"Automation {automation_id} deactivated (fallback scene '{fallback.name}' applied)"
                    )
            except ValueError:
                logger.warning(
                    f"Automation {automation_id}: fallback scene {fallback_id} not found"
                )
        else:
            logger.info(f"Automation {automation_id} deactivated (no fallback scene configured)")

    def _fire_event(self, automation_id: str, action: str) -> None:
        try:
            self._manager.fire_event(
                {
                    "type": "automation_state_changed",
                    "automation_id": automation_id,
                    "action": action,
                }
            )
        except Exception as e:
            logger.error("Automation action failed: %s", e, exc_info=True)

    # ===== Public query methods (used by API) =====

    def get_automation_state(self, automation_id: str) -> dict:
        """Get runtime state of a single automation."""
        is_active = automation_id in self._active_automations
        return {
            "is_active": is_active,
            "last_activated_at": self._last_activated.get(automation_id),
            "last_deactivated_at": self._last_deactivated.get(automation_id),
        }

    def get_all_automation_states(self) -> Dict[str, dict]:
        """Get runtime states of all automations."""
        result = {}
        for automation in self._store.get_all_automations():
            result[automation.id] = self.get_automation_state(automation.id)
        return result

    async def trigger_evaluate(self) -> None:
        """Run a single evaluation cycle immediately (used after enabling an automation)."""
        try:
            await self._evaluate_all()
        except Exception as e:
            logger.error(f"Immediate automation evaluation error: {e}", exc_info=True)

    async def set_webhook_state(self, token: str, active: bool) -> None:
        """Set webhook rule state and trigger immediate evaluation."""
        self._webhook_states[token] = active
        await self.trigger_evaluate()

    def get_webhook_state(self, token: str) -> bool:
        """Read current webhook state (False if never called)."""
        return self._webhook_states.get(token, False)

    async def deactivate_if_active(self, automation_id: str) -> None:
        """Deactivate an automation immediately (used when disabling/deleting)."""
        if automation_id in self._active_automations:
            await self._deactivate_automation(automation_id)


# Bind the per-rule-type handler table once after the class is fully defined.
# This replaces the per-call dict-rebuild that the inline ``_evaluate_rule``
# used to do and gives us a single place to assert coverage against the
# Rule subclass set imported from storage.
AutomationEngine._RULE_HANDLERS = {
    StartupRule: AutomationEngine._handle_startup,
    ApplicationRule: AutomationEngine._handle_application,
    TimeOfDayRule: AutomationEngine._handle_time_of_day,
    SystemIdleRule: AutomationEngine._handle_system_idle,
    DisplayStateRule: AutomationEngine._handle_display_state,
    MQTTRule: AutomationEngine._handle_mqtt,
    WebhookRule: AutomationEngine._handle_webhook,
    HomeAssistantRule: AutomationEngine._handle_home_assistant,
    HTTPPollRule: AutomationEngine._handle_http_poll,
}


def _assert_rule_handler_coverage() -> None:
    """Every concrete Rule subclass imported by this module must have a handler.

    Runs at module import so a new Rule subclass added without an
    accompanying ``_handle_*`` method + ``_RULE_HANDLERS`` entry fails the
    server boot loudly instead of silently being dropped on the floor by
    ``_evaluate_rule``'s "no handler → False" fallback.
    """
    expected = {
        StartupRule,
        ApplicationRule,
        TimeOfDayRule,
        SystemIdleRule,
        DisplayStateRule,
        MQTTRule,
        WebhookRule,
        HomeAssistantRule,
        HTTPPollRule,
    }
    registered = set(AutomationEngine._RULE_HANDLERS.keys())
    missing = expected - registered
    extra = registered - expected
    if missing or extra:
        problems = []
        if missing:
            problems.append(f"missing handlers: {sorted(c.__name__ for c in missing)}")
        if extra:
            problems.append(f"unregistered classes: {sorted(c.__name__ for c in extra)}")
        raise RuntimeError(
            "AutomationEngine._RULE_HANDLERS is out of sync with imported Rule subclasses: "
            + "; ".join(problems)
        )


_assert_rule_handler_coverage()
