# WashData - Home Assistant integration for appliance cycle monitoring via smart plugs.
# Copyright (C) 2026 Lukas Bandura
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Manager for WashData."""

# pylint: disable=broad-exception-caught

from __future__ import annotations

import logging
import hashlib
import inspect
import math
import uuid
import asyncio
from asyncio import Task
from collections.abc import Coroutine
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any, cast
import numpy as np

if TYPE_CHECKING:
    from .store import StoreBridge

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Context, Event, HomeAssistant, State, callback
from homeassistant.helpers.event import (
    async_call_later,
    async_track_state_change_event,
    async_track_state_report_event,
    async_track_time_interval,
)
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.exceptions import HomeAssistantError
from homeassistant.const import STATE_UNAVAILABLE, STATE_HOME
from homeassistant.util import dt as dt_util
import homeassistant.helpers.event as evt
from homeassistant.helpers import script as script_helper
from homeassistant.helpers import translation

from .const import (
    DOMAIN,
    CONF_POWER_SENSOR,
    CONF_PROFILE_EVIDENCE_SOURCES,
    CONF_MIN_POWER,
    CONF_OFF_DELAY,
    CONF_NOTIFY_SERVICE,
    CONF_NOTIFY_ACTIONS,
    CONF_NOTIFY_START_SERVICES,
    CONF_NOTIFY_FINISH_SERVICES,
    CONF_NOTIFY_LIVE_SERVICES,
    CONF_NOTIFY_CYCLE_TIMERS,
    CONF_NOTIFY_PEOPLE,
    CONF_NOTIFY_ONLY_WHEN_HOME,
    CONF_NOTIFY_FIRE_EVENTS,
    CONF_NOTIFY_EVENTS,
    CONF_NO_UPDATE_ACTIVE_TIMEOUT,
    CONF_LOW_POWER_NO_UPDATE_TIMEOUT, # Import new constant
    CONF_SMOOTHING_WINDOW,
    CONF_PROFILE_DURATION_TOLERANCE,
    CONF_INTERRUPTED_MIN_SECONDS,
    CONF_PROGRESS_RESET_DELAY,
    CONF_LEARNING_CONFIDENCE,
    CONF_DURATION_TOLERANCE,
    CONF_AUTO_LABEL_CONFIDENCE,
    CONF_AUTO_MAINTENANCE,
    CONF_MAINTENANCE_REMINDER_CYCLES,
    DEFAULT_MAINTENANCE_REMINDER_CYCLES,
    CONF_PROFILE_MATCH_INTERVAL,
    CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
    CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
    CONF_MAX_PAST_CYCLES,
    CONF_MAX_FULL_TRACES_PER_PROFILE,
    CONF_MAX_FULL_TRACES_UNLABELED,
    CONF_WATCHDOG_INTERVAL,
    CONF_AUTO_TUNE_NOISE_EVENTS_THRESHOLD,
    CONF_COMPLETION_MIN_SECONDS,
    CONF_NOTIFY_BEFORE_END_MINUTES,
    CONF_PROFILE_MATCH_THRESHOLD,
    CONF_PROFILE_UNMATCH_THRESHOLD,
    CONF_DEVICE_TYPE,
    CONF_START_DURATION_THRESHOLD,
    CONF_END_REPEAT_COUNT,
    CONF_MIN_OFF_GAP,
    CONF_START_ENERGY_THRESHOLD,
    CONF_END_ENERGY_THRESHOLD,
    CONF_START_THRESHOLD_W,
    CONF_STOP_THRESHOLD_W,
    CONF_POWER_OFF_THRESHOLD_W,
    CONF_POWER_OFF_DELAY,
    CONF_SAMPLING_INTERVAL,
    CONF_SAVE_DEBUG_TRACES,
    CONF_DTW_BANDWIDTH,
    CONF_EXTERNAL_END_TRIGGER_ENABLED,
    CONF_EXTERNAL_END_TRIGGER,
    CONF_EXTERNAL_END_TRIGGER_INVERTED,
    CONF_ANTI_WRINKLE_ENABLED,
    CONF_ANTI_WRINKLE_MAX_POWER,
    CONF_ANTI_WRINKLE_MAX_DURATION,
    CONF_ANTI_WRINKLE_EXIT_POWER,
    CONF_ANTI_WRINKLE_IDLE_TIMEOUT,
    CONF_DISHWASHER_END_SPIKE_QUIET_RELEASE,
    DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS,
    CONF_SMART_TERMINATION_DURATION_RATIO,
    DEFAULT_SMART_TERMINATION_DURATION_RATIO,
    DEFAULT_SMART_TERMINATION_DURATION_RATIO_BY_DEVICE,
    CONF_DELAY_START_DETECT_ENABLED,
    CONF_DELAY_CONFIRM_SECONDS,
    CONF_DELAY_TIMEOUT_HOURS,
    CONF_PUMP_STUCK_DURATION,
    DEFAULT_PUMP_STUCK_DURATION,
    EVENT_PUMP_STUCK,
    DEVICE_TYPE_PUMP,
    SIGNAL_WASHER_UPDATE,
    NOTIFY_EVENT_START,
    NOTIFY_EVENT_FINISH,
    NOTIFY_EVENT_LIVE,
    NOTIFY_EVENT_CLEAN,
    NOTIFY_EVENT_TIMER,
    EVENT_CYCLE_STARTED,
    EVENT_CYCLE_ENDED,
    DEFAULT_MIN_POWER,
    DEFAULT_OFF_DELAY,
    DEFAULT_NO_UPDATE_ACTIVE_TIMEOUT,
    DEFAULT_NO_UPDATE_ACTIVE_TIMEOUT_BY_DEVICE,
    DEFAULT_SMOOTHING_WINDOW,
    DEFAULT_PROFILE_DURATION_TOLERANCE,
    DEFAULT_INTERRUPTED_MIN_SECONDS,
    DEFAULT_COMPLETION_MIN_SECONDS,
    DEFAULT_NOTIFY_BEFORE_END_MINUTES,
    DEFAULT_PROFILE_MATCH_THRESHOLD,
    DEFAULT_PROFILE_UNMATCH_THRESHOLD,
    DEFAULT_SAMPLING_INTERVAL,
    DEFAULT_PROGRESS_RESET_DELAY,
    DEFAULT_POWER_OFF_THRESHOLD_W,
    DEFAULT_PROFILE_EVIDENCE_SOURCES,
    DEFAULT_POWER_OFF_DELAY,
    DEFAULT_LEARNING_CONFIDENCE,
    DEFAULT_DURATION_TOLERANCE,
    DEFAULT_AUTO_LABEL_CONFIDENCE,
    DEFAULT_AUTO_MAINTENANCE,
    DEFAULT_PROFILE_MATCH_INTERVAL,
    DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO,
    DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO_BY_DEVICE,
    DEFAULT_ANTI_WRINKLE_ENABLED,
    DEFAULT_ANTI_WRINKLE_MAX_POWER,
    DEFAULT_ANTI_WRINKLE_MAX_DURATION,
    DEFAULT_ANTI_WRINKLE_EXIT_POWER,
    DEFAULT_ANTI_WRINKLE_IDLE_TIMEOUT,
    DEFAULT_DELAY_START_DETECT_ENABLED,
    DEFAULT_DELAY_CONFIRM_SECONDS,
    DEFAULT_DELAY_TIMEOUT_HOURS,
    DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO,
    DEFAULT_MAX_PAST_CYCLES,
    DEFAULT_MAX_FULL_TRACES_PER_PROFILE,
    CONF_NOTIFY_TITLE,
    CONF_NOTIFY_ICON,
    CONF_NOTIFY_START_MESSAGE,
    CONF_NOTIFY_FINISH_MESSAGE,
    CONF_NOTIFY_PRE_COMPLETE_MESSAGE,
    CONF_NOTIFY_LIVE_INTERVAL_SECONDS,
    CONF_NOTIFY_LIVE_OVERRUN_PERCENT,
    CONF_NOTIFY_LIVE_CHRONOMETER,
    CONF_NOTIFY_LIVE_STICKY,
    CONF_NOTIFY_LIVE_CLICK_ACTION,
    DEFAULT_NOTIFY_LIVE_STICKY,
    DEFAULT_NOTIFY_LIVE_CLICK_ACTION,
    CONF_NOTIFY_REMINDER_MESSAGE,
    CONF_NOTIFY_TIMEOUT_SECONDS,
    CONF_NOTIFY_CHANNEL,
    CONF_NOTIFY_FINISH_CHANNEL,
    CONF_ENERGY_PRICE_STATIC,
    CONF_ENERGY_PRICE_ENTITY,
    CONF_ENERGY_SENSOR,
    CONF_PEAK_RATE_THRESHOLD,
    CONF_PEAK_RATE_MESSAGE,
    DEFAULT_PEAK_RATE_MESSAGE,
    CONF_DOOR_SENSOR_ENTITY,
    CONF_DOOR_OPENS_AT_END,
    CONF_DOOR_END_DWELL_SECONDS,
    DEFAULT_DOOR_OPENS_AT_END,
    DEFAULT_DOOR_END_DWELL_SECONDS,
    CONF_PAUSE_CUTS_POWER,
    CONF_SWITCH_ENTITY,
    CONF_NOTIFY_UNLOAD_DELAY_MINUTES,
    CONF_NOTIFY_UNLOAD_MESSAGE,
    CONF_NOTIFY_UNLOAD_REPEAT,
    DEFAULT_NOTIFY_UNLOAD_DELAY_MINUTES,
    DEFAULT_NOTIFY_UNLOAD_MESSAGE,
    DEFAULT_NOTIFY_UNLOAD_REPEAT,
    NOTIFY_UNLOAD_REPEAT_MAX_REMINDERS,
    CONF_NOTIFY_MILESTONES,
    CONF_NOTIFY_MILESTONE_MESSAGE,
    DEFAULT_NOTIFY_MILESTONES,
    DEFAULT_NOTIFY_MILESTONE_MESSAGE,
    STATE_CLEAN,
    STATE_FINISHED,
    STATE_INTERRUPTED,
    STATE_FORCE_STOPPED,
    DEFAULT_NOTIFY_TITLE,
    DEFAULT_NOTIFY_START_MESSAGE,
    DEFAULT_NOTIFY_FINISH_MESSAGE,
    DEFAULT_NOTIFY_PRE_COMPLETE_MESSAGE,
    DEFAULT_NOTIFY_LIVE_WAITING_MESSAGE,
    DEFAULT_NOTIFY_ONLY_WHEN_HOME,
    DEFAULT_NOTIFY_FIRE_EVENTS,
    DEFAULT_NOTIFY_LIVE_INTERVAL_SECONDS,
    DEFAULT_NOTIFY_LIVE_OVERRUN_PERCENT,
    DEFAULT_NOTIFY_LIVE_CHRONOMETER,
    DEFAULT_NOTIFY_REMINDER_MESSAGE,
    DEFAULT_NOTIFY_TIMEOUT_SECONDS,
    DEFAULT_NOTIFY_CHANNEL,
    DEFAULT_NOTIFY_FINISH_CHANNEL,

    DEFAULT_MAX_FULL_TRACES_UNLABELED,
    DEFAULT_DTW_BANDWIDTH,
    DEFAULT_WATCHDOG_INTERVAL,
    resolve_sampling_interval_default,
    resolve_watchdog_interval_default,
    resolve_start_duration_default,
    resolve_smart_termination_duration_ratio_default,
    CONF_MATCH_PERSISTENCE,
    DEFAULT_MATCH_PERSISTENCE,
    DEFAULT_MATCH_REVERT_RATIO,
    DEFAULT_AUTO_TUNE_NOISE_EVENTS_THRESHOLD,
    DEFAULT_DEVICE_TYPE,
    DEFAULT_START_DURATION_THRESHOLD,
    DEFAULT_END_REPEAT_COUNT,
    DEFAULT_MIN_OFF_GAP,
    DEFAULT_MIN_OFF_GAP_BY_DEVICE,
    DEFAULT_MAX_DEFERRAL_SECONDS,
    ENDING_HARD_FINALIZE_MIN_QUIET_S,
    DEFAULT_START_ENERGY_THRESHOLDS_BY_DEVICE,
    DEFAULT_END_ENERGY_THRESHOLD,
    DEVICE_COMPLETION_THRESHOLDS,
    CYCLE_UNDERRUN_ANOMALY_RATIO,
    ENERGY_ANOMALY_Z_THRESHOLD,
    TERMINAL_DROP_MIN_CLEAN_CYCLES,
    TERMINAL_DROP_MIN_QUIET_SPAN_S,
    TERMINAL_DROP_EARLINESS_RATIO,
    TERMINAL_DROP_MIN_PEAK_RATIO,
    TERMINAL_DROP_PEAK_FAMILIAR_TOL,
    ML_MATCH_COMMIT_THRESHOLD,
    STATE_RUNNING,
    STATE_OFF,
    STATE_STARTING,
    STATE_PAUSED,
    STATE_USER_PAUSED,
    STATE_ENDING,
    STATE_ANTI_WRINKLE,
    STATE_DELAY_WAIT,
    STATE_IDLE,
    STATE_UNKNOWN,
)
from .cycle_detector import CycleDetector, CycleDetectorConfig
from .learning import LearningManager
from .profile_store import (
    ProfileStore,
    decompress_power_data,
    is_terminal_drop,
    terminal_drop_baseline,
)
from .signal_processing import integrate_wh, energy_gap_threshold_s
from .recorder import CycleRecorder
from .diag_buffer import DiagBuffer
from .log_utils import DeviceLoggerAdapter
from .time_utils import power_data_to_offsets
from . import analysis
from . import progress as progress_mod
from . import notification_rules as notif_rules
from .phase_segmenter import phase_matching_enabled

_LOGGER = logging.getLogger(__name__)

# Sentinel "message" understood by the Home Assistant companion app as "dismiss the
# card carrying this tag" rather than as text to display. It is only meaningful
# alongside a `tag`, and only on mobile_app targets - see _send_notification_service,
# which must never deliver it as a visible message.
_CLEAR_NOTIFICATION_MARKER = "clear_notification"

# Finish-type notification events that would wake someone and are therefore gated by
# the quiet-hours (do-not-disturb) window. Live-progress ticks (NOTIFY_EVENT_LIVE)
# and the start notification (NOTIFY_EVENT_START) are intentionally excluded.
_QUIET_HOURS_EVENT_TYPES = frozenset(
    {NOTIFY_EVENT_FINISH, NOTIFY_EVENT_CLEAN, "pre_complete"}
)


# Detector states in which the power sensor must not be swapped out. Every state
# with an in-flight cycle, plus ANTI_WRINKLE: its tumble pulses are still being
# attributed to the cycle that just finished, so re-pointing the listener there
# would splice a different appliance into that tail.
_SENSOR_SWAP_BLOCKED_STATES = frozenset(
    {
        STATE_STARTING,
        STATE_RUNNING,
        STATE_PAUSED,
        STATE_ENDING,
        STATE_ANTI_WRINKLE,
    }
)


def _sanitize_ranking(raw_list: list[dict[str, Any]], limit: int = 5) -> list[dict[str, Any]]:
    """Top-N ranking candidates stripped of the heavy `current`/`sample` power
    arrays, safe to persist on cycle_data and to include in the 32KB-limited
    EVENT_CYCLE_ENDED payload."""
    out: list[dict[str, Any]] = []
    for cand in (raw_list or [])[:limit]:
        out.append({
            "name": cand.get("name"),
            "score": round(float(cand.get("score", 0.0)), 3),
            "profile_duration": cand.get("profile_duration"),
        })
    return out

# Notification-data keys that may only be forwarded to mobile_app_* notify targets.
# Strict-schema platforms (e.g. Signal) reject unknown keys, so these are added per
# service only when the target is a mobile app. Includes the iOS Live Activity
# enrichment keys (subtitle/content_state/activity) so they never reach non-mobile
# platforms.
_MOBILE_ONLY_EXTRA_KEYS = (
    "tag",
    "timeout",
    "channel",
    "priority",
    "actions",
    "sticky",
    "clickAction",
    "subtitle",
    "content_state",
    "activity",
)


def _pn_create(
    hass: HomeAssistant,
    message: str,
    *,
    title: str | None = None,
    notification_id: str | None = None,
) -> None:
    """Best-effort persistent notification creation.

    Deliberately goes through ``hass.components.persistent_notification`` rather than
    a direct ``homeassistant.components.persistent_notification`` import: the test
    suite stubs out the whole ``homeassistant`` module and mocks this dynamic
    attribute, so a direct import would both fail under test and bypass those mocks.
    Failures are logged at debug (not silently swallowed) so a stuck notification is
    at least visible in the logs.
    """
    try:
        components = getattr(cast(Any, hass), "components", None)
        pn = getattr(cast(Any, components), "persistent_notification", None)
        if pn is None:
            return
        result = pn.async_create(message, title=title, notification_id=notification_id)
        if inspect.iscoroutine(result):
            hass.async_create_task(result)
    except Exception:  # noqa: BLE001 - best-effort; surface the failure in logs
        _LOGGER.debug("persistent_notification create failed (id=%s)", notification_id, exc_info=True)


def _pn_dismiss(hass: HomeAssistant, notification_id: str) -> None:
    """Best-effort persistent notification dismissal.

    Uses the ``hass.components`` accessor for the same test-mocking reason as
    :func:`_pn_create`; failures are logged at debug rather than swallowed.
    """
    try:
        components = getattr(cast(Any, hass), "components", None)
        pn = getattr(cast(Any, components), "persistent_notification", None)
        if pn is None:
            return
        result = pn.async_dismiss(notification_id)
        if inspect.iscoroutine(result):
            hass.async_create_task(result)
    except Exception:  # noqa: BLE001 - best-effort; surface the failure in logs
        _LOGGER.debug("persistent_notification dismiss failed (id=%s)", notification_id, exc_info=True)


class WashDataManager:
    """Manages a single washing machine instance."""

    @property
    def store_bridge(self) -> "StoreBridge":
        """Lazy community-store bridge (kept for the entry so the token cache persists)."""
        if self._store_bridge is None:
            from .store import StoreBridge
            self._store_bridge = StoreBridge(self.hass, self.profile_store)
        return self._store_bridge

    def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
        """Initialize the manager."""
        self.hass = hass
        self.config_entry = config_entry
        self.entry_id = config_entry.entry_id
        self._logger = DeviceLoggerAdapter(_LOGGER, config_entry.title)
        self.diag_buffer = DiagBuffer(config_entry.title)

        # Prioritize options -> data for power sensor (allows changing it)
        self.power_sensor_entity_id = config_entry.options.get(
            CONF_POWER_SENSOR, config_entry.data.get(CONF_POWER_SENSOR)
        )
        self.device_type = config_entry.options.get(
            CONF_DEVICE_TYPE,
            config_entry.data.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE),
        )

        # Initialize attributes to satisfy pylint
        self._off_delay = float(DEFAULT_OFF_DELAY)
        self._no_update_active_timeout = float(DEFAULT_NO_UPDATE_ACTIVE_TIMEOUT)
        self._low_power_no_update_timeout = 3600.0 # Default 1h
        self._notify_before_end_minutes = float(DEFAULT_NOTIFY_BEFORE_END_MINUTES)
        self._notify_start_services: list[str] = []
        self._notify_finish_services: list[str] = []
        self._notify_live_services: list[str] = []
        self._notify_actions: list[dict[str, Any]] = []
        self._notify_script: Any = None  # cached Script; invalidated on options reload
        self._notify_people: list[str] = []
        self._notify_cycle_timers: list[dict[str, Any]] = []
        self._fired_cycle_timers: set[int] = set()
        self._timer_pause_pn_id: str | None = None
        self._timer_pause_mobile_tag: str | None = None
        self._remove_timer_action_listener: Any | None = None
        self._timer_ui_strings: dict[str, str] = {}
        self._notify_only_when_home = DEFAULT_NOTIFY_ONLY_WHEN_HOME
        self._notify_fire_events = DEFAULT_NOTIFY_FIRE_EVENTS
        self._notify_live_interval_seconds = DEFAULT_NOTIFY_LIVE_INTERVAL_SECONDS
        self._notify_live_overrun_percent = DEFAULT_NOTIFY_LIVE_OVERRUN_PERCENT
        self._notify_live_chronometer = DEFAULT_NOTIFY_LIVE_CHRONOMETER
        self._notify_live_sticky = DEFAULT_NOTIFY_LIVE_STICKY
        self._notify_live_click_action = DEFAULT_NOTIFY_LIVE_CLICK_ACTION
        self._notify_timeout_seconds = DEFAULT_NOTIFY_TIMEOUT_SECONDS
        self._pending_notifications: list[dict[str, Any]] = []
        # Quiet-hours (do-not-disturb) hold queue + release timer. Finish-type
        # notifications that would fire inside the window are parked here and flushed
        # at the end of the window by a single async_call_later timer.
        self._quiet_pending_notifications: list[dict[str, Any]] = []
        self._remove_quiet_hours_timer: Any | None = None
        self._remove_notify_people_listener = None
        self._live_notification_sent_count = 0

        # HA restart gap tracking: gaps in the power trace caused by integration
        # restarts during an active cycle.  Each entry is a dict:
        #   start_ts: ISO timestamp of gap start (= last snapshot save time)
        #   end_ts:   ISO timestamp of gap end (= restoration time)
        #   gap_seconds: duration in seconds
        #   profile: matched profile name at restoration time, or None
        #   match_confidence: match confidence at restoration time, or None
        # Cleared and stored into cycle_data["restart_gaps"] at cycle end.
        # Matching always uses real readings only; this list is for display/anomaly.
        self._restart_gaps: list[dict[str, Any]] = []

        # External energy-meter snapshot for the current cycle (issue #316).
        # Captured at cycle start, read back at cycle end for the start->end delta.
        # Both survive a restart via the active-cycle snapshot.
        self._energy_meter_start: float | None = None
        self._energy_meter_source: str | None = None

        # Pause tracking (user-triggered)
        self._user_pause_start: datetime | None = None
        self._total_user_paused_seconds: float = 0.0
        self._is_user_paused: bool = False
        self._pause_cuts_power: bool = bool(
            config_entry.options.get(CONF_PAUSE_CUTS_POWER, False)
        )

        # Door sensor + clean state
        self._door_sensor_entity: str | None = config_entry.options.get(
            CONF_DOOR_SENSOR_ENTITY
        ) or None
        # Auto-open dishwasher (#342): a sustained door-open at cycle end finalizes
        # the cycle after a dwell instead of setting the sticky user-pause.
        self._door_opens_at_end: bool = bool(
            config_entry.options.get(CONF_DOOR_OPENS_AT_END, DEFAULT_DOOR_OPENS_AT_END)
        )
        self._door_end_dwell_seconds: int = int(
            config_entry.options.get(CONF_DOOR_END_DWELL_SECONDS, DEFAULT_DOOR_END_DWELL_SECONDS)
        )
        self._remove_door_end_dwell: Any = None
        self._remove_door_sensor_listener = None
        self._is_clean_state: bool = False
        self._clean_state_start: datetime | None = None
        self._notified_clean_laundry: bool = False
        # Set by _dispatch_notification when a call is queued for later (quiet
        # hours / presence) rather than sent; read by dedup-flag callers.
        self._last_dispatch_deferred: bool = False
        self._notify_unload_delay_minutes: int = int(
            config_entry.options.get(
                CONF_NOTIFY_UNLOAD_DELAY_MINUTES, DEFAULT_NOTIFY_UNLOAD_DELAY_MINUTES
            )
        )
        # Repeat the unload reminder until dismissed / door-open (opt-in, #374).
        self._notify_unload_repeat: bool = bool(
            config_entry.options.get(
                CONF_NOTIFY_UNLOAD_REPEAT, DEFAULT_NOTIFY_UNLOAD_REPEAT
            )
        )
        # Set when the user taps the reminder's "stop reminding" action; timestamp of
        # the last reminder sent, used to pace the repeats; and the mobile action
        # listener remover (mirrors the timer-pause interactive-notification wiring).
        self._unload_nag_dismissed: bool = False
        self._last_unload_nag_time: datetime | None = None
        self._unload_nag_count: int = 0  # repeat-mode safety bound (#374)
        self._remove_unload_action_listener: Any | None = None
        self._live_notification_cap = 0
        self._last_live_notification_time: datetime | None = None
        self._live_waiting_notification_sent = False
        self._live_chronometer_overrun_sent = False
        # iOS Live Activity: whether the "start" lifecycle marker has been emitted on
        # the first live notification of the current cycle. Reset per cycle.
        self._live_activity_started = False
        # Single per-device identity shared by start/live/reminder/finished so each
        # replaces the previous on the mobile app (and collapses to one entry on the
        # persistent-notification fallback). The clean-laundry nag uses its own tag
        # since it fires up to an hour after finish and should not clobber the thread.
        self._lifecycle_tag = f"ha_washdata_{self.entry_id}_lifecycle"
        self._lifecycle_pn_id = self._lifecycle_tag
        self._clean_tag = f"ha_washdata_{self.entry_id}_clean"
        # Backwards-compatible alias for existing live-notification call sites/tests.
        self._live_notification_tag = self._lifecycle_tag
        self._start_event_fired = False
        self._cycle_start_time: datetime | None = None
        # Per-cycle UUID used to key ranking snapshots; prevents cross-contamination
        # between cycles that happen to share the same second-resolution start_time.
        self._ranking_snapshot_cycle_id: str = ""

        # State
        self._current_power = 0.0
        # Power-based Off detection (issue #284): timestamp at which power first fell
        # below the power-off threshold while in a terminal state. None = not currently
        # below (or feature disabled). Cleared on new cycle / when power rises.
        self._power_off_below_since: datetime | None = None
        # One-shot cancellable timer armed when power first drops below the power-off
        # threshold, so the terminal->Off reset fires promptly after power_off_delay
        # instead of waiting for the next 60s expiry poll. Cancelled on power rise /
        # nag hold / terminal reset / new cycle.
        self._remove_power_off_timer: Any | None = None
        self._last_reading_time: datetime | None = None
        self._last_real_reading_time: datetime | None = None # Track last real sensor update
        self._noise_events: list[datetime] = []
        self._noise_max_powers: list[float] = []
        self._last_match_result = None
        self._last_phase_estimate_time = None
        self._sample_intervals: list[float] = []
        self._sample_interval_stats: dict[str, Any] = {}
        self._matching_task: Task[Any] | None = None
        self._cycle_end_task: Task[Any] | None = None
        # Detached store-touching tasks (matching trigger, active-cycle clear,
        # post-cycle processing) tracked so async_shutdown can cancel them before a
        # reload/unload swaps the ProfileStore out from under them.
        self._background_tasks: set[Task[Any]] = set()
        self._is_shutdown: bool = False
        self._last_state_save = 0.0
        self._last_cycle_end_time: datetime | None = None
        self._remove_state_expiry_timer = None

        # Components
        match_threshold = config_entry.options.get(
            CONF_PROFILE_MATCH_THRESHOLD, DEFAULT_PROFILE_MATCH_THRESHOLD
        )
        unmatch_threshold = config_entry.options.get(
            CONF_PROFILE_UNMATCH_THRESHOLD, DEFAULT_PROFILE_UNMATCH_THRESHOLD
        )
        self._unmatch_threshold = unmatch_threshold

        self.profile_store = ProfileStore(
            hass,
            self.entry_id,
            min_duration_ratio=config_entry.options.get(
                CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
                DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO_BY_DEVICE.get(
                    self.device_type, DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO
                ),
            ),
            max_duration_ratio=config_entry.options.get(
                CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
                DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO,
            ),
            save_debug_traces=config_entry.options.get(CONF_SAVE_DEBUG_TRACES, False),
            match_threshold=match_threshold,
            unmatch_threshold=unmatch_threshold,
            device_name=config_entry.title,
        )
        self.profile_store.dtw_bandwidth = float(
            config_entry.options.get(CONF_DTW_BANDWIDTH, DEFAULT_DTW_BANDWIDTH)
        )
        # Stage-4 energy discriminator: integrated energy for WM/washer-dryer,
        # mean power elsewhere (see analysis.stage4_energy_mode).
        self.profile_store.energy_mode = analysis.stage4_energy_mode(self.device_type)
        # Which cycle categories may shape a profile. The store cannot read entry
        # options, so the manager pushes this in (same as energy_mode above).
        self.profile_store.evidence_sources = config_entry.options.get(
            CONF_PROFILE_EVIDENCE_SOURCES, DEFAULT_PROFILE_EVIDENCE_SOURCES
        )
        self.learning_manager = LearningManager(
            hass, self.entry_id, self.profile_store, self.device_type,
            device_name=config_entry.title,
        )
        self.recorder = CycleRecorder(hass, self.entry_id, device_name=config_entry.title)
        self._store_bridge: Any = None  # lazy community-store bridge (online features)

        # Priority: Options > Data > Default
        min_power = config_entry.options.get(
            CONF_MIN_POWER, config_entry.data.get(CONF_MIN_POWER, DEFAULT_MIN_POWER)
        )
        off_delay = config_entry.options.get(
            CONF_OFF_DELAY, config_entry.data.get(CONF_OFF_DELAY, DEFAULT_OFF_DELAY)
        )
        progress_reset_delay = config_entry.options.get(
            CONF_PROGRESS_RESET_DELAY, DEFAULT_PROGRESS_RESET_DELAY
        )
        self._no_update_active_timeout = float(
            config_entry.options.get(
                CONF_NO_UPDATE_ACTIVE_TIMEOUT,
                DEFAULT_NO_UPDATE_ACTIVE_TIMEOUT,
            )
        )
        self._low_power_no_update_timeout = float(
            config_entry.options.get(CONF_LOW_POWER_NO_UPDATE_TIMEOUT, 3600.0)
        )
        self._off_delay = float(config_entry.options.get(CONF_OFF_DELAY, DEFAULT_OFF_DELAY))
        self._learning_confidence = config_entry.options.get(
            CONF_LEARNING_CONFIDENCE, DEFAULT_LEARNING_CONFIDENCE
        )
        self._duration_tolerance = config_entry.options.get(
            CONF_DURATION_TOLERANCE, DEFAULT_DURATION_TOLERANCE
        )
        self._auto_label_confidence = config_entry.options.get(
            CONF_AUTO_LABEL_CONFIDENCE, DEFAULT_AUTO_LABEL_CONFIDENCE
        )

        self._profile_match_interval = int(
            config_entry.options.get(
                CONF_PROFILE_MATCH_INTERVAL, DEFAULT_PROFILE_MATCH_INTERVAL
            )
        )
        self._notify_before_end_minutes = int(
            config_entry.options.get(
                CONF_NOTIFY_BEFORE_END_MINUTES, DEFAULT_NOTIFY_BEFORE_END_MINUTES
            )
        )
        self._load_notify_services(config_entry)
        self._notify_actions = list(
            cast(list[dict[str, Any]], config_entry.options.get(CONF_NOTIFY_ACTIONS, []) or [])
        )
        self._notify_people = list(
            config_entry.options.get(CONF_NOTIFY_PEOPLE, []) or []
        )
        self._notify_only_when_home = bool(
            config_entry.options.get(
                CONF_NOTIFY_ONLY_WHEN_HOME, DEFAULT_NOTIFY_ONLY_WHEN_HOME
            )
        )
        self._notify_fire_events = bool(
            config_entry.options.get(CONF_NOTIFY_FIRE_EVENTS, DEFAULT_NOTIFY_FIRE_EVENTS)
        )
        self._notify_live_interval_seconds = int(
            config_entry.options.get(
                CONF_NOTIFY_LIVE_INTERVAL_SECONDS,
                DEFAULT_NOTIFY_LIVE_INTERVAL_SECONDS,
            )
        )
        self._notify_live_overrun_percent = int(
            config_entry.options.get(
                CONF_NOTIFY_LIVE_OVERRUN_PERCENT,
                DEFAULT_NOTIFY_LIVE_OVERRUN_PERCENT,
            )
        )
        self._notify_live_chronometer = bool(
            config_entry.options.get(
                CONF_NOTIFY_LIVE_CHRONOMETER,
                DEFAULT_NOTIFY_LIVE_CHRONOMETER,
            )
        )
        self._notify_live_sticky = bool(
            config_entry.options.get(
                CONF_NOTIFY_LIVE_STICKY, DEFAULT_NOTIFY_LIVE_STICKY
            )
        )
        self._notify_live_click_action = str(
            config_entry.options.get(
                CONF_NOTIFY_LIVE_CLICK_ACTION, DEFAULT_NOTIFY_LIVE_CLICK_ACTION
            )
            or ""
        ).strip()
        self._notify_timeout_seconds = int(
            config_entry.options.get(
                CONF_NOTIFY_TIMEOUT_SECONDS, DEFAULT_NOTIFY_TIMEOUT_SECONDS
            )
        )

        # Advanced options
        smoothing_window = int(config_entry.options.get("smoothing_window", 5))
        interrupted_min_seconds = int(
            config_entry.options.get("interrupted_min_seconds", 150)
        )
        # Get device specific default for completion threshold
        device_default_completion = DEVICE_COMPLETION_THRESHOLDS.get(
            self.device_type, DEFAULT_COMPLETION_MIN_SECONDS
        )
        completion_min_seconds = int(
            config_entry.options.get(
                CONF_COMPLETION_MIN_SECONDS, device_default_completion
            )
        )

        start_duration_threshold = float(
            config_entry.options.get(
                CONF_START_DURATION_THRESHOLD,
                resolve_start_duration_default(self.device_type),
            )
        )
        end_repeat_count = int(
            config_entry.options.get(CONF_END_REPEAT_COUNT, DEFAULT_END_REPEAT_COUNT)
        )

        self._logger.info(
            "Manager init: min_power=%sW, off_delay=%ss, type=%s",
            min_power,
            off_delay,
            self.device_type,
        )

        config = CycleDetectorConfig(
            min_power=float(min_power),
            off_delay=int(off_delay),
            smoothing_window=smoothing_window,
            interrupted_min_seconds=interrupted_min_seconds,
            completion_min_seconds=completion_min_seconds,
            start_duration_threshold=start_duration_threshold,
            end_repeat_count=end_repeat_count,
            min_off_gap=int(
                config_entry.options.get(
                    CONF_MIN_OFF_GAP,
                    DEFAULT_MIN_OFF_GAP_BY_DEVICE.get(
                        self.device_type, DEFAULT_MIN_OFF_GAP
                    ),
                )
            ),
            start_energy_threshold=float(
                config_entry.options.get(
                    CONF_START_ENERGY_THRESHOLD,
                    DEFAULT_START_ENERGY_THRESHOLDS_BY_DEVICE.get(self.device_type, 0.2)
                )
            ),
            end_energy_threshold=float(
                config_entry.options.get(CONF_END_ENERGY_THRESHOLD, DEFAULT_END_ENERGY_THRESHOLD)
            ),
            start_threshold_w=float(
                config_entry.options.get(
                    CONF_START_THRESHOLD_W,
                    float(min_power) + max(1.0, 0.1 * float(min_power)),
                )
            ),
            stop_threshold_w=float(
                config_entry.options.get(
                    CONF_STOP_THRESHOLD_W,
                    float(min_power) * 0.6 if float(min_power) > 0 else 2.0,
                )
            ),
            power_off_threshold_w=float(
                config_entry.options.get(
                    CONF_POWER_OFF_THRESHOLD_W, DEFAULT_POWER_OFF_THRESHOLD_W
                )
            ),
            power_off_delay=float(
                config_entry.options.get(CONF_POWER_OFF_DELAY, DEFAULT_POWER_OFF_DELAY)
            ),
            min_duration_ratio=float(
                config_entry.options.get(
                    CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
                    DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO_BY_DEVICE.get(
                        self.device_type, DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO
                    ),
                )
            ),
            match_interval=int(
                config_entry.options.get(
                    CONF_PROFILE_MATCH_INTERVAL, DEFAULT_PROFILE_MATCH_INTERVAL
                )
            ),
            # `profile_match_threshold` was stored on the ProfileStore and never read
            # anywhere, so the #288 workaround (raise it so near-duplicate profiles
            # stop being trusted mid-cycle) silently did nothing. Wire it to the gate
            # it was always documented to control; the default is the value that used
            # to be hard-coded there, so nothing changes unless it was tuned.
            match_confidence_threshold=float(
                config_entry.options.get(
                    CONF_PROFILE_MATCH_THRESHOLD, DEFAULT_PROFILE_MATCH_THRESHOLD
                )
            ),
            anti_wrinkle_enabled=bool(
                config_entry.options.get(
                    CONF_ANTI_WRINKLE_ENABLED, DEFAULT_ANTI_WRINKLE_ENABLED
                )
            ),
            anti_wrinkle_max_power=float(
                config_entry.options.get(
                    CONF_ANTI_WRINKLE_MAX_POWER, DEFAULT_ANTI_WRINKLE_MAX_POWER
                )
            ),
            anti_wrinkle_max_duration=float(
                config_entry.options.get(
                    CONF_ANTI_WRINKLE_MAX_DURATION, DEFAULT_ANTI_WRINKLE_MAX_DURATION
                )
            ),
            anti_wrinkle_exit_power=float(
                config_entry.options.get(
                    CONF_ANTI_WRINKLE_EXIT_POWER, DEFAULT_ANTI_WRINKLE_EXIT_POWER
                )
            ),
            anti_wrinkle_idle_timeout=float(
                config_entry.options.get(
                    CONF_ANTI_WRINKLE_IDLE_TIMEOUT, DEFAULT_ANTI_WRINKLE_IDLE_TIMEOUT
                )
            ),
            dishwasher_end_spike_quiet_release=float(
                config_entry.options.get(
                    CONF_DISHWASHER_END_SPIKE_QUIET_RELEASE,
                    DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS,
                )
            ),
            # #393: resolve the device-type default HERE (not in the gate) so the
            # field always carries a real float - playground.effective_settings()
            # skips a None-valued field, which would desync the sim from the detector.
            smart_termination_duration_ratio=float(
                config_entry.options.get(
                    CONF_SMART_TERMINATION_DURATION_RATIO,
                    resolve_smart_termination_duration_ratio_default(self.device_type),
                )
            ),
            delay_detect_enabled=bool(
                config_entry.options.get(
                    CONF_DELAY_START_DETECT_ENABLED, DEFAULT_DELAY_START_DETECT_ENABLED
                )
            ),
            delay_confirm_seconds=float(
                config_entry.options.get(
                    CONF_DELAY_CONFIRM_SECONDS, DEFAULT_DELAY_CONFIRM_SECONDS
                )
            ),
            delay_timeout_seconds=float(
                config_entry.options.get(
                    CONF_DELAY_TIMEOUT_HOURS, DEFAULT_DELAY_TIMEOUT_HOURS
                )
            ) * 3600.0,
            # #378: without this the detector keeps the WASHING_MACHINE default and
            # every non-washing-machine device runs the washing-machine detection
            # path (the whole dishwasher/#43 branch is otherwise dead in production).
            device_type=self.device_type,
        )
        self._config = config


        def profile_matcher_wrapper(
            readings: list[tuple[datetime, float]],
        ) -> tuple[str | None, float, float, str | None] | None:
            """Wraps profile store matching logic with detector callback signature.

            The real match is offloaded to an async task that calls
            ``detector.update_match`` later, so this returns ``None`` in that case -
            the detector's contract is "None == async offload, I'll be called back"
            (see ``_try_profile_match``). It must NOT return a placeholder tuple:
            a non-empty tuple is truthy, so the detector would feed it straight into
            ``update_match`` on every match tick, spuriously logging the
            "invalid raw_expected_duration 0.0" debug line and momentarily zeroing
            ``_last_match_confidence`` between real async updates. Only the manual
            override path below returns a genuine synchronous tuple.
            """
            # Manual program override
            if self._manual_program_active and self._current_program:
                elapsed_seconds = 0.0
                if len(readings) > 1:
                    elapsed_seconds = max(
                        0.0,
                        (readings[-1][0] - readings[0][0]).total_seconds(),
                    )

                expected_duration = float(self._matched_profile_duration or 0.0)
                manual_phase = self.profile_store.check_phase_match(
                    self._current_program,
                    elapsed_seconds,
                )
                return (
                    self._current_program,
                    1.0,
                    expected_duration,
                    manual_phase or "Manual",
                )

            if not readings:
                return None

            # Snapshotted for thread safety indirectly by task logic
            # We don't need a wrapper task if we unify with _update_estimates matching
            # but for now let's keep the detector callback as a trigger
            self._spawn_tracked(self._async_perform_combined_matching(readings))
            return None

        self.detector = CycleDetector(
            config,
            self._on_state_change,
            self._on_cycle_end,
            profile_matcher=profile_matcher_wrapper,
            device_name=config_entry.title,
            end_confidence_provider=self._ml_end_confidence,
            terminal_drop_provider=self._terminal_drop_provider,
        )
        self._ml_end_expectation_cache: tuple[str, dict[str, float]] | None = None
        # (cycle_count, earliest_quiet_offset|None, peak_range|None) for the
        # terminal-drop baselines; keyed by cycle count so it auto-invalidates
        # when history grows.
        self._terminal_drop_cache: (
            tuple[int, float | None, tuple[float, float] | None] | None
        ) = None
        # Cycle count an executor refresh of the baseline is in-flight/done for, so
        # the loop never recomputes it (issue #311) and never double-schedules.
        self._terminal_drop_refresh_n: int | None = None

        self._remove_listener = None
        self._remove_report_listener = None  # state_reported (unchanged re-reports) #363/#329
        self._remove_external_trigger_listener = None  # External cycle end trigger
        self._remove_watchdog = None
        self._watchdog_interval = int(
            config_entry.options.get(
                CONF_WATCHDOG_INTERVAL,
                resolve_watchdog_interval_default(self.device_type),
            )
        )
        self._match_persistence = int(
            config_entry.options.get(CONF_MATCH_PERSISTENCE, DEFAULT_MATCH_PERSISTENCE)
        )
        self._sampling_interval = float(
            config_entry.options.get(
                CONF_SAMPLING_INTERVAL,
                resolve_sampling_interval_default(self.device_type),
            )
        )
        self._noise_events_threshold = int(
            config_entry.options.get(
                CONF_AUTO_TUNE_NOISE_EVENTS_THRESHOLD,
                DEFAULT_AUTO_TUNE_NOISE_EVENTS_THRESHOLD,
            )
        )
        self._current_program: str = "off"
        self._time_remaining: float | None = None
        self._total_duration: float | None = None
        self._last_total_duration_update: datetime | None = None
        self._cycle_progress: float = 0.0
        self._smoothed_progress: float = 0.0  # Smoothed progress tracking for EMA
        # Live projected total energy/cost for the running cycle (None until a
        # reliable progress estimate exists). Derived from accumulated energy and
        # the (ML-blended) progress fraction; surfaced as progress-sensor attrs.
        self._projected_energy_wh: float | None = None
        self._projected_cost: float | None = None
        # Runtime overrun anomaly (soft, visible; never a notification). "none"
        # or "overrun" once a running cycle exceeds its matched profile's expected
        # duration by CYCLE_OVERRUN_ANOMALY_RATIO. Surfaced as a state-sensor attr
        # and frozen onto the cycle at end for panel badging.
        self._cycle_anomaly: str = "none"
        self._overrun_ratio: float = 0.0
        # Post-cycle anomaly cache: holds energy/underrun anomaly from the last
        # completed cycle so sensor attributes can surface them after idle.
        self._last_cycle_post_anomaly: dict = {}
        self._cycle_completed_time: datetime | None = None  # Track when cycle finished
        self._progress_reset_delay: int = int(
            progress_reset_delay
        )  # Reset progress after idle
        self._last_reading_time: datetime | None = None
        self._current_power: float = 0.0
        self._last_estimate_time: datetime | None = None
        self._last_match_ambiguous: bool = False
        self._matched_profile_duration: float | None = None
        self._last_match_confidence: float = 0.0
        # Sample interval tracking (seconds) for adaptive timing
        # Profile matching duration tolerance (0.25 = ±25%)
        self._profile_duration_tolerance: float = float(
            config_entry.options.get("profile_duration_tolerance", 0.25)
        )


        self._remove_maintenance_scheduler = None
        self._remove_ml_training_scheduler = None
        self._ml_training_failures = 0  # consecutive gate failures for auto-disable
        self._ml_training_running = False  # True while a training run is in flight
        self._profile_sample_repair_stats: dict[str, int] | None = None

        self._last_suggestion_update: datetime | None = None

        # Pump Monitor state
        self._pump_stuck_duration: int = int(
            config_entry.options.get(CONF_PUMP_STUCK_DURATION, DEFAULT_PUMP_STUCK_DURATION)
        )
        self._pump_stuck: bool = False  # True once the stuck threshold has fired for this cycle

        self._manual_program_active: bool = False
        self._notified_start: bool = False
        self._notified_pre_completion: bool = False
        self._last_match_result: Any = None  # Stores full MatchResult object
        self._score_history: dict[str, list[float]] = {}  # Tracks recent scores for trend analysis
        self._match_persistence_counter: dict[str, int] = {}  # Tracks consecutive matches
        self._unmatch_persistence_counter: int = 0  # Tracks consecutive low-confidence matches
        self._current_match_candidate: str | None = None  # Pending profile name

    async def _async_perform_combined_matching(
        self, readings: list[tuple[datetime, float]]
    ) -> None:
        """PRIMARY matching task: Updates both Manager and Detector using best method."""
        self._logger.debug(
            "Matching trigger: readings=%d, task_exists=%s",
            len(readings) if readings else 0,
            getattr(self, "_matching_task", None) is not None
        )
        # Prevent concurrent matching tasks
        current_task = self._matching_task
        if current_task is not None and not current_task.done():
            self._logger.debug("Matching skipped: previous task still running")
            return

        try:
            if not readings:
                self._logger.debug("Matching skipped: no readings")
                return

            # Skip match entirely when no real profiles exist — nothing to match against.
            if not self.profile_store.has_real_profiles:
                self._logger.debug("Matching skipped: no real profiles configured yet")
                return

            self._matching_task = self.hass.async_create_task(self._async_do_perform_matching(readings))
        except Exception as e:
            self._logger.error("Perform combined matching trigger failed: %s", e)

    async def _async_do_perform_matching(self, readings: list[tuple[datetime, float]]) -> None:
        """Inner task to handle actual matching logic."""
        try:
            end_time = readings[-1][0]
            start_time = readings[0][0]
            current_duration = (end_time - start_time).total_seconds()

            # 1. RUN BETTER ASYNC MATCHING
            result = await self.profile_store.async_match_profile(
                 readings,
                 current_duration
            )

            # 2. UPDATE MANAGER STATE (Estimates, Program Name, etc.)
            self._last_match_result = result
            self._last_match_ambiguous = result.is_ambiguous

            profile_name = result.best_profile
            confidence = result.confidence
            matched_duration = result.expected_duration
            phase_name = result.matched_phase

            # --- Switching Logic (Temporal Persistence) ---
            should_switch = False
            switch_reason = ""

            # Identify current program score from results
            current_program_score = 0.0
            for c in result.candidates:
                if c.get("name") == self._current_program:
                    current_program_score = c.get("score", 0.0)
                    break

            # CASE: Divergence Detection (Score Drop)
            # If current matched program has a significant drop from its own peak score,
            # we should consider unmatching it even if it's still the "best" candidate.
            if (
                self._current_program not in ("detecting...", "off", "starting", "unknown")
                and profile_name == self._current_program
            ):
                history: list[float] = self._score_history.get(self._current_program, [])
                if len(history) > 3:
                    peak_score = max(history)
                    # If score drops by more than 40% from peak AND is below threshold, unmatch.
                    # This catches divergence faster than waiting for fixed unmatch_threshold.
                    if confidence < peak_score * (1.0 - DEFAULT_MATCH_REVERT_RATIO):
                        self._unmatch_persistence_counter += 1
                        if self._unmatch_persistence_counter >= self._match_persistence:
                            self._current_program = "detecting..."
                            self._matched_profile_duration = None
                            self._unmatch_persistence_counter = 0
                            self._logger.info(
                                "Divergence detected for profile '%s' (confidence %.3f < 60%% of peak %.3f). "
                                "Reverting to detection.",
                                profile_name, confidence, peak_score
                            )
                            # Reset profile_name so Case 3 doesn't re-trigger
                            profile_name = "detecting..."

            # Update persistence for the best profile
            if profile_name and profile_name != "detecting...":
                self._match_persistence_counter[profile_name] = self._match_persistence_counter.get(profile_name, 0) + 1

                # Check if this is the same candidate as before
                if profile_name != self._current_match_candidate:
                    # Reset counter for old candidate if it wasn't locked in
                    self._current_match_candidate = profile_name
                    self._match_persistence_counter[profile_name] = 1
            else:
                self._current_match_candidate = None

            is_persistent = profile_name and self._match_persistence_counter.get(profile_name, 0) >= self._match_persistence

            # --- Live-match features: compute always for ranking history + ML gate ---
            # Features are cheap scalars derived from the current trace.  We compute
            # them whenever there is a non-ambiguous candidate so they can be recorded
            # as a training snapshot regardless of whether ML models are opted in.
            # The opt-in ML commit check then uses the same features when enabled.
            ml_commit_score: float | None = None
            _live_feat: dict[str, float] | None = None
            _top2_score: float | None = None
            if (
                profile_name
                and profile_name != "detecting..."
                and not result.is_ambiguous
                and self._current_program in ("detecting...",)
            ):
                try:
                    from .ml.feature_extraction import live_match_features  # noqa: PLC0415

                    first_ts = readings[0][0]
                    pts = [
                        ((ts - first_ts).total_seconds(), float(pw))
                        for ts, pw in readings
                    ]
                    top1_dist = max(0.0, 1.0 - confidence)
                    top2_raw: float | None = None
                    if len(result.candidates) > 1:
                        s2 = result.candidates[1].get("score", 0.0) or 0.0
                        top2_raw = max(0.0, 1.0 - float(s2))
                        _top2_score = float(result.candidates[1].get("score", 0.0) or 0.0)
                    n_profiles = len(self.profile_store.get_profiles())
                    _live_feat = live_match_features(
                        points=pts,
                        elapsed_s=current_duration,
                        top1_distance=top1_dist,
                        top2_distance=top2_raw,
                        top1_median_duration_s=float(result.expected_duration or 0),
                        candidate_count=max(1, n_profiles),
                    )
                    # --- ML early-commit gate (opt-in) ---
                    # When the user has opted into experimental ML models, query the
                    # live_match_commit model for P(top-1 is correct).
                    try:
                        from .ml.engine import ml_models_enabled, resolve_scorer  # noqa: PLC0415
                        if ml_models_enabled(self.config_entry.options):
                            match_fn, _ = resolve_scorer("live_match", self.profile_store)
                            if match_fn is not None:
                                ml_commit_score = float(match_fn(_live_feat))
                                self._logger.debug(
                                    "Live-match ML commit score for '%s': %.3f (threshold %.2f)",
                                    profile_name,
                                    ml_commit_score,
                                    ML_MATCH_COMMIT_THRESHOLD,
                                )
                    except Exception:  # noqa: BLE001
                        pass
                except Exception:  # noqa: BLE001 - ML must never break matching
                    pass

            # --- Record ranking snapshot for live_match on-device training ---
            # Snapshot is recorded unconditionally (not gated on ML opt-in) so that
            # training data accumulates even before the user enables ML models.
            # Confirmed labels are back-filled at cycle end.
            if _live_feat is not None and self._cycle_start_time:
                try:
                    self.profile_store.record_match_ranking_snapshot(
                        start_time_iso=self._cycle_start_time.isoformat(),
                        features=_live_feat,
                        top1_profile=profile_name or "",
                        top1_score=float(confidence),
                        top2_score=_top2_score,
                        candidate_count=max(1, len(self.profile_store.get_profiles())),
                        cycle_id=self._ranking_snapshot_cycle_id,
                    )
                except Exception:  # noqa: BLE001 - never break matching
                    pass

            ml_early_commit = (
                ml_commit_score is not None
                and ml_commit_score >= ML_MATCH_COMMIT_THRESHOLD
                and confidence >= 0.30
            )

            # Case 1: Initial Match from "detecting..."
            if (
                profile_name
                and confidence >= 0.15
                and (not result.is_ambiguous or is_persistent)
                and (not self._matched_profile_duration or self._current_program == "detecting...")
            ):
                if is_persistent:
                    should_switch = True
                    switch_reason = f"initial_match (persistent {self._match_persistence_counter[profile_name]}x)"
                elif ml_early_commit:
                    should_switch = True
                    switch_reason = (
                        f"initial_match (ML commit score {ml_commit_score:.3f} >= {ML_MATCH_COMMIT_THRESHOLD})"
                    )
                else:
                    self._logger.debug(
                        "Match persistence: %s at %d/%d matches. Stay at detecting...",
                        profile_name, self._match_persistence_counter.get(profile_name, 0), self._match_persistence
                    )

            # Case 2: Mid-cycle override (different profile)
            elif (
                profile_name
                and self._current_program != profile_name
                and self._current_program not in ("detecting...", "off", "starting", "unknown")
            ):
                # High Confidence Override: Bypass persistence if match is VERY strong
                if confidence > 0.8 and (confidence - current_program_score) > 0.15:
                    should_switch = True
                    switch_reason = f"high_confidence_override ({confidence:.3f} vs {current_program_score:.3f})"

                # Normal Switch: Requires persistence AND either better score + trend
                elif is_persistent:
                    if confidence > current_program_score and self._analyze_trend(profile_name):
                        # Add a minimum score gap for mid-cycle switching (0.05) to prevent flapping
                        if (confidence - current_program_score) > 0.05:
                            should_switch = True
                            switch_reason = f"positive_trend_persistent ({confidence:.3f} > {current_program_score:.3f})"

            # Case 3: Unmatching (confidence drop)
            elif (
                self._current_program not in ("detecting...", "off", "starting", "unknown")
                and profile_name == self._current_program
                and confidence < self._unmatch_threshold
            ):
                self._unmatch_persistence_counter += 1
                is_unmatch_persistent = self._unmatch_persistence_counter >= self._match_persistence

                if is_unmatch_persistent:
                    self._current_program = "detecting..."
                    self._matched_profile_duration = None
                    self._unmatch_persistence_counter = 0
                    self._logger.info(
                        "Unmatched profile '%s' (confidence %.3f < threshold %.3f persistent %dx). "
                        "Reverting to detection.",
                        profile_name,
                        confidence,
                        self._unmatch_threshold,
                        self._match_persistence
                    )
                else:
                    self._logger.debug(
                        "Unmatch persistence: %s at %d/%d low-confidence matches. Stay at %s...",
                        profile_name, self._unmatch_persistence_counter, self._match_persistence, profile_name
                    )

            # Reset unmatch counter if confidence is healthy
            # AND we didn't just detect a divergence
            elif (
                profile_name == self._current_program
                and confidence >= self._unmatch_threshold
                and not (len(self._score_history.get(self._current_program, [])) > 3 and confidence < max(self._score_history[self._current_program]) * (1.0 - DEFAULT_MATCH_REVERT_RATIO))
            ):
                self._unmatch_persistence_counter = 0

            if should_switch:
                if profile_name is None:
                    self._current_program = "detecting..."
                else:
                    self._current_program = profile_name
                self._last_match_confidence = confidence
                self._unmatch_persistence_counter = 0 # Reset on switch
                if profile_name in self._match_persistence_counter:
                    self._match_persistence_counter[profile_name] = self._match_persistence # Lock it in

                avg_duration = float(matched_duration)
                self._matched_profile_duration = avg_duration if avg_duration > 0 else None
                self._logger.info(
                     "Switching to profile '%s' (reason: %s). Expected duration: %.0fs (%smin)",
                     profile_name, switch_reason, avg_duration, int(avg_duration / 60),
                )
            elif profile_name == self._current_program:
                # Same program, but update confidence for sensors
                self._last_match_confidence = confidence
            elif not self._matched_profile_duration:
                self._current_program = "detecting..."

            self._last_estimate_time = dt_util.now()

            # Update score history for all candidates to track trends
            for cand in result.candidates:
                cname = cand.get("name")
                if cname:
                    history = self._score_history.setdefault(cname, [])
                    history.append(float(cand.get("score", 0.0)))
                    if len(history) > 20:
                        history.pop(0)

            # Note: _update_remaining_only() and notify move to end of flow

            # 3. UPDATE DETECTOR (Envelopes, Deferral, State Transitions)
            current_matched = self.detector.matched_profile
            verified_pause = getattr(self.detector, "_verified_pause", False)
            current_power = readings[-1][1] if readings else 0.0

            # --- Envelope Verification for Mismatches & Pauses ---
            # Check alignment if we have a match and power is low, to confirm if
            # this is a legitimate (auto-detected) pause or a mismatch.  Skipped
            # while the user has explicitly paused (issue #306): the user pause is
            # authoritative and must not be re-judged by the envelope heuristic
            # (see the verified_pause override below).
            stop_thresh = float(self.detector.config.stop_threshold_w)
            if current_matched and current_power < stop_thresh and not self._is_user_paused:
                formatted = power_data_to_offsets(cast(list[list[Any] | tuple[Any, ...]], readings))
                try:
                    profile_store_any = cast(Any, self.profile_store)
                    verify_alignment = profile_store_any.async_verify_alignment
                    is_confirmed, mapped_time, _ = (
                        await verify_alignment(current_matched, formatted)
                    )
                except Exception as e: # pylint: disable=broad-exception-caught
                    self._logger.error(
                        "Alignment verification crashed for profile %s: %s",
                        current_matched, e, exc_info=True
                    )
                    is_confirmed = False
                    mapped_time = 0.0

                if is_confirmed:
                    if not verified_pause:
                        self._logger.info(
                            "Envelope verified expected low power phase for %s. Enabling verified pause.",
                            current_matched
                        )
                    verified_pause = True
                    # Smart Termination within Envelope block. Compare the mapped
                    # position against the envelope's OWN time span (not avg_duration,
                    # a differently-derived trimmed mean): mapped_time is capped at the
                    # grid span, so span/avg_duration < 1 would make the 0.95 release
                    # unreachable and the cycle would hang to the deferral cap (#348).
                    try:
                        span = self.profile_store.envelope_time_span(current_matched)
                        if span > 0 and (mapped_time / span) > 0.95:
                            verified_pause = False
                            self._logger.info(
                                "Smart Termination: near end of profile (%.0f/%.0fs). Releasing pause lock.",
                                mapped_time, span,
                            )
                        else:
                            # Diagnostic (#346): the release is held; show how far the
                            # trace mapped vs the 95%% release point (no behaviour change).
                            self._logger.debug(
                                "Smart Termination held for %s: mapped %.0f/%.0fs (%.0f%%) below 95%% release%s",
                                current_matched, mapped_time, span,
                                (100.0 * mapped_time / span) if span > 0 else 0.0,
                                "" if span > 0 else " (envelope span unavailable)",
                            )
                    except Exception as e:
                        self._logger.debug("Smart Termination alignment verification failed: %s", e)
                else:
                    if verified_pause:
                        self._logger.info(
                            "Envelope indicates UNEXPECTED low power for %s. Disabling verified pause.",
                            current_matched
                        )
                    verified_pause = False

            # --- High Power Clear ---
            stop_threshold = getattr(self.detector.config, "stop_threshold_w", 5.0)

            if current_power > stop_threshold * 10:
                verified_pause = False

            # --- Sustained-quiet release of an auto-detected pause (issue #375) ---
            # An envelope-verified pause bridges a genuine low-power phase (e.g. a
            # dishwasher's passive drying).  When the appliance instead goes truly
            # silent at the real end, the envelope alignment can keep re-confirming
            # against a long near-zero drying tail baked into the profile by earlier
            # force-stopped cycles, and Smart Termination's >95%-of-span release is
            # unreachable because the trace goes quiet BEFORE that learned tail ends.
            # The flag then freezes True and every ENDING finalize backstop (all
            # gated on `not _verified_pause`) is defeated, so the cycle hangs for
            # hours until the watchdog's multi-hour silence limit force-ends it.
            # Release the auto-pause once the cycle has completed its expected
            # duration AND has been continuously sub-threshold for the finalize
            # quiet floor: the drying (if any) is over, so let the normal end path
            # finalize.  This mirrors the dishwasher `quiet_released` gate the
            # detector already trusts in `_should_defer_finish`.  A real user pause
            # is authoritative and re-asserted below, so it is never released here.
            expected_dur = self.detector.expected_duration_seconds
            # Gap-free tally only: a telemetry outage is unobserved time and must
            # not satisfy the quiet floor that releases the auto-detected pause
            # (mirrors the detector's dishwasher quiet-release gates). Fall back to
            # the plain tally if the attribute is missing (older detector).
            time_below = getattr(
                self.detector,
                "_time_below_threshold_gapfree",
                getattr(self.detector, "_time_below_threshold", 0.0),
            )
            if (
                verified_pause
                and not self._is_user_paused
                and expected_dur > 0
                and current_duration >= expected_dur
                and time_below >= ENDING_HARD_FINALIZE_MIN_QUIET_S
            ):
                self._logger.info(
                    "Releasing auto-detected pause for %s: reached expected "
                    "duration (%.0fs >= %.0fs) and sustained-quiet %.0fs - "
                    "allowing normal cycle finish (issue #375).",
                    current_matched or self._current_program,
                    current_duration,
                    expected_dur,
                    time_below,
                )
                verified_pause = False

            # A user-initiated pause (Pause Cycle button, or the door-open soft
            # pause) stays in force until the user resumes (issue #306).  The
            # heuristics above only govern *auto-detected* low-power phases; without
            # this override they clear verified_pause and the cycle is finalized
            # (leaving the "Paused by user" state, e.g. a dishwasher closes at the
            # 1 h min-off-gap timeout) instead of waiting for Resume.  Re-asserting
            # here also repairs the flag after a restart, since the detector state
            # snapshot does not persist _verified_pause.
            if self._is_user_paused:
                verified_pause = True

            # --- Consistency Override ---
            # If envelope verified or mismatched, ensure manager program matches
            if profile_name != self._current_program and (verified_pause or result.is_confident_mismatch):
                if profile_name:
                    self._current_program = profile_name
                    self._last_match_confidence = confidence
                    # Try to fetch duration if we switched back to matched
                    try:
                        prof = self.profile_store.get_profile(profile_name)
                        if prof:
                            self._matched_profile_duration = float(prof.get("avg_duration", 0))
                    except Exception as e:
                        self._logger.debug("Failed to fetch profile duration on switch: %s", e)
                else:
                    self._current_program = "detecting..."
                    self._matched_profile_duration = None

            # --- HEURISTICS (Descriptive Phases) ---
            if not phase_name:
                if self.device_type == "dishwasher" and self.detector.is_waiting_low_power():
                    phase_name = "Drying"
                elif self.device_type == "washing_machine" and current_power > 200:
                    phase_name = "Spinning"
                elif self.device_type == "washing_machine" and self.detector.is_waiting_low_power():
                    phase_name = "Rinsing/Soaking"

            # Push updates to detector
            self.detector.set_verified_pause(verified_pause)
            # Element 8 is the narrow #288-only prefix verdict and element 9 the
            # matched profile's own tail power level, both for the #364 guards. The
            # detector tolerates shorter tuples, so other callers stay valid.
            self.detector.update_match(
                (profile_name, confidence, matched_duration, phase_name,
                 result.is_confident_mismatch, result.is_ambiguous,
                 result.is_prefix_ambiguous,
                 result.is_prefix_ambiguous_full_shape,
                 self.profile_store.profile_tail_power(profile_name) if profile_name else None)
            )

            # --- LOGGING (Unified) ---
            self._logger.info(
                "Profile match attempt: name=%s, confidence=%.3f, duration=%.0fs, samples=%d",
                profile_name, confidence, current_duration, len(readings),
            )

            self._update_remaining_only()

            # --- START NOTIFICATION LOGIC ---
            # Fallback for restart-recovery: fires only if the immediate notification in
            # _on_state_change was missed (e.g., HA restarted mid-cycle before snapshot).
            if not getattr(self, "_notified_start", False):
                if self._notify_fire_events and not self._start_event_fired:
                    self.hass.bus.async_fire(
                        EVENT_CYCLE_STARTED,
                        {
                            "entry_id": self.entry_id,
                            "device_name": self.config_entry.title,
                            "device_type": self.device_type,
                            "program": self._current_program,
                            "start_time": (
                                self._cycle_start_time or dt_util.now()
                            ).isoformat(),
                        },
                    )
                    self._start_event_fired = True

                if self._notify_start_services or self._notify_actions:
                    msg_template = self.config_entry.options.get(
                        CONF_NOTIFY_START_MESSAGE, DEFAULT_NOTIFY_START_MESSAGE
                    )
                    msg = self._safe_format_template(
                        msg_template,
                        fallback_template=DEFAULT_NOTIFY_START_MESSAGE,
                        device=self.config_entry.title,
                        program=self._current_program,
                    )
                    # B4: append a peak-rate advisory tip when the current price is
                    # at/above the configured threshold. Purely informational.
                    tip = self._peak_rate_tip(
                        self.config_entry.options, self._resolve_energy_price()
                    )
                    if tip:
                        msg = f"{msg}\n{tip}"

                    self._dispatch_notification(
                        msg,
                        event_type=NOTIFY_EVENT_START,
                        extra_vars={
                            "program": self._current_program,
                            "tag": self._lifecycle_tag,
                        },
                    )
                    self._notified_start = True
                    self._logger.info("Sent start notification for program '%s'", self._current_program)

                    # Ensure pre-completion notifications never precede cycle-start signaling.
                    self._check_pre_completion_notification()

            self._check_live_progress_notification()
            self._notify_update()

        except Exception as e:
            self._logger.error("Perform combined matching failed: %s", e, exc_info=True)

    @property
    def top_candidates(self) -> list[dict[str, Any]]:
        """Return a lightweight list of top candidates from the last match."""
        if not self._last_match_result:
            return []

        # Get raw list from ranking (best) or candidates
        raw_list: list[dict[str, Any]] = []
        if hasattr(self._last_match_result, "ranking") and self._last_match_result.ranking:
            raw_list = self._last_match_result.ranking
        elif hasattr(self._last_match_result, "candidates"):
            raw_list = self._last_match_result.candidates

        # SANITIZE: Remove heavy power arrays before sending to Home Assistant attributes
        return _sanitize_ranking(raw_list)

    @property
    def phase_description(self) -> str:
        """Return a description of the current phase.

        Prefers the *functional* progress-driven phase (the visual per-profile
        phase configurator's ranges, indexed by the live ML-blended progress) so
        the readout stays accurate even when a cycle runs longer/shorter than the
        profile's nominal timeline. Falls back to the matcher's phase, then the
        detector sub-state/state.
        """
        live = self._current_phase_from_progress()
        if live:
            return live
        if self._last_match_result and self._last_match_result.matched_phase:
            return self._last_match_result.matched_phase
        if self.detector.sub_state:
            return self.detector.sub_state
        return self.detector.state

    def _current_phase_from_progress(self) -> str | None:
        """Live phase from the profile's configured ranges + ML-blended progress.

        This is the *merge* of the visual phase configurator with the runtime
        estimator: one phase definition (the per-profile ranges the user draws),
        indexed by the smoothed progress fraction rather than raw elapsed seconds,
        so overrun/underrun cycles still name the phase correctly. Returns None
        (caller falls back) when not running, no profile is matched, or the
        profile has no configured phase ranges. Never raises.
        """
        return progress_mod.current_phase(
            self.profile_store,
            self.detector.state,
            self._current_program,
            self._cycle_progress,
        )

    @property
    def match_ambiguity(self) -> bool:
        """Return True if the last match was ambiguous."""
        if self._last_match_result and hasattr(self._last_match_result, "is_ambiguous"):
            return self._last_match_result.is_ambiguous
        return False

    @property
    def last_ambiguity_margin(self) -> float | None:
        """Return the score margin between top-1 and top-2 candidates, or None."""
        result = self._last_match_result
        if result is None:
            return None
        return getattr(result, "ambiguity_margin", None)

    # Note: last_match_details property is defined later in the class
    # It returns MatchResult from _last_match_result
    async def _attempt_state_restoration(self) -> None:
        """Attempt to restore active cycle state from storage."""
        active_snapshot = self.profile_store.get_active_cycle()

        # Check current power state first
        state = self.hass.states.get(self.power_sensor_entity_id)
        current_power = 0.0
        power_is_valid = False

        if state and state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
            try:
                current_power = float(state.state)
                power_is_valid = True
            except (ValueError, TypeError):
                # Power sensor state is not numeric during restoration; treat as 0W
                self._logger.debug(
                    "Power sensor %s state %r is not numeric during restoration; "
                    "treating as 0W and not restoring by power",
                    self.power_sensor_entity_id,
                    getattr(state, "state", None),
                )

        should_restore = False
        active_snapshot_to_restore: dict[str, Any] | None = (
            active_snapshot if isinstance(active_snapshot, dict) else None
        )

        # Helper to check if a snapshot is viable
        def is_viable_restore(last_save_time: datetime) -> bool:
            now = dt_util.now()
            # Handle timezone mismatch gracefully
            if last_save_time.tzinfo is None:
                # Assume naive means local system time, convert to aware
                last_save_time = last_save_time.replace(tzinfo=now.tzinfo)

            age = (now - last_save_time).total_seconds()

            # Unconditional restore window (30 mins)
            if age < 1800:
                return True
            # Extended window if power is confirmed HIGH (60 mins)
            if (
                age < 3600
                and power_is_valid
                and current_power >= self._config.min_power
            ):
                return True
            return False

        last_save = self.profile_store.get_last_active_save()
        if last_save and last_save.tzinfo is None:
            # Normalize naive legacy timestamps to system time
            last_save = last_save.replace(tzinfo=dt_util.now().tzinfo)

        if active_snapshot_to_restore is not None and last_save and is_viable_restore(last_save):
            should_restore = True
            age = (dt_util.now() - last_save).total_seconds()
            age = (dt_util.now() - last_save).total_seconds()
            self._logger.info(
                "Found recently saved active cycle (last_save=%s, age=%.0fs), restoring...",
                last_save,
                age
            )
            # strict extension logic unless the user wants to enforce it.
            active_snapshot_to_restore["sub_state"] = (
                active_snapshot_to_restore.get("sub_state") or "Restored"
            )
            # NOTE: We disable dynamic min duration enforcement on recovery since we
            # might have missed data
            active_snapshot_to_restore["dynamic_min_duration"] = None

        # FALLBACK: Resurrection Logic
        if not should_restore:
            past_cycles = self.profile_store.get_past_cycles()
            if past_cycles:
                last_cycle = past_cycles[-1]
                last_end_str = last_cycle.get("end_time")
                if last_end_str:
                    last_end = dt_util.parse_datetime(last_end_str)
                    if last_end:
                        gap = (dt_util.now() - last_end).total_seconds()
                        is_recent = gap < 1200  # 20 mins
                        status = last_cycle.get("status")

                        if is_recent and status != "completed":
                            self._logger.info(
                                "Found recent interrupted cycle in history "
                                "(id=%s, gap=%.0fs). Resurrecting...",
                                last_cycle["id"],
                                gap,
                            )
                            try:
                                power_data = decompress_power_data(last_cycle)
                                if power_data:
                                    # decompress_power_data returns (offset_seconds, watts)
                                    # tuples, but restore_state_snapshot parses reading[0]
                                    # as an ISO datetime. Convert offsets to absolute ISO
                                    # timestamps (base = cycle start) so the resurrected
                                    # trace is not silently dropped (B4).
                                    _res_start = dt_util.parse_datetime(
                                        last_cycle["start_time"]
                                    )
                                    if _res_start is not None:
                                        if _res_start.tzinfo is None:
                                            _res_start = _res_start.replace(
                                                tzinfo=dt_util.now().tzinfo
                                            )
                                        power_readings = [
                                            (
                                                (
                                                    _res_start
                                                    + timedelta(seconds=float(off))
                                                ).isoformat(),
                                                p,
                                            )
                                            for off, p in power_data
                                        ]
                                    else:
                                        power_readings = power_data
                                    active_snapshot_to_restore = {
                                        # Reconstruct basic running state
                                        "state": "running",
                                        "sub_state": "Resurrected",
                                        "current_cycle_start": last_cycle["start_time"],
                                        "last_active_time": last_cycle["end_time"],
                                        "low_power_start": None,
                                        "cycle_max_power": (
                                            max([p for _, p in power_data])
                                            if power_data
                                            else 0
                                        ),
                                        "power_readings": power_readings,
                                        "ma_buffer": (
                                            [p for _, p in power_data[-10:]]
                                            if power_data
                                            else []
                                        ),
                                        "end_condition_count": 0,
                                        "extension_count": 0,
                                        "dynamic_min_duration": None,
                                        "matched_profile": last_cycle.get(
                                            "profile_name"
                                        ),
                                    }
                                    should_restore = True
                                    past_cycles.pop()
                                    await self.profile_store.async_save()
                            except Exception as e:
                                self._logger.error("Failed to resurrect cycle: %s", e)

        if should_restore and active_snapshot_to_restore:
            try:
                # A cycle paused by the user while still in STARTING is promoted
                # to PAUSED before restoration so that (a) the false-start abort
                # cannot fire on the first low-power reading, and (b) the PAUSED
                # branch of the restore block below re-applies the user-pause state
                # and re-asserts verified_pause (issue #306).
                if (
                    active_snapshot_to_restore.get("state") == STATE_STARTING
                    and active_snapshot_to_restore.get("is_user_paused")
                ):
                    active_snapshot_to_restore = {
                        **active_snapshot_to_restore,
                        "state": STATE_PAUSED,
                    }

                self.detector.restore_state_snapshot(active_snapshot_to_restore)

                # Anti-wrinkle keepalive anchor (#339). The keepalive in
                # _handle_state_expiry needs a "sensor last spoke" timestamp, but
                # _last_real_reading_time is only ever set by a live reading, so a
                # restart into ANTI_WRINKLE with an already-silent plug leaves it
                # None and the keepalive can never fire -- pinning the mode until
                # the next cycle. The snapshot save is driven by real readings, so
                # last_save is the best available proxy. Scoped to ANTI_WRINKLE so
                # no other timer sees a synthetic anchor.
                if self.detector.state == STATE_ANTI_WRINKLE and last_save:
                    self._last_real_reading_time = last_save

                # Restore if in any active state (Running, Paused, Ending)
                if self.detector.state in (STATE_RUNNING, STATE_PAUSED, STATE_ENDING):
                    # Restore manual program flag if present
                    self._manual_program_active = active_snapshot_to_restore.get(
                        "manual_program", False
                    )

                    # Restore the external energy-meter snapshot (issue #316) so a
                    # restart mid-cycle keeps an accurate start->end delta.
                    self._energy_meter_start = active_snapshot_to_restore.get(
                        "energy_meter_start"
                    )
                    self._energy_meter_source = active_snapshot_to_restore.get(
                        "energy_meter_source"
                    )

                    # If we restored into a low-power state, ensure we don't
                    # immediately quit. For now we just log this; the cycle
                    # detector's off_delay will handle actual shutdown.
                    if power_is_valid and current_power < self._config.min_power:
                        self._logger.debug(
                            "Restored active cycle in low-power state "
                            "(power=%.2fW < min_power=%.2fW); waiting for "
                            "detector off_delay before marking as finished",
                            current_power,
                            self._config.min_power,
                        )

                    if self.detector.matched_profile:
                        self._current_program = self.detector.matched_profile
                        self._logger.info(
                            "Restored/Resurrected washer cycle with profile: %s",
                            self._current_program,
                        )
                    else:
                        self._current_program = "detecting..."
                    
                    # Restore persisted start-notification/event flags from snapshot.
                    self._notified_start = bool(
                        active_snapshot_to_restore.get("notified_start", False)
                    )
                    self._start_event_fired = bool(
                        active_snapshot_to_restore.get("start_event_fired", False)
                    )

                    # Restore user-pause state from snapshot.
                    self._is_user_paused = bool(
                        active_snapshot_to_restore.get("is_user_paused", False)
                    )
                    _pause_start_raw = active_snapshot_to_restore.get("user_pause_start")
                    self._user_pause_start = (
                        dt_util.parse_datetime(_pause_start_raw)
                        if isinstance(_pause_start_raw, str) and _pause_start_raw
                        else None
                    )
                    self._total_user_paused_seconds = float(
                        active_snapshot_to_restore.get("total_user_paused_seconds", 0.0)
                    )
                    # get_state_snapshot() does not persist the detector's
                    # verified-pause flag, so a user-paused cycle would otherwise be
                    # finalized on the first ENDING timeout after a restart (issue
                    # #306).  Re-assert it so the pause survives the reload.
                    if self._is_user_paused:
                        self.detector.set_verified_pause(True)

                    # Auto-open dishwasher: arm the dwell if we restored into ENDING
                    # with the door already open. _on_state_change is not called
                    # during restoration, so the transition guard there would not fire.
                    self._maybe_arm_door_end_dwell_if_open()

                    # Record the restart gap so the Cycles tab can shade it and
                    # anomaly detection can surface it.  Only meaningful when
                    # last_save is known and the dark period exceeds 30 s.
                    # Matching always uses real readings only (Option B from the
                    # gap-fill analysis); synthetic fill is intentionally NOT added
                    # to _power_readings to prevent circular-bias inflation.
                    if last_save:
                        gap_end = dt_util.now()
                        gap_secs = (gap_end - last_save).total_seconds()
                        if gap_secs > 30:
                            self._restart_gaps.append({
                                "start_ts": last_save.isoformat(),
                                "end_ts": gap_end.isoformat(),
                                "gap_seconds": round(gap_secs, 1),
                                "profile": self.detector.matched_profile,
                                "match_confidence": getattr(
                                    self.detector, "match_confidence", None
                                ),
                            })
                            self._logger.info(
                                "HA restart gap recorded: %.0fs (%.1f min) in active cycle; "
                                "power trace has a hole — no synthetic fill (matching integrity)",
                                gap_secs,
                                gap_secs / 60,
                            )

                    self._start_watchdog()
                else:
                    await self.profile_store.async_clear_active_cycle()
            except Exception as err:
                self._logger.warning("Failed to restore active cycle: %s, clearing", err)
                await self.profile_store.async_clear_active_cycle()
        else:
            if last_save:
                age = (dt_util.now() - last_save).total_seconds()
                self._logger.info("Active cycle too stale (age=%.0fs), clearing", age)
            await self.profile_store.async_clear_active_cycle()

    async def async_setup(self) -> None:
        """Set up the manager."""
        await self.profile_store.async_load()
        try:
            _trans = await translation.async_get_translations(
                self.hass, self.hass.config.language, "options", {DOMAIN}
            )
            # Cache of manager-side fixed UI-string templates resolved from the
            # options.error.* translation namespace (timer notifications, the
            # duration-vs-typical finish variable, and the live "waiting" message).
            # The inline English mirrors the strings.json values so the fallback is
            # never the sole source and the code stays behaviour-identical in English.
            self._timer_ui_strings = {
                k: _trans.get(f"component.{DOMAIN}.options.error.{k}", v)
                for k, v in {
                    "timer_default_message": "{device}: {minutes} min timer",
                    "timer_pause_action_title": "Resume Cycle",
                    "timer_pause_body_suffix": "The cycle is paused. Open the WashData panel to resume.",
                    "unload_dismiss_action_title": "Stop reminding",
                    "vs_typical_longer": "{pct}% longer than usual",
                    "vs_typical_shorter": "{pct}% shorter than usual",
                    "notify_live_waiting_message": "{device}: No profile matched yet.",
                }.items()
            }
        except Exception:  # noqa: BLE001
            pass
        # Apply configurable duration tolerance to profile store
        try:
            self.profile_store.set_duration_tolerance(self._profile_duration_tolerance)
            self.profile_store.set_retention_limits(
                max_past_cycles=int(
                    self.config_entry.options.get(
                        CONF_MAX_PAST_CYCLES, DEFAULT_MAX_PAST_CYCLES
                    )
                ),
                max_full_traces_per_profile=int(
                    self.config_entry.options.get(
                        CONF_MAX_FULL_TRACES_PER_PROFILE,
                        DEFAULT_MAX_FULL_TRACES_PER_PROFILE,
                    )
                ),
                max_full_traces_unlabeled=int(
                    self.config_entry.options.get(
                        CONF_MAX_FULL_TRACES_UNLABELED,
                        DEFAULT_MAX_FULL_TRACES_UNLABELED,
                    )
                ),
            )
        except Exception:
            pass

        # Repair broken sample_cycle_id references (can happen after aggressive retention)
        try:
            stats = await self.profile_store.async_repair_profile_samples()
            self._profile_sample_repair_stats = stats
            if stats.get("profiles_repaired", 0) or stats.get(
                "cycles_labeled_as_sample", 0
            ):
                self._logger.warning(
                    "Repaired profile sample references for %s: %s",
                    self.entry_id,
                    stats,
                )
                await self.profile_store.async_save()
        except Exception:
            self._logger.exception(
                "Failed repairing profile sample references for %s", self.entry_id
            )

        # Subscribe to power sensor updates (state changes AND unchanged re-reports)
        self._subscribe_power_sensor()

        # Attempt to restore state (BEFORE starting listener)
        await self._attempt_state_restoration()

        # Restore last cycle end time to ensure ghost cycle suppression works after restart
        try:
            cycles = self.profile_store.get_past_cycles()
            if cycles:
                # Find last completed cycle with a valid end time
                for cycle in reversed(cycles):
                    if cycle.get("end_time") and cycle.get("status") == "completed":
                        ts = dt_util.parse_datetime(cycle["end_time"])
                        if ts:
                            self._last_cycle_end_time = ts
                            self._logger.debug("Restored last cycle end time: %s", ts)
                            break
        except Exception:  # pylint: disable=broad-exception-caught
            self._logger.debug("Failed to restore last cycle end time")

        # Load recorder state
        await self.recorder.async_load()

        # Force initial update from current state (in case it's already stable)
        state = self.hass.states.get(self.power_sensor_entity_id)
        if state and state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
            try:
                power = float(state.state)
                now = dt_util.now()
                self.detector.process_reading(power, now)
            except (ValueError, TypeError):
                pass

        # Trigger migration/compression of old cycle format
        # This is safe to run repeatedly (it skips already compressed cycles)
        await self.profile_store.async_migrate_cycles_to_compressed()

        # Backfill match_confidence for labeled cycles that predate the field
        self.hass.async_create_task(
            self.profile_store.async_backfill_match_confidence()
        )

        # Subscribe to external cycle end trigger (if enabled)
        await self._setup_external_end_trigger()

        # Subscribe to door sensor (if configured)
        await self._setup_door_sensor_listener()

        # Subscribe to person presence changes for notification gating
        await self._setup_notify_people_listener()

        # Register schedulers (maintenance + ML training). These are also re-
        # registered on every config reload; calling them here ensures they
        # survive HA restarts without requiring the user to re-save settings.
        await self._setup_maintenance_scheduler()
        self._setup_ml_training_scheduler()

    def _load_notify_services(self, config_entry: ConfigEntry) -> None:
        """Load notification service lists, migrating legacy single-service config."""
        self._notify_start_services = list(config_entry.options.get(CONF_NOTIFY_START_SERVICES, []) or [])
        self._notify_finish_services = list(config_entry.options.get(CONF_NOTIFY_FINISH_SERVICES, []) or [])
        self._notify_live_services = list(config_entry.options.get(CONF_NOTIFY_LIVE_SERVICES, []) or [])
        raw_timers = config_entry.options.get(CONF_NOTIFY_CYCLE_TIMERS, []) or []
        self._notify_cycle_timers = [
            t for t in raw_timers
            if isinstance(t, dict) and isinstance(t.get("offset_minutes"), (int, float)) and t["offset_minutes"] > 0
        ]
        # Backward compat: migrate old single notify_service + notify_events to new per-event lists
        if not (self._notify_start_services or self._notify_finish_services or self._notify_live_services):
            _old_svc = config_entry.options.get(CONF_NOTIFY_SERVICE, "")
            _old_events = list(config_entry.options.get(CONF_NOTIFY_EVENTS, []) or [])
            if _old_svc:
                if not _old_events or NOTIFY_EVENT_START in _old_events:
                    self._notify_start_services = [_old_svc]
                if not _old_events or NOTIFY_EVENT_FINISH in _old_events:
                    self._notify_finish_services = [_old_svc]
                if not _old_events or NOTIFY_EVENT_LIVE in _old_events:
                    self._notify_live_services = [_old_svc]

    async def async_reload_config(self, config_entry: ConfigEntry) -> None:
        """
        Reload configuration options without interrupting running cycle detection.

        Updates detector config in-place.
        Handles Power Sensor entity change by reconnecting listener.
        """
        self._logger.info("Reloading configuration for %s", self.entry_id)
        # Replace reference
        self.config_entry = config_entry

        # Check if power sensor changed
        new_sensor = config_entry.options.get(
            CONF_POWER_SENSOR, config_entry.data.get(CONF_POWER_SENSOR)
        )
        if new_sensor and new_sensor != self.power_sensor_entity_id:
            # Block sensor changes when a cycle is active to prevent inconsistent state
            d_state = self.detector.state
            self._logger.debug(
                "Reloading config: detector.state=%r (type=%s), RUNNING=%r",
                d_state,
                type(d_state),
                STATE_RUNNING,
            )
            if d_state in _SENSOR_SWAP_BLOCKED_STATES:
                # Skip the sensor change but continue with the other config
                # updates: returning here would silently drop every setting
                # saved alongside the sensor in the same submission.
                self._logger.warning(
                    "Cannot change power sensor from %s to %s while the "
                    "detector is in state %s. Please wait for the current "
                    "cycle to complete before changing the power sensor.",
                    self.power_sensor_entity_id,
                    new_sensor,
                    d_state,
                )
            else:
                self._logger.info(
                    "Power sensor changed: %s -> %s", self.power_sensor_entity_id, new_sensor
                )
                self.power_sensor_entity_id = new_sensor
                # Re-attach change + report listeners to the new sensor
                # (helper removes the old ones first).
                self._subscribe_power_sensor()
                # Force update from new sensor
                state = self.hass.states.get(self.power_sensor_entity_id)
                if state and state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
                    try:
                        power = float(state.state)
                        self.detector.process_reading(power, dt_util.now())
                    except ValueError:
                        self._logger.debug(
                            "Initial power value for %s after config reload is not numeric: %r",
                            self.power_sensor_entity_id,
                            state.state,
                        )

        # Update device type
        self.device_type = config_entry.options.get(
            CONF_DEVICE_TYPE,
            config_entry.data.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE),
        )
        # Propagate to learning pipeline (captured at construction time)
        self.learning_manager.device_type = self.device_type
        self.learning_manager.suggestion_engine.device_type = self.device_type

        # Update detector config in-place
        old_min_power = self.detector.config.min_power
        old_off_delay = self.detector.config.off_delay
        old_smoothing = self.detector.config.smoothing_window
        old_interrupted_min = self.detector.config.interrupted_min_seconds

        # Get new values from config
        new_min_power = float(
            config_entry.options.get(CONF_MIN_POWER, DEFAULT_MIN_POWER)
        )
        new_off_delay = int(config_entry.options.get(CONF_OFF_DELAY, DEFAULT_OFF_DELAY))
        new_smoothing = int(
            config_entry.options.get(CONF_SMOOTHING_WINDOW, DEFAULT_SMOOTHING_WINDOW)
        )
        new_interrupted_min = int(
            config_entry.options.get(
                CONF_INTERRUPTED_MIN_SECONDS, DEFAULT_INTERRUPTED_MIN_SECONDS
            )
        )
        self.detector.config.match_interval = int(
            config_entry.options.get(
                CONF_PROFILE_MATCH_INTERVAL, DEFAULT_PROFILE_MATCH_INTERVAL
            )
        )
        # Keep the detector's copy in sync so a panel edit takes effect without a
        # restart, exactly like min_duration_ratio below.
        self.detector.config.match_confidence_threshold = float(
            config_entry.options.get(
                CONF_PROFILE_MATCH_THRESHOLD, DEFAULT_PROFILE_MATCH_THRESHOLD
            )
        )
        self.profile_store.dtw_bandwidth = float(
            config_entry.options.get(CONF_DTW_BANDWIDTH, DEFAULT_DTW_BANDWIDTH)
        )
        # Stage-4 energy discriminator: integrated energy for WM/washer-dryer,
        # mean power elsewhere (see analysis.stage4_energy_mode).
        self.profile_store.energy_mode = analysis.stage4_energy_mode(self.device_type)
        # Which cycle categories may shape a profile. Changing it changes every
        # profile's curve, so rebuild them all now: envelopes are otherwise only rebuilt
        # on a cycle end or a label change, so the user would tick the box and see
        # nothing happen for days.
        _prev_evidence = self.profile_store.evidence_sources
        self.profile_store.evidence_sources = config_entry.options.get(
            CONF_PROFILE_EVIDENCE_SOURCES, DEFAULT_PROFILE_EVIDENCE_SOURCES
        )
        if self.profile_store.evidence_sources != _prev_evidence:
            self._logger.info(
                "Profile evidence sources changed %s -> %s; rebuilding all envelopes",
                list(_prev_evidence), list(self.profile_store.evidence_sources),
            )
            # Tracked, not fire-and-forget: this writes to the ProfileStore, so a
            # reload/unload mid-rebuild must be able to cancel it before the store is
            # swapped out (see _spawn_tracked).
            self._spawn_tracked(self.profile_store.async_rebuild_all_envelopes())

        # Device default
        dev_def = DEVICE_COMPLETION_THRESHOLDS.get(
            self.device_type, DEFAULT_COMPLETION_MIN_SECONDS
        )
        new_completion_min = int(
            config_entry.options.get(CONF_COMPLETION_MIN_SECONDS, dev_def)
        )

        new_start_threshold = float(
            config_entry.options.get(
                CONF_START_DURATION_THRESHOLD,
                resolve_start_duration_default(self.device_type),
            )
        )
        new_end_repeat_count = int(
            config_entry.options.get(CONF_END_REPEAT_COUNT, DEFAULT_END_REPEAT_COUNT)
        )

        # Power Hysteresis Thresholds
        new_start_threshold_w = float(
            config_entry.options.get(
                CONF_START_THRESHOLD_W,
                float(new_min_power) + max(1.0, 0.1 * float(new_min_power)),
            )
        )
        new_stop_threshold_w = float(
            config_entry.options.get(
                CONF_STOP_THRESHOLD_W,
                max(0.0, float(new_min_power) - max(0.5, 0.1 * float(new_min_power))),
            )
        )
        new_power_off_threshold_w = float(
            config_entry.options.get(
                CONF_POWER_OFF_THRESHOLD_W, DEFAULT_POWER_OFF_THRESHOLD_W
            )
        )
        new_power_off_delay = float(
            config_entry.options.get(CONF_POWER_OFF_DELAY, DEFAULT_POWER_OFF_DELAY)
        )

        new_start_energy = float(
            config_entry.options.get(
                CONF_START_ENERGY_THRESHOLD,
                DEFAULT_START_ENERGY_THRESHOLDS_BY_DEVICE.get(self.device_type, 0.2)
            )
        )
        new_end_energy = float(
            config_entry.options.get(CONF_END_ENERGY_THRESHOLD, DEFAULT_END_ENERGY_THRESHOLD)
        )

        new_anti_wrinkle_enabled = bool(
            config_entry.options.get(
                CONF_ANTI_WRINKLE_ENABLED, DEFAULT_ANTI_WRINKLE_ENABLED
            )
        )
        new_anti_wrinkle_max_power = float(
            config_entry.options.get(
                CONF_ANTI_WRINKLE_MAX_POWER, DEFAULT_ANTI_WRINKLE_MAX_POWER
            )
        )
        new_anti_wrinkle_max_duration = float(
            config_entry.options.get(
                CONF_ANTI_WRINKLE_MAX_DURATION, DEFAULT_ANTI_WRINKLE_MAX_DURATION
            )
        )
        new_anti_wrinkle_exit_power = float(
            config_entry.options.get(
                CONF_ANTI_WRINKLE_EXIT_POWER, DEFAULT_ANTI_WRINKLE_EXIT_POWER
            )
        )
        new_anti_wrinkle_idle_timeout = float(
            config_entry.options.get(
                CONF_ANTI_WRINKLE_IDLE_TIMEOUT, DEFAULT_ANTI_WRINKLE_IDLE_TIMEOUT
            )
        )
        new_dishwasher_end_spike_quiet_release = float(
            config_entry.options.get(
                CONF_DISHWASHER_END_SPIKE_QUIET_RELEASE,
                DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS,
            )
        )
        new_smart_termination_duration_ratio = float(
            config_entry.options.get(
                CONF_SMART_TERMINATION_DURATION_RATIO,
                resolve_smart_termination_duration_ratio_default(self.device_type),
            )
        )
        new_delay_detect_enabled = bool(
            config_entry.options.get(
                CONF_DELAY_START_DETECT_ENABLED, DEFAULT_DELAY_START_DETECT_ENABLED
            )
        )
        new_delay_confirm_seconds = float(
            config_entry.options.get(
                CONF_DELAY_CONFIRM_SECONDS, DEFAULT_DELAY_CONFIRM_SECONDS
            )
        )
        new_delay_timeout_seconds = float(
            config_entry.options.get(
                CONF_DELAY_TIMEOUT_HOURS, DEFAULT_DELAY_TIMEOUT_HOURS
            )
        ) * 3600.0

        # Apply all detector config updates
        # #378: keep the detector's device_type in sync when the appliance type
        # is changed in the UI, so the correct detection branch runs after reload.
        self.detector.config.device_type = self.device_type
        self.detector.config.min_power = new_min_power
        self.detector.config.off_delay = new_off_delay
        self.detector.config.smoothing_window = new_smoothing
        self.detector.config.interrupted_min_seconds = new_interrupted_min
        self.detector.config.completion_min_seconds = new_completion_min
        self.detector.config.start_duration_threshold = new_start_threshold
        self.detector.config.end_repeat_count = new_end_repeat_count
        self.detector.config.start_threshold_w = new_start_threshold_w
        self.detector.config.stop_threshold_w = new_stop_threshold_w
        self.detector.config.power_off_threshold_w = new_power_off_threshold_w
        self.detector.config.power_off_delay = new_power_off_delay
        self.detector.config.start_energy_threshold = new_start_energy
        self.detector.config.end_energy_threshold = new_end_energy
        self.detector.config.anti_wrinkle_enabled = new_anti_wrinkle_enabled
        self.detector.config.anti_wrinkle_max_power = new_anti_wrinkle_max_power
        self.detector.config.anti_wrinkle_max_duration = new_anti_wrinkle_max_duration
        self.detector.config.anti_wrinkle_exit_power = new_anti_wrinkle_exit_power
        self.detector.config.anti_wrinkle_idle_timeout = new_anti_wrinkle_idle_timeout
        self.detector.config.dishwasher_end_spike_quiet_release = new_dishwasher_end_spike_quiet_release
        self.detector.config.smart_termination_duration_ratio = new_smart_termination_duration_ratio
        self.detector.config.delay_detect_enabled = new_delay_detect_enabled
        self.detector.config.delay_confirm_seconds = new_delay_confirm_seconds
        self.detector.config.delay_timeout_seconds = new_delay_timeout_seconds

        # Pump Monitor setting
        self._pump_stuck_duration = int(
            config_entry.options.get(CONF_PUMP_STUCK_DURATION, DEFAULT_PUMP_STUCK_DURATION)
        )

        if (
            old_min_power != new_min_power
            or old_off_delay != new_off_delay
            or old_smoothing != new_smoothing
            or old_interrupted_min != new_interrupted_min
        ):
            self._logger.info(
                "Updated detector config: min_power %.1fW->%.1fW, off_delay %ds->%ds, "
                "smoothing %d->%d, interrupted_min %ds->%ds",
                old_min_power,
                new_min_power,
                old_off_delay,
                new_off_delay,
                old_smoothing,
                new_smoothing,
                old_interrupted_min,
                new_interrupted_min,
            )

        # Update profile matching parameters
        old_min_ratio, old_max_ratio = self.profile_store.get_duration_ratio_limits()

        new_min_ratio = float(
            config_entry.options.get(
                CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
                DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO_BY_DEVICE.get(
                    self.device_type, DEFAULT_PROFILE_MATCH_MIN_DURATION_RATIO
                ),
            )
        )
        new_max_ratio = float(
            config_entry.options.get(
                CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
                DEFAULT_PROFILE_MATCH_MAX_DURATION_RATIO,
            )
        )

        if old_min_ratio != new_min_ratio or old_max_ratio != new_max_ratio:
            self.profile_store.set_duration_ratio_limits(
                min_ratio=new_min_ratio, max_ratio=new_max_ratio
            )
            # Keep the detector's copy in sync: _should_defer_finish() reads
            # detector.config.min_duration_ratio, which otherwise keeps the
            # construction-time value until a restart (diverging from the matcher).
            self.detector.config.min_duration_ratio = new_min_ratio
            self._logger.info(
                "Updated duration ratios: min %.2f→%.2f, max %.2f→%.2f",
                old_min_ratio,
                new_min_ratio,
                old_max_ratio,
                new_max_ratio,
            )

        # Update match interval
        old_interval = self._profile_match_interval
        new_interval = int(
            config_entry.options.get(
                CONF_PROFILE_MATCH_INTERVAL, DEFAULT_PROFILE_MATCH_INTERVAL
            )
        )
        if old_interval != new_interval:
            self._profile_match_interval = new_interval
            self._logger.info("Updated match interval: %ds→%ds", old_interval, new_interval)

        # Update other configurable options
        self._profile_duration_tolerance = float(
            config_entry.options.get(
                CONF_PROFILE_DURATION_TOLERANCE, DEFAULT_PROFILE_DURATION_TOLERANCE
            )
        )


        # Update notification settings
        self._load_notify_services(config_entry)
        self._notify_actions = list(
            cast(list[dict[str, Any]], config_entry.options.get(CONF_NOTIFY_ACTIONS, []) or [])
        )
        self._notify_script = None
        self._notify_people = list(
            config_entry.options.get(CONF_NOTIFY_PEOPLE, []) or []
        )
        self._notify_only_when_home = bool(
            config_entry.options.get(
                CONF_NOTIFY_ONLY_WHEN_HOME, DEFAULT_NOTIFY_ONLY_WHEN_HOME
            )
        )
        self._notify_fire_events = bool(
            config_entry.options.get(CONF_NOTIFY_FIRE_EVENTS, DEFAULT_NOTIFY_FIRE_EVENTS)
        )
        self._notify_before_end_minutes = int(
            config_entry.options.get(
                CONF_NOTIFY_BEFORE_END_MINUTES, DEFAULT_NOTIFY_BEFORE_END_MINUTES
            )
        )
        self._notify_live_interval_seconds = int(
            config_entry.options.get(
                CONF_NOTIFY_LIVE_INTERVAL_SECONDS,
                DEFAULT_NOTIFY_LIVE_INTERVAL_SECONDS,
            )
        )
        self._notify_live_overrun_percent = int(
            config_entry.options.get(
                CONF_NOTIFY_LIVE_OVERRUN_PERCENT,
                DEFAULT_NOTIFY_LIVE_OVERRUN_PERCENT,
            )
        )
        self._notify_live_chronometer = bool(
            config_entry.options.get(
                CONF_NOTIFY_LIVE_CHRONOMETER,
                DEFAULT_NOTIFY_LIVE_CHRONOMETER,
            )
        )
        self._notify_live_sticky = bool(
            config_entry.options.get(
                CONF_NOTIFY_LIVE_STICKY, DEFAULT_NOTIFY_LIVE_STICKY
            )
        )
        self._notify_live_click_action = str(
            config_entry.options.get(
                CONF_NOTIFY_LIVE_CLICK_ACTION, DEFAULT_NOTIFY_LIVE_CLICK_ACTION
            )
            or ""
        ).strip()
        self._notify_timeout_seconds = int(
            config_entry.options.get(
                CONF_NOTIFY_TIMEOUT_SECONDS, DEFAULT_NOTIFY_TIMEOUT_SECONDS
            )
        )

        # Reload door sensor / pause config
        self._pause_cuts_power = bool(config_entry.options.get(CONF_PAUSE_CUTS_POWER, False))
        self._door_sensor_entity = config_entry.options.get(CONF_DOOR_SENSOR_ENTITY) or None
        self._door_opens_at_end = bool(
            config_entry.options.get(CONF_DOOR_OPENS_AT_END, DEFAULT_DOOR_OPENS_AT_END)
        )
        self._door_end_dwell_seconds = int(
            config_entry.options.get(CONF_DOOR_END_DWELL_SECONDS, DEFAULT_DOOR_END_DWELL_SECONDS)
        )
        self._notify_unload_delay_minutes = int(
            config_entry.options.get(
                CONF_NOTIFY_UNLOAD_DELAY_MINUTES, DEFAULT_NOTIFY_UNLOAD_DELAY_MINUTES
            )
        )
        self._notify_unload_repeat = bool(
            config_entry.options.get(
                CONF_NOTIFY_UNLOAD_REPEAT, DEFAULT_NOTIFY_UNLOAD_REPEAT
            )
        )

        # Re-subscribe to external cycle end trigger
        await self._setup_external_end_trigger()

        # Re-subscribe to door sensor. Cancel any dwell armed for the previous door
        # config first — after a sensor/auto-open/dwell change the old sensor may no
        # longer emit the close event that cancels it, so a stale timer could finalize
        # the cycle on outdated config. Re-evaluate for the new configuration after.
        self._cancel_door_end_dwell()
        await self._setup_door_sensor_listener()
        self._maybe_arm_door_end_dwell_if_open()

        # Re-subscribe to person presence changes for notification gating
        await self._setup_notify_people_listener()

        # If a cycle is currently active and live notifications are now enabled,
        # reset counters and fire the first live notification immediately so the
        # user doesn't have to wait for the next power sensor poll.
        if self.detector.state in (STATE_RUNNING, STATE_PAUSED, STATE_ENDING):
            if self._notify_live_services or self._notify_actions:
                self._reset_live_notification_state()
                self._check_live_progress_notification()

        # Trigger entity updates to reflect any changes
        async_dispatcher_send(self.hass, f"ha_washdata_update_{self.entry_id}")

        if self.detector:
            self.detector.config.profile_duration_tolerance = self._profile_duration_tolerance

        # Schedule midnight maintenance if enabled
        await self._setup_maintenance_scheduler()

        # Schedule on-device ML retraining if enabled (Stage 4, gated)
        self._setup_ml_training_scheduler()

        # Update sampling interval
        old_sampling = self._sampling_interval
        new_sampling = float(
            config_entry.options.get(
                CONF_SAMPLING_INTERVAL,
                resolve_sampling_interval_default(self.device_type),
            )
        )
        if old_sampling != new_sampling:
            self._sampling_interval = new_sampling
            self._logger.info(
                "Updated sampling interval: %.1fs -> %.1fs", old_sampling, new_sampling
            )

        # Watchdog cadence: like sampling above, this was only read at construction, so a
        # changed CONF_WATCHDOG_INTERVAL (or a device-type change selecting a new default)
        # otherwise kept the old cadence until the manager was recreated. Re-arm an active
        # watchdog so the new interval takes effect mid-cycle.
        old_watchdog = self._watchdog_interval
        new_watchdog = int(
            config_entry.options.get(
                CONF_WATCHDOG_INTERVAL,
                resolve_watchdog_interval_default(self.device_type),
            )
        )
        if old_watchdog != new_watchdog:
            self._watchdog_interval = new_watchdog
            self._logger.info(
                "Updated watchdog interval: %ds -> %ds", old_watchdog, new_watchdog
            )
            if self._remove_watchdog:  # active cycle: cancel and re-register at the new cadence
                self._stop_watchdog()
                self._start_watchdog()

        # RESTORE STATE (only if recent enough, otherwise treat as stale)
        await self._attempt_state_restoration()

        self._logger.info("Configuration reloaded successfully")

    def _spawn_tracked(self, coro: Coroutine[Any, Any, Any]) -> Task[Any]:
        """Create a detached task and track it so shutdown can cancel it.

        Use for fire-and-forget tasks that touch the ProfileStore (matching
        trigger, active-cycle clear, post-cycle processing): if a reload/unload
        swaps the store out mid-flight, an untracked task would keep writing to the
        stale store. The task auto-removes itself from the set when it finishes.
        """
        task = self.hass.async_create_task(coro)
        # Real HA always returns a Task; guard for degenerate returns (e.g. a
        # mocked hass in tests) so tracking never breaks the caller.
        if task is not None and hasattr(task, "add_done_callback"):
            self._background_tasks.add(task)
            task.add_done_callback(self._background_tasks.discard)
        return task

    async def async_shutdown(self) -> None:
        """Shutdown."""
        self._is_shutdown = True
        # Cancel in-flight matching and cycle-end tasks so they don't race a
        # freshly-loaded ProfileStore on reload_config_entry.
        _to_await: list[Task[Any]] = []
        if self._matching_task and not self._matching_task.done():
            self._matching_task.cancel()
            _to_await.append(self._matching_task)
        if self._cycle_end_task and not self._cycle_end_task.done():
            self._cycle_end_task.cancel()
            _to_await.append(self._cycle_end_task)
        # Cancel every other tracked detached task (matching trigger, active-cycle
        # clear, post-cycle processing) for the same reason.
        for task in list(self._background_tasks):
            if not task.done():
                task.cancel()
                _to_await.append(task)
        # Drain cancelled tasks so they don't race the freshly-reloaded ProfileStore.
        if _to_await:
            await asyncio.gather(*_to_await, return_exceptions=True)
        if self._remove_listener:
            self._remove_listener()
        if self._remove_report_listener:
            self._remove_report_listener()
            self._remove_report_listener = None
        if self._remove_external_trigger_listener:
            self._remove_external_trigger_listener()
        if self._remove_door_sensor_listener:
            self._remove_door_sensor_listener()
            self._remove_door_sensor_listener = None
        self._cancel_door_end_dwell()
        # Drop the repeat-unload-reminder dismiss action listener (#374).
        self._remove_unload_dismiss_listener()
        if self._remove_notify_people_listener:
            self._remove_notify_people_listener()
            self._remove_notify_people_listener = None
            self._pending_notifications = []
        # Cancel any pending quiet-hours release timer so it doesn't fire after unload.
        self._cancel_quiet_hours_timer()
        self._quiet_pending_notifications = []
        # Cancel the power-off one-shot reset timer so it can't fire post-unload.
        self._cancel_power_off_timer()
        if self._remove_watchdog:
            self._remove_watchdog()
        if (
            hasattr(self, "_remove_state_expiry_timer")
            and self._remove_state_expiry_timer
        ):
            self._remove_state_expiry_timer()
        if self._remove_maintenance_scheduler:
            self._remove_maintenance_scheduler()
        if self._remove_ml_training_scheduler:
            self._remove_ml_training_scheduler()
            self._remove_ml_training_scheduler = None

        self.diag_buffer.uninstall()

        # Dismiss the timer-pause notification so it doesn't linger on mobile or
        # sidebar after HA restarts / integration unloads.
        try:
            self._clear_timer_pause_notification()
        except Exception:  # noqa: BLE001
            pass

        # Dismiss any active live/progress notification so it doesn't linger on
        # mobile devices across HA restarts or integration unloads with a stale
        # (and eventually negative) chronometer.
        try:
            self._clear_live_progress_notification()
        except Exception:  # noqa: BLE001
            self._logger.debug("Failed to clear live notification on shutdown", exc_info=True)

        # Save active state before shutdown
        if self.detector.state in {STATE_RUNNING, STATE_PAUSED, STATE_STARTING, STATE_ENDING}:
            snapshot = self._augment_active_snapshot(self.detector.get_state_snapshot())
            await self.profile_store.async_save_active_cycle(snapshot)

        self._last_reading_time = None

    async def _setup_external_end_trigger(self) -> None:
        """Set up listener for external cycle end trigger binary sensor."""
        # Remove existing listener if any
        if self._remove_external_trigger_listener:
            self._remove_external_trigger_listener()
            self._remove_external_trigger_listener = None

        # Check if enabled
        enabled = self.config_entry.options.get(
            CONF_EXTERNAL_END_TRIGGER_ENABLED, False
        )
        if not enabled:
            self._logger.debug("External cycle end trigger is disabled")
            return

        # Get entity ID
        entity_id = self.config_entry.options.get(CONF_EXTERNAL_END_TRIGGER, "")
        if not entity_id:
            self._logger.debug("External cycle end trigger: no entity configured")
            return

        self._logger.info(
            "Setting up external cycle end trigger: %s", entity_id
        )

        # Subscribe to state changes
        self._remove_external_trigger_listener = async_track_state_change_event(
            self.hass, [entity_id], self._handle_external_trigger_change
        )

    async def _setup_door_sensor_listener(self) -> None:
        """Set up listener for optional door sensor binary sensor."""
        if self._remove_door_sensor_listener:
            self._remove_door_sensor_listener()
            self._remove_door_sensor_listener = None

        entity_id = self._door_sensor_entity
        if not entity_id:
            self._logger.debug("Door sensor not configured")
            return

        self._logger.info("Setting up door sensor listener: %s", entity_id)
        self._remove_door_sensor_listener = async_track_state_change_event(
            self.hass, [entity_id], self._handle_door_sensor_change
        )

    @callback
    def _handle_door_sensor_change(self, event: Event[evt.EventStateChangedData]) -> None:
        """Handle door sensor state changes.

        Opening the door during an active cycle confirms an intentional pause (verified_pause).
        Opening the door after a cycle clears the 'Clean' state.
        Note: door closing does NOT auto-resume a cycle - the user must do this explicitly.
        """
        new_state = event.data.get("new_state")
        old_state = event.data.get("old_state")

        if new_state is None:
            return

        new_val = new_state.state
        old_val = old_state.state if old_state else None

        # Ignore unavailability transitions
        if new_val in ("unavailable", "unknown") or (
            old_val in ("unavailable", "unknown")
        ):
            return

        door_open = new_val == "on"  # binary_sensor: on = open

        if door_open:
            if self._is_clean_state:
                # User opened the door after the cycle - laundry retrieved
                self._logger.debug("Door opened: clearing Clean state")
                self._is_clean_state = False
                self._clean_state_start = None
                self._notified_clean_laundry = False
                self._reset_unload_nag_tracking()
                # Dismiss a delivered clean reminder (and purge any queued ones)
                # so it does not linger on the phone after the laundry is taken.
                self._clear_clean_notification()
                self._notify_update()
            elif (
                self._door_opens_at_end
                and self.detector.state in (STATE_RUNNING, STATE_ENDING)
            ):
                # Auto-open dishwasher (#342): the machine pops its door at the end.
                # A sustained open means the cycle finished; a brief open (adding an
                # item) does not. Arm a dwell timer instead of the sticky user-pause
                # (which would strand the cycle in user_paused). If the door stays
                # open past the dwell we finalize; if it closes first we cancel.
                self._logger.debug(
                    "Door opened on auto-open device: arming %ss end dwell",
                    self._door_end_dwell_seconds,
                )
                self._arm_door_end_dwell()
                self._notify_update()
            elif self.detector.state in (STATE_RUNNING, STATE_STARTING, STATE_PAUSED, STATE_ENDING):
                # Door opened during active cycle → soft pause confirmation
                self._logger.debug(
                    "Door opened during active cycle: setting verified_pause=True"
                )
                self.detector.set_verified_pause(True)
                if not self._is_user_paused:
                    self._is_user_paused = True
                    self._user_pause_start = dt_util.now()
                self._notify_update()
        else:
            # Door closed: cancel a pending auto-open finalize (it was a brief open,
            # not the end-of-cycle door pop). No auto-resume otherwise (#342).
            if self._remove_door_end_dwell is not None:
                self._logger.debug("Door closed before end dwell: cancelling finalize")
                self._cancel_door_end_dwell()
                self._notify_update()

    def _maybe_arm_door_end_dwell_if_open(self) -> None:
        """Arm the end-dwell timer when in RUNNING or ENDING with the door already open.

        Called from both ``_on_state_change`` (live transition) and the
        snapshot-restoration path (where ``_on_state_change`` is not invoked).
        Matches the state set accepted by ``_handle_door_sensor_change`` so that
        restoring a RUNNING snapshot with the door already open re-arms the dwell.
        """
        if not (
            self.detector.state in (STATE_RUNNING, STATE_ENDING)
            and self._door_opens_at_end
            and self._door_sensor_entity
            and self._remove_door_end_dwell is None
        ):
            return
        door_state = self.hass.states.get(self._door_sensor_entity)
        if door_state and door_state.state == "on":
            self._logger.debug(
                "Door already open on ENDING: arming %ss end dwell",
                self._door_end_dwell_seconds,
            )
            self._arm_door_end_dwell()

    def _arm_door_end_dwell(self) -> None:
        """(Re)arm the auto-open door-end dwell timer (#342)."""
        self._cancel_door_end_dwell()
        self._remove_door_end_dwell = async_call_later(
            self.hass,
            float(max(1, self._door_end_dwell_seconds)),
            self._door_end_dwell_fired,
        )

    def _cancel_door_end_dwell(self) -> None:
        """Cancel a pending auto-open door-end dwell timer, if any (#342)."""
        if self._remove_door_end_dwell is not None:
            self._remove_door_end_dwell()
            self._remove_door_end_dwell = None

    @callback
    def _door_end_dwell_fired(self, _now: Any) -> None:
        """The door stayed open past the dwell on an auto-open device: the cycle has
        finished, so finalize it as completed (same path as the External End
        Trigger). The dwell is normally cancelled on door-close, but re-validate the
        live conditions here defensively — a close event could have been missed, or a
        user pause could have landed mid-dwell — before finalizing (#342)."""
        self._remove_door_end_dwell = None
        door_state = (
            self.hass.states.get(self._door_sensor_entity)
            if self._door_sensor_entity
            else None
        )
        if (
            self.detector.state in (STATE_RUNNING, STATE_ENDING)
            and self._door_opens_at_end
            and not self._is_user_paused
            and door_state is not None
            and door_state.state == "on"
        ):
            # The end-of-cycle door pop follows the power drop, so an appliance still
            # drawing above the stop threshold means the door was opened mid-cycle
            # (loading a dish and walking off) rather than at the end - finalizing
            # there would record a running cycle as completed.
            #
            # Re-arm rather than abandon: the dwell is one-shot, so dropping it here
            # would permanently lose the door-based finalize for a machine that just
            # happens to be mid-pulse when the timer lands (fan/zeolite drying can
            # draw with the door already popped), leaving the cycle to the power
            # timeout that #342 exists to short-circuit. Re-arming is asymmetric: it
            # can only ever delay the finalize, never skip it.
            stop_thr = 0.0
            try:
                stop_thr = float(getattr(self.detector.config, "stop_threshold_w", 0.0) or 0.0)
            except (TypeError, ValueError):
                stop_thr = 0.0
            if stop_thr > 0.0 and self._current_power >= stop_thr:
                self._logger.debug(
                    "Door-end dwell fired but power %.1fW is still at/above the stop "
                    "threshold %.1fW: treating as a mid-cycle door open, re-arming",
                    self._current_power,
                    stop_thr,
                )
                self._arm_door_end_dwell()
                return
            self._logger.info(
                "Door held open %ss on auto-open device: finalizing cycle",
                self._door_end_dwell_seconds,
            )
            self.detector.user_stop()
            self._notify_update()
        else:
            self._logger.debug(
                "Door-end dwell fired but conditions no longer hold "
                "(state=%s, door=%s, user_paused=%s): not finalizing",
                self.detector.state,
                getattr(door_state, "state", None),
                self._is_user_paused,
            )

    async def _setup_notify_people_listener(self) -> None:
        """Set up listener for person presence changes used by notification gating."""
        if self._remove_notify_people_listener:
            self._remove_notify_people_listener()
            self._remove_notify_people_listener = None

        if self._notify_only_when_home and self._notify_people:
            self._remove_notify_people_listener = async_track_state_change_event(
                self.hass, self._notify_people, self._handle_notify_person_change
            )
            # If someone is already home when (re-)attaching, flush any queued
            # notifications immediately so they aren't stranded.
            if self._pending_notifications and self._is_any_notify_person_home():
                person_entity_id: str | None = None
                person_name: str | None = None
                for eid in self._notify_people:
                    state = self.hass.states.get(eid)
                    if state and state.state == STATE_HOME:
                        person_entity_id = eid
                        person_name = state.name or state.attributes.get(
                            "friendly_name", eid
                        )
                        break
                pending = list(self._pending_notifications)
                self._pending_notifications = []
                for entry in pending:
                    self._dispatch_notification(
                        entry["message"],
                        title=entry.get("title"),
                        icon=entry.get("icon"),
                        event_type=entry.get("event_type"),
                        person_entity_id=person_entity_id,
                        person_name=person_name,
                        extra_vars=entry.get("extra_vars"),
                        allow_deferral=False,
                        allow_presence_deferral=False,
                    )
        else:
            self._pending_notifications = []

    @callback
    def _handle_external_trigger_change(self, event: Event[evt.EventStateChangedData]) -> None:
        """Handle external trigger sensor state change."""
        new_state = event.data.get("new_state")
        old_state = event.data.get("old_state")

        if new_state is None:
            return

        inverted = self.config_entry.options.get(
            CONF_EXTERNAL_END_TRIGGER_INVERTED, False
        )

        new_value = new_state.state
        old_value = old_state.state if old_state else None

        # Ignore unavailability/unknown transitions (reconnects, disconnects)
        if old_value is None or old_value in ("unavailable", "unknown") or new_value in (
            "unavailable",
            "unknown",
        ):
            return

        # Determine if triggered based on inversion setting
        triggered = False
        if not inverted:
            # Normal: Trigger on transition to "on"
            if new_value == "on" and old_value != "on":
                triggered = True
        else:
            # Inverted: Trigger on transition to "off"
            if new_value == "off" and old_value != "off":
                triggered = True

        if triggered:
            self._logger.info(
                "External cycle end trigger activated by %s (inverted=%s)",
                event.data.get("entity_id"),
                inverted
            )
            # End cycle with "completed" status (not interrupted)
            if self.detector.state in (STATE_ANTI_WRINKLE, STATE_DELAY_WAIT):
                self.detector.reset(STATE_OFF)
                self._logger.info("%s exited via external trigger", self.detector.state)
            elif self.detector.state != STATE_OFF:
                self.detector.user_stop()
                self._logger.info("Cycle completed via external trigger")

    async def _setup_maintenance_scheduler(self) -> None:
        """Set up daily maintenance task at midnight."""
        auto_maintenance = self.config_entry.options.get(
            CONF_AUTO_MAINTENANCE,
            self.config_entry.data.get(CONF_AUTO_MAINTENANCE, DEFAULT_AUTO_MAINTENANCE),
        )

        # Cancel existing scheduler if any
        if self._remove_maintenance_scheduler:
            self._remove_maintenance_scheduler()
            self._remove_maintenance_scheduler = None

        if not auto_maintenance:
            self._logger.debug("Auto-maintenance disabled")
            return

        async def run_maintenance(_now: datetime | None = None) -> None:
            """Run maintenance task."""
            self._logger.info("Running scheduled maintenance")
            try:
                stats = await self.profile_store.async_run_maintenance()
                self._logger.info("Maintenance completed: %s", stats)
                # Refresh persisted cycle health as part of nightly maintenance.
                await self.async_recompute_cycle_health()
            except Exception as err:  # pylint: disable=broad-exception-caught
                self._logger.error("Maintenance failed: %s", err, exc_info=True)

        # Fire daily at local midnight with a single, cleanly-cancellable handle.
        # async_track_time_change auto-repeats every day, so there is no manual
        # rescheduling that could leak handles or double-register the callback.
        self._remove_maintenance_scheduler = evt.async_track_time_change(
            self.hass, run_maintenance, hour=0, minute=0, second=0
        )
        self._logger.info("Scheduled daily maintenance at local midnight")

    def _setup_ml_training_scheduler(self) -> None:
        """Schedule the daily on-device ML retraining (Stage 4, gated).

        Uses ``async_track_time_change`` which fires every day at the configured
        hour with a single, cleanly-cancellable handle (no manual rescheduling).
        No-op unless the ``ENABLE_ML_TRAINING`` build flag and the per-device
        opt-in are both set.
        """
        from .const import (
            ENABLE_ML_TRAINING,
            CONF_ML_TRAINING_ENABLED,
            CONF_ML_TRAINING_HOUR,
            DEFAULT_ML_TRAINING_ENABLED,
            DEFAULT_ML_TRAINING_HOUR,
        )

        if self._remove_ml_training_scheduler:
            self._remove_ml_training_scheduler()
            self._remove_ml_training_scheduler = None

        if not ENABLE_ML_TRAINING:
            return
        opts = {**self.config_entry.data, **self.config_entry.options}
        if not opts.get(CONF_ML_TRAINING_ENABLED, DEFAULT_ML_TRAINING_ENABLED):
            self._logger.debug("On-device ML training disabled")
            return

        try:
            hour = int(opts.get(CONF_ML_TRAINING_HOUR, DEFAULT_ML_TRAINING_HOUR))
        except (TypeError, ValueError):
            hour = DEFAULT_ML_TRAINING_HOUR
        hour = max(0, min(23, hour))

        async def _scheduled(_now: datetime) -> None:
            await self.async_run_ml_training(force=False)

        self._remove_ml_training_scheduler = evt.async_track_time_change(
            self.hass, _scheduled, hour=hour, minute=0, second=0
        )
        self._logger.info("Scheduled on-device ML training daily at %02d:00", hour)

    async def async_run_ml_training(self, force: bool = False) -> dict[str, Any]:
        """Retrain the ML models from this device's own cycles (gated + guarded).

        Returns a summary dict. ``force`` bypasses the min-cycle / interval /
        idle guards (used by the manual service). Never raises to the caller.
        """
        from .const import (
            ENABLE_ML_TRAINING,
            CONF_ML_TRAINING_MIN_CYCLES,
            CONF_ML_TRAINING_INTERVAL_DAYS,
            DEFAULT_ML_TRAINING_MIN_CYCLES,
            DEFAULT_ML_TRAINING_INTERVAL_DAYS,
            EVENT_ML_TRAINING_COMPLETE,
        )

        if not ENABLE_ML_TRAINING:
            return {"ok": False, "reason": "ml_training_disabled"}

        opts = {**self.config_entry.data, **self.config_entry.options}
        # Snapshot on the event loop before any executor offload (training +
        # matcher tuning): get_past_cycles() returns the live mutable list, so a
        # concurrent cycle add / retention trim could otherwise change the input
        # mid-run.
        cycles = list(self.profile_store.get_past_cycles())

        if not force:
            # Don't train mid-cycle; wait for a quiet moment.
            if self.detector and self.detector.state in {
                STATE_RUNNING, STATE_PAUSED, STATE_STARTING, STATE_ENDING
            }:
                self._logger.debug("Skipping scheduled ML training: device active")
                return {"ok": False, "reason": "device_active"}
            min_cycles = int(opts.get(CONF_ML_TRAINING_MIN_CYCLES, DEFAULT_ML_TRAINING_MIN_CYCLES))
            if len(cycles) < min_cycles:
                self._logger.debug(
                    "Skipping scheduled ML training: need %d cycles, have %d",
                    min_cycles,
                    len(cycles),
                )
                return {"ok": False, "reason": f"need {min_cycles} cycles, have {len(cycles)}"}
            # Respect the minimum retrain interval.
            interval_days = int(
                opts.get(CONF_ML_TRAINING_INTERVAL_DAYS, DEFAULT_ML_TRAINING_INTERVAL_DAYS)
            )
            last = self._last_ml_training_at()
            if last is not None:
                age_days = (dt_util.now() - last).total_seconds() / 86400.0
                if age_days < interval_days:
                    self._logger.debug(
                        "Skipping scheduled ML training: retrained %.1fd ago (<%dd)",
                        age_days,
                        interval_days,
                    )
                    return {"ok": False, "reason": f"retrained {age_days:.1f}d ago (<{interval_days}d)"}

        if self._ml_training_running:
            return {"ok": False, "reason": "already_running"}
        self._ml_training_running = True
        self.notify_update()
        try:
            from .ml.training_task import async_run_training

            summary = await async_run_training(self.hass, self)
        except Exception as err:  # noqa: BLE001 - training must never break the integration
            self._logger.error("On-device ML training failed: %s", err, exc_info=True)
            return {"ok": False, "reason": "exception", "error": str(err)}
        finally:
            self._ml_training_running = False
            self.notify_update()

        promoted = list(summary.get("promoted", {}).keys())
        if promoted:
            self._ml_training_failures = 0
            # Consumers (ML Lab, MLSuggestionEngine) read the trained specs live
            # from the store via ml.engine.resolve_scorer, so no refresh is needed.
            self._logger.info("On-device ML training promoted models: %s", promoted)
        else:
            self._ml_training_failures += 1
            self._logger.info(
                "On-device ML training produced no promotable models (attempt %d)",
                self._ml_training_failures,
            )

        # Stage 4/5: tune the matcher's scoring weights from this device's own
        # cycles (same held-out promotion discipline as the models). Independent
        # of model promotion; runs on every training pass.
        matching = await self._tune_matching_config(cycles)

        # Record that training *ran* now, regardless of whether anything was
        # promoted, so "Last trained" advances on every run (a run that doesn't
        # beat the baseline previously left the timestamp stuck at the last
        # promotion). Never let a persistence hiccup break the run.
        try:
            _run_iso = dt_util.now().isoformat()
            await self.profile_store.set_ml_last_training_run(_run_iso)
            # Track each capability's held-out score over time (drift/fit trend).
            await self.profile_store.append_ml_training_history(
                _run_iso, summary.get("results", [])
            )
        except Exception as err:  # noqa: BLE001
            self._logger.debug("Failed to persist last-training-run timestamp: %s", err)

        self.hass.bus.async_fire(
            EVENT_ML_TRAINING_COMPLETE,
            {
                "entry_id": self.entry_id,
                "device_name": self.config_entry.title,
                "promoted": promoted,
                "results": summary.get("results", []),
                "matching": matching,
            },
        )
        # A promoted model changes the health-model signature, so recompute the
        # persisted per-cycle health now rather than lazily on the next view.
        if promoted:
            try:
                await self.async_recompute_cycle_health()
            except Exception as err:  # noqa: BLE001
                self._logger.debug("Post-training health recompute failed: %s", err)

        return {
            "ok": True,
            "promoted": promoted,
            "results": summary.get("results", []),
            "matching": matching,
        }

    async def _tune_matching_config(self, cycles: list[dict[str, Any]]) -> dict[str, Any]:
        """Tune + (if it beats the shipped defaults on a held-out split) persist
        the matcher's scoring weights for this device. Executor-offloaded and
        never raises. Returns the tuner status dict for logging / the UI event.
        """
        try:
            from .ml.matching_tuner import tune_matching_config

            result = await self.hass.async_add_executor_job(
                tune_matching_config, cycles, self.device_type
            )
        except Exception as err:  # noqa: BLE001 - tuning must never break training
            self._logger.debug("Matching-config tuning failed: %s", err)
            return {"promoted": False, "reason": "exception", "error": str(err)}

        if result.get("promoted") and result.get("config"):
            record = {
                "config": result["config"],
                "trained_at": dt_util.now().isoformat(),
                "cycle_count": len(cycles),
                "baseline_test_top1": result.get("baseline_test_top1"),
                "tuned_test_top1": result.get("tuned_test_top1"),
            }
            await self.profile_store.set_matching_config(record)
            self._logger.info(
                "On-device matcher tuning promoted (top-1 %.3f -> %.3f): %s",
                result.get("baseline_test_top1") or 0.0,
                result.get("tuned_test_top1") or 0.0,
                result["config"],
            )
        else:
            self._logger.debug(
                "On-device matcher tuning not promoted: %s", result.get("reason")
            )
        return result

    async def async_recompute_cycle_health(self) -> int:
        """Recompute + persist per-cycle ML health against the current model.

        Health is cached on each cycle and only recomputed at defined triggers
        (this method): on-device retraining, scheduled auto-maintenance and the
        Diagnostics "Process History" action. Panel loads reuse the cache. Runs
        the CPU work in an executor and never raises to the caller.
        """
        import functools  # pylint: disable=import-outside-toplevel

        try:
            from .ws_api import _compute_ml_comparison  # pylint: disable=import-outside-toplevel
        except Exception:  # pylint: disable=broad-exception-caught
            return 0
        opts = {**self.config_entry.data, **self.config_entry.options}
        off_delay = int(opts.get(CONF_OFF_DELAY, DEFAULT_OFF_DELAY))
        try:
            result = await self.hass.async_add_executor_job(
                functools.partial(
                    _compute_ml_comparison, self.profile_store, off_delay, force_recompute=True
                )
            )
        except Exception as err:  # noqa: BLE001
            self._logger.debug("Cycle-health recompute failed: %s", err)
            return 0
        health_updates = result.get("_health_updates", {})
        if health_updates:
            for cycle in self.profile_store.get_past_cycles():
                cid = cycle.get("id")
                if cid in health_updates:
                    cycle["ml_health"] = health_updates[cid]
        if result.get("_health_dirty") or health_updates:
            await self.profile_store.async_save()
        return int(result.get("evaluated_count", 0))

    def _last_ml_training_at(self) -> datetime | None:
        """When on-device training last *ran* (not just last promoted a model).

        Prefers the persisted last-run timestamp so a manual/scheduled run that
        produced no promotable model still advances "Last trained" and the retrain
        interval. Falls back to the newest promoted model's ``trained_at`` for
        installs from before run-time tracking existed.
        """
        run_iso = self.profile_store.get_ml_last_training_run()
        if isinstance(run_iso, str):
            parsed = dt_util.parse_datetime(run_iso)
            if parsed is not None:
                return parsed
        latest: datetime | None = None
        for record in (self.profile_store.get_ml_model_versions() or {}).values():
            ts = record.get("trained_at") if isinstance(record, dict) else None
            if not isinstance(ts, str):
                continue
            try:
                parsed = dt_util.parse_datetime(ts)
            except (ValueError, TypeError):
                parsed = None
            if parsed is not None and (latest is None or parsed > latest):
                latest = parsed
        return latest

    @callback
    def _subscribe_power_sensor(self) -> None:
        """(Re)subscribe to the power sensor's state changes AND unchanged reports.

        HA fires EVENT_STATE_CHANGED only when the value (or attributes) change.
        A plug that periodically re-reports the same value (Tasmota TelePeriod,
        Zigbee max reporting interval) fires EVENT_STATE_REPORTED instead, which a
        state_changed-only subscription never receives. Missing those reports means
        a flat sub-threshold tail never advances the detector's end-of-cycle timer
        (#363) and a finished cycle lags by the plug's reporting interval (#329).

        Both events route into the same handler. Per HA, a single write fires either
        state_changed (value/attrs differ) or state_reported (unchanged), never both,
        so there is no double-counting. Report events carry ``new_state`` without an
        ``old_state``; ``_async_power_changed`` already tolerates a missing old_state.
        """
        if self._remove_listener:
            self._remove_listener()
        if self._remove_report_listener:
            self._remove_report_listener()
        self._remove_listener = async_track_state_change_event(
            self.hass, [self.power_sensor_entity_id], self._async_power_changed
        )
        self._remove_report_listener = async_track_state_report_event(
            self.hass, [self.power_sensor_entity_id], self._async_power_changed
        )

    @callback
    def _async_power_changed(self, event: Any) -> None:
        """Handle power sensor state change."""
        event_data = cast(dict[str, Any], getattr(event, "data", {}))
        new_state = cast(State | None, event_data.get("new_state"))
        if new_state is None or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
            return

        try:
            power = float(new_state.state)
        except ValueError:
            return

        # Capture every raw sensor reading before any throttling or processing.
        # Use the sensor's own report timestamp so the trace reflects when the plug
        # actually reported the value, not when we received it. last_reported
        # advances on every write (incl. unchanged re-reports #363), whereas
        # last_updated only advances on a value change, so an unchanged re-report
        # would otherwise be stamped with a stale time.
        report_ts = getattr(new_state, "last_reported", None) or new_state.last_updated
        self.diag_buffer.record_power(power, report_ts)

        # RECORD MODE INTERCEPTION
        if self.recorder.is_recording:
            self.recorder.process_reading(power)
            self._current_power = power
            self._last_reading_time = dt_util.now()
            self._notify_update()
            return

        now = dt_util.now()

        # Throttle updates to avoid CPU overload on noisy sensors.
        # Low-power readings bypass throttling when:
        #   (a) a cycle is active (RUNNING/ENDING/PAUSED) — critical end-of-cycle signal, or
        #   (b) this is a genuine power DROP from above min_power — captures power-off events
        #       that occur before the detector has processed the previous above-threshold reading.
        # Without the guard, an idle device at 0W fires an update on every sensor poll (typically
        # every 1–5 s), flooding the detector with zero-value no-ops.
        min_p = float(self.detector.config.min_power)
        # For the "genuine drop" bypass, compare against the previous RAW sensor
        # value (old_state), not _current_power: the latter is only updated after a
        # reading passes the throttle, so a suppressed high reading would leave it
        # low and the following low reading would be throttled too, missing a short
        # high->low transition. old_state reflects the plug's actual prior value.
        prev_raw_power = self._current_power
        old_state = cast(State | None, event_data.get("old_state"))
        if old_state is not None and old_state.state not in (
            STATE_UNKNOWN, STATE_UNAVAILABLE
        ):
            try:
                prev_raw_power = float(old_state.state)
            except ValueError:
                pass
        is_low_power = power < min_p and (
            self.detector.state in (STATE_RUNNING, STATE_PAUSED, STATE_ENDING)
            or prev_raw_power >= min_p  # genuine drop from active power
        )

        if (
            not is_low_power
            and self._last_reading_time
            and (now - self._last_reading_time).total_seconds() < self._sampling_interval
        ):
            return

        # Track observed power readings for learning - only while a cycle is
        # active (#394). An appliance is idle ~98% of the time; running the 5-min
        # auto-tune pass and training the sample-interval cadence model on the
        # standby heartbeat is constant background work (a store rewrite every few
        # minutes) that buys nothing AND skews every operational suggestion, since
        # the idle publish-on-change heartbeat is not the in-cycle sampling
        # cadence those suggestions are sized from. The detector below still
        # receives EVERY reading, so the next cycle's start is never missed - only
        # the learning call is gated.
        if self.detector.state in (
            STATE_STARTING,
            STATE_RUNNING,
            STATE_PAUSED,
            STATE_ENDING,
        ):
            self.learning_manager.process_power_reading(
                power, now, self._last_reading_time
            )
        self._last_reading_time = now
        self._last_real_reading_time = now # Track real update
        self._current_power = power
        self.detector.process_reading(power, now)

        if self._cycle_start_time is None and self.detector.current_cycle_start is not None:
            self._cycle_start_time = self.detector.current_cycle_start

        # If running (or paused/ending), try to match profile and update estimates
        if self.detector.state in (
            STATE_RUNNING,
            STATE_PAUSED,
            STATE_ENDING,
            STATE_STARTING,
        ):
            self._update_estimates()
            # Periodically save state every 60s to avoid flash wear
            # We need a tracker.
            self._check_state_save(now)

        self._notify_update()

    def _check_state_save(self, now: datetime) -> None:
        """Periodically save active state."""
        last_save = getattr(self, "_last_state_save", None)
        if not last_save or (now - last_save).total_seconds() > 60:
            # Fire and forget save task
            # Inject manual program flag into snapshot before saving
            snapshot = self._augment_active_snapshot(self.detector.get_state_snapshot())

            self.hass.async_create_task(
                self.profile_store.async_save_active_cycle(snapshot)
            )
            self._last_state_save = now

    async def _run_final_match_from_cycle_data(self, cycle_data: dict[str, Any]) -> None:
        """Run final profile match using the cycle's power data before it's saved.

        This is called from _on_cycle_end when _current_program is still 'detecting...'
        to ensure we try matching with complete cycle data before persistence.
        """
        # Cycle data from detector stores power_data as [[offset_seconds, power], ...],
        # where offsets are relative to cycle start.
        power_data = cycle_data.get("power_data", [])
        duration = cycle_data.get("duration", 0)

        if not power_data or len(power_data) < 10:
            self._logger.debug("Insufficient power data for final match (< 10 readings)")
            return

        # power_data is already in [[offset_seconds, power], ...] format for matching.
        self._logger.info(
            "Running final match from cycle data: %s samples, %.0fs duration",
            len(power_data),
            duration,
        )

        result = await self.profile_store.async_match_profile(power_data, duration)
        profile_name = result.best_profile
        confidence = result.confidence

        # Store result for debug data
        self._last_match_result = result

        # Accept match at lower threshold since cycle is complete
        # Also ignore ambiguity for completed cycles - pick the best match
        if profile_name and confidence >= 0.15:
            self._logger.info(
                "Final match from cycle data: '%s' with confidence %.3f",
                profile_name,
                confidence,
            )
            self._current_program = profile_name
            self._last_match_confidence = confidence
        else:
            self._logger.info(
                "No confident match from cycle data (best: %s, conf=%.3f)",
                profile_name,
                confidence,
            )

    def _start_watchdog(self) -> None:
        """Start the watchdog timer when a cycle begins."""
        if self._remove_watchdog:
            return  # Already running

        interval = self._watchdog_interval
        self._logger.debug(
            "Starting watchdog timer (configured=%ss)",
            self._watchdog_interval,
        )
        self._remove_watchdog = async_track_time_interval(
            self.hass, self._watchdog_check_stuck_cycle, timedelta(seconds=interval)
        )

    def _stop_watchdog(self) -> None:
        """Stop the watchdog timer when cycle ends."""
        if self._remove_watchdog:
            self._logger.debug("Stopping watchdog timer")
            self._remove_watchdog()
            self._remove_watchdog = None

    def _start_state_expiry_timer(self) -> None:
        """Start timer to reset state to OFF and progress to 0% after idle period."""
        if not hasattr(self, "_remove_state_expiry_timer"):
            self._remove_state_expiry_timer = None

        if self._remove_state_expiry_timer:
            return  # Already running

        self._logger.debug(
            "Starting state expiry timer (will reset after %ss)",
            self._progress_reset_delay,
        )
        self._remove_state_expiry_timer = async_track_time_interval(
            self.hass,
            self._handle_state_expiry,
            timedelta(seconds=60),  # Check every minute
        )

    def _stop_state_expiry_timer(self) -> None:
        """Stop the state expiry timer."""
        if (
            hasattr(self, "_remove_state_expiry_timer")
            and self._remove_state_expiry_timer
        ):
            self._logger.debug("Stopping state expiry timer")
            self._remove_state_expiry_timer()
            self._remove_state_expiry_timer = None

    async def _handle_state_expiry(self, now: datetime) -> None:
        """Check if state and progress should be reset (auto-expiration)."""
        # Anti-wrinkle keepalive (#339): the mode's idle-timeout and 2 h safety cap
        # only advance inside CycleDetector.process_reading, and the watchdog is
        # stopped for the whole anti-wrinkle tail. A publish-on-change plug can send
        # one final 0 W reading and then go fully silent, so with no further events
        # the mode is pinned in ANTI_WRINKLE for hours. This timer keeps ticking, so
        # when the real sensor has been silent longer than off_delay we inject a
        # synthetic 0 W reading, letting the detector's own logic exit the mode. Gate
        # on _last_real_reading_time (a genuine tumble pulse still resets the idle
        # timer via the normal handler) and never bump it here, so real silence stays
        # detectable and a still-reporting plug drives itself.
        if self.detector.state == STATE_ANTI_WRINKLE:
            last_real = self._last_real_reading_time
            if (
                last_real is not None
                and (now - last_real).total_seconds() > self._off_delay
            ):
                self._logger.debug(
                    "Anti-wrinkle keepalive: sensor silent for %.0fs (> off_delay %ss), "
                    "injecting synthetic 0 W so the idle/2h-cap timer can advance",
                    (now - last_real).total_seconds(),
                    self._off_delay,
                )
                self.detector.process_reading(0.0, now)
                self._notify_update()
            return
        if (
            not self._cycle_completed_time
            or self.detector.state == STATE_RUNNING
            or self.detector.state == STATE_DELAY_WAIT
        ):
            # Cycle is running or not completed, don't reset
            return

        time_since_complete = (now - self._cycle_completed_time).total_seconds()

        # Clean laundry nag notification. In repeat mode (#374) the reminder re-fires
        # every delay-minutes and carries a "stop reminding" action; otherwise it is a
        # single one-shot (the default, unchanged).
        if (
            self._is_clean_state
            and self._clean_state_start is not None
            and self._notify_unload_delay_minutes > 0
            and not self._unload_nag_dismissed
        ):
            delay_s = self._notify_unload_delay_minutes * 60
            first_due = (
                not self._notified_clean_laundry
                and (now - self._clean_state_start).total_seconds() >= delay_s
            )
            repeat_due = (
                self._notify_unload_repeat
                and self._notified_clean_laundry
                and self._unload_nag_count < NOTIFY_UNLOAD_REPEAT_MAX_REMINDERS
                and self._last_unload_nag_time is not None
                and (now - self._last_unload_nag_time).total_seconds() >= delay_s
            )
            if first_due or repeat_due:
                if self._notify_finish_services or self._notify_actions:
                    duration_min = int(time_since_complete / 60)
                    msg_template = self.config_entry.options.get(
                        CONF_NOTIFY_UNLOAD_MESSAGE, DEFAULT_NOTIFY_UNLOAD_MESSAGE
                    )
                    msg = self._safe_format_template(
                        msg_template,
                        fallback_template=DEFAULT_NOTIFY_UNLOAD_MESSAGE,
                        device=self.config_entry.title,
                        duration=duration_min,
                        delay=self._notify_unload_delay_minutes,
                    )
                    extra_vars: dict[str, Any] = {"tag": self._clean_tag}
                    if self._notify_unload_repeat:
                        # Actionable "stop reminding" button + sticky so the user can
                        # end the repeats from the notification (mobile_app targets;
                        # ignored by other platforms). Door-open still ends it too.
                        extra_vars["actions"] = [
                            {
                                "action": self._unload_dismiss_action_id,
                                "title": self._timer_ui_strings.get(
                                    "unload_dismiss_action_title", "Stop reminding"
                                ),
                            }
                        ]
                        extra_vars["sticky"] = "true"
                    sent = self._dispatch_notification(
                        msg,
                        event_type=NOTIFY_EVENT_CLEAN,
                        extra_vars=extra_vars,
                    )
                    if sent:
                        self._notified_clean_laundry = True
                        self._last_unload_nag_time = now
                        self._unload_nag_count += 1
                        if self._notify_unload_repeat:
                            self._ensure_unload_dismiss_listener()
                        self._logger.info(
                            "Sent clean laundry nag notification (%.0f min after "
                            "cycle end)%s",
                            time_since_complete / 60,
                            " [repeat]" if repeat_due else "",
                        )
                    elif self._last_dispatch_deferred:
                        # Held for quiet-hours / presence delivery; the queued copy
                        # fires later. Mark handled so the 60s expiry tick doesn't
                        # enqueue a duplicate nag every minute for the whole window.
                        self._notified_clean_laundry = True
                        self._last_unload_nag_time = now
                        self._unload_nag_count += 1
                        if self._notify_unload_repeat:
                            self._ensure_unload_dismiss_listener()
                else:
                    self._notified_clean_laundry = True
                    self._last_unload_nag_time = now
                    self._unload_nag_count += 1

        # Defer leaving the terminal state while a clean-state unload notification is
        # still pending. Without this guard the 30-min progress reset (or an early
        # power-off) fires before the unload nag, clearing _is_clean_state before the
        # notification can fire. In repeat mode the hold persists across every repeat
        # until the user dismisses it or opens the door. Both expiry modes below honour
        # it (as does the power-off one-shot timer).
        nag_pending = self._unload_nag_active(now)

        # Power-based Off detection (issue #284): opt-in, and only valid when the
        # threshold sits below stop_threshold_w (so it cannot fire while a cycle could
        # still be running, and cannot re-trigger the #267 spin-down ghost cycle). It is
        # evaluated ONLY in a terminal state; active states never reach here because
        # _cycle_completed_time is None until cycle end.
        cfg = self.detector.config
        pot = cfg.power_off_threshold_w
        stop_w = cfg.stop_threshold_w
        power_off_enabled = (
            isinstance(pot, (int, float))
            and isinstance(stop_w, (int, float))
            and 0.0 < pot < stop_w
            and self.detector.state
            in (STATE_FINISHED, STATE_INTERRUPTED, STATE_FORCE_STOPPED)
        )

        if power_off_enabled:
            # Power owns the Off transition. The classic timer still zeroes the progress
            # bar after progress_reset_delay, but the terminal state PERSISTS until the
            # machine is actually switched off (no timer fallback, by design: a machine
            # whose standby never drops below the threshold stays "Finished").
            if (
                time_since_complete > self._progress_reset_delay
                and self._cycle_progress != 0.0
            ):
                self._cycle_progress = 0.0
                self._notify_update()

            if nag_pending:
                # Hold the terminal state (and pause power sampling) until the nag fires.
                self._power_off_below_since = None
                self._cancel_power_off_timer()
                return

            if self._current_power < cfg.power_off_threshold_w:
                if self._power_off_below_since is None:
                    self._power_off_below_since = now
                    # Arm a precise one-shot reset instead of waiting for the next
                    # 60s poll (the poll below stays as a backstop).
                    self._arm_power_off_timer(cfg.power_off_delay)
                elif (
                    now - self._power_off_below_since
                ).total_seconds() >= cfg.power_off_delay:
                    self._logger.debug(
                        "Power-based Off: %.2fW below %.2fW for >= %.0fs in %s. "
                        "Resetting to OFF.",
                        self._current_power,
                        cfg.power_off_threshold_w,
                        cfg.power_off_delay,
                        self.detector.state,
                    )
                    self._reset_terminal_to_off()
            else:
                # Power rose back above the threshold: restart the debounce window.
                self._power_off_below_since = None
                self._cancel_power_off_timer()
            return

        # Timer-based Off (feature disabled): classic behaviour, unchanged.
        self._power_off_below_since = None
        self._cancel_power_off_timer()
        if time_since_complete > self._progress_reset_delay:
            if nag_pending:
                return
            # Auto-expire the "Finished" (or other terminal) state
            self._logger.debug(
                "State expiry: cycle idle for %.0fs (threshold: %ss). Resetting to OFF.",
                time_since_complete,
                self._progress_reset_delay,
            )
            self._reset_terminal_to_off()

    def _reset_terminal_to_off(self) -> None:
        """Return a terminal state (Finished/Interrupted/Force-Stopped, incl. the Clean
        overlay) to OFF and clear all post-cycle bookkeeping.

        Single owner of the terminal -> OFF transition, shared by the timer-based and
        the power-based (issue #284) expiry paths so the two can never diverge.
        """
        self._cycle_progress = 0.0
        self._cycle_completed_time = None
        # Clear the Clean overlay too, or check_state() keeps reporting "Clean".
        self._is_clean_state = False
        self._clean_state_start = None
        self._notified_clean_laundry = False
        self._reset_unload_nag_tracking()
        self._power_off_below_since = None
        self._cancel_power_off_timer()
        self.detector.reset(STATE_OFF)
        self._stop_state_expiry_timer()
        self._notify_update()

    def _cancel_power_off_timer(self) -> None:
        """Cancel the pending power-off one-shot reset timer, if armed."""
        if self._remove_power_off_timer is not None:
            self._remove_power_off_timer()
            self._remove_power_off_timer = None

    def _arm_power_off_timer(self, delay: float) -> None:
        """Arm a single cancellable one-shot power-off reset timer.

        Fires ``delay`` seconds after power first fell below the power-off
        threshold, so the terminal->Off transition does not have to wait for the
        next 60s expiry poll. The callback re-verifies the condition before acting,
        so a timer left armed after power rose (before the next poll cancels it) is
        a harmless no-op.
        """
        self._cancel_power_off_timer()

        @callback
        def _fire(_now: datetime) -> None:
            self._remove_power_off_timer = None
            self._power_off_timer_check()

        self._remove_power_off_timer = async_call_later(
            self.hass, max(0.0, float(delay)), _fire
        )

    def _power_off_timer_check(self) -> None:
        """One-shot power-off timer callback: reset to Off only if still valid."""
        cfg = self.detector.config
        pot = cfg.power_off_threshold_w
        stop_w = cfg.stop_threshold_w
        # Same enable + terminal-state guard as the poll path.
        if not (
            isinstance(pot, (int, float))
            and isinstance(stop_w, (int, float))
            and 0.0 < pot < stop_w
            and self.detector.state
            in (STATE_FINISHED, STATE_INTERRUPTED, STATE_FORCE_STOPPED)
        ):
            self._power_off_below_since = None
            return
        # Re-verify the below-threshold debounce (power may have risen since arming).
        if self._power_off_below_since is None or self._current_power >= pot:
            return
        if (
            dt_util.now() - self._power_off_below_since
        ).total_seconds() < cfg.power_off_delay:
            return
        # Honour the clean-laundry unload nag hold (mirrors the poll path).
        if self._unload_nag_active(dt_util.now()):
            return
        self._logger.debug(
            "Power-based Off (one-shot timer): %.2fW below %.2fW for >= %.0fs in %s. "
            "Resetting to OFF.",
            self._current_power,
            pot,
            cfg.power_off_delay,
            self.detector.state,
        )
        self._reset_terminal_to_off()

    async def _watchdog_check_stuck_cycle(self, now: datetime) -> None:
        """Watchdog: check if cycle is stuck (no updates for too long)."""
        if self.detector.state not in (STATE_RUNNING, STATE_STARTING, STATE_PAUSED, STATE_ENDING):
            return

        if not self._last_reading_time:
            return

        # Refresh the remaining-time / progress estimate on the watchdog cadence
        # (sampling-derived: max(30, 2*sampling+1)s), independently of incoming power
        # events. A publish-on-change plug emits nothing during a flat low-power tail
        # (e.g. a dishwasher's ~30 min drying phase at 0 W), so the event-driven
        # _update_remaining_only never runs and the displayed countdown freezes at
        # whatever value it last showed. The estimate is wall-clock based
        # (net_elapsed_seconds), so this tick advances it correctly with zero new
        # readings; it no-ops until a profile is matched. Kept ahead of the
        # keepalive/force-end branches below so even a verified-pause drying tail
        # (which skips those branches) still ticks down.
        if self.detector.state in (STATE_RUNNING, STATE_PAUSED, STATE_ENDING):
            self._update_remaining_only()
            self._notify_update()

        time_since_any_update = (now - self._last_reading_time).total_seconds()

        # Calculate time since REAL update (if available, else fallback to any update)
        last_real = self._last_real_reading_time or self._last_reading_time
        time_since_real_update = (now - last_real).total_seconds()

        elapsed = self.detector.get_elapsed_seconds()
        expected = getattr(self.detector, "expected_duration_seconds", 0)

        # 0a. PUMP STUCK DETECTION (Pump Monitor only)
        # If a pump cycle has been running longer than the configured stuck threshold,
        # fire a single warning event so the user can wire an automation/alert.
        # Skip while user-paused or detector-verified-pause to avoid false positives.
        _verified_pause = getattr(self.detector, "_verified_pause", False)
        if self.device_type == DEVICE_TYPE_PUMP and not self._pump_stuck and not self._is_user_paused and not _verified_pause:
            adjusted_elapsed = elapsed - self._total_user_paused_seconds
            if adjusted_elapsed >= self._pump_stuck_duration:
                self._pump_stuck = True
                self._logger.warning(
                    "Pump stuck detected: cycle has been running for %.0fs net "
                    "(threshold: %ds). Firing %s event.",
                    adjusted_elapsed,
                    self._pump_stuck_duration,
                    EVENT_PUMP_STUCK,
                )
                self.hass.bus.async_fire(
                    EVENT_PUMP_STUCK,
                    {
                        "device": self.config_entry.title,
                        "entry_id": self.entry_id,
                        "elapsed_seconds": round(adjusted_elapsed),
                        "threshold_seconds": self._pump_stuck_duration,
                    },
                )
                self._notify_update()

        # 0. ZOMBIE KILLER (Hard Limit)
        # If cycle has run significantly longer than expected (300%), kill it.
        # Only applies if we have a profile match. Skip while user-paused or
        # detector-verified-pause to avoid killing legitimately paused cycles.
        _verified_pause_zombie = getattr(self.detector, "_verified_pause", False)
        if (
            expected > 0
            and not self._is_user_paused
            and not _verified_pause_zombie
        ):
            adjusted_elapsed = elapsed - self._total_user_paused_seconds
            if adjusted_elapsed > (expected * 3.0) and adjusted_elapsed > 14400:
                self._logger.warning(
                    "Watchdog: Zombie cycle detected (%.0fs net > 300%% of expected %.0fs). Force-ending.",
                    adjusted_elapsed, expected
                )
                self.detector.force_end(now)
                self._current_power = 0.0  # Force 0W
                self._notify_update()
                return

        # Secondary zombie guard for unmatched cycles (expected == 0 when no profile
        # has been matched). The detector hard-caps RUNNING at 8h but a chatty sensor
        # that never goes silent can keep the watchdog from reaching the end state.
        # Kill here after 4h so a stuck false-start doesn't run indefinitely.
        elif (
            expected == 0
            and not self._is_user_paused
            and not _verified_pause_zombie
            and elapsed > 14400
            # Gate on the active detector state, not _current_program: the latter is
            # only set to "detecting..." on the RUNNING transition, so a cycle stuck
            # in STARTING keeps a stale program and would never hit this 4h guard.
            and self.detector.state in (
                STATE_STARTING, STATE_RUNNING, STATE_PAUSED, STATE_ENDING
            )
        ):
            self._logger.warning(
                "Watchdog: Unmatched cycle exceeded 4h (%.0fs). Force-ending.",
                elapsed,
            )
            self.detector.force_end(now)
            self._current_power = 0.0
            self._notify_update()
            return

        # 1. GHOST CYCLE SUPPRESSOR
        # If we are "detecting" for more than 10 minutes and haven't seen an update for 5 minutes,
        # it's likely a pump-out spike or an accidental start (ghost cycle).
        # We end it aggressively ONLY if it started shortly after another cycle ended (Suspicious Window).
        cycle_start = self.detector.current_cycle_start
        is_suspicious = False
        if cycle_start and self._last_cycle_end_time:
            # Dishwashers have a drain pump-out that fires 3-8 min after the main
            # cycle ends; use a wider suspicious window so the ghost suppressor can
            # catch it without false-positives on washing machines / dryers.
            suspicious_window = 600 if self.device_type == "dishwasher" else 180
            if (cycle_start - self._last_cycle_end_time).total_seconds() < suspicious_window:
                is_suspicious = True

        # For dishwashers in the suspicious window, kill pump-out ghosts faster.
        # Pump-outs last 1-3 min then go silent; the standard 10-min wait allows
        # them to accumulate too much runtime before suppression fires.
        dishwasher_pump_out = (
            is_suspicious
            and self.device_type == "dishwasher"
            and elapsed > 180   # 3 minutes
            and time_since_real_update > 60  # 1 minute of silence
        )

        if (
            self._current_program == "detecting..."
            and is_suspicious
            and (
                dishwasher_pump_out
                or (elapsed > 600 and time_since_real_update > 300)
            )
        ):
            self._logger.warning(
                "Watchdog: Ghost cycle suppressed (within suspicious window). Detecting for %.0fs with %.0fs silence.",
                elapsed, time_since_real_update
            )
            self.detector.force_end(now)
            self._current_power = 0.0
            self._notify_update()
            return

        # --- LOW POWER HANDLING ---
        # If we are in a low power state (waiting for off_delay or drying profile),
        # we treat silence leniently. We inject keepalives until the stricter
        # low_power_no_update_timeout is reached.

        # Dishwashers can have very long silent drying phases (up to 2h)
        # We use the device-specific timeout as the floor for this effective timeout.
        # The floor is applied unconditionally - dishwashers have passive drying phases
        # even when no profile has been matched yet.  The original restriction to matched
        # cycles caused premature kills: with the default 3600s timeout, an unmatched
        # dishwasher cycle was killed ~1h after the last sensor update, while the
        # physical drying phase could still have 1-2h of silent runtime remaining.
        low_power_floor = DEFAULT_NO_UPDATE_ACTIVE_TIMEOUT_BY_DEVICE.get(
            self.device_type, 0
        )
        effective_low_power_timeout = max(
            low_power_floor, self._low_power_no_update_timeout
        )

        # Profile-Aware Extension:
        # If we have a matched profile, ensure we don't kill during the expected duration.
        if expected > 0 and elapsed < expected:
            # Extend timeout to cover the remaining expected duration + buffer
            remaining = expected - elapsed
            # Allow silence up to remaining + 1800s (30m buffer for drying/pause)
            extended_timeout = remaining + 1800
            if extended_timeout > effective_low_power_timeout:
                effective_low_power_timeout = extended_timeout

        # Verified Pause Extension:
        # If the manager/store has confirmed this is a legitimate pause (e.g. Drying),
        # allow even more leniency up to the global deferral limit.
        if getattr(self.detector, "_verified_pause", False):
            # Allow silence up to DEFAULT_MAX_DEFERRAL_SECONDS (default 2h) + buffer
            pause_limit = DEFAULT_MAX_DEFERRAL_SECONDS + 1800
            if pause_limit > effective_low_power_timeout:
                effective_low_power_timeout = pause_limit
                self._logger.debug(
                    "Watchdog: Extending timeout to %.0fs due to verified pause",
                    effective_low_power_timeout
                )

        if self.detector.is_waiting_low_power():

            # 2. Staleness Check
            if time_since_real_update > effective_low_power_timeout:
                self._logger.warning(
                    "Watchdog: Force-ending cycle. Low-power state stale for %.0fs (> %.0fs).",
                    time_since_real_update,
                    effective_low_power_timeout
                )
                self.detector.force_end(now)
                self._last_reading_time = now
                self._current_power = 0.0
                self._notify_update()
                return

            # 3. Injection Check (Keepalive)
            # 3a. Honour the user-configured no_update_active_timeout for low-power silence.
            # Publish-on-change sensors go completely silent once they stabilise at a low
            # standby value (e.g. 1 W).  The existing off_delay-based injection fires
            # every 2 watchdog ticks, which is fine with a short watchdog interval but can
            # take many minutes with a larger one.  Respecting no_update_active_timeout
            # here gives users a predictable upper bound on how long a cycle lingers after
            # the appliance reaches standby, consistent with what the setting implies.
            # Verified pauses (e.g. dishwasher drying confirmed by envelope) are excluded
            # so that legitimate long silent phases are not prematurely terminated.
            if (
                not getattr(self.detector, "_verified_pause", False)
                and time_since_real_update > self._no_update_active_timeout
            ):
                self._logger.debug(
                    "Watchdog: Low-power real-update silence (%.0fs) > no_update_active_timeout (%.0fs). "
                    "Injecting 0W keepalive to advance accumulator.",
                    time_since_real_update,
                    self._no_update_active_timeout,
                )
                self.detector.process_reading(0.0, now)
                self._last_reading_time = now
                self._current_power = 0.0
                self._notify_update()
                return

            # 3b. Fallback: inject 0W when any-update silence exceeds off_delay.
            # This keeps the accumulator moving even when no_update_active_timeout has
            # not been exceeded (e.g. the user left it at the default 600 s).
            if time_since_any_update > self._config.off_delay:
                self._logger.debug(
                    "Watchdog: Low power silence (%.0fs). Injecting 0W keepalive.",
                    time_since_any_update
                )
                # Ensure we handle the injection cleanly
                # Do NOT update _last_real_reading_time here
                self.detector.process_reading(0.0, now)
                self._last_reading_time = now # Resets 'any' timer so we don't spam
                self._current_power = 0.0
                self._notify_update()
                return

            return

        # Fallback for old "Case 1.5" logic (Low Power but NOT is_waiting_low_power)
        # Check this BEFORE High Power timeout to prevent trapping "Not Yet Waiting" states
        # Inject as soon as the earliest of: off_delay silence OR no_update_active_timeout.
        if self._current_power <= self.detector.config.min_power and (
            time_since_any_update > self._config.off_delay
            or time_since_real_update > self._no_update_active_timeout
        ):
            # Treating as start of low power wait
            self._logger.debug("Watchdog: Silence at low power (%.0fs). Injecting 0W.", time_since_any_update)
            self.detector.process_reading(0.0, now)
            self._last_reading_time = now
            self._current_power = 0.0
            self._notify_update()
            return

        # --- HIGH POWER HANDLING (Normal) ---
        # If power is high, we expect frequent updates.

        if time_since_any_update > self._no_update_active_timeout:

            # Check if high power (running)
            if self._current_power > self.detector.config.min_power:
                # Allow extended silence if within reasonable cycle bounds
                expected = getattr(self.detector, "expected_duration_seconds", 0)
                elapsed = self.detector.get_elapsed_seconds()
                limit = (expected + 14400) if expected > 0 else 14400 # 4h default

                if elapsed < limit:
                    self._logger.info(
                        "Watchdog: High power (%.1fW) stale (%.0fs). Injecting refresh.",
                        self._current_power, time_since_any_update
                    )
                    self.detector.process_reading(self._current_power, now)
                    self._last_reading_time = now
                    self._notify_update()
                    return

            # If we get here, it's truly stuck/offline
            self._logger.warning(
                "Watchdog: Force-ending cycle. Active state stale for %.0fs (> timeout).",
                time_since_any_update
            )
            self.detector.force_end(now)
            self._current_power = 0.0  # FIX: Reset current power
            self._notify_update()
            return


    def _on_state_change(self, old_state: str, new_state: str) -> None:
        """Handle state change from detector."""
        self._logger.debug("Washer state changed: %s -> %s", old_state, new_state)
        self.diag_buffer.record_state(
            old_state, new_state, self._current_program, dt_util.now()
        )
        # A new cycle starting while we are still showing the completed/Clean
        # overlay (the progress-reset window) must clear that overlay and cancel
        # the expiry timer right away, so the UI leaves "Finished" and the unload
        # nag stops immediately instead of waiting for the reset window - and so
        # the expiry timer cannot race the new cycle and reset us to OFF (#267).
        if new_state == STATE_STARTING and self._cycle_completed_time is not None:
            self._cycle_completed_time = None
            self._is_clean_state = False
            self._clean_state_start = None
            self._notified_clean_laundry = False
            self._reset_unload_nag_tracking()
            self._cycle_progress = 0.0
            self._power_off_below_since = None
            self._cancel_power_off_timer()
            self._stop_state_expiry_timer()
        if new_state == STATE_RUNNING:
            new_cycle_detected = old_state in (STATE_OFF, STATE_STARTING, STATE_UNKNOWN)
            # Only reset estimates if we are truly starting a NEW cycle (from off or starting)
            # If we transition from PAUSED or ENDING, it's a resume - keep estimates!
            if new_cycle_detected:
                self._cycle_completed_time = None
                self._stop_state_expiry_timer()

                self._current_program = "detecting..."
                self._manual_program_active = False
                self._notified_pre_completion = False
                self._time_remaining = None
                self._total_duration = None
                self._cycle_progress = 0
                self._matched_profile_duration = None
                self._last_estimate_time = None
                self._score_history = {}  # Reset score history on new cycle
                self._match_persistence_counter = {}  # Reset persistence counter
                self._unmatch_persistence_counter = 0  # Reset unmatch counter
                self._current_match_candidate = None  # Reset candidate
                self._notified_start = False # Reset start notification state
                self._start_event_fired = False
                self._last_cycle_post_anomaly = {}  # Clear previous cycle's anomaly cache
                self._cycle_start_time = self.detector.current_cycle_start or dt_util.now()
                self._ranking_snapshot_cycle_id = str(uuid.uuid4())
                self._reset_live_notification_state()
                # Snapshot the external energy meter (issue #316) so cycle end can
                # take an accurate start->end delta. No-op when none is configured.
                self._snapshot_energy_meter_start()

                # Reset pause tracking and clean state for new cycle
                self._is_user_paused = False
                self._user_pause_start = None
                self._total_user_paused_seconds = 0.0
                self._is_clean_state = False
                self._fired_cycle_timers = set()
                self._clear_timer_pause_notification()
                self._clean_state_start = None
                self._notified_clean_laundry = False
                self._reset_unload_nag_tracking()

                self._start_watchdog()  # Start watchdog when cycle starts

                # Fire the start event immediately on cycle detection so listeners always
                # receive it, even when no profile match occurs yet.
                if self._notify_fire_events:
                    self.hass.bus.async_fire(
                        EVENT_CYCLE_STARTED,
                        {
                            "entry_id": self.entry_id,
                            "device_name": self.config_entry.title,
                            "device_type": self.device_type,
                            "program": self._current_program or "unknown",
                            "start_time": self._cycle_start_time.isoformat(),
                        },
                    )
                    self._start_event_fired = True
                    # Mark the start fully handled ONLY when there is no push to send, so
                    # the restart-recovery fallback does not re-enter for event-only
                    # configs. When a push service/action IS configured, leave
                    # _notified_start False so the push block below still fires: a config
                    # with both events and push must get both (event delivery is tracked
                    # separately by _start_event_fired).
                    if not (self._notify_start_services or self._notify_actions):
                        self._notified_start = True

                # Fire push notification immediately - do not wait for profile matching.
                if not self._notified_start and (self._notify_start_services or self._notify_actions):
                    msg_template = self.config_entry.options.get(
                        CONF_NOTIFY_START_MESSAGE, DEFAULT_NOTIFY_START_MESSAGE
                    )
                    msg = self._safe_format_template(
                        msg_template,
                        fallback_template=DEFAULT_NOTIFY_START_MESSAGE,
                        device=self.config_entry.title,
                        program=self._current_program,
                    )
                    # B4: append a peak-rate advisory tip when the current price is
                    # at/above the configured threshold. Purely informational.
                    tip = self._peak_rate_tip(
                        self.config_entry.options, self._resolve_energy_price()
                    )
                    if tip:
                        msg = f"{msg}\n{tip}"
                    self._dispatch_notification(
                        msg,
                        event_type=NOTIFY_EVENT_START,
                        extra_vars={
                            "program": self._current_program,
                            "tag": self._lifecycle_tag,
                        },
                    )
                    self._notified_start = True
                    self._logger.info(
                        "Sent start notification for program '%s'", self._current_program
                    )
                    self._check_pre_completion_notification()
            else:
                self._logger.debug("Cycle resumed from %s, preserving estimates", old_state)
                # Ensure watchdog is running
                self._start_watchdog()

        # Auto-open dishwasher: arm the dwell if ENDING while the door is already
        # open (door event missed, or door popped just as ENDING fired) (#342).
        self._maybe_arm_door_end_dwell_if_open()

        # Stop watchdog when transitioning to OFF from any active state
        if new_state == STATE_OFF:
            self._stop_watchdog()  # Stop watchdog regardless of previous state
            self._cycle_start_time = None

        self._notify_update()

    def _discard_cycle_cleanup(self) -> None:
        """Discard cleanup for a ghost/noise blip that is never persisted.

        The detector still transitions into a terminal state (FINISHED/INTERRUPTED)
        when it fires the cycle-end callback, and a live active-cycle snapshot may be
        sitting in the store. The normal cycle-end tail clears that snapshot and arms
        the terminal-state expiry so the UI returns to Off; a suppressed ghost skips
        that tail, so mirror the essential parts here — otherwise the device is
        stranded in a terminal state with a stale active snapshot until the next
        cycle. Deliberately does NOT persist, notify, or run the learning pipeline.
        """
        self._spawn_tracked(self.profile_store.async_clear_active_cycle())
        # Anchor the terminal state so _handle_state_expiry (and power-off) can act,
        # then arm the expiry timer that resets terminal -> Off after the reset delay.
        self._cycle_completed_time = dt_util.now()
        self._start_state_expiry_timer()

    def _on_cycle_end(self, cycle_data: dict[str, Any]) -> None:
        """Handle cycle end - clear all active timers and state."""
        duration = cycle_data["duration"]
        max_power = cycle_data.get("max_power", 0)

        # IMMEDIATELY stop all active timers when cycle determined to have ended
        self._stop_watchdog()  # Stop active cycle watchdog
        self._stop_state_expiry_timer()  # Cancel any pending progress reset
        self._clear_timer_pause_notification()
        self._cancel_door_end_dwell()  # Discard stale auto-open dwell (#342)
        prev_cycle_end_time = self._last_cycle_end_time
        self._last_cycle_end_time = dt_util.now()
        self._pump_stuck = False  # Reset for next pump cycle

        # Auto-Tune: Check for ghost cycles (short duration AND low energy)
        # Ghost = duration < 60s AND total energy < 0.05 Wh (avoids killing pump-out spikes)
        power_data = cycle_data.get("power_data", [])
        cycle_energy_wh = 0.0
        if power_data and len(power_data) >= 2:
            valid: list[tuple[float, float]] = []
            for p in power_data:
                try:
                    valid.append((float(p[0]), float(p[1])))
                except (TypeError, ValueError, IndexError):
                    pass
            if len(valid) >= 2:
                try:
                    valid.sort(key=lambda x: x[0])
                    ts = np.array([v[0] for v in valid])
                    ps = np.array([v[1] for v in valid])
                    # Shared trapezoidal integrator with a data-driven outage gap
                    # (single source with ProfileStore.add_cycle).
                    cycle_energy_wh = integrate_wh(
                        ts, ps, max_gap_s=energy_gap_threshold_s(ts)
                    )
                except (TypeError, ValueError, ArithmeticError):
                    cycle_energy_wh = 0.0

        # Ghost cycle: short AND low energy (real cycles have energy even if short).
        # Suppress it exactly like the dishwasher pump-out branch below: feed the
        # auto-tune counter but do NOT store it or run the cycle-end pipeline. Without
        # the return a sub-60 s / sub-0.05 Wh blip would fall through to persistence
        # and the (un-gated) finish notification, firing a phantom "cycle finished".
        if duration < 60 and cycle_energy_wh < 0.05:
            self._handle_noise_cycle(max_power)
            self._discard_cycle_cleanup()
            return  # Do not store this as a real cycle
        if self.device_type == "dishwasher" and prev_cycle_end_time is not None:
            # Pump-out suppression: dishwashers end cycles with a brief drain pump
            # (typically 30-300 s, < 1 Wh) a few minutes after the main cycle
            # finishes.  If a short, low-energy cycle starts within 10 minutes of
            # the previous cycle, treat it as a pump-out ghost and do not store it.
            cycle_start_str = cycle_data.get("start_time")
            cycle_start_dt = (
                dt_util.parse_datetime(cycle_start_str) if cycle_start_str else None
            )
            if cycle_start_dt is not None:
                gap = (cycle_start_dt - prev_cycle_end_time).total_seconds()
                if 0 < gap < 600 and duration < 300 and cycle_energy_wh < 1.0:
                    self._logger.info(
                        "Suppressing dishwasher pump-out ghost: "
                        "gap=%.0fs, duration=%.0fs, energy=%.3f Wh",
                        gap,
                        duration,
                        cycle_energy_wh,
                    )
                    self._handle_noise_cycle(max_power)
                    self._discard_cycle_cleanup()
                    return  # Do not store this as a real cycle

        # Store energy for notification and persistence (calculated above for ghost detection)
        cycle_data["energy_wh"] = round(cycle_energy_wh, 3)

        # External energy meter (issue #316): when an accurate start->end delta is
        # available, record it alongside the integrated value and mark the source.
        # The integrated energy_wh above is left untouched so matching / ML / anomaly
        # stats stay internally consistent; only user-facing figures prefer the meter.
        cycle_data["energy_source"] = "integration"
        meter_wh = self._compute_meter_energy_wh()
        if meter_wh is not None:
            cycle_data["energy_meter_wh"] = round(meter_wh, 3)
            cycle_data["energy_source"] = "meter"

        # Schedule heavy post-processing asynchronously. Capture this cycle's identity
        # token so the async tail can tell if a NEW cycle started while it was awaiting
        # (power changes are handled synchronously, so a back-to-back load can drive the
        # detector into a fresh RUNNING before post-processing completes). See B1 in
        # _async_process_cycle_end.
        end_token = self._ranking_snapshot_cycle_id
        if not self._is_shutdown:
            self._cycle_end_task = self._spawn_tracked(
                self._async_process_cycle_end(cycle_data, cycle_token=end_token)
            )

    def _ml_end_confidence(
        self, points: list[tuple[float, float]], expected_duration: float
    ) -> float | None:
        """Opt-in ML end-guard provider handed to the CycleDetector.

        Returns P(the latest low-power event is the true cycle end) from the
        shipped or on-device-trained cycle-end model, or ``None`` when ML models
        are disabled for this device, no profile is matched, or the model /
        features are unavailable. ``None`` means the detector keeps its existing
        power/energy-based behavior, so this can only ever *defer* a completion.
        """
        try:
            from .ml.engine import ml_models_enabled, resolve_scorer

            if not ml_models_enabled(self.config_entry.options):
                return None
            profile_name = self._current_program
            if (
                not profile_name
                or profile_name in ("off", "detecting...", "restored...")
                or profile_name not in self.profile_store.get_profiles()
            ):
                return None
            end_fn, _ = resolve_scorer("end", self.profile_store)
            if end_fn is None:
                return None
            expectation = self._profile_end_expectation(profile_name, expected_duration)
            if expectation is None:
                return None
            from .ml.feature_extraction import latest_end_event_features

            features = latest_end_event_features(points, expectation)
            if features is None:
                return None
            return float(end_fn(features))
        except Exception as err:  # noqa: BLE001 - ML must never break detection
            self._logger.debug("ML end-guard scoring skipped: %s", err)
            return None

    def _profile_end_expectation(
        self, profile_name: str, expected_duration: float
    ) -> dict[str, float] | None:
        """Median duration/energy/peak for a matched profile, for end features.

        Cached per profile so the guard does not re-decompress history on every
        low-power reading during ENDING. The detector's authoritative expected
        duration overrides the median when available.
        """
        expectation, self._ml_end_expectation_cache = progress_mod.profile_end_expectation(
            self.profile_store,
            profile_name,
            expected_duration,
            self._ml_end_expectation_cache,
        )
        return expectation

    def _terminal_drop_provider(
        self, points: list[tuple[float, float]], expected_duration: float
    ) -> bool:
        """Opt-in terminal-drop detector handed to the CycleDetector.

        Returns ``True`` when the current low-power event is a hard cliff-to-~0
        that began at an elapsed offset EARLIER than this device has ever
        legitimately gone quiet (learned from its own completed cycles) - i.e. an
        anomalously-early drop that is almost certainly a real stop (plug pulled /
        cancelled), not a soak pause.  The detector then finalizes quickly instead
        of waiting out the full soak-bridging ``min_off_gap``.

        A very early drop is below the matcher's duration gate, so match
        confidence is not available to confirm familiarity; instead the cycle's
        **power level** must be one this device has produced before (see
        ``is_terminal_drop``) - a cycle drawing power unlike anything in its
        history is treated as a possible new program and deferred.

        Returns ``False`` (keep the proven slow path) when the ML/anomaly opt-in
        is off, there is too little history to trust the baseline, the cycle
        looks novel, or the drop is not anomalously early.  Never raises - the
        anomaly signal must never break detection.
        """
        try:
            from .ml.engine import ml_models_enabled

            if not ml_models_enabled(self.config_entry.options):
                return False
            earliest, peak_range = self._terminal_drop_baseline()
            return is_terminal_drop(
                points,
                earliest,
                peak_range,
                float(self.detector.config.stop_threshold_w),
                TERMINAL_DROP_EARLINESS_RATIO,
                TERMINAL_DROP_MIN_PEAK_RATIO,
                TERMINAL_DROP_PEAK_FAMILIAR_TOL,
            )
        except Exception as err:  # noqa: BLE001 - anomaly signal must never break detection
            self._logger.debug("Terminal-drop detection skipped: %s", err)
            return False

    def _terminal_drop_baseline(self) -> tuple[float | None, tuple[float, float] | None]:
        """Cached (earliest-quiet-offset, historical-peak-range) for this device.

        Both are learned from the device's completed cycles and used by the
        terminal-drop detector (anomaly + familiarity gates).  Keyed by cycle
        count so it refreshes as history grows.

        The recompute decompresses every completed trace, which is too heavy to run
        on the event loop inside the detector's reading path (issue #311). So this
        NEVER recomputes synchronously: on a miss/stale cache it schedules an
        executor refresh and serves the last known baseline in the meantime (one
        cycle stale is harmless for an anomaly heuristic). Until the first refresh
        lands there is no baseline, so it returns ``(None, None)`` and
        ``is_terminal_drop`` defers to the proven slow end-detection."""
        cycles = self.profile_store.get_past_cycles()
        n = len(cycles)
        cache = self._terminal_drop_cache
        if cache is not None and cache[0] == n:
            return cache[1], cache[2]
        self._schedule_terminal_drop_refresh(n)
        if cache is not None:
            return cache[1], cache[2]
        return None, None

    def _schedule_terminal_drop_refresh(self, n: int) -> None:
        """Kick a one-shot executor refresh of the terminal-drop baseline for the
        current cycle count, unless one is already in-flight/done for it."""
        if self._terminal_drop_refresh_n == n:
            return
        self._terminal_drop_refresh_n = n
        self.hass.async_create_task(self._async_refresh_terminal_drop_baseline(n))

    async def _async_refresh_terminal_drop_baseline(self, n: int) -> None:
        """Recompute the baseline off the event loop and cache it. Never raises -
        the anomaly signal must never break detection."""
        try:
            # Snapshot the list on the loop before handing it to the executor.
            cycles = list(self.profile_store.get_past_cycles())
            stop_threshold = float(self.detector.config.stop_threshold_w)
            earliest, peak_range = await self.hass.async_add_executor_job(
                terminal_drop_baseline,
                cycles,
                stop_threshold,
                TERMINAL_DROP_MIN_QUIET_SPAN_S,
                TERMINAL_DROP_MIN_CLEAN_CYCLES,
            )
            self._terminal_drop_cache = (len(cycles), earliest, peak_range)
        except Exception as err:  # noqa: BLE001 - anomaly signal must never break detection
            self._logger.debug("Terminal-drop baseline refresh failed: %s", err)
            # Allow a later reading to retry the refresh for this count.
            if self._terminal_drop_refresh_n == n:
                self._terminal_drop_refresh_n = None

    def _ml_progress_percent(
        self,
        trace: list[tuple[datetime, float]],
        profile_name: str,
    ) -> float | None:
        """ML completion-fraction estimate (0-100) for the running cycle, or None.

        Uses the on-device ``remaining_time`` regressor (a ``standardized_linear``
        head with no shipped baseline) to predict how far through the cycle we
        are, learning this device's own progress curve rather than assuming the
        matched profile's median duration. Gated on the ML opt-in and only active
        once training has promoted a regressor; otherwise returns ``None`` so the
        caller keeps the proven phase-aware estimate untouched. Never raises.
        """
        return progress_mod.ml_progress_percent(
            self.profile_store,
            self.config_entry.options,
            float(self._matched_profile_duration or 0.0),
            trace,
            profile_name,
            self._profile_end_expectation,
            self._logger,
        )

    def _ml_energy_total(
        self,
        trace: list[tuple[datetime, float]],
        profile_name: str,
    ) -> float | None:
        """Predicted total cycle energy (Wh) from the on-device ``total_energy``
        regressor, or None.

        The regressor predicts the *energy-completion fraction* (energy so far ÷
        final energy); total = ``energy_so_far / fraction``. Because energy
        accumulates non-linearly (heating front-loads it), this is more stable —
        especially early — than dividing accumulated energy by *time* progress
        (the fallback in :meth:`_update_projected_energy`). Same gating as the
        remaining-time regressor: opt-in, inert until a model is promoted. Never
        raises.
        """
        return progress_mod.ml_energy_total(
            self.profile_store,
            self.config_entry.options,
            float(self._matched_profile_duration or 0.0),
            trace,
            profile_name,
            self._profile_end_expectation,
            self._logger,
        )

    def _compute_cycle_quality_score(
        self,
        cycle_data: dict[str, Any],
        past_cycles_snapshot: list[dict[str, Any]] | None = None,
    ) -> None:
        """Score a just-finished cycle with the hybrid_curve_quality model (opt-in).

        When ML models are enabled for this device, computes P(cycle is a problem)
        and stores it under ``cycle_data["ml_quality_score"]``.  A high score means
        the cycle may be mis-detected or corrupt; the learning manager uses it to
        downgrade auto-labeling to a feedback request so the user can confirm.
        Never raises — scoring failure is silently ignored to keep cycle storage safe.
        """
        try:
            from .ml.engine import ml_models_enabled, resolve_scorer
            from .ml.feature_extraction import quality_features

            if not ml_models_enabled(self.config_entry.options):
                return
            quality_fn, _ = resolve_scorer("quality", self.profile_store)
            if quality_fn is None:
                return

            profile_name = cycle_data.get("profile_name")
            if not profile_name:
                return

            points = decompress_power_data(cycle_data)
            if not points or len(points) < 4:
                return

            # Build profile median stats from stored labeled cycles.
            durations: list[float] = []
            energies: list[float] = []
            peaks: list[float] = []
            # This function runs in an executor thread and the event loop may
            # append cycles concurrently; iterating (or even copying) the live list
            # here is a data race. Prefer the snapshot taken on the event loop at
            # the call site; only fall back to a local copy for direct callers.
            cycles = (
                past_cycles_snapshot
                if past_cycles_snapshot is not None
                else list(self.profile_store.get_past_cycles())
            )
            for c in cycles:
                if c.get("profile_name") != profile_name:
                    continue
                if c.get("duration") is not None:
                    durations.append(float(c["duration"]))
                if c.get("energy_wh") is not None:
                    energies.append(float(c["energy_wh"]))
                if c.get("max_power") is not None:
                    peaks.append(float(c["max_power"]))

            if not durations:
                return

            med_dur = float(np.median(durations))
            med_energy = float(np.median(energies)) if energies else 500.0
            med_peak = float(np.median(peaks)) if peaks else 500.0

            match_conf = float(cycle_data.get("match_confidence") or 0.0)
            conf_known = match_conf > 0
            proxy_dist = max(0.0, 1.0 - match_conf) if conf_known else 0.25
            proxy_margin = match_conf if conf_known else 0.30
            proxy_fit = match_conf if conf_known else 0.75

            feat = quality_features(
                points=points,
                profile_median_duration_s=med_dur,
                profile_median_energy_wh=med_energy,
                profile_median_peak_w=med_peak,
                profile_distance=proxy_dist,
                label_margin=proxy_margin,
                profile_fit_score=proxy_fit,
                flag_count=len(cycle_data.get("artifacts", [])),
            )
            score = round(float(quality_fn(feat)), 3)
            cycle_data["ml_quality_score"] = score
            self._logger.debug(
                "ML quality score (profile=%s): %.3f", profile_name, score
            )
        except Exception:  # noqa: BLE001 - never break cycle storage
            pass

    def _resolve_energy_price(self) -> float | None:
        """Current energy price per kWh, or None when none is configured.

        A price entity (e.g. a dynamic tariff) takes precedence over the static
        value. Used to freeze each cycle's cost at completion time.
        """
        options = self.config_entry.options
        price_entity = options.get(CONF_ENERGY_PRICE_ENTITY)
        if price_entity:
            state = self.hass.states.get(price_entity)
            if state is not None:
                try:
                    return float(state.state)
                except (ValueError, TypeError):
                    pass
        static = options.get(CONF_ENERGY_PRICE_STATIC)
        if static is not None:
            try:
                return float(static)
            except (ValueError, TypeError):
                pass
        return None

    def _read_energy_meter(self) -> tuple[float, str] | None:
        """Read the configured external energy meter, normalized to Wh.

        Returns ``(value_wh, entity_id)`` or ``None`` when no meter is configured
        or its reading is not usable (unknown/unavailable/non-numeric state, or an
        unrecognised unit). Never raises -- every failure path returns ``None`` so
        the caller falls back to the integrated energy (issue #316).
        """
        entity_id = self.config_entry.options.get(CONF_ENERGY_SENSOR)
        if not entity_id:
            return None
        state = self.hass.states.get(entity_id)
        if state is None or state.state in (None, "unknown", "unavailable", ""):
            return None
        try:
            value = float(state.state)
        except (ValueError, TypeError):
            return None
        unit = str(state.attributes.get("unit_of_measurement") or "").strip().lower()
        # Normalize to Wh. An unrecognised unit is treated as unusable so a
        # mis-configured entity falls back rather than reporting a wrong figure.
        scale = {"wh": 1.0, "kwh": 1000.0, "mwh": 1_000_000.0}.get(unit)
        if scale is None:
            return None
        return value * scale, entity_id

    def _snapshot_energy_meter_start(self) -> None:
        """Capture the meter reading at cycle start (issue #316)."""
        snap = self._read_energy_meter()
        if snap is None:
            self._energy_meter_start = None
            self._energy_meter_source = None
        else:
            self._energy_meter_start, self._energy_meter_source = snap

    def _compute_meter_energy_wh(self) -> float | None:
        """Cycle energy from the external meter's start->end delta, or None.

        Falls back (returns ``None``) when: no start was captured; no meter is
        configured now; the configured entity differs from the one snapshotted at
        start (source changed mid-cycle -- no cross-meter delta); the current
        reading is unusable; or the delta is <= 0 (counter reset or stuck plug).
        """
        start = self._energy_meter_start
        source = self._energy_meter_source
        if start is None or source is None:
            return None
        cur = self._read_energy_meter()
        if cur is None:
            return None
        cur_wh, cur_source = cur
        if cur_source != source:
            return None
        delta = cur_wh - start
        if delta <= 0:
            return None
        return delta

    @staticmethod
    def _cycle_report_energy_wh(cycle_data: dict[str, Any]) -> float:
        """User-facing reported energy (Wh): meter value if present, else integrated.

        The integrated ``energy_wh`` is always stored and used internally (matching,
        ML, anomaly, envelopes); only cost/lifetime/notifications/panel display
        prefer the more accurate meter figure when one was captured (issue #316).
        """
        meter = cycle_data.get("energy_meter_wh")
        if meter is not None:
            try:
                return float(meter)
            except (ValueError, TypeError):
                pass
        try:
            return float(cycle_data.get("energy_wh", 0.0))
        except (ValueError, TypeError):
            return 0.0

    def _augment_active_snapshot(self, snapshot: dict[str, Any]) -> dict[str, Any]:
        """Add manager-owned fields to a detector snapshot before persisting.

        The detector snapshot only carries detector state; these fields are owned
        by the manager and must survive a restart alongside it. Kept in one place
        so every save site (shutdown, periodic, pause, resume) stays consistent.
        """
        snapshot["manual_program"] = self._manual_program_active
        snapshot["notified_start"] = self._notified_start
        snapshot["start_event_fired"] = self._start_event_fired
        snapshot["is_user_paused"] = self._is_user_paused
        snapshot["user_pause_start"] = (
            self._user_pause_start.isoformat() if self._user_pause_start else None
        )
        snapshot["total_user_paused_seconds"] = self._total_user_paused_seconds
        snapshot["energy_meter_start"] = self._energy_meter_start
        snapshot["energy_meter_source"] = self._energy_meter_source
        return snapshot

    @staticmethod
    def _format_vs_typical(
        duration: float,
        median: float | None,
        *,
        longer_template: str = "{pct}% longer than usual",
        shorter_template: str = "{pct}% shorter than usual",
    ) -> str:
        """Human comparison of a cycle's duration to its profile median.

        Returns "" when there is no usable median or the difference is under 1%.
        This fills the ``vs_typical`` variable of the finish-message template. The
        text itself is fixed (not user-editable), so it is localizable: callers pass
        the resolved ``options.error.vs_typical_*`` templates; the English defaults
        here mirror strings.json and are used as the resilient fallback.
        """
        try:
            if not median or float(median) <= 0:
                return ""
            pct = round((float(duration) - float(median)) / float(median) * 100)
        except (ValueError, TypeError, ZeroDivisionError):
            return ""
        try:
            if pct >= 1:
                return longer_template.format(pct=pct)
            if pct <= -1:
                return shorter_template.format(pct=abs(pct))
        except (KeyError, IndexError, ValueError):
            # Malformed translation template; fall back to the English default.
            if pct >= 1:
                return f"{pct}% longer than usual"
            if pct <= -1:
                return f"{abs(pct)}% shorter than usual"
        return ""

    def _peak_rate_tip(self, options: dict[str, Any], price: float | None) -> str:
        """Return a peak-rate advisory tip for the start notification, or "".

        Appended only when a positive ``peak_rate_threshold`` is configured and the
        current price meets/exceeds it. Purely informational — no scheduling or
        appliance control. Never raises; a bad threshold is skipped silently.
        """
        try:
            raw = options.get(CONF_PEAK_RATE_THRESHOLD)
            if raw in (None, ""):
                return ""
            threshold = float(raw)
            if threshold <= 0 or price is None or float(price) < threshold:
                return ""
            tip_template = options.get(CONF_PEAK_RATE_MESSAGE) or DEFAULT_PEAK_RATE_MESSAGE
            return self._safe_format_template(
                tip_template,
                fallback_template=DEFAULT_PEAK_RATE_MESSAGE,
                device=self.config_entry.title,
                price=f"{float(price):.3f}",
            )
        except (ValueError, TypeError):
            return ""

    async def _async_process_cycle_end(
        self, cycle_data: dict[str, Any], cycle_token: str | None = None
    ) -> None:
        """Process cycle completion asynchronously (heavy tasks).

        ``cycle_token`` is the ``_ranking_snapshot_cycle_id`` captured when this cycle
        ended. The terminal-state reset at the tail is skipped if a new cycle has
        started since (token changed), so back-to-back cycles are not clobbered (B1).
        """

        # FINAL PROFILE MATCH: If still detecting, try one last match with complete cycle data
        if self._current_program in ("detecting...", "restored..."):
            await self._run_final_match_from_cycle_data(cycle_data)

        # B1: freeze THIS cycle's live context into immutable locals now, before the
        # persistence / auto-label / lifetime-energy awaits below. A new cycle can
        # start synchronously during any of those awaits (a back-to-back load drives
        # the detector into a fresh RUNNING via _on_state_change, which rolls
        # _current_program back to "detecting..." and resets the match fields). The
        # tail (event payload, finish notification, learning inputs) must describe the
        # cycle that just finished, not whatever the live fields hold by the time each
        # await returns. The final match above is what determines these values for
        # this cycle, so capture right after it.
        program = self._current_program
        match_result = self._last_match_result
        match_confidence = self._last_match_confidence
        matched_profile_duration = self._matched_profile_duration
        cycle_anomaly = self._cycle_anomaly
        overrun_ratio = self._overrun_ratio

        # If we had a runtime match, attach the profile name for persistence
        if (
            program
            and program not in ("off", "detecting...", "restored...")
            and program in self.profile_store.get_profiles()
        ):
            cycle_data["profile_name"] = program
            cycle_data["label_source"] = "auto_match"
            if match_confidence:
                cycle_data["match_confidence"] = float(match_confidence)

        # Attach extensive debug data if available (and configured)
        if match_result:
            ranking = getattr(match_result, "ranking", [])
            # Top-5 ranking stored unconditionally (small, high training value).
            # SANITIZE: strip heavy current/sample arrays — this field is NOT in the
            # EVENT_CYCLE_ENDED exclusion set, so it must stay small (32KB limit).
            cycle_data["match_ranking_top5"] = _sanitize_ranking(ranking)
            cycle_data["debug_data"] = {
                "ranking": ranking,
                "details": getattr(match_result, "debug_details", {}),
                "ambiguous": getattr(match_result, "is_ambiguous", False),
            }

        # Post-Cycle Auto-Labeling (if not already matched)
        # Offload this match too if needed
        if not cycle_data.get("profile_name") and self._auto_label_confidence > 0:
            res = await self.profile_store.async_match_profile(
                cycle_data["power_data"], cycle_data["duration"]
            )
            if res.best_profile and res.confidence >= self._auto_label_confidence:
                cycle_data["profile_name"] = res.best_profile
                cycle_data["label_source"] = "auto_label_post"
                cycle_data["match_confidence"] = float(res.confidence)
                # Top-5 from post-cycle match (may differ from live match ranking).
                # Sanitize: strip heavy current/sample arrays (these fields are NOT
                # in the fired-event exclusion set, so they must stay small to keep
                # EVENT_CYCLE_ENDED under HA's 32KB event-data limit).
                cycle_data["match_ranking_top5"] = _sanitize_ranking(
                    getattr(res, "ranking", [])
                )
                self._logger.info(
                    "Post-cycle auto-labeled as '%s' (confidence: %.2f)",
                    res.best_profile,
                    res.confidence,
                )

        # Back-fill confirmed label on any ranking snapshots captured during this cycle
        # so the live_match on-device trainer can use them as labelled examples.
        _start_iso = cycle_data.get("start_time")
        _confirmed_profile = cycle_data.get("profile_name")
        if _start_iso and _confirmed_profile:
            try:
                self.profile_store.confirm_match_ranking_snapshots(
                    _start_iso,
                    _confirmed_profile,
                    # Use the token captured when THIS cycle ended, not the live
                    # field, which may already have rolled to a newly-started cycle
                    # during the awaits above (else this cycle's snapshots go
                    # unlabelled and a new cycle's snapshot gets mislabelled).
                    cycle_id=cycle_token or None,
                )
            except Exception:  # noqa: BLE001
                pass

        # Compute envelope conformance for the matched profile.
        # Stored as cycle_data["envelope_conformance"] so the panel and quality
        # gate can display/use it.  Only runs when we have a profile + power trace.
        _ep = cycle_data.get("profile_name")
        _pd = cycle_data.get("power_data")
        if _ep and isinstance(_pd, list) and len(_pd) >= 4:
            try:
                from .time_utils import power_data_to_offsets  # noqa: PLC0415
                _pts = [(float(o), float(p)) for o, p in power_data_to_offsets(_pd, _start_iso)]
                if len(_pts) >= 4:
                    conformance_rec = self.profile_store.compute_envelope_conformance(_ep, _pts)
                    if conformance_rec is not None:
                        cycle_data["envelope_conformance"] = conformance_rec.get("conformance")
                    # Transient artifacts (door-open pauses, out-of-band dips/spikes)
                    # for graph markers + a Cycles-list badge; [] when none.
                    artifacts = self.profile_store.detect_cycle_artifacts(_ep, _pts)
                    if artifacts:
                        cycle_data["artifacts"] = artifacts
            except Exception:  # noqa: BLE001
                pass

        # Freeze the runtime overrun anomaly onto the cycle for panel badging.
        # "overrun" means the cycle ran materially longer than its matched
        # profile's typical duration; purely informational (never a notification).
        if cycle_anomaly and cycle_anomaly != "none":
            cycle_data["anomaly"] = cycle_anomaly
            if overrun_ratio > 0:
                cycle_data["overrun_ratio"] = round(float(overrun_ratio), 3)

        # A1: Underrun check — computed post-cycle only, not a live signal.
        # Only applied when no runtime anomaly was detected (underrun and overrun are mutually exclusive).
        try:
            if not cycle_data.get("anomaly") or cycle_data["anomaly"] == "none":
                _uc_profile = cycle_data.get("profile_name")
                _uc_dur = float(cycle_data.get("duration", 0))
                if _uc_profile and _uc_dur > 0:
                    _uc_median = self.profile_store.get_profile_median_duration(_uc_profile)
                    if (
                        isinstance(_uc_median, (int, float))
                        and not isinstance(_uc_median, bool)
                        and _uc_median > 0
                        and _uc_dur < _uc_median * CYCLE_UNDERRUN_ANOMALY_RATIO
                    ):
                        cycle_data["anomaly"] = "underrun"
                        cycle_data["underrun_ratio"] = round(_uc_dur / _uc_median, 3)
        except Exception:  # noqa: BLE001
            pass

        # A2: Energy spike/low anomaly — stored separately from duration anomaly.
        try:
            _ea_profile = cycle_data.get("profile_name")
            _ea_energy = float(cycle_data.get("energy_wh", 0))
            if _ea_profile and _ea_energy > 0:
                _ea_stats = self.profile_store.get_profile_energy_stats(_ea_profile)
                if (
                    isinstance(_ea_stats, dict)
                    and isinstance(_ea_stats.get("std_wh"), (int, float))
                    and _ea_stats["std_wh"] > 0
                ):
                    _ea_z = (_ea_energy - _ea_stats["avg_wh"]) / _ea_stats["std_wh"]
                    cycle_data["energy_z_score"] = round(_ea_z, 2)
                    if _ea_z > ENERGY_ANOMALY_Z_THRESHOLD:
                        cycle_data["energy_anomaly"] = "energy_spike"
                    elif _ea_z < -ENERGY_ANOMALY_Z_THRESHOLD:
                        cycle_data["energy_anomaly"] = "energy_low"
        except Exception:  # noqa: BLE001
            pass

        # Cache post-cycle anomaly data so sensor attributes surface it while idle.
        self._last_cycle_post_anomaly = {
            k: cycle_data[k]
            for k in ("anomaly", "underrun_ratio", "energy_anomaly", "energy_z_score")
            if k in cycle_data
        }

        # Store any HA restart gaps that occurred during this cycle.
        # The panel shades these regions in the power trace and shows a badge.
        # Matching always uses real readings only (no synthetic fill in power_data).
        # Copy them onto the cycle now but keep the source list intact until the
        # cycle is confirmed persisted (below) — clearing here would lose them if
        # async_add_cycle() fails.
        restart_gaps_snapshot: list[dict[str, Any]] | None = None
        if self._restart_gaps:
            restart_gaps_snapshot = list(self._restart_gaps)
            cycle_data["restart_gaps"] = restart_gaps_snapshot

        # Freeze the energy cost onto the cycle using the price in effect NOW, so
        # later price changes never rewrite historical costs. Stored as a number
        # (currency per the configured price's unit); absent when no price is set.
        price = self._resolve_energy_price()
        if price is not None:
            cycle_data["energy_price"] = price
            cycle_data["cost"] = round(
                self._cycle_report_energy_wh(cycle_data) / 1000.0 * price, 4
            )

        # Score cycle quality with the ML model before persisting so the score is
        # stored on the cycle record and available to the learning manager immediately.
        # Must run BEFORE async_add_cycle so get_past_cycles() inside the scorer does
        # not yet include the current cycle, keeping reference statistics uncontaminated.
        # Only opted-in devices reach the scorer, and only then is it offloaded to the
        # executor: on a long trace its NumPy feature extraction is O(N) and must not
        # block the event loop (mirrors the profile matcher). Gating here avoids a
        # pointless thread-hop for the default (ML-off) case where the scorer no-ops.
        # The scorer mutates only cycle_data (nothing else touches it here) and never
        # raises, so this is executor-safe.
        from .ml.engine import ml_models_enabled  # noqa: PLC0415

        if ml_models_enabled(self.config_entry.options):
            # Snapshot past_cycles on the event loop before offloading: iterating
            # (or copying) the live list inside the executor races the loop
            # appending this just-finished cycle.
            past_cycles_snapshot = list(self.profile_store.get_past_cycles())
            await self.hass.async_add_executor_job(
                self._compute_cycle_quality_score, cycle_data, past_cycles_snapshot
            )

        # Add cycle to store immediately (still sync but offloadable parts optimized
        # internally if possible)
        # Note: add_cycle is mostly safe (signature calc is O(N) but fast enough for
        # single cycle).
        # We could offload signature calc to analysis logic if really needed, but let's
        # stick to match profile optimization first.
        cycle_persisted = False
        try:
            await self.profile_store.async_add_cycle(cycle_data)
            cycle_persisted = True
            # The cycle (with its restart_gaps) is now durably stored, so it is safe
            # to drop the live buffer. Doing this only after a confirmed persist means
            # a failed save keeps the gaps for the next cycle-end attempt.
            if restart_gaps_snapshot is not None:
                self._restart_gaps.clear()
            profile_name = cycle_data.get("profile_name")
            if profile_name:
                await self.profile_store.async_rebuild_envelope(profile_name)
        except Exception as e: # pylint: disable=broad-exception-caught
            self._logger.error("Failed to add cycle to store: %s", e)

        # C2: bump the persisted lifetime cycle counter. Unlike ``cycle_count``
        # (== len(history), which regresses when history is trimmed/merged), this
        # monotonic counter only ever increments — and only on a real persisted
        # cycle — so milestones stay correct across retention limits. Captured here
        # for the milestone check below. The write is persisted by the lifetime-energy
        # save immediately after (same store, one save).
        prev_lifetime_count: int | None = None
        cur_lifetime_count: int | None = None
        if cycle_persisted:
            try:
                prev_lifetime_count = self._lifetime_cycle_count()
                cur_lifetime_count = prev_lifetime_count + 1
                # In-memory only; persisted by the batched lifetime-energy save below.
                self.profile_store.set_lifetime_cycle_count(cur_lifetime_count)
            except Exception:  # noqa: BLE001 - counter must never break cycle end
                prev_lifetime_count = None
                cur_lifetime_count = None

        # B1: accumulate lifetime energy for the HA Energy dashboard sensor. Runs
        # exactly once per persisted cycle so the TOTAL_INCREASING meter never
        # double-counts. Never breaks cycle end.
        if cycle_persisted:
            try:
                await self.profile_store.async_add_lifetime_energy_wh(
                    self._cycle_report_energy_wh(cycle_data)
                )
            except Exception as e:  # pylint: disable=broad-exception-caught
                self._logger.debug("Failed to accumulate lifetime energy: %s", e)

        # Ensure cycle has a stable ID even if store add failed (or did not mutate).
        if not cycle_data.get("id"):
            try:
                unique_str = f"{cycle_data['start_time']}_{cycle_data['duration']}"
                cycle_data["id"] = hashlib.sha256(unique_str.encode()).hexdigest()[:12]
            except Exception:  # noqa: BLE001
                pass

        # B1: only clear the active-cycle snapshot if it still belongs to THIS cycle.
        # If a new cycle started during the awaits above, it now owns the active
        # snapshot; clearing it here would strip the new cycle's restart-resilience.
        if cycle_token is None or self._ranking_snapshot_cycle_id == cycle_token:
            self._spawn_tracked(self.profile_store.async_clear_active_cycle())

        # Auto post-process: merge fragmented cycles from last 3 hours
        self._spawn_tracked(self._run_post_cycle_processing())

        # Prepare cycle data for event (enrich if needed)
        # IMPORTANT: Exclude large fields to prevent exceeding HA's 32KB event data limit
        excluded_fields = {"power_data", "debug_data", "power_trace"}
        event_cycle_data = {
            k: v for k, v in cycle_data.items() if k not in excluded_fields
        }
        event_cycle_data["device_type"] = self.device_type
        # Add program if missing or generic (use THIS cycle's captured program, not
        # the live field which may already belong to a newly-started cycle).
        if "profile_name" not in event_cycle_data and program:
            event_cycle_data["profile_name"] = program

        if self._notify_fire_events:
            self.hass.bus.async_fire(
                EVENT_CYCLE_ENDED,
                {
                    "entry_id": self.entry_id,
                    "device_name": self.config_entry.title,
                    "cycle_data": event_cycle_data,
                    "program": event_cycle_data.get("profile_name", "unknown"),
                    "duration": event_cycle_data.get("duration"),
                    "start_time": event_cycle_data.get("start_time"),
                    "end_time": event_cycle_data.get("end_time") or dt_util.now().isoformat(),
                },
            )

        # Purge pending live entries and reset counters, but don't send a service-level
        # clear: the finished notification below reuses the lifecycle tag and replaces
        # the live card in place (sending a clear first would cause a dismiss/recreate
        # flicker). The action-based clear marker still fires for action templates.
        self._clear_live_progress_notification(clear_services=False)

        # Send notification if enabled
        if self._notify_finish_services or self._notify_actions:
            msg_template = self.config_entry.options.get(CONF_NOTIFY_FINISH_MESSAGE, DEFAULT_NOTIFY_FINISH_MESSAGE)
            duration_min = int(cycle_data['duration'] / 60)
            program_name = event_cycle_data.get("profile_name", "unknown")

            energy_kwh = round(self._cycle_report_energy_wh(cycle_data) / 1000, 3)

            # Reuse the cost frozen onto the cycle above (same price resolution).
            cost_val = cycle_data.get("cost")
            cost_str = f"{cost_val:.2f}" if cost_val is not None else ""

            # B3: extra finish-notification template variables. All are safe to
            # ignore in a template — str.format drops unused kwargs.
            time_finished = dt_util.now().strftime("%H:%M")
            # Prefer the monotonic lifetime counter (falls back to the retained count).
            finished_cycle_count = (
                cur_lifetime_count if cur_lifetime_count is not None else self.cycle_count
            )
            vs_typical = ""
            matched_name = cycle_data.get("profile_name")
            if matched_name:
                _median = self.profile_store.get_profile_median_duration(matched_name)
                vs_typical = self._format_vs_typical(
                    cycle_data.get("duration", 0.0),
                    _median,
                    longer_template=self._timer_ui_strings.get(
                        "vs_typical_longer", "{pct}% longer than usual"
                    ),
                    shorter_template=self._timer_ui_strings.get(
                        "vs_typical_shorter", "{pct}% shorter than usual"
                    ),
                )

            msg = self._safe_format_template(
                msg_template,
                fallback_template=DEFAULT_NOTIFY_FINISH_MESSAGE,
                device=self.config_entry.title,
                duration=duration_min,
                program=program_name,
                energy_kwh=f"{energy_kwh:.3f}",
                cost=cost_str,
                time_finished=time_finished,
                cycle_count=finished_cycle_count,
                vs_typical=vs_typical,
            )
            self._dispatch_notification(
                msg,
                event_type=NOTIFY_EVENT_FINISH,
                extra_vars={
                    "duration_minutes": duration_min,
                    "duration_seconds": cycle_data["duration"],
                    "program": program_name,
                    "energy_kwh": energy_kwh,
                    "cost": cost_str,
                    "time_finished": time_finished,
                    "cycle_count": finished_cycle_count,
                    "vs_typical": vs_typical,
                    # Same lifecycle tag as start/live so the finished alert replaces
                    # the live notification in place. No live_update/alert_once here,
                    # so the companion app surfaces it with sound.
                    "tag": self._lifecycle_tag,
                    # C3: end the iOS Live Activity (mobile_app_* only downstream).
                    "activity": "end",
                },
            )

        # C2: milestone (cycle-count achievement) notification. Fires at most once per
        # cycle, only when the cycle actually persisted (so the lifetime count is real)
        # and a finish delivery channel is configured. Respects quiet hours via
        # _dispatch_notification's finish-type gate.
        if cycle_persisted:
            self._maybe_notify_milestone(prev_lifetime_count, cur_lifetime_count)

        # Request user feedback if we had a confident match.
        # AND perform learning analysis on the completed cycle.
        # IMPORTANT: this must happen before we clear match state.
        # Only run when the cycle was actually persisted — an unpersisted cycle
        # has no store entry to reference, so a pending-feedback record would
        # dangle forever. Use THIS cycle's captured match context (not the live
        # fields, which may already belong to a newly-started cycle after the awaits).
        if cycle_persisted:
            self.learning_manager.process_cycle_end(
                cycle_data,
                detected_profile=program,
                confidence=match_confidence or 0.0,
                predicted_duration=matched_profile_duration,
                match_result=match_result,
            )

        # B1: a new cycle may have started while the heavy post-processing above was
        # awaiting. If so, the manager's live-cycle fields (_current_program,
        # _cycle_start_time, _ranking_snapshot_cycle_id, progress) now belong to the
        # NEW cycle. Zeroing them here — and re-arming the state-expiry timer — used to
        # clobber the running cycle and, once it hit PAUSED/ENDING past the reset delay,
        # reset it to Off mid-run. Detect the new cycle via the identity token and skip
        # the terminal-state reset; cycle A was already persisted/learned/notified above.
        if cycle_token is not None and self._ranking_snapshot_cycle_id != cycle_token:
            self._logger.debug(
                "Cycle-end post-processing completed after a new cycle started "
                "(token %s -> %s); skipping terminal-state reset to preserve the "
                "live cycle.",
                cycle_token,
                self._ranking_snapshot_cycle_id,
            )
            self._notify_update()
            return

        # Clear all state and timers - zero everything out
        self._current_program = "off"
        self._manual_program_active = False
        self._notified_pre_completion = False
        self._time_remaining = None
        self._matched_profile_duration = None
        self._last_estimate_time = None
        self._last_match_result = None  # Clear so phase sensor resets to "Off" (issue #192)
        self._cycle_progress = 100.0  # 100% = cycle complete
        self._cycle_completed_time = dt_util.now()
        self._cycle_start_time = None
        self._ranking_snapshot_cycle_id = ""
        self._reset_live_notification_state()

        # Reset pause tracking for the next cycle
        self._is_user_paused = False
        self._user_pause_start = None
        self._total_user_paused_seconds = 0.0

        # Enter Clean state if door sensor is configured and door is currently closed
        self._is_clean_state = False
        self._clean_state_start = None
        self._notified_clean_laundry = False
        self._reset_unload_nag_tracking()
        if self._door_sensor_entity:
            door_state = self.hass.states.get(self._door_sensor_entity)
            if door_state and door_state.state == "off":  # binary_sensor: off = closed
                self._is_clean_state = True
                self._clean_state_start = dt_util.now()
                self._logger.debug(
                    "Cycle ended with door closed: entering Clean state"
                )

        # Start progress reset timer to go back to 0% after user unload window
        self._start_state_expiry_timer()

        self._notify_update()

    @property
    def profile_sample_repair_stats(self) -> dict[str, int] | None:
        """Return statistics from profile sample repair operation."""
        return self._profile_sample_repair_stats

    @property
    def suggestions(self) -> dict[str, Any]:
        """Suggested settings computed by learning/heuristics (never auto-applied)."""
        return self.profile_store.get_suggestions()

    # ------------------------------------------------------------------
    # C1 - Quiet hours (do-not-disturb window)
    # ------------------------------------------------------------------
    def _quiet_hours_bounds(self) -> tuple[int, int] | None:
        """Return validated (start_hour, end_hour) or None when the feature is off.

        Off when either hour is unset/None/non-int/out-of-range, or start == end.
        """
        return notif_rules.quiet_hours_bounds(self.config_entry.options)

    def _in_quiet_hours(self, when: datetime | None = None) -> bool:
        """Return True when ``when`` (default now) falls inside the quiet window.

        Supports windows that wrap midnight (start > end, e.g. 22 -> 7 means
        22:00-06:59). The end hour is exclusive at the hour granularity, so a window
        of start=22, end=7 covers hours 22, 23, 0..6.
        """
        return notif_rules.in_quiet_hours(
            self._quiet_hours_bounds(), when or dt_util.now()
        )

    def _seconds_until_quiet_end(self, when: datetime | None = None) -> float:
        """Seconds from ``when`` until the next end-of-quiet-window boundary (end:00).

        Returns 0.0 when the feature is off or when not currently in quiet hours.
        """
        return notif_rules.seconds_until_quiet_end(
            self._quiet_hours_bounds(), when or dt_util.now()
        )

    def _queue_quiet_hours_notification(
        self,
        message: str,
        *,
        title: str | None,
        icon: str | None,
        event_type: str | None,
        extra_vars: dict[str, Any] | None,
    ) -> None:
        """Park a finish-type notification until the quiet window ends."""
        self._quiet_pending_notifications.append(
            {
                "message": message,
                "title": title,
                "icon": icon,
                "event_type": event_type,
                "extra_vars": extra_vars,
            }
        )
        self._schedule_quiet_hours_flush()

    def _schedule_quiet_hours_flush(self) -> None:
        """(Re)arm the single async_call_later timer that flushes the quiet queue."""
        if self._remove_quiet_hours_timer is not None:
            # A timer is already pending; keep it (all queued items share one release).
            return
        delay = self._seconds_until_quiet_end()
        if delay <= 0:
            # Not actually in quiet hours (defensive) -> flush immediately.
            self._flush_quiet_hours_notifications()
            return

        @callback
        def _fire(_now: datetime) -> None:
            self._remove_quiet_hours_timer = None
            self._flush_quiet_hours_notifications()

        self._remove_quiet_hours_timer = async_call_later(self.hass, delay, _fire)

    def _flush_quiet_hours_notifications(self) -> None:
        """Deliver every queued quiet-hours notification (same service/message/tag)."""
        if self._remove_quiet_hours_timer is not None:
            self._remove_quiet_hours_timer()
            self._remove_quiet_hours_timer = None
        if not self._quiet_pending_notifications:
            return
        pending = list(self._quiet_pending_notifications)
        self._quiet_pending_notifications = []
        for entry in pending:
            # Disable ONLY the quiet-hours re-hold (the window is closing), but keep
            # presence gating on: if nobody is home and notify_only_when_home is set,
            # the item must stay queued in the presence queue rather than fire into an
            # empty house. (Previously allow_deferral=False disabled both, delivering
            # to nobody.)
            self._dispatch_notification(
                entry["message"],
                title=entry.get("title"),
                icon=entry.get("icon"),
                event_type=entry.get("event_type"),
                extra_vars=entry.get("extra_vars"),
                allow_deferral=False,
                allow_presence_deferral=True,
            )

    def _cancel_quiet_hours_timer(self) -> None:
        """Cancel the pending quiet-hours release timer (shutdown/unload)."""
        if self._remove_quiet_hours_timer is not None:
            self._remove_quiet_hours_timer()
            self._remove_quiet_hours_timer = None

    # ------------------------------------------------------------------
    # C2 - Milestone (cycle-count achievement) notifications
    # ------------------------------------------------------------------
    @staticmethod
    def _milestone_crossed(
        prev_count: int, cur_count: int, milestones: Any
    ) -> int | None:
        """Return the milestone just crossed, or None.

        A milestone ``m`` is crossed when ``prev_count < m <= cur_count``. Empty or
        malformed ``milestones`` is a no-op (returns None). If several are crossed in
        one step the largest is returned so a single, most-significant notification
        fires.
        """
        return notif_rules.milestone_crossed(prev_count, cur_count, milestones)

    def _lifetime_cycle_count(self) -> int:
        """Persisted monotonic lifetime completed-cycle count.

        Unlike ``cycle_count`` (== len(retained history)), this only ever increments
        and never regresses when history is trimmed/merged, so it is the correct basis
        for milestone crossings. Falls back to ``cycle_count`` if the persisted value
        is unavailable. Never raises.
        """
        try:
            return self.profile_store.get_lifetime_cycle_count()
        except Exception:  # noqa: BLE001
            try:
                return int(self.cycle_count)
            except Exception:  # noqa: BLE001
                return 0

    def _maybe_notify_milestone(
        self, prev_count: int | None = None, cur_count: int | None = None
    ) -> int | None:
        """Fire one milestone notification if the lifetime count just crossed one.

        Called at cycle end AFTER the cycle has persisted. ``prev_count``/``cur_count``
        are the persisted lifetime counter's values from before/after this cycle's
        persist; when omitted they are resolved from the persisted counter
        (previous = current - 1). Using the monotonic lifetime counter (not
        ``cycle_count`` == len(history)) keeps milestones correct across retention
        trims/merges. Returns the crossed milestone value (for tests/logging) or None.
        Never raises.
        """
        try:
            if not (self._notify_finish_services or self._notify_actions):
                return None
            milestones = self.config_entry.options.get(
                CONF_NOTIFY_MILESTONES, DEFAULT_NOTIFY_MILESTONES
            )
            if cur_count is None:
                cur_count = self._lifetime_cycle_count()
            if prev_count is None:
                prev_count = cur_count - 1
            crossed = self._milestone_crossed(prev_count, cur_count, milestones)
            if crossed is None:
                return None
            msg_template = self.config_entry.options.get(
                CONF_NOTIFY_MILESTONE_MESSAGE, DEFAULT_NOTIFY_MILESTONE_MESSAGE
            )
            msg = self._safe_format_template(
                msg_template,
                fallback_template=DEFAULT_NOTIFY_MILESTONE_MESSAGE,
                device=self.config_entry.title,
                cycle_count=crossed,
            )
            self._dispatch_notification(
                msg,
                event_type=NOTIFY_EVENT_FINISH,
                extra_vars={
                    "cycle_count": crossed,
                    # Distinct tag so a milestone alert does not clobber (or get
                    # clobbered by) the lifecycle finish thread.
                    "tag": f"{self._lifecycle_tag}_milestone",
                },
            )
            self._logger.info(
                "Sent milestone notification: %s cycles", crossed
            )
            return crossed
        except Exception as err:  # pylint: disable=broad-exception-caught
            self._logger.debug("Milestone notification check failed: %s", err)
            return None

    # ------------------------------------------------------------------
    # C3 - iOS Live Activity enrichment (HA Companion beta, mobile_app_* only)
    # ------------------------------------------------------------------
    @staticmethod
    def _build_ios_live_activity_extras(
        *,
        state: str,
        progress_pct: float,
        eta_timestamp: Any,
        program: str | None,
        device: str,
        activity: str | None = None,
    ) -> dict[str, Any]:
        """Build the iOS Live Activity payload additions (mobile-only keys).

        Returns a dict containing ``content_state`` (always), ``subtitle`` (only when
        a program is matched) and ``activity`` (only when a lifecycle marker is
        supplied). These keys are forwarded to mobile_app_* targets only by
        ``_send_notification_service``; other platforms never receive them.
        """
        try:
            pct = int(round(float(progress_pct)))
        except (TypeError, ValueError):
            pct = 0
        pct = max(0, min(100, pct))
        extras: dict[str, Any] = {
            "content_state": {
                "state": state,
                "progress_pct": pct,
                "eta_timestamp": eta_timestamp,
                "program": program or "",
                "device": device,
            }
        }
        if program:
            extras["subtitle"] = program
        if activity:
            extras["activity"] = activity
        return extras

    @staticmethod
    def _mobile_service_extras(
        ev: dict[str, Any], notify_service: str | None
    ) -> dict[str, Any]:
        """Return extra_vars keys allowed only on mobile_app_* targets.

        For non-mobile services this is always empty, so strict-schema platforms and
        the iOS Live Activity enrichment keys stay isolated to mobile targets.
        """
        if not WashDataManager._is_mobile_notify_service(notify_service):
            return {}
        return {k: ev[k] for k in _MOBILE_ONLY_EXTRA_KEYS if k in ev}

    def _safe_format_template(
        self,
        template: Any,
        *,
        fallback_template: str | None = None,
        **kwargs: Any,
    ) -> str:
        """Format templates safely and return a resilient fallback on any error."""
        text_template = str(template)
        try:
            return text_template.format(**kwargs)
        except Exception as err:  # pylint: disable=broad-exception-caught
            self._logger.debug(
                "Failed to format notification template %r with %s: %s",
                text_template,
                kwargs,
                err,
            )

        if fallback_template:
            try:
                return fallback_template.format(**kwargs)
            except Exception as err:  # pylint: disable=broad-exception-caught
                self._logger.debug(
                    "Failed to format fallback notification template %r with %s: %s",
                    fallback_template,
                    kwargs,
                    err,
                )

        device = str(kwargs.get("device") or self.config_entry.title)
        program = kwargs.get("program")
        if program:
            return f"{device}: {program}"
        return device

    def _get_services_for_event(self, event_type: str | None) -> list[str]:
        """Return the configured notify service list for the given event type."""
        if event_type == NOTIFY_EVENT_START:
            return self._notify_start_services
        if event_type in (NOTIFY_EVENT_FINISH, "pre_complete", NOTIFY_EVENT_CLEAN):
            return self._notify_finish_services
        if event_type == NOTIFY_EVENT_LIVE:
            return self._notify_live_services
        if event_type == NOTIFY_EVENT_TIMER:
            # Cycle timers go to all configured services (start union finish, deduped).
            return list(dict.fromkeys(
                self._notify_start_services + self._notify_finish_services
            ))
        return []

    def _resolve_channel(self, event_type: str | None) -> str | None:
        """Resolve the Android notification channel name for an event type.

        Finished, the clean-laundry nag, and the pre-completion reminder route to the
        dedicated finish channel (so they can carry their own sound), falling back to
        the status channel. Start/live use the status channel. An empty configured
        value means "omit channel" so existing setups are unchanged.
        """
        status_channel = self.config_entry.options.get(
            CONF_NOTIFY_CHANNEL, DEFAULT_NOTIFY_CHANNEL
        )
        finish_channel = self.config_entry.options.get(
            CONF_NOTIFY_FINISH_CHANNEL, DEFAULT_NOTIFY_FINISH_CHANNEL
        )
        if event_type in (NOTIFY_EVENT_FINISH, NOTIFY_EVENT_CLEAN, "pre_complete"):
            return (finish_channel or status_channel) or None
        return status_channel or None

    def _log_notification(
        self,
        event_type: str | None,
        message: str,
        *,
        targets: str = "",
        deferred_reason: str | None = None,
        delivered: bool = True,
    ) -> None:
        """Emit log lines for a notification's send / defer / drop.

        Every user-facing notification funnels through ``_dispatch_notification``,
        so this is the single place that records what WashData notified about,
        where it went, and whether it was delivered, deferred (quiet-hours /
        presence hold), or dropped.

        Log contract (regression-locked by ``test_manager_notification_logging``):
        - **INFO** ``"Notification sent (<event>)"`` — one line per discrete
          notification (start/finish/milestone/clean/pause/…). Target list and
          message body are omitted to prevent entity-ID PII from leaking into
          bug-report logs.
        - **DEBUG** ``"Notification sent (<event>) via <targets>: <summary>"`` —
          full target list and truncated message body for troubleshooting.
        - **DEBUG** for live-progress ticks (high-frequency in-place updates).
        - **DEBUG** for deferred (quiet-hours/presence hold) and not-delivered paths.

        Deferred items are re-dispatched when the hold clears and log again as
        "sent" on actual delivery.
        """
        label = event_type or "notification"
        # Countdown / finish messages can span multiple lines - collapse to one.
        summary = " ".join(str(message).split())
        if len(summary) > 200:
            summary = summary[:197] + "..."
        is_live = event_type == NOTIFY_EVENT_LIVE
        if deferred_reason:
            self._logger.debug(
                "Notification deferred (%s) - %s: %s", label, deferred_reason, summary
            )
        elif not delivered:
            self._logger.debug(
                "Notification not delivered (%s) - no matching target: %s",
                label, summary,
            )
        elif is_live:
            self._logger.debug(
                "Notification sent (%s) via %s: %s", label, targets, summary
            )
        else:
            self._logger.info("Notification sent (%s)", label)
            self._logger.debug(
                "Notification sent (%s) via %s: %s", label, targets, summary
            )

    def _dispatch_notification(
        self,
        message: str,
        *,
        title: str | None = None,
        icon: str | None = None,
        event_type: str | None = None,
        person_entity_id: str | None = None,
        person_name: str | None = None,
        extra_vars: dict[str, Any] | None = None,
        allow_deferral: bool = True,
        allow_presence_deferral: bool = True,
    ) -> bool:
        """Route notification via actions or notify service with optional gating.

        ``allow_deferral`` gates the quiet-hours (do-not-disturb) hold; a
        quiet-window flush passes ``allow_deferral=False`` so the released item is
        not re-held by the still-closing window. ``allow_presence_deferral`` gates
        the "notify only when home" presence hold *independently* — a quiet-hours
        flush must keep presence gating on (nobody home => stay queued), so it
        leaves ``allow_presence_deferral=True``. Only the presence flush (which
        runs *because* someone is now home) disables both.
        """
        # Signals whether this call *queued* the notification for later delivery
        # (quiet-hours / presence hold) instead of sending or dropping it. Callers
        # that use a "fire once" dedup flag (e.g. the clean-laundry nag) must treat
        # a deferral as handled, otherwise they re-queue a duplicate on every retry
        # tick for the whole quiet/away window.
        self._last_dispatch_deferred = False
        if not title:
            title_template = self.config_entry.options.get(CONF_NOTIFY_TITLE, DEFAULT_NOTIFY_TITLE)
            title = self._safe_format_template(
                title_template,
                fallback_template=DEFAULT_NOTIFY_TITLE,
                device=self.config_entry.title,
            )

        if not icon:
            icon = self.config_entry.options.get(CONF_NOTIFY_ICON)

        if person_entity_id is None and self._notify_people:
            for candidate in self._notify_people:
                state = self.hass.states.get(candidate)
                if state and state.state == STATE_HOME:
                    person_entity_id = candidate
                    person_name = state.name or state.attributes.get(
                        "friendly_name", candidate
                    )
                    break

        variables: dict[str, Any] = {
            "device": self.config_entry.title,
            "program": self._current_program,
            "message": message,
            "title": title,
            "icon": icon,
            "event_type": event_type,
            "person_entity_id": person_entity_id,
            "person_name": person_name,
        }
        if extra_vars:
            variables.update(extra_vars)

        # Channel + auto-dismiss timeout apply to every event type. Inject into both
        # the action variables and the notify-service extra_vars so both delivery
        # paths honour them. Empty channel / zero timeout are omitted (no-op default).
        channel = self._resolve_channel(event_type)
        if channel:
            variables["channel"] = channel
            extra_vars = {**(extra_vars or {}), "channel": channel}
        if self._notify_timeout_seconds > 0:
            variables["timeout"] = self._notify_timeout_seconds
            extra_vars = {**(extra_vars or {}), "timeout": self._notify_timeout_seconds}

        # Quiet hours (do-not-disturb): hold finish-type notifications that would
        # wake someone and deliver them at the end of the window. Live-progress ticks
        # and the start notification are never delayed. Guarded by allow_deferral so a
        # quiet-window flush (allow_deferral=False) cannot re-defer.
        if (
            allow_deferral
            and event_type in _QUIET_HOURS_EVENT_TYPES
            and self._in_quiet_hours()
        ):
            self._queue_quiet_hours_notification(
                message,
                title=title,
                icon=icon,
                event_type=event_type,
                extra_vars=extra_vars,
            )
            self._last_dispatch_deferred = True
            self._log_notification(event_type, message, deferred_reason="quiet hours")
            return False

        if (
            allow_presence_deferral
            and self._notify_only_when_home
            and self._notify_people
        ):
            if not self._is_any_notify_person_home():
                if event_type == NOTIFY_EVENT_LIVE:
                    self._pending_notifications = [
                        entry
                        for entry in self._pending_notifications
                        if entry.get("event_type") != NOTIFY_EVENT_LIVE
                    ]
                self._pending_notifications.append(
                    {
                        "message": message,
                        "title": title,
                        "icon": icon,
                        "event_type": event_type,
                        "extra_vars": extra_vars,
                    }
                )
                self._last_dispatch_deferred = True
                self._log_notification(
                    event_type, message, deferred_reason="nobody home"
                )
                return False

        actions_sent = False
        if self._notify_actions:
            actions_sent = bool(self._run_notification_actions(variables))

        # If actions fired and there are no per-event services, skip the
        # service/persistent-notification path entirely.
        services = self._get_services_for_event(event_type)
        if actions_sent and not services:
            self._log_notification(event_type, message, targets="actions")
            return True

        service_sent = self._send_notification_service(
            message,
            services=services,
            title=title,
            icon=icon,
            event_type=event_type,
            extra_vars=extra_vars,
        )

        if actions_sent or service_sent:
            targets: list[str] = []
            if actions_sent:
                targets.append("actions")
            if service_sent:
                # _send_notification_service falls back to a persistent
                # notification only when no notify services are configured.
                targets.extend(services if services else ["persistent_notification"])
            self._log_notification(
                event_type, message, targets=", ".join(targets)
            )
        else:
            self._log_notification(event_type, message, delivered=False)
        return actions_sent or service_sent

    def _send_notification_service(
        self,
        message: str,
        *,
        services: list[str],
        title: str | None = None,
        icon: str | None = None,
        event_type: str | None = None,
        extra_vars: dict[str, Any] | None = None,
    ) -> bool:
        """Send a notification to each configured notify service, or fall back to persistent notification."""
        ev = extra_vars or {}
        # Base payload shared by all notification platforms.
        data: dict[str, Any] = {}
        if icon:
            data["icon"] = icon

        # Live-progress-only payload keys (countdown, progress bar, throttle markers).
        # Live updates are already gated to mobile_app targets by the guard below,
        # so these keys never reach strict-schema platforms.
        if event_type == NOTIFY_EVENT_LIVE:
            for key in (
                "progress",
                "progress_max",
                "live_update",
                "alert_once",
                "cycle_seconds",
                "time_remaining_seconds",
                "minutes_left",
                "live_updates_sent",
                "live_updates_cap",
                "chronometer",
                "when",
                "countdown",
            ):
                if key in ev:
                    data[key] = ev[key]

        sent = False
        for notify_service in services:
            if event_type == NOTIFY_EVENT_LIVE and not self._is_mobile_notify_service(
                notify_service
            ):
                self._logger.debug(
                    "Skipping live notification for non-mobile notify service: %s",
                    notify_service,
                )
                continue

            # Mobile-app-specific keys (tag/timeout/channel/priority) plus the iOS
            # Live Activity enrichment keys (subtitle/content_state/activity) are
            # rejected by some strict-schema platforms such as Signal Messenger.
            # Only add them for mobile_app targets; all other platforms receive
            # the base payload only.
            svc_data = dict(data)
            svc_data.update(self._mobile_service_extras(ev, notify_service))

            state = (
                self.hass.states.get(notify_service)
                if notify_service.startswith("notify.")
                else None
            )
            # A notify *entity* (has a state, domain "notify") only registers the
            # entity service notify.send_message — NOT a legacy notify.<object_id>
            # domain service. So route entity targets through send_message
            # regardless of svc_data; that schema accepts only entity_id/message/
            # title, so drop any unsupported extras (icon, mobile/iOS keys) rather
            # than fall through to a legacy service that would raise ServiceNotFound
            # and silently drop the notification. Legacy notify.mobile_app_* targets
            # have no entity state (state is None) and correctly take the else path.
            if state is not None and getattr(state, "domain", None) == "notify":
                # A dismiss marker is carried *by* its tag, and send_message cannot
                # carry one - delivering it anyway would show the user a card whose
                # body literally reads "clear_notification". There is no way to
                # dismiss an entity-target card, so skip the send entirely. (Before
                # entity targets were routed here, these calls always had a tag and
                # so took the legacy-service path, where the tag works.)
                if message == _CLEAR_NOTIFICATION_MARKER and ev.get("tag"):
                    self._logger.debug(
                        "Notify entity %s: skipping dismiss marker (tag=%s) - "
                        "notify.send_message cannot carry a tag, so it would be "
                        "delivered as visible text",
                        notify_service, ev.get("tag"),
                    )
                    continue
                if svc_data:
                    self._logger.debug(
                        "Notify entity %s: dropping %d unsupported payload key(s) "
                        "(%s) - notify.send_message accepts only message/title",
                        notify_service, len(svc_data), ", ".join(sorted(svc_data)),
                    )
                service_data: dict[str, Any] = {
                    "entity_id": notify_service,
                    "message": message,
                }
                if title:
                    service_data["title"] = title
                self.hass.async_create_task(
                    self.hass.services.async_call(
                        "notify", "send_message", service_data
                    )
                )
            else:
                domain, service = (
                    notify_service.split(".", 1)
                    if "." in notify_service
                    else ("notify", notify_service)
                )
                service_data = {"message": message, "title": title}
                if svc_data:
                    service_data["data"] = svc_data
                self.hass.async_create_task(
                    self.hass.services.async_call(domain, service, service_data)
                )
            sent = True

        if not sent:
            if event_type == NOTIFY_EVENT_LIVE:
                return False
            # A dismiss marker is not content: if no target could carry it, there is
            # nothing to show. Falling through would post a persistent-notification
            # card whose body reads "clear_notification" - the same leak the entity
            # branch above guards against, just via the other exit. The callers that
            # send this marker dismiss their own persistent notification separately
            # (_pn_dismiss), so nothing is lost by returning early.
            if message == _CLEAR_NOTIFICATION_MARKER:
                return False
            # Reuse the notification's tag as a stable persistent-notification id so
            # the HA notifications tab collapses the lifecycle thread to one entry
            # instead of accumulating a new card per cycle (issue #248/#249 clutter).
            _pn_create(
                self.hass,
                message,
                title=title,
                notification_id=ev.get("tag"),
            )
            return True

        return sent

    def _run_notification_actions(self, variables: dict[str, Any]) -> bool:
        """Run configured notification actions."""
        actions: list[dict[str, Any]] = self._notify_actions
        if not actions:
            return False

        if self._notify_script is None:
            try:
                self._notify_script = script_helper.Script(
                    self.hass,
                    actions,
                    name=f"{self.config_entry.title} notification",
                    domain=DOMAIN,
                    logger=_LOGGER,
                )
            except (ValueError, TypeError, HomeAssistantError) as err:
                self._logger.error(
                    "Invalid notification action configuration for %s: %s",
                    self.config_entry.title,
                    err,
                )
                return False
            except Exception as err:
                self._logger.exception(
                    "Unexpected error while building notification actions for %s: %s",
                    self.config_entry.title,
                    err,
                )
                return False
        script = self._notify_script

        try:
            action_task = self.hass.async_create_task(
                script.async_run(variables, context=Context())
            )
            # This method is synchronous, so the script runs fire-and-forget: a
            # failure inside async_run() would otherwise land after we return True
            # and be swallowed. Surface it via a done-callback so an action-only
            # setup at least logs the drop. (A user-visible fallback would require
            # awaiting, i.e. making the whole notification-dispatch chain async.)
            def _log_action_failure(task: Task[Any]) -> None:
                if task.cancelled():
                    return
                exc = task.exception()
                if exc is not None:
                    self._logger.warning(
                        "Notification action execution failed for %s: %s",
                        self.config_entry.title,
                        exc,
                    )

            action_task.add_done_callback(_log_action_failure)
            return True
        except HomeAssistantError as err:
            self._logger.warning(
                "Notification action execution failed for %s: %s",
                self.config_entry.title,
                err,
            )
            return False
        except Exception as err:
            self._logger.exception(
                "Unexpected error while scheduling notification actions for %s: %s",
                self.config_entry.title,
                err,
            )
            return False

    def _is_any_notify_person_home(self) -> bool:
        """Return True when any configured person is home."""
        for person_entity_id in self._notify_people:
            state = self.hass.states.get(person_entity_id)
            if state and state.state == STATE_HOME:
                return True
        return False

    @callback
    def _handle_notify_person_change(self, event: Event[evt.EventStateChangedData]) -> None:
        """Handle person state changes to release pending notifications."""
        new_state = event.data.get("new_state")
        if not new_state or new_state.state != STATE_HOME:
            return

        if not self._pending_notifications:
            return

        person_entity_id = new_state.entity_id
        person_name = new_state.name or new_state.attributes.get(
            "friendly_name", person_entity_id
        )
        pending: list[dict[str, Any]] = list(self._pending_notifications)
        self._pending_notifications = []
        for entry in pending:
            sent = self._dispatch_notification(
                entry["message"],
                title=entry.get("title"),
                icon=entry.get("icon"),
                event_type=entry.get("event_type"),
                person_entity_id=person_entity_id,
                person_name=person_name,
                extra_vars=entry.get("extra_vars"),
                allow_deferral=False,
                allow_presence_deferral=False,
            )
            if sent and entry.get("event_type") == NOTIFY_EVENT_LIVE:
                ev_raw = entry.get("extra_vars")
                ev: dict[str, Any] = ev_raw if isinstance(ev_raw, dict) else {}
                if "progress" not in ev:
                    self._live_waiting_notification_sent = True
                else:
                    self._live_notification_sent_count += 1
                    self._last_live_notification_time = dt_util.now()

    def _handle_noise_cycle(self, max_power: float) -> None:
        """Handle a detected noise cycle."""
        # Clean up old noise events > 24h
        now = dt_util.now()
        self._noise_events = [
            t
            for t in getattr(self, "_noise_events", [])
            if (now - t).total_seconds() < 86400
        ]
        self._noise_events.append(now)

        # Track max power of noise
        self._noise_max_powers = getattr(self, "_noise_max_powers", [])
        self._noise_max_powers.append(max_power)

        # If noise events exceed threshold in 24h, trigger tune
        if len(self._noise_events) >= self._noise_events_threshold:
            self.hass.async_create_task(self._tune_threshold())

    async def _tune_threshold(self) -> None:
        """Increase the minimum power threshold."""
        current_min = self.detector.config.min_power

        # Calculate new suggested threshold
        # Max of observed noise * 1.2 safety factor
        noise_max = max(self._noise_max_powers)
        new_min = noise_max * 1.2

        # Cap absolute max to avoid runaway (e.g. 50W)
        if new_min > 50.0:
            new_min = 50.0

        if new_min <= current_min:
            # Clear events so we don't loop try to update
            self._noise_events = []
            self._noise_max_powers = []
            return

        self._logger.info(
            "Auto-Tune suggestion: min_power from %.1fW -> %.1fW due to noise",
            current_min,
            new_min,
        )

        # Store a suggestion (do not mutate user-set options). The suggestion is
        # surfaced in the panel (Settings suggestions banner / per-field pill);
        # WashData intentionally does not raise a persistent notification here.
        self.profile_store.set_suggestion(
            CONF_MIN_POWER,
            float(new_min),
            f"Auto-tune: {len(self._noise_events)} ghost cycles detected in 24h",
        )
        await self.profile_store.async_save()

        # Reset trackers
        self._noise_events = []
        self._noise_max_powers = []

    def _update_estimates(self) -> None:
        """Update time remaining and profile estimates."""
        if self.detector.state in (
            STATE_OFF,
            STATE_UNKNOWN,
            STATE_IDLE,
            STATE_STARTING,
            STATE_ANTI_WRINKLE,
            STATE_DELAY_WAIT,
        ):
            self._current_program = "off"
            self._time_remaining = None
            self._total_duration = None
            self._cycle_progress = 0.0
            self._projected_energy_wh = None
            self._projected_cost = None
            self._cycle_anomaly = "none"
            self._overrun_ratio = 0.0
            self._last_match_result = None
            self._notify_update()
            return

        now = dt_util.now()

        # Throttle heavy matching to configured interval (default: 5 minutes)
        effective_match_interval = self._profile_match_interval
        if (
            self._last_estimate_time
            and (now - self._last_estimate_time).total_seconds()
            < effective_match_interval
        ):
            # Still update remaining/progress if we already have a match
            self._update_remaining_only()
            self._check_pre_completion_notification()
            self._check_live_progress_notification()
            return

        # SKIP matching if manual program is active
        if self._manual_program_active:
            self._last_estimate_time = now  # touch timestamp to throttle estimates loop
            self._update_remaining_only()
            # Also check notifications in loop
            self._check_pre_completion_notification()
            self._check_live_progress_notification()
            self._notify_update()
            return

        # No matching task trigger here anymore!
        # The detector callback handles it.
        # Just update progress/remaining based on existing match.
        self._update_remaining_only()
        self._check_pre_completion_notification()
        self._check_live_progress_notification()
        self._notify_update()

    # _async_run_matching removed in favor of _async_perform_combined_matching

    def _analyze_trend(self, profile_name: str) -> bool:
        """Analyze score history to detect positive trend.

        Returns True if score has increased in at least 7 of the last 10 intervals.
        Requires at least 5 samples history to make a determination.
        """
        history = self._score_history.get(profile_name, [])
        if len(history) < 5:
            return False

        # Use last 11 points to get 10 intervals (or fewer if history short)
        recent = history[-11:]
        if len(recent) < 2:
            return False

        up_count = sum(1 for i in range(1, len(recent)) if recent[i] > recent[i - 1])
        total_intervals = len(recent) - 1

        # Proportional threshold (7/10 => 0.7)
        return (up_count / total_intervals) >= 0.70

    def _reset_live_notification_state(self) -> None:
        """Reset per-cycle live notification counters and timers."""
        self._live_notification_sent_count = 0
        self._live_notification_cap = 0
        self._last_live_notification_time = None
        self._live_waiting_notification_sent = False
        self._live_chronometer_overrun_sent = False
        self._live_activity_started = False

    @staticmethod
    def _is_mobile_notify_service(notify_service: str | None) -> bool:
        """Return True when configured notify target is a mobile app service."""
        if not notify_service:
            return False
        _, service = (
            notify_service.split(".", 1)
            if "." in notify_service
            else ("notify", notify_service)
        )
        return service.startswith("mobile_app")

    @property
    def _timer_pause_action_id(self) -> str:
        """Stable mobile action ID for timer-pause Resume button, unique per device."""
        return f"RESUME_WD_{self.entry_id[:8].upper()}"

    def _estimate_live_notification_cap(self) -> int:
        """Compute hard cap for live updates from estimated cycle duration and overrun margin."""
        interval = max(30, int(self._notify_live_interval_seconds))
        estimated_duration = float(
            self._matched_profile_duration
            or self._total_duration
            or max(float(self.detector.get_elapsed_seconds()), float(interval))
        )
        estimated_updates = max(1, int(np.ceil(estimated_duration / interval)))
        overrun_ratio = max(0, float(self._notify_live_overrun_percent)) / 100.0
        return max(1, int(np.ceil(estimated_updates * (1.0 + overrun_ratio))))

    def _apply_live_notification_prefs(self, extra_vars: dict[str, Any]) -> None:
        """Inject the user's opt-in live-notification data keys (#347).

        ``sticky`` keeps the live notification on screen when tapped; ``clickAction``
        gives the notification a tap target (e.g. a dashboard path). Both are
        mobile-only keys forwarded only to ``mobile_app_*`` live targets. Defaults
        (sticky off, empty clickAction) add nothing, so the payload is byte-identical
        to before unless the user opts in.
        """
        if self._notify_live_sticky:
            extra_vars["sticky"] = "true"
        if self._notify_live_click_action:
            extra_vars["clickAction"] = self._notify_live_click_action

    def _check_live_progress_notification(self) -> None:
        """Send throttled live progress notifications for compatible mobile targets."""
        if not self._notify_live_services and not self._notify_actions:
            return
        if self.detector.state not in (STATE_RUNNING, STATE_PAUSED, STATE_ENDING):
            return

        has_profile_match = bool(
            self._matched_profile_duration and self._matched_profile_duration > 0
        )
        if has_profile_match:
            # A profile has been matched - reset the waiting latch so future
            # "no profile yet" phases (e.g. after a cycle restart) will send
            # the waiting message again.
            self._live_waiting_notification_sent = False
        if not has_profile_match:
            # Suppress the waiting notification when no profiles exist at all —
            # the setup card explains the state instead.
            if not self.profile_store.has_real_profiles:
                return
            if self._live_waiting_notification_sent:
                return

            # Fixed (non user-editable) live message: localize via the cached
            # options.error template, falling back to the English default.
            waiting_template = self._timer_ui_strings.get(
                "notify_live_waiting_message", DEFAULT_NOTIFY_LIVE_WAITING_MESSAGE
            )
            msg = self._safe_format_template(
                waiting_template,
                fallback_template=DEFAULT_NOTIFY_LIVE_WAITING_MESSAGE,
                device=self.config_entry.title,
                program=self._current_program,
            )
            waiting_extra_vars: dict[str, Any] = {
                "tag": self._live_notification_tag,
                "live_update": True,
                "alert_once": True,
            }
            # C3: mark the first live notification of the cycle so iOS can begin a
            # Live Activity even before a profile is matched (mobile-only key).
            if not self._live_activity_started:
                waiting_extra_vars["activity"] = "start"
            self._apply_live_notification_prefs(waiting_extra_vars)
            sent = self._dispatch_notification(
                msg,
                event_type=NOTIFY_EVENT_LIVE,
                extra_vars=waiting_extra_vars,
            )
            self._live_waiting_notification_sent = sent
            if sent:
                self._live_activity_started = True
            return

        interval = max(30, int(self._notify_live_interval_seconds))
        now = dt_util.now()
        if self._last_live_notification_time and (
            now - self._last_live_notification_time
        ).total_seconds() < interval:
            return

        cap_candidate = self._estimate_live_notification_cap()
        if cap_candidate > self._live_notification_cap:
            self._live_notification_cap = cap_candidate

        total_seconds = int(
            max(
                1,
                round(
                    float(
                        self._total_duration
                        or self._matched_profile_duration
                        or self.detector.get_elapsed_seconds()
                    )
                ),
            )
        )
        remaining_seconds = int(max(0, round(float(self._time_remaining or 0.0))))
        elapsed_seconds = max(0, total_seconds - remaining_seconds)

        # When a chronometer notification is on the phone but the estimate has
        # expired, bypass the cap once to replace the frozen "0:00" countdown
        # with a plain text update so the user isn't left with a stale timer.
        chronometer_overrun = (
            self._notify_live_chronometer
            and remaining_seconds <= 0
            and self._live_notification_sent_count > 0
            and not self._live_chronometer_overrun_sent
        )
        if not chronometer_overrun and self._live_notification_sent_count >= self._live_notification_cap:
            return
        minutes_left = max(1, math.ceil(remaining_seconds / 60))

        msg_template = self.config_entry.options.get(
            CONF_NOTIFY_PRE_COMPLETE_MESSAGE,
            DEFAULT_NOTIFY_PRE_COMPLETE_MESSAGE,
        )
        msg = self._safe_format_template(
            msg_template,
            fallback_template=DEFAULT_NOTIFY_PRE_COMPLETE_MESSAGE,
            device=self.config_entry.title,
            minutes=minutes_left,
            program=self._current_program,
        )

        extra_vars: dict[str, Any] = {
            "tag": self._live_notification_tag,
            "progress": elapsed_seconds,
            "progress_max": total_seconds,
            "live_update": True,
            "alert_once": True,
            "cycle_seconds": total_seconds,
            "time_remaining_seconds": remaining_seconds,
            "minutes_left": minutes_left,
            "live_updates_sent": self._live_notification_sent_count + 1,
            "live_updates_cap": self._live_notification_cap,
        }
        if self._notify_live_chronometer and remaining_seconds > 0:
            extra_vars["chronometer"] = True
            extra_vars["when"] = int(now.timestamp()) + remaining_seconds
            extra_vars["countdown"] = True

        # C3: iOS Live Activity enrichment. Derived from the SAME values feeding the
        # flat progress/when keys above. Forwarded to mobile_app_* targets only (see
        # _send_notification_service); non-mobile live targets are already skipped.
        eta_timestamp = int(now.timestamp()) + remaining_seconds
        progress_pct = (
            100.0 * elapsed_seconds / total_seconds if total_seconds > 0 else 0.0
        )
        activity_marker = None if self._live_activity_started else "start"
        extra_vars.update(
            self._build_ios_live_activity_extras(
                state="paused" if self.detector.state == STATE_PAUSED else "running",
                progress_pct=progress_pct,
                eta_timestamp=eta_timestamp,
                program=self._current_program,
                device=self.config_entry.title,
                activity=activity_marker,
            )
        )
        self._apply_live_notification_prefs(extra_vars)
        sent = self._dispatch_notification(
            msg,
            event_type=NOTIFY_EVENT_LIVE,
            extra_vars=extra_vars,
        )
        if sent:
            self._live_activity_started = True
            if chronometer_overrun:
                self._live_chronometer_overrun_sent = True
            else:
                self._live_notification_sent_count += 1
            self._last_live_notification_time = now

    def _clear_live_progress_notification(self, clear_services: bool = True) -> None:
        """Clear active live/progress notifications and purge stale deferred alerts.

        On cycle finish (``clear_services=False``) the finished notification carries
        the same lifecycle tag and replaces the live notification in place, so we must
        NOT also send a service-level ``clear_notification`` (it would briefly dismiss
        then re-create the card). The pending-purge, the action-based clear marker
        (kept for backward compatibility with custom action templates), and the state
        reset still run. On shutdown (``clear_services=True``) no finished notification
        follows, so the explicit service clear is required to dismiss the live card.
        """
        # Purge queued live-progress entries and stale start/pre-complete entries
        # so a completed cycle cannot replay them later.
        live_tag = self._live_notification_tag
        self._pending_notifications = [
            entry
            for entry in self._pending_notifications
            if not (
                (
                    entry.get("event_type") == NOTIFY_EVENT_LIVE
                    and isinstance(entry.get("extra_vars"), dict)
                    and entry["extra_vars"].get("tag") == live_tag
                    and entry["extra_vars"].get("live_update") is True
                )
                or entry.get("event_type") in {NOTIFY_EVENT_START, "pre_complete"}
            )
        ]

        # Always emit the clear when the user has any live channel configured.
        # The in-memory sent-count is unreliable after an HA restart (it resets
        # to 0 while the notification still lives on the phone), and a no-op
        # clear for a non-existent tag is harmless on the mobile_app side.
        if not self._notify_live_services and not self._notify_actions:
            self._reset_live_notification_state()
            return

        # Invoke notification actions to clear live notification in action-based setups
        # Include full context variables expected by notification action handlers
        self._run_notification_actions(
            {
                "device": self.config_entry.title,
                "program": "",  # Cleared marker
                "message": _CLEAR_NOTIFICATION_MARKER,  # Clear marker for action handlers
                "title": "",  # Clear title
                "icon": None,
                "event_type": NOTIFY_EVENT_LIVE,
                "person_entity_id": None,
                "person_name": None,
                "tag": self._live_notification_tag,
                "live_update": True,
                "alert_once": True,
                # C3: tell iOS to end the Live Activity (mobile-only key downstream).
                "activity": "end",
            }
        )

        if clear_services:
            self._send_notification_service(
                _CLEAR_NOTIFICATION_MARKER,
                services=self._notify_live_services,
                event_type=NOTIFY_EVENT_LIVE,
                extra_vars={
                    "tag": self._live_notification_tag,
                    "live_update": True,
                    "alert_once": True,
                    # C3: iOS Live Activity end marker (mobile_app_* only downstream).
                    "activity": "end",
                },
            )

        # Reset live-update state flags and counters.
        self._reset_live_notification_state()

    def _clear_clean_notification(self) -> None:
        """Dismiss a delivered clean-laundry reminder and purge any queued ones.

        The clean nag uses its own tag (``_clean_tag``) rather than the lifecycle
        tag, so nothing replaces it once the clean state resolves. Mirror the
        lifecycle clear here so a delivered reminder is removed from the mobile
        app instead of lingering. A clear for a non-existent tag is harmless, so
        this runs whenever the user has any clean/finish delivery configured.
        """
        # Drop the repeat-reminder dismiss action listener too, so no stale mobile
        # action stays wired once the reminder is gone (#374).
        self._remove_unload_dismiss_listener()
        # Drop any still-queued clean entries so they cannot replay later — from
        # both the presence-hold queue and the quiet-hours queue (the nag can be
        # deferred into either).
        self._pending_notifications = [
            n for n in self._pending_notifications
            if n.get("event_type") != NOTIFY_EVENT_CLEAN
        ]
        self._quiet_pending_notifications = [
            n for n in self._quiet_pending_notifications
            if n.get("event_type") != NOTIFY_EVENT_CLEAN
        ]

        # Only mobile_app targets understand the "clear_notification" marker;
        # non-mobile targets (email, Telegram, etc.) would receive it as a
        # literal message. Mirror the pattern from _cancel_timer_mobile_notification.
        mobile_services = [
            s for s in self._get_services_for_event(NOTIFY_EVENT_CLEAN)
            if self._is_mobile_notify_service(s)
        ]
        if not mobile_services and not self._notify_actions:
            return

        if self._notify_actions:
            self._run_notification_actions(
                {
                    "device": self.config_entry.title,
                    "program": "",
                    "message": _CLEAR_NOTIFICATION_MARKER,
                    "title": "",
                    "icon": None,
                    "event_type": NOTIFY_EVENT_CLEAN,
                    "person_entity_id": None,
                    "person_name": None,
                    "tag": self._clean_tag,
                }
            )
        if mobile_services:
            self._send_notification_service(
                _CLEAR_NOTIFICATION_MARKER,
                services=mobile_services,
                event_type=NOTIFY_EVENT_CLEAN,
                extra_vars={"tag": self._clean_tag},
            )

    @property
    def _unload_dismiss_action_id(self) -> str:
        """Stable mobile action ID for the unload-reminder dismiss button, per device."""
        return f"UNLOAD_STOP_WD_{self.entry_id[:8].upper()}"

    def _unload_nag_active(self, now: datetime) -> bool:
        """Whether the terminal state must be held alive for the unload reminder.

        Default (one-shot) behaviour: hold only until the single reminder is due, so
        the 30-min progress reset / power-based Off cannot clear the Clean state before
        the reminder fires. Repeat mode (``CONF_NOTIFY_UNLOAD_REPEAT``, #374): hold
        indefinitely so the reminder keeps re-firing, until the user dismisses it from
        the notification or opens the door (both release the hold).
        """
        if (
            not self._is_clean_state
            or self._notify_unload_delay_minutes <= 0
            or self._cycle_completed_time is None
        ):
            return False
        if self._notify_unload_repeat:
            # Only hold while a delivery channel exists — otherwise no reminder (and
            # no dismiss button) is ever sent (dispatch is gated on the same
            # condition), so the hold would strand the Clean state forever.
            # Also bounded by NOTIFY_UNLOAD_REPEAT_MAX_REMINDERS: the "Stop
            # reminding" button is mobile_app-only, so a non-mobile target leaves
            # the door sensor as the only escape, and a sensor that never reports
            # open would pin Clean state (and suppress power-based Off) forever.
            return (
                bool(self._notify_finish_services or self._notify_actions)
                and not self._unload_nag_dismissed
                and self._unload_nag_count < NOTIFY_UNLOAD_REPEAT_MAX_REMINDERS
            )
        return (
            not self._notified_clean_laundry
            and (now - self._cycle_completed_time).total_seconds()
            < self._notify_unload_delay_minutes * 60
        )

    def _ensure_unload_dismiss_listener(self) -> None:
        """Register the mobile action listener for the reminder's dismiss button (once).

        Mirrors the timer-pause interactive notification wiring: a single
        ``mobile_app_notification_action`` listener that matches this device's action
        ID, marks the reminder dismissed, and clears the delivered card.
        """
        if self._remove_unload_action_listener is not None:
            return
        action_id = self._unload_dismiss_action_id

        @callback
        def _on_unload_action(event: Any) -> None:
            if event.data.get("action") == action_id:
                self._logger.debug(
                    "Unload reminder dismissed via notification action"
                )
                self._unload_nag_dismissed = True
                self._clear_clean_notification()
                self._notify_update()

        self._remove_unload_action_listener = self.hass.bus.async_listen(
            "mobile_app_notification_action",
            _on_unload_action,
        )

    def _remove_unload_dismiss_listener(self) -> None:
        """Drop the unload-reminder dismiss action listener, if registered."""
        if self._remove_unload_action_listener is not None:
            self._remove_unload_action_listener()
            self._remove_unload_action_listener = None

    def _reset_unload_nag_tracking(self) -> None:
        """Reset repeat-reminder tracking and drop the dismiss listener.

        Called wherever the Clean state is cleared so the next cycle's reminder starts
        fresh and no stale mobile action listener leaks.
        """
        self._remove_unload_dismiss_listener()
        self._unload_nag_dismissed = False
        self._last_unload_nag_time = None
        self._unload_nag_count = 0

    def _check_pre_completion_notification(self) -> None:
        """Check and send pre-completion notification."""
        if notif_rules.should_notify_pre_completion(
            self._notify_before_end_minutes,
            self._notified_pre_completion,
            self._time_remaining,
            self._cycle_progress,
            self._last_match_ambiguous,
        ):
            # Send notification!
            self._notified_pre_completion = True

            # Distinct reminder message (not the live-update template) so the one-time
            # "X minutes left" alert is not confused with the recurring live ticks that
            # reuse CONF_NOTIFY_PRE_COMPLETE_MESSAGE.
            msg_template = self.config_entry.options.get(
                CONF_NOTIFY_REMINDER_MESSAGE, DEFAULT_NOTIFY_REMINDER_MESSAGE
            )
            minutes_left = self._notify_before_end_minutes

            msg = self._safe_format_template(
                msg_template,
                fallback_template=DEFAULT_NOTIFY_REMINDER_MESSAGE,
                device=self.config_entry.title,
                minutes=minutes_left,
                program=self._current_program,
            )
            self._dispatch_notification(
                msg,
                event_type="pre_complete",
                extra_vars={
                    # Share the lifecycle tag so the reminder updates the live thread in
                    # place. No alert_once -> the companion app makes a sound once; it is
                    # routed to the finish channel (see _resolve_channel) for audibility.
                    "tag": self._lifecycle_tag,
                    "minutes_left": minutes_left,
                    "minutes": minutes_left,
                    "priority": "high",
                },
            )
            self._logger.info("Sent pre-completion notification: %s", msg)

    def _update_projected_energy(self) -> None:
        """Project total energy/cost for the running cycle.

        Prefers the on-device ``total_energy`` regressor (which models energy's
        non-linear accumulation); otherwise falls back to
        ``energy_so_far / progress_fraction`` (progress already carries the ML
        remaining-time blend, so it personalizes to this device's real cycle
        length). Cost uses the same price resolution that freezes each completed
        cycle's cost, so a running estimate and the final frozen value are
        consistent. Clears to ``None`` when progress is too low, there is no energy
        yet, or projection would be implausible. Never raises — a projection
        failure must not disturb the estimate loop.
        """
        try:
            trace = self.detector.get_power_trace()
            energy_so_far = float(
                getattr(self.detector, "_energy_since_idle_wh", 0.0) or 0.0
            )
            price = self._resolve_energy_price()
        except Exception:  # noqa: BLE001 - projection must never break estimates
            self._projected_energy_wh = None
            self._projected_cost = None
            return
        wh, cost = progress_mod.projected_energy(
            self.profile_store,
            self.config_entry.options,
            float(self._matched_profile_duration or 0.0),
            trace,
            self._current_program,
            float(self._cycle_progress or 0.0),
            energy_so_far,
            price,
            self._profile_end_expectation,
            self._logger,
        )
        self._projected_energy_wh = wh
        self._projected_cost = cost

    def _update_cycle_anomaly(self, duration_so_far: float) -> None:
        """Flag a *soft* runtime overrun anomaly for the running cycle.

        Sets ``_overrun_ratio = elapsed / expected`` and ``_cycle_anomaly`` to
        ``"overrun"`` once the ratio crosses ``CYCLE_OVERRUN_ANOMALY_RATIO``. This
        is purely a visible signal (state-sensor attribute + cycle metadata); it
        never notifies and never terminates (the zombie-killer owns hard limits).
        No-op / cleared when no profile duration is known. Never raises.
        """
        self._overrun_ratio, self._cycle_anomaly = progress_mod.cycle_anomaly(
            self._matched_profile_duration, duration_so_far
        )

    def _update_remaining_only(self) -> None:
        """Recompute remaining/progress using phase-aware estimation."""
        # Throttle updates and only clear on truly dead states
        if self.detector.state in (STATE_OFF, STATE_UNKNOWN, STATE_IDLE):
            self._time_remaining = None
            self._total_duration = None
            self._cycle_progress = 0.0
            self._smoothed_progress = 0.0
            self._projected_energy_wh = None
            self._projected_cost = None
            self._cycle_anomaly = "none"
            self._overrun_ratio = 0.0
            return

        now = dt_util.now()
        if (
            self._last_phase_estimate_time
            and (now - self._last_phase_estimate_time).total_seconds() < 5.0
        ):
            return
        self._last_phase_estimate_time = now

        # Use net elapsed (wall-clock minus user-paused time) for all time estimates
        # so that paused time is excluded from progress / remaining / total duration.
        duration_so_far = float(self.net_elapsed_seconds)
        self._check_cycle_timers(duration_so_far)

        if not (self._matched_profile_duration and self._matched_profile_duration > 0):
            # No profile matched - don't provide misleading time estimates.
            self._time_remaining = None
            self._total_duration = None
            self._cycle_progress = 0.0
            self._smoothed_progress = 0.0
            self._projected_energy_wh = None
            self._projected_cost = None
            self._cycle_anomaly = "none"
            self._overrun_ratio = 0.0
            self._logger.debug(
                "No profile matched yet, elapsed=%smin", int(duration_so_far / 60)
            )
            return

        # Compute the phase-aware and ML progress inputs via the manager's own
        # wrappers (so per-call caching + test mocks apply), then hand them to the
        # shared pure smoothing/back-calc in :mod:`progress` - the identical math
        # the Playground simulation runs.
        trace = self.detector.get_power_trace()
        phase_result = None
        if len(trace) >= 10 and self._current_program != "detecting...":
            phase_result = self._estimate_phase_progress(
                trace, duration_so_far, self._current_program
            )
        ml_pct = self._ml_progress_percent(trace, self._current_program)

        # Opt-in phase-resolved ETA (washing machine / washer-dryer only). Segment
        # the observed-so-far trace, match against cached per-profile phase profiles,
        # and blend the per-role budget remaining into the estimate (progress.py
        # owns the blend). Gated + guarded: any failure leaves the proven estimate
        # untouched (phase_remaining_s stays None -> byte-identical behaviour).
        phase_remaining_s: float | None = None
        if (
            len(trace) >= 10
            and self._current_program not in ("detecting...", "off", None)
            and phase_matching_enabled(self.config_entry.options, self.device_type)
        ):
            pr = self.profile_store.phase_remaining(
                trace, self.device_type, self._current_program
            )
            if pr is not None:
                phase_remaining_s = pr.get("remaining_s")

        result = progress_mod.compute_progress(
            self.device_type,
            float(self._matched_profile_duration),
            duration_so_far,
            self._smoothed_progress,
            phase_result,
            ml_pct,
            self._logger,
            phase_remaining_s=phase_remaining_s,
        )

        self._cycle_progress = result.progress
        self._smoothed_progress = result.smoothed
        self._time_remaining = result.remaining
        self._total_duration = result.total
        self._last_total_duration_update = now
        self._update_projected_energy()
        self._update_cycle_anomaly(duration_so_far)

    def _check_cycle_timers(self, elapsed_seconds: float) -> None:
        """Fire any user-configured cycle timers whose offset has been reached."""
        if not self._notify_cycle_timers:
            return
        if self.detector.state not in (STATE_RUNNING, STATE_PAUSED):
            return

        elapsed_minutes = elapsed_seconds / 60.0
        for idx, timer in enumerate(self._notify_cycle_timers):
            if idx in self._fired_cycle_timers:
                continue
            offset = float(timer.get("offset_minutes", 0))
            if elapsed_minutes < offset:
                continue

            self._fired_cycle_timers.add(idx)
            raw_msg = timer.get("message") or ""
            fmt_kwargs = {
                "device": self.config_entry.title,
                "program": self._current_program or "",
                "minutes": int(offset),
            }
            msg = self._safe_format_template(
                raw_msg or self._timer_ui_strings.get("timer_default_message", "{device}: {minutes} min timer"),
                **fmt_kwargs,
            )
            auto_pause = bool(timer.get("auto_pause", False))
            timer_tag = f"{self._lifecycle_tag}_timer_{idx}"
            if auto_pause:
                # Defer the ENTIRE interactive notification until the pause takes
                # effect. The Resume action + sticky flag (and the action listener
                # that makes the button work) are all created together in
                # _setup_timer_pause_notification only on pause success, so a
                # no-op/failed pause never leaves a sticky "Resume Cycle" card with
                # a dead button. _check_cycle_timers is sync, so bridge via a task.
                self.hass.async_create_task(
                    self._async_auto_pause_and_notify(msg, timer_tag)
                )
            else:
                self._dispatch_notification(
                    msg,
                    event_type=NOTIFY_EVENT_TIMER,
                    extra_vars={"tag": timer_tag},
                    allow_deferral=False,
                    allow_presence_deferral=False,
                )
            self._logger.info(
                "Cycle timer #%d fired at %.0fs (%.1f min): %s",
                idx, elapsed_seconds, offset, msg,
            )

    async def _async_auto_pause_and_notify(self, msg: str, tag: str) -> None:
        """Pause the cycle for an auto-pause timer, then show the pause UI on success.

        The interactive pause notification is created only after the pause actually
        takes effect, so a no-op/failed pause never leaves a stale "paused" card.
        """
        if await self.async_pause_cycle():
            self._setup_timer_pause_notification(msg, tag)

    def _setup_timer_pause_notification(self, msg: str, tag: str) -> None:
        """Create the interactive pause notification: mobile card + HA sidebar + action listener.

        Called from _async_auto_pause_and_notify only after async_pause_cycle() has
        actually taken effect. Sends the interactive mobile notification (Resume
        action + sticky), creates the HA persistent notification for sidebar
        visibility, and registers the mobile action listener — all together, so the
        Resume button always has a live listener behind it and only ever appears
        when the cycle is genuinely paused.
        """
        self._clear_timer_pause_notification()

        # Interactive mobile notification — dispatched now (post-pause) rather than
        # at timer-fire time, so a failed/no-op pause never shows a dead Resume card.
        self._dispatch_notification(
            msg,
            event_type=NOTIFY_EVENT_TIMER,
            extra_vars={
                "tag": tag,
                "actions": [
                    {
                        "action": self._timer_pause_action_id,
                        "title": self._timer_ui_strings.get(
                            "timer_pause_action_title", "Resume Cycle"
                        ),
                    }
                ],
                "sticky": "true",
            },
            allow_deferral=False,
            allow_presence_deferral=False,
        )

        self._timer_pause_pn_id = tag
        self._timer_pause_mobile_tag = tag

        _body_suffix = self._timer_ui_strings.get(
            "timer_pause_body_suffix", "The cycle is paused. Open the WashData panel to resume."
        )
        _pn_create(
            self.hass,
            f"{msg}\n\n{_body_suffix}",
            title=f"WashData: {self.config_entry.title}",
            notification_id=tag,
        )

        action_id = self._timer_pause_action_id

        @callback
        def _on_mobile_action(event: Any) -> None:
            if event.data.get("action") == action_id:
                self.hass.async_create_task(self.async_resume_cycle())

        self._remove_timer_action_listener = self.hass.bus.async_listen(
            "mobile_app_notification_action",
            _on_mobile_action,
        )

    def _clear_timer_pause_notification(self) -> None:
        """Dismiss the active timer-pause notification (both HA persistent and mobile)."""
        if self._remove_timer_action_listener is not None:
            self._remove_timer_action_listener()
            self._remove_timer_action_listener = None

        if self._timer_pause_pn_id:
            _pn_dismiss(self.hass, self._timer_pause_pn_id)
            self._timer_pause_pn_id = None

        if self._timer_pause_mobile_tag:
            services = self._get_services_for_event(NOTIFY_EVENT_TIMER)
            mobile_services = [s for s in services if self._is_mobile_notify_service(s)]
            if mobile_services:
                self._send_notification_service(
                    _CLEAR_NOTIFICATION_MARKER,
                    services=mobile_services,
                    event_type=NOTIFY_EVENT_TIMER,
                    extra_vars={"tag": self._timer_pause_mobile_tag},
                )
            self._timer_pause_mobile_tag = None

    def _estimate_phase_progress(
        self,
        current_power_data: list[tuple[datetime, float]] | list[tuple[str, float]],
        current_duration: float,
        profile_name: str,
    ) -> tuple[float, float] | None:
        """Phase-aware progress estimate. Thin wrapper over :mod:`progress`."""
        return progress_mod.estimate_phase_progress(
            self.profile_store,
            current_power_data,
            current_duration,
            profile_name,
            self._logger,
            quiet_threshold_w=float(
                getattr(self.detector.config, "stop_threshold_w", 0.0) or 0.0
            ),
        )

    def _notify_update(self) -> None:
        """Notify entities of update."""
        async_dispatcher_send(self.hass, SIGNAL_WASHER_UPDATE.format(self.entry_id))

    def notify_update(self) -> None:
        """Public method to notify entities of update."""
        self._notify_update()

    @property
    def is_user_paused(self) -> bool:
        """Return True if cycle is currently user-paused."""
        return self._is_user_paused

    @property
    def is_clean_state(self) -> bool:
        """Return True if machine is in Clean state (cycle ended, door not yet opened)."""
        return self._is_clean_state

    @property
    def net_elapsed_seconds(self) -> float:
        """Elapsed seconds in the current cycle, excluding user-paused time."""
        raw = float(self.detector.get_elapsed_seconds())
        paused = self._total_user_paused_seconds
        if self._user_pause_start is not None:
            paused += (dt_util.now() - self._user_pause_start).total_seconds()
        return max(0.0, raw - paused)

    def check_state(self):
        """Return current detector state."""
        if self.recorder.is_recording:
            return STATE_RUNNING
        # A completed cycle ends in STATE_FINISHED, not STATE_OFF; accept both
        # or the door-sensor Clean state (#153) is never surfaced (#282).
        if self._is_clean_state and self.detector.state in (
            STATE_OFF,
            STATE_FINISHED,
        ):
            return STATE_CLEAN
        if self._is_user_paused:
            return STATE_USER_PAUSED
        return self.detector.state

    def list_phase_catalog(self, device_type: str) -> list[dict[str, Any]]:
        """Return the merged phase catalog for a device type."""
        return self.profile_store.list_phase_catalog(device_type)

    def get_profile_phase_ranges_for_device(
        self,
        profile_name: str,
        device_type: str,
    ) -> list[dict[str, Any]]:
        """Return phase ranges assigned to a profile for a given device type."""
        return self.profile_store.get_profile_phase_ranges_for_device(
            profile_name,
            device_type,
        )

    @property
    def sub_state(self) -> str | None:
        """Return more granular state info (e.g. current phase)."""
        if self.recorder.is_recording:
            return "Recording"
        return self.detector.sub_state

    @property
    def current_program(self):
        """Return the current program name."""
        return self._current_program

    @property
    def time_remaining(self):
        """Return estimated time remaining in seconds."""
        return self._time_remaining

    @property
    def total_duration(self) -> float | None:
        """Return total predicted duration in seconds."""
        return self._total_duration

    @property
    def last_total_duration_update(self) -> datetime | None:
        """Return when total duration was last refined."""
        return self._last_total_duration_update

    @property
    def cycle_progress(self):
        """Return cycle progress as a percentage."""
        return self._cycle_progress

    @property
    def projected_energy_wh(self) -> float | None:
        """Projected total energy (Wh) for the running cycle, or None."""
        return self._projected_energy_wh

    @property
    def projected_cost(self) -> float | None:
        """Projected total cost for the running cycle, or None when no price."""
        return self._projected_cost

    @property
    def cycle_anomaly(self) -> str:
        """Runtime anomaly state for the current cycle ("none" | "overrun")."""
        return self._cycle_anomaly

    @property
    def overrun_ratio(self) -> float:
        """Elapsed / expected duration for the running cycle (0.0 when unknown)."""
        return self._overrun_ratio

    @property
    def last_cycle_post_anomaly(self) -> dict:
        """Post-cycle anomaly data from the last completed cycle.

        Contains subset of keys present: anomaly (underrun/overrun/none),
        underrun_ratio, energy_anomaly (energy_spike/energy_low), energy_z_score.
        Empty dict when no completed cycle or no anomaly detected.
        """
        return self._last_cycle_post_anomaly

    @property
    def restart_gaps(self) -> list[dict]:
        """HA restart gaps recorded during the current active cycle (may be empty)."""
        return self._restart_gaps

    @property
    def maintenance_due(self) -> list[str]:
        """Maintenance event types whose reminder threshold has been reached (E2).

        Surfaced as a state-sensor attribute + read by the panel banner. Never a
        notification. Returns an empty list on any error.
        """
        try:
            cfg = self.config_entry.options.get(CONF_MAINTENANCE_REMINDER_CYCLES)
            if not isinstance(cfg, dict) or not cfg:
                cfg = DEFAULT_MAINTENANCE_REMINDER_CYCLES
            return self.profile_store.get_maintenance_due(cfg)
        except Exception:  # noqa: BLE001
            return []

    @property
    def current_power(self):
        """Return current power reading in watts."""
        return self._current_power

    @property
    def cycle_start_time(self) -> datetime | None:
        """Return the start time of the current cycle."""
        return self.detector.current_cycle_start

    @property
    def last_cycle_end_time(self) -> datetime | None:
        """Return when the most recent completed cycle ended (or None).

        Set at cycle end and restored from stored history on startup. Consumed by
        the conversation intent handler to answer "how long ago did it finish".
        """
        return self._last_cycle_end_time

    @property
    def last_match_details(self) -> dict[str, Any] | None:
        """Return details of the last profile match."""
        res = getattr(self, "_last_match_result", None)
        return res.to_dict() if res else None

    @property
    def samples_recorded(self):
        """Return the number of power samples recorded in current cycle."""
        return self.detector.samples_recorded

    @property
    def sample_interval_stats(self):
        """Return statistics about sampling intervals."""
        return self._sample_interval_stats

    @property
    def pump_stuck(self) -> bool:
        """Return True if the pump stuck threshold has fired for the current cycle."""
        return self._pump_stuck

    @property
    def pump_runs_today(self) -> int:
        """Return the number of completed pump cycles that started in the last 24 hours.

        Counts all past cycles whose ``start_time`` falls within the rolling 24-hour
        window ending now.  Returns 0 for non-pump device types.
        """
        if self.device_type != DEVICE_TYPE_PUMP:
            return 0
        cutoff = dt_util.now().timestamp() - 86400.0
        count = 0
        for cycle in self.profile_store.get_past_cycles():
            start_raw = cycle.get("start_time")
            if not start_raw:
                continue
            try:
                if isinstance(start_raw, str):
                    parsed = dt_util.parse_datetime(start_raw)
                    if parsed is None:
                        continue
                    ts = parsed.timestamp()
                else:
                    ts = float(start_raw)
                if ts >= cutoff:
                    count += 1
            except (TypeError, ValueError):
                continue
        return count

    @property
    def cycle_count(self) -> int:
        """Return the total number of completed cycles stored for this device."""
        return len(self.profile_store.get_past_cycles())

    @property
    def lifetime_energy_kwh(self) -> float:
        """Lifetime accumulated energy (kWh) for the HA Energy dashboard sensor."""
        return round(self.profile_store.get_lifetime_energy_wh() / 1000.0, 3)

    @property
    def manual_program_active(self) -> bool:
        """Return True if a manual program override is active."""
        return getattr(self, "_manual_program_active", False)

    def set_manual_program(self, profile_name: str) -> None:
        """Manually set the current program."""
        if self.detector.state != "running":
            return
        profiles_raw: Any = None
        try:
            profiles_raw = self.profile_store.get_profiles()
        except Exception:  # pylint: disable=broad-exception-caught
            profiles_raw = None

        if isinstance(profiles_raw, dict):
            profiles: dict[str, Any] = cast(dict[str, Any], profiles_raw)
        else:
            profiles_fallback = getattr(self.profile_store, "_data", {}).get(
                "profiles", {}
            )
            profiles = (
                cast(dict[str, Any], profiles_fallback)
                if isinstance(profiles_fallback, dict)
                else {}
            )

        if profile_name not in profiles:
            self._logger.warning("Cannot set manual program: '%s' not found", profile_name)
            return

        self._current_program = profile_name
        self._manual_program_active = True

        # Update expected duration immediately
        profile = profiles.get(profile_name)
        if profile:
            avg = float(profile.get("avg_duration", 0.0))
            if avg > 0:
                self._matched_profile_duration = avg
                self._logger.info(
                    "Manual program set to %s, duration=%.0fs", profile_name, avg
                )

                # Update estimates if running
                if self.detector.state == "running":
                    self._update_estimates()

    async def async_pause_cycle(self) -> bool:
        """Pause the current cycle (user-triggered).

        Sets verified_pause so the cycle is not finalized when power drops.
        Optionally cuts power to the switch entity if CONF_PAUSE_CUTS_POWER is enabled.

        Returns True if the cycle was paused, False if it was a no-op (wrong state).
        """
        if self.detector.state not in (STATE_RUNNING, STATE_STARTING, STATE_PAUSED, STATE_ENDING):
            self._logger.debug(
                "async_pause_cycle: ignored (detector state=%s)", self.detector.state
            )
            return False

        if self._is_user_paused:
            self._logger.debug("async_pause_cycle: already user-paused, ignoring")
            return False

        self._logger.info("Cycle paused by user")
        prev_verified = self.detector._verified_pause
        self._is_user_paused = True
        self._user_pause_start = dt_util.now()
        self.detector.set_verified_pause(True)

        if self._pause_cuts_power:
            switch_entity = self.config_entry.options.get(
                CONF_SWITCH_ENTITY
            ) or self.config_entry.data.get(CONF_SWITCH_ENTITY)
            if switch_entity:
                self._logger.info(
                    "pause_cuts_power: turning off switch %s", switch_entity
                )
                try:
                    await self.hass.services.async_call(
                        "switch", "turn_off", {"entity_id": switch_entity}, blocking=True
                    )
                except HomeAssistantError as err:
                    self._logger.warning(
                        "pause_cuts_power: failed to turn off %s: %s - rolling back pause state",
                        switch_entity, err,
                    )
                    self._is_user_paused = False
                    self._user_pause_start = None
                    self.detector.set_verified_pause(prev_verified)
                    return False

        # A user pause overrides an auto-open dwell: cancel it so the pending timer
        # can't finalize the cycle out from under the pause (#342).
        self._cancel_door_end_dwell()

        snapshot = self._augment_active_snapshot(self.detector.get_state_snapshot())
        self.hass.async_create_task(self.profile_store.async_save_active_cycle(snapshot))
        self._notify_update()
        return True

    async def async_resume_cycle(self) -> bool:
        """Resume a user-paused cycle.

        Accumulates elapsed paused time and clears the verified pause flag.
        Optionally restores power via the switch entity if CONF_PAUSE_CUTS_POWER is enabled.

        Returns True if the cycle was resumed, False if it was a no-op (not paused).
        """
        if not self._is_user_paused:
            self._logger.debug("async_resume_cycle: not user-paused, ignoring")
            return False

        now = dt_util.now()
        prev_pause_start = self._user_pause_start
        accumulated = (
            (now - prev_pause_start).total_seconds()
            if prev_pause_start is not None else 0.0
        )

        self._total_user_paused_seconds += accumulated
        self._user_pause_start = None
        self._is_user_paused = False
        self.detector.set_verified_pause(False)
        self._logger.info(
            "Cycle resumed by user (total paused: %.0fs)", self._total_user_paused_seconds
        )

        if self._pause_cuts_power:
            switch_entity = self.config_entry.options.get(
                CONF_SWITCH_ENTITY
            ) or self.config_entry.data.get(CONF_SWITCH_ENTITY)
            if switch_entity:
                self._logger.info(
                    "pause_cuts_power: turning on switch %s", switch_entity
                )
                try:
                    await self.hass.services.async_call(
                        "switch", "turn_on", {"entity_id": switch_entity}, blocking=True
                    )
                except HomeAssistantError as err:
                    self._logger.warning(
                        "pause_cuts_power: failed to turn on %s: %s - rolling back resume state",
                        switch_entity, err,
                    )
                    self._total_user_paused_seconds -= accumulated
                    self._user_pause_start = prev_pause_start
                    self._is_user_paused = True
                    self.detector.set_verified_pause(True)
                    return False

        # Dismiss the interactive pause notification only after the resume (incl. the
        # switch turn-on) has actually succeeded — a rolled-back resume above returns
        # early with the card still up, matching the real (still-paused) state.
        self._clear_timer_pause_notification()

        snapshot = self._augment_active_snapshot(self.detector.get_state_snapshot())
        self.hass.async_create_task(self.profile_store.async_save_active_cycle(snapshot))
        self._notify_update()
        return True

    async def async_terminate_cycle(self) -> None:
        """Force terminate the current cycle via user request."""
        self._logger.warning("Force terminating cycle by user request")

        # Trigger natural cycle end via detector
        # This will call _on_cycle_end callback, which handles:
        # - Saving to profile store
        # - Clearing active cycle persistence
        # - Post-processing/Merging
        # - Notifications
        self.detector.user_stop()

        # We DO NOT clear manager state manually here (e.g. self._current_program)
        # because we want the UI to show the "Clean" state with the just-finished
        # program info. The standard reset timers in _on_cycle_end /
        # _async_power_changed will handle cleanup after delay.

        # Force a state update to reflect the change immediately
        self._notify_update()

    async def async_start_recording(self) -> None:
        """Start manual recording of a cycle."""
        if self.recorder.is_recording:
            self._logger.warning("Already recording")
            return

        # Ensure we are in a clean state (stop any running cycle first?)
        # If running, user should probably stop it? Or force stop?
        # Plan said "unregulated", so we just start recording.
        # But if cycle_detector thinks it's running, we should probably "pause" it
        # or just override state. My override in checks_state handles UI.
        # But should we clear current program?
        if self.detector.state != "off":
            self._logger.info("Forcing detector reset before recording")
            self.detector.reset()

        await self.recorder.start_recording()
        self._notify_update()

    async def async_stop_recording(self) -> None:
        """Stop manual recording."""
        if not self.recorder.is_recording:
            return

        await self.recorder.stop_recording()
        self._notify_update()

    def clear_manual_program(self) -> None:
        """Clear manual program override."""
        if not self._manual_program_active:
            return

        self._manual_program_active = False
        # If running, revert to detecting so auto-detection can resume?
        if self.detector.state == "running":
            self._current_program = "detecting..."
            self._matched_profile_duration = None
            self._update_estimates()  # Trigger immediate re-detection attempt
        else:
            # If not running, clear the forced program
            self._current_program = "off"
            self._matched_profile_duration = None

        self._notify_update()
        self._logger.info("Manual program cleared, reverting to auto-detection")

    async def _run_post_cycle_processing(self) -> None:
        """Run post-cycle processing (merge fragments, split anomalies)."""
        try:
            # User Feedback: Use 5 hour lookback and configured gap settings
            stats = await self.profile_store.async_run_maintenance()

            # Log significant actions
            merged = stats.get("merged_cycles", 0)
            split = stats.get("split_cycles", 0)
            if merged > 0 or split > 0:
                self._logger.info(
                    "Post-cycle processing: Merged %s, Split %s cycle(s)", merged, split
                )

            # Note: async_run_maintenance saves automatically if changes occur
        except Exception as e:  # pylint: disable=broad-exception-caught
            self._logger.error("Post-cycle processing failed: %s", e)