# 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/>.
"""WebSocket API commands for the WashData full-screen panel."""
from __future__ import annotations

import asyncio
import collections
import functools
import json
import logging
import math
import os
import re
import time
import uuid
from datetime import timedelta
from typing import Any

import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.storage import Store
from homeassistant.util import dt as dt_util

from .const import (
    CONF_AUTO_LABEL_CONFIDENCE,
    CONF_NAME,
    CONF_COMPLETION_MIN_SECONDS,
    CONF_DEVICE_TYPE,
    CONF_DISHWASHER_END_SPIKE_QUIET_RELEASE,
    CONF_SMART_TERMINATION_DURATION_RATIO,
    CONF_DOOR_SENSOR_ENTITY,
    CONF_ANTI_WRINKLE_EXIT_POWER,
    CONF_ANTI_WRINKLE_MAX_POWER,
    CONF_DURATION_TOLERANCE,
    CONF_END_ENERGY_THRESHOLD,
    CONF_END_REPEAT_COUNT,
    CONF_ENERGY_SENSOR,
    CONF_EXTERNAL_END_TRIGGER,
    CONF_LEARNING_CONFIDENCE,
    CONF_LINKED_DEVICE,
    CONF_MAINTENANCE_REMINDER_CYCLES,
    CONF_MIN_OFF_GAP,
    CONF_MIN_POWER,
    CONF_NO_UPDATE_ACTIVE_TIMEOUT,
    CONF_OFF_DELAY,
    CONF_PROFILE_DURATION_TOLERANCE,
    CONF_PROFILE_MATCH_INTERVAL,
    CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
    CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
    CONF_PROFILE_MATCH_THRESHOLD,
    CONF_PROFILE_MIN_WARMUP_CYCLES,
    CONF_PROFILE_UNMATCH_THRESHOLD,
    CONF_PUMP_STUCK_DURATION,
    CONF_POWER_OFF_THRESHOLD_W,
    CONF_SAMPLING_INTERVAL,
    CONF_SMOOTHING_WINDOW,
    CONF_START_DURATION_THRESHOLD,
    CONF_START_THRESHOLD_W,
    CONF_STOP_THRESHOLD_W,
    CONF_SWITCH_ENTITY,
    CONF_WATCHDOG_INTERVAL,
    DEFAULT_DEVICE_TYPE,
    DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS,
    DEFAULT_SMART_TERMINATION_DURATION_RATIO,
    DEFAULT_SMART_TERMINATION_DURATION_RATIO_BY_DEVICE,
    resolve_sampling_interval_default,
    resolve_watchdog_interval_default,
    resolve_start_duration_default,
    resolve_smart_termination_duration_ratio_default,
    DEFAULT_MAINTENANCE_REMINDER_CYCLES,
    DEFAULT_MIN_POWER,
    DEFAULT_OFF_DELAY,
    DEFAULT_OFF_DELAY_BY_DEVICE,
    DEFAULT_PROFILE_MATCH_THRESHOLD,
    DEVICE_TYPE_PUMP,
    MAINTENANCE_EVENT_TYPES,
    DEVICE_TYPES,
    DOMAIN,
    ENABLE_ML_SUGGESTIONS,
    ENABLE_ML_TRAINING,
    HISTORY_IMPORT_CHUNK_BYTES,
    HISTORY_IMPORT_CHUNK_SAMPLES,
    HISTORY_IMPORT_MAX_BYTES,
    HISTORY_IMPORT_MAX_ROWS,
    HISTORY_IMPORT_MAX_SEGMENTS,
    HISTORY_IMPORT_MAX_TOTAL_CYCLES,
    HISTORY_IMPORT_RECORDER_EMPTY_DAY_STOP,
    HISTORY_IMPORT_RECORDER_MAX_DAYS,
    PLAYGROUND_PRESET_MAX,
    SHOW_ML_LAB,
    STATE_COLORS,
)
from . import history_import
from . import playground
from . import task_registry
from .cycle_detector import CycleDetectorConfig
from .options_utils import strip_null_options
from .setup_advisor import compute_setup_phase
from .ws_schema import WS_OPEN_RESPONSES, WS_RESPONSE_TYPES

_LOGGER = logging.getLogger(__name__)

# Populated once during async_setup_entry (stored in hass.data["ha_washdata_version"])
# so ws_get_constants never does blocking I/O on the event loop.  A module-level
# read_text() here would run on the event loop the first time ws_api is imported
# inside async_setup_entry (#328/#335).
_INTEGRATION_VERSION: str = ""

# ─── WS response contract (Group H1) ────────────────────────────────────────────
# Debug-only validation of every send_result payload against the TypedDict
# registered for its command in ws_schema.py. OFF by default so production has
# ZERO overhead: _send_result forwards straight to connection.send_result unless
# the flag is on. Enable by exporting HA_WASHDATA_WS_CONTRACT=1 before starting
# HA, or by flipping ws_api._WS_CONTRACT_CHECK = True (tests do the latter).
_WS_CONTRACT_CHECK: bool = bool(os.environ.get("HA_WASHDATA_WS_CONTRACT"))


def _validate_ws_contract(command: str, data: Any) -> list[str]:
    """Return contract-violation messages for ``data`` vs its response TypedDict.

    Pure and defensive: an unknown command or a non-dict payload for a typed
    command is reported, missing required top-level keys are reported, and
    unexpected top-level keys are reported unless the command is registered as
    open-ended in ``WS_OPEN_RESPONSES``. Returns an empty list when everything
    checks out. Never raises.
    """
    td = WS_RESPONSE_TYPES.get(command)
    if td is None:
        return [f"{command}: no response type registered"]
    if not isinstance(data, dict):
        return [f"{command}: response is {type(data).__name__}, expected dict"]
    required = set(getattr(td, "__required_keys__", ()) or ())
    optional = set(getattr(td, "__optional_keys__", ()) or ())
    keys = set(data.keys())
    problems: list[str] = []
    missing = required - keys
    if missing:
        problems.append(f"{command}: missing required keys {sorted(missing)}")
    if command not in WS_OPEN_RESPONSES:
        unexpected = keys - (required | optional)
        if unexpected:
            problems.append(f"{command}: unexpected keys {sorted(unexpected)}")
    return problems


def _send_result(
    connection: websocket_api.ActiveConnection,
    msg_id: int,
    command: str,
    data: Any,
) -> None:
    """Send a WS result, validating its shape against the contract in debug mode.

    Behaviourally identical to ``connection.send_result(msg_id, data)``; the
    contract check is a no-op (never touched) unless ``_WS_CONTRACT_CHECK`` is on,
    and even then it only logs — it never mutates ``data`` nor raises, so it can
    never change what the client receives.
    """
    if __debug__ and _WS_CONTRACT_CHECK:
        try:
            problems = _validate_ws_contract(command, data)
            if problems:
                _LOGGER.warning("WS contract mismatch: %s", "; ".join(problems))
        except Exception as exc:  # pylint: disable=broad-exception-caught
            _LOGGER.debug("WS contract check failed for %s: %s", command, exc)
    connection.send_result(msg_id, data)


# Fields too large or not serialisable to send over WebSocket.
_CYCLE_STRIP_KEYS = frozenset({"power_data", "power_trace", "debug_data", "samples"})

# Settings keys that can be staged from suggestions. Mirrors the OptionsFlow's
# _suggestion_keys_to_apply so the panel and the flow agree on what is tunable.
_SUGGESTION_KEYS: tuple[str, ...] = (
    CONF_MIN_POWER,
    CONF_OFF_DELAY,
    CONF_WATCHDOG_INTERVAL,
    CONF_NO_UPDATE_ACTIVE_TIMEOUT,
    CONF_SAMPLING_INTERVAL,
    CONF_PROFILE_MATCH_INTERVAL,
    CONF_AUTO_LABEL_CONFIDENCE,
    CONF_DURATION_TOLERANCE,
    CONF_PROFILE_DURATION_TOLERANCE,
    CONF_PROFILE_MATCH_MIN_DURATION_RATIO,
    CONF_PROFILE_MATCH_MAX_DURATION_RATIO,
    CONF_MIN_OFF_GAP,
    CONF_START_THRESHOLD_W,
    CONF_STOP_THRESHOLD_W,
    CONF_END_ENERGY_THRESHOLD,
    # Stage 1 detection suggestions
    CONF_SMOOTHING_WINDOW,
    CONF_START_DURATION_THRESHOLD,
    CONF_COMPLETION_MIN_SECONDS,
    CONF_LEARNING_CONFIDENCE,
    CONF_PROFILE_MATCH_THRESHOLD,
    CONF_END_REPEAT_COUNT,
    # Device-class-specific suggestions from reconcile_suggestions
    CONF_PROFILE_UNMATCH_THRESHOLD,
    CONF_POWER_OFF_THRESHOLD_W,
    CONF_ANTI_WRINKLE_EXIT_POWER,
    CONF_ANTI_WRINKLE_MAX_POWER,
    CONF_PUMP_STUCK_DURATION,
)

# Suggestion keys coerced to int when applied (mirrors the OptionsFlow).
_SUGGESTION_INT_KEYS: frozenset[str] = frozenset({
    CONF_OFF_DELAY,
    CONF_WATCHDOG_INTERVAL,
    CONF_NO_UPDATE_ACTIVE_TIMEOUT,
    CONF_PROFILE_MATCH_INTERVAL,
    CONF_MIN_OFF_GAP,
    CONF_SMOOTHING_WINDOW,
    CONF_COMPLETION_MIN_SECONDS,
    CONF_END_REPEAT_COUNT,
    CONF_PUMP_STUCK_DURATION,
})


def _coerce_suggested(key: str, val: Any) -> Any:
    """Round a raw suggestion value the way the UI would apply it.

    Int keys are floored to int, everything else rounded to 4 dp - the same
    coercion ``ws_get_suggestions`` does before the equivalence test. The device
    pill's badge filter must use it too, or a value like 30.4 for an int key with
    a current 30 is hidden in the Settings list (30 == 30) but still counted on
    the pill, leaving a badge the user cannot clear. Returns the raw value
    unchanged if it is not numeric.
    """
    try:
        return int(float(val)) if key in _SUGGESTION_INT_KEYS else round(float(val), 4)
    except (TypeError, ValueError):
        return val


def _suggestion_equivalent(suggested: Any, current: Any) -> bool:
    """True when a suggested value is effectively the same as the current one.

    Numeric-tolerant so an int option (30) matches a float suggestion (30.0);
    a missing current value never counts as equivalent (so it still surfaces).
    Used to hide suggestions that would not change anything.
    """
    if current is None:
        return False
    try:
        return abs(float(suggested) - float(current)) < 1e-6
    except (TypeError, ValueError):
        return str(suggested) == str(current)

# Settings surfaced in the ML Lab side-by-side comparison: key -> (label, unit).
# Order defines display order. Only keys either engine produces are shown.
_ML_COMPARE_SETTINGS: tuple[tuple[str, str, str], ...] = (
    (CONF_OFF_DELAY, "Off Delay", "s"),
    (CONF_END_REPEAT_COUNT, "End Repeat Count", "x"),
    (CONF_AUTO_LABEL_CONFIDENCE, "Auto-label Confidence", ""),
    (CONF_STOP_THRESHOLD_W, "Stop Threshold", "W"),
    (CONF_START_THRESHOLD_W, "Start Threshold", "W"),
    (CONF_END_ENERGY_THRESHOLD, "End Energy Threshold", "Wh"),
    (CONF_MIN_POWER, "Min Power", "W"),
    (CONF_MIN_OFF_GAP, "Min Off Gap", "s"),
    (CONF_DURATION_TOLERANCE, "Duration Tolerance", ""),
)


def _downsample(samples: Any, max_points: int = 240) -> list[list[float]]:
    """Reduce a [(offset_s, watts), ...] series to ~max_points, preserving extrema.

    Splits the series into contiguous buckets and keeps each bucket's MIN and MAX
    power sample (in time order), plus the global first and last. Two points per
    bucket, so ~max_points/2 buckets keeps the payload at the same budget striding
    used. Unlike striding this never drops a single-sample load peak (it is its
    bucket's max) or a lone 0 W self-shutdown sample between two non-zero readings
    (a zero is always its bucket's min) - the signals that carry the meaning.

    Power curves can hold thousands of points; the panel only needs enough to draw
    a faithful line, and WebSocket payloads should stay lean.
    """
    try:
        pairs = list(samples or [])
    except TypeError:
        return []
    n = len(pairs)
    if n == 0:
        return []

    def _pt(item: Any) -> list[float]:
        return [round(float(item[0]), 2), round(float(item[1]), 1)]

    if n <= max_points:
        return [_pt(it) for it in pairs]

    nbuckets = max(1, max_points // 2)
    keep: set[int] = {0, n - 1}
    for b in range(nbuckets):
        lo = (b * n) // nbuckets
        hi = ((b + 1) * n) // nbuckets
        if hi <= lo:
            continue
        min_i = max_i = lo
        min_v = max_v = float(pairs[lo][1])
        for i in range(lo + 1, hi):
            v = float(pairs[i][1])
            if v < min_v:
                min_v, min_i = v, i
            elif v > max_v:
                max_v, max_i = v, i
        keep.add(min_i)
        keep.add(max_i)
    return [_pt(pairs[i]) for i in sorted(keep)]


async def _recorder_power(
    hass: HomeAssistant,
    entity_id: str,
    start_dt: Any,
    *,
    end_dt: Any = None,
) -> list[tuple[float, float]]:
    """Raw (unix_ts, watts) readings for entity_id over a window, via the recorder.

    ``end_dt`` defaults to now (the live chart overlay's use). Passing it lets a caller
    read the history in bounded windows instead of one unbounded query - a month of
    5-second data is millions of rows in a single recorder-executor job.
    """
    try:
        from homeassistant.components.recorder import (  # pylint: disable=import-outside-toplevel
            get_instance,
            history,
        )
    except Exception:  # pylint: disable=broad-exception-caught
        return []
    # tz-aware; use dt_util.now() per the datetime convention
    window_end = end_dt if end_dt is not None else dt_util.now()

    def _query() -> list[tuple[float, float]]:
        res = history.state_changes_during_period(
            hass, start_dt, window_end, entity_id, include_start_time_state=True
        )
        rows: list[tuple[float, float]] = []
        start_ts = start_dt.timestamp() if hasattr(start_dt, "timestamp") else None
        for s in res.get(entity_id, []) or []:
            try:
                ts = s.last_changed.timestamp()
                # include_start_time_state yields the state in force at the window
                # start, whose last_changed can predate it by days. Clamp it to the
                # window so a caller reading day-by-day windows does not see a huge
                # synthetic leading gap (or the same reading twice).
                if start_ts is not None and ts < start_ts:
                    ts = start_ts
                rows.append((ts, round(float(s.state), 1)))
            except (ValueError, TypeError):
                continue
        return rows

    try:
        return await get_instance(hass).async_add_executor_job(_query)
    except Exception:  # pylint: disable=broad-exception-caught
        return []


def _cycle_kwh(c: dict[str, Any]) -> float | None:
    """Cycle energy in kWh for display.

    Prefers the external meter's reading (``energy_meter_wh``, issue #316) when a
    cycle recorded one, otherwise the integrated ``energy_wh``. Cycles store energy
    in Wh; convert to kWh.
    """
    wh = c.get("energy_meter_wh")
    if wh is None:
        wh = c.get("energy_wh")
    if wh is not None:
        try:
            return round(float(wh) / 1000.0, 4)
        except (TypeError, ValueError):
            pass
    return c.get("energy_kwh")


# ─── Helpers ──────────────────────────────────────────────────────────────────

def _get_manager(hass: HomeAssistant, entry_id: str) -> Any | None:
    domain_data: dict[str, Any] = hass.data.get(DOMAIN, {})
    return domain_data.get(entry_id) if isinstance(domain_data, dict) else None


# Per-entry serialization lock for the heavy, multi-await store-mutating WS
# handlers (process_recording / reprocess_history / import_config). Holding one
# lock for the whole operation prevents two of them from interleaving and
# clobbering each other's persisted state or colliding on cycle IDs. Stored on
# hass.data (not module-global) so each lock is created inside — and bound to —
# the running event loop, which keeps it correct across test event loops.
_WS_WRITE_LOCKS_KEY = f"{DOMAIN}_ws_write_locks"


def _entry_write_lock(hass: HomeAssistant, entry_id: str) -> asyncio.Lock:
    """Return the shared per-entry write lock, creating it on first use."""
    locks: dict[str, asyncio.Lock] = hass.data.setdefault(_WS_WRITE_LOCKS_KEY, {})
    lock = locks.get(entry_id)
    if lock is None:
        lock = asyncio.Lock()
        locks[entry_id] = lock
    return lock


def _get_entry(hass: HomeAssistant, entry_id: str) -> Any | None:
    return next(
        (e for e in hass.config_entries.async_entries(DOMAIN) if e.entry_id == entry_id),
        None,
    )


def _err_not_found(connection: websocket_api.ActiveConnection, msg_id: int, entry_id: str) -> None:
    connection.send_error(msg_id, "not_found", f"No active WashData manager for entry {entry_id!r}")


def _strip_cycle(c: dict[str, Any]) -> dict[str, Any]:
    return {k: v for k, v in c.items() if k not in _CYCLE_STRIP_KEYS}


def _cycle_capabilities(cycle: dict[str, Any], origin: str) -> dict[str, Any]:
    """What the panel may offer for one cycle, given which list it lives in.

    Three separate answers, because "not a real cycle" is not one capability:

    * ``is_reference`` - it lives outside ``past_cycles``, so it feeds envelopes and the
      matcher but never usage statistics. Always derived from list membership, never from
      a ``meta.source`` string: the cycle list and the inspector both answer through here,
      and they must not be able to disagree.
    * ``labelable`` - a profile can be assigned to it. True everywhere: naming the
      programs found in imported history (#344) is the entire point of that feature, and
      the store labels a non-real cycle in place.
    * ``editable`` - trim / split / review apply. Real cycles only; those store functions
      operate on ``past_cycles`` and would silently no-op elsewhere.

    ``origin`` is the list name from :meth:`ProfileStore.find_stored_cycle`
    (``past`` / ``reference`` / ``backfill``) and is passed through so the UI can say
    where a cycle came from - a curated community-store template and a segment the
    importer detected in the user's own history warrant different wording.
    """
    if origin == "past":
        return {"is_reference": False, "labelable": True, "editable": True}
    return {
        "is_reference": True,
        "labelable": True,
        "editable": False,
        "cycle_origin": origin or "reference",
    }


# Option keys that are identity/transient churn and are never recorded in the
# settings changelog (D7): name/title edits flow through the separate `title`
# kwarg, and suggestion application uses its own apply_suggestions command.
_CHANGELOG_SKIP_KEYS = frozenset({CONF_NAME})

# Identity keys that must NEVER be persisted into entry.options; they are
# partitioned out of any submitted/imported option payload before it is saved.
# In this integration only the display name is a pure options-forbidden identity
# key -- it is carried by the config entry title. device_type / power_sensor /
# min_power deliberately live in entry.options post-3.6 (config_flow writes them
# there and the manager resolves them options-first), so they are NOT listed
# here; relocating them to entry.data would shadow the option-first reads.
_OPTIONS_IDENTITY_KEYS = frozenset({CONF_NAME})


def _json_safe(value: Any) -> Any:
    """Best-effort coercion of an option value to a JSON-serializable form."""
    try:
        json.dumps(value)
        return value
    except (TypeError, ValueError):
        return str(value)


def _diff_option_changes(
    old_effective: dict[str, Any], submitted: dict[str, Any]
) -> list[dict[str, Any]]:
    """Diff submitted option values against the pre-update effective options.

    Returns one changelog entry ``{"key", "old", "new", "timestamp"}`` per key
    whose value genuinely changed, skipping identity/transient keys. Only keys
    present in ``submitted`` are considered so unrelated merged options never
    produce spurious entries. ``old=None`` with a real ``new`` value is
    recorded; an unchanged ``None -> None`` is not.
    """
    ts = dt_util.now().isoformat()
    changes: list[dict[str, Any]] = []
    for key, new_val in submitted.items():
        if key in _CHANGELOG_SKIP_KEYS:
            continue
        old_val = old_effective.get(key)
        if old_val == new_val:
            continue
        changes.append(
            {
                "key": str(key),
                "old": _json_safe(old_val),
                "new": _json_safe(new_val),
                "timestamp": ts,
            }
        )
    return changes


# ─── Panel config + RBAC ────────────────────────────────────────────────────────

_PANEL_STORE_VERSION = 1
_PANEL_STORE_FILE = "ha_washdata_panel"
_PANEL_DATA_KEY = "ha_washdata_panel_cfg"

_LEVEL_RANK = {"none": 0, "read": 1, "edit": 2, "full": 3}
_PANEL_TABS = ("status", "history", "profiles", "settings", "tools", "panel", "ml_lab", "playground")

# Valid values for the two per-user string prefs (validated in ws_set_user_prefs).
_PREF_DATE_FORMATS = ("relative", "absolute")
# lang_override: empty string clears it (fall back to system language); otherwise a
# BCP-47-ish tag (e.g. "en", "pt-BR", "sr-Latn"). Kept as a bounded pattern rather
# than coupling the WS handler to the translations/panel/ language file list —
# the panel already falls back to system language for any tag it can't load.
_PREF_LANG_TAG_RE = re.compile(r"^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$")

# Commands that require 'full' (destructive or full-data export/import).
_FULL_COMMANDS = frozenset({
    "wipe_history", "import_config", "export_config", "clear_debug_data", "reprocess_history",
    "trigger_ml_training",
    # Selective export/import wizard (same full-data reach as the wholesale variants).
    "get_export_inventory", "analyze_import", "export_config_selective", "import_config_selective",
    # Reverting on-device models / matcher tuning discards learned state -> full access.
    "revert_matching_config", "revert_ml_models",
    # Historical power-data import: ingests a whole power history and writes cycles.
    "history_import_begin", "history_import_chunk", "history_import_recorder",
    "start_history_import_scan", "apply_history_import",
})
# Commands allowed for any authenticated user regardless of device permissions.
_OPEN_COMMANDS = frozenset({
    "get_constants", "get_panel_config", "set_user_prefs",
})
# Admin-only commands.
# Commands that require administrator access ALWAYS — even when RBAC is disabled
# (default), where every authenticated user otherwise resolves to "full". These are
# destructive/global: they can wipe stored data, overwrite config, read/write files
# on disk, or reprocess the whole history. A non-admin HA user must not reach them.
_ADMIN_COMMANDS = frozenset({
    "set_panel_config",
    "get_logs",
    "wipe_history",
    "import_config",
    "export_config",
    "get_export_inventory",
    "analyze_import",
    "export_config_selective",
    "import_config_selective",
    "reprocess_history",
    "clear_debug_data",
    # Global community-store mutations: these change the ONE integration-wide GitHub
    # connection / online flag shared by every entry, so an editor of a single device
    # must not be able to connect, disconnect, or toggle online for the whole install.
    "store_connect",
    "store_disconnect",
    "store_set_online",
    "store_set_prefs",
    # Historical power-data import: reads a whole recorder history (or an uploaded
    # export of one) and writes cycles into the store, so it is admin-only even with
    # RBAC disabled, exactly like the selective import wizard.
    "history_import_begin",
    "history_import_chunk",
    "history_import_recorder",
    "start_history_import_scan",
    "apply_history_import",
})
# Mutating commands intentionally allowed at the 'read' level. Picking the live
# program is a benign runtime action (it changes detection, not stored data), so
# read users may use the Status program selector. The Playground simulation is a
# read-only what-if replay (it never persists anything) whose name does not start
# with get_, so it is whitelisted here to gate at the 'read' level.
_READ_WRITE_COMMANDS = frozenset({
    "set_program",
    "run_playground_cycle_detail",
    "run_playground_history",
    "run_playground_sweep",
    # Background-task registry: read-level runtime actions (watch progress, fetch
    # a what-if/maintenance result, or stop a task). None mutate stored data.
    "list_tasks",
    "subscribe_tasks",
    "cancel_task",
    "get_task_result",
    "start_playground_history",
    "start_playground_sweep",
    "start_playground_cycle_detail",
    # Community store: read-only browse is read-level (writes below default to 'edit').
    "store_status",
    "store_search_devices",
    "store_list_brands",
    "store_get_profiles",
    "store_get_cycles",
    "store_get_device_quality",
    "store_get_device_profiles",
    "store_get_catalog_entry",
    # NB: store_refresh_catalog is deliberately NOT here. It drops the install-wide
    # catalog cache in the shared StoreClient, so a read-level user could bust the
    # 1-hour TTL that protects the free-tier read budget on repeat. It defaults to
    # 'edit', like the other install-wide store actions above read level.
})

_LOG_BUFFER_KEY = "ha_washdata_log_buffer"
_LOG_LEVELS = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "ERROR": 40, "CRITICAL": 50}


class _RingLogHandler(logging.Handler):
    """In-memory ring buffer of recent ha_washdata log records for the Logs page."""

    def __init__(self, maxlen: int = 500) -> None:
        super().__init__()
        self.records: collections.deque = collections.deque(maxlen=maxlen)

    def emit(self, record: logging.LogRecord) -> None:
        try:
            self.records.append({
                "ts": record.created,
                "level": record.levelname,
                "logger": record.name.split(".")[-1],
                "device": getattr(record, "wd_device", None),
                "msg": record.getMessage(),
            })
        except Exception:  # pylint: disable=broad-exception-caught
            pass


def _default_panel_cfg() -> dict[str, Any]:
    return {
        "panel": {"poll_interval_s": 5, "default_tab": "status", "hidden_tabs": []},
        "rbac": {"enabled": False, "default_level": "none", "users": {}},
        "prefs": {},
    }


async def async_load_panel_config(hass: HomeAssistant) -> None:
    """Load (once) the panel-global config + RBAC store into hass.data."""
    if _PANEL_DATA_KEY in hass.data:
        return
    store = Store(hass, _PANEL_STORE_VERSION, _PANEL_STORE_FILE)
    cfg = _default_panel_cfg()
    try:
        loaded = await store.async_load()
        if isinstance(loaded, dict):
            if isinstance(loaded.get("panel"), dict):
                cfg["panel"].update(loaded["panel"])
            if isinstance(loaded.get("rbac"), dict):
                for k in ("enabled", "default_level", "users"):
                    if k in loaded["rbac"]:
                        cfg["rbac"][k] = loaded["rbac"][k]
                if not isinstance(cfg["rbac"].get("users"), dict):
                    cfg["rbac"]["users"] = {}
            if isinstance(loaded.get("prefs"), dict):
                cfg["prefs"] = loaded["prefs"]
    except Exception as exc:  # pylint: disable=broad-exception-caught
        _LOGGER.warning("Failed to load panel config, using defaults: %s", exc)
    hass.data[_PANEL_DATA_KEY] = {"store": store, "data": cfg}

    if _LOG_BUFFER_KEY not in hass.data:
        handler = _RingLogHandler()
        handler.setLevel(logging.DEBUG)
        wd_logger = logging.getLogger("custom_components.ha_washdata")
        wd_logger.addHandler(handler)
        # The ring handler captures at the logger's configured effective level
        # (HA's default is INFO, so lifecycle activity shows out of the box). We do
        # NOT raise the logger level here: doing so would override a user who set
        # this integration to WARNING and leak INFO records into home-assistant.log.
        # To see more in the panel Logs view, set the integration's log level in HA.
        hass.data[_LOG_BUFFER_KEY] = handler


def _panel_data(hass: HomeAssistant) -> dict[str, Any]:
    holder = hass.data.get(_PANEL_DATA_KEY)
    return holder["data"] if holder else _default_panel_cfg()


async def _save_panel_data(hass: HomeAssistant) -> None:
    holder = hass.data.get(_PANEL_DATA_KEY)
    if holder:
        await holder["store"].async_save(holder["data"])


def _effective_level(hass: HomeAssistant, user: Any, entry_id: str | None) -> str:
    """Resolve a user's access level for a device (none/read/edit/full)."""
    if user is None:
        return "none"
    if getattr(user, "is_admin", False):
        return "full"
    rbac = _panel_data(hass).get("rbac", {})
    if not rbac.get("enabled"):
        return "full"  # RBAC disabled -> unrestricted (original behavior)
    u = (rbac.get("users") or {}).get(user.id)
    if isinstance(u, dict):
        if entry_id and entry_id in (u.get("devices") or {}):
            return u["devices"][entry_id]
        return u.get("default", "none")
    return rbac.get("default_level", "none")


# Background-task commands that may reference a task by id with no device context.
_TASK_COMMANDS = frozenset({"list_tasks", "subscribe_tasks", "cancel_task", "get_task_result"})


def _rbac_ok(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]) -> bool:
    """Authorize a command for the calling user; sends an error and returns False if denied."""
    user = getattr(connection, "user", None)
    if user is None:
        connection.send_error(msg["id"], "unauthorized", "No authenticated user")
        return False
    if getattr(user, "is_admin", False):
        return True
    cmd = str(msg.get("type", "")).split("/", 1)[-1]
    if cmd in _ADMIN_COMMANDS:
        connection.send_error(msg["id"], "forbidden", "Administrator access required")
        return False
    if cmd in _OPEN_COMMANDS:
        return True
    entry_id = msg.get("entry_id")
    # Background-task commands can address a task by id without an entry_id. Under RBAC,
    # a non-admin must not read/cancel a task on a device they aren't authorized for by
    # simply omitting entry_id (which would otherwise fall through the allow-all below).
    # When RBAC is disabled this block is skipped entirely, so default behavior is intact.
    if not entry_id and cmd in _TASK_COMMANDS and _panel_data(hass).get("rbac", {}).get("enabled"):
        task_id = msg.get("task_id")
        if task_id:
            from . import task_registry  # pylint: disable=import-outside-toplevel

            task = task_registry.get_registry(hass).get(task_id)
            entry_id = getattr(task, "entry_id", None) if task is not None else None
            if not entry_id:
                connection.send_error(msg["id"], "forbidden", "Task not found or not authorized")
                return False
        else:
            # list_tasks / subscribe_tasks with no device context: require a device so
            # the result set can be authorized (the panel passes entry_id per device).
            connection.send_error(msg["id"], "forbidden", "You need to specify a device")
            return False
    if not entry_id:
        return True  # no device context and not admin/open: harmless read-style command
    if cmd in _READ_WRITE_COMMANDS:
        required = "read"
    elif cmd in _FULL_COMMANDS:
        required = "full"
    elif cmd.startswith("get_"):
        required = "read"
    else:
        required = "edit"
    have = _effective_level(hass, user, entry_id)
    if _LEVEL_RANK.get(have, 0) >= _LEVEL_RANK[required]:
        return True
    connection.send_error(msg["id"], "forbidden", f"You need {required} access to this device")
    return False


def _guard(handler: Any) -> Any:
    """Wrap a websocket handler with an RBAC check.

    Uses functools.wraps so the websocket_command/async_response markers and
    schema attributes carry over verbatim, keeping sync (@callback) and async
    (@async_response) handlers working unchanged.
    """
    @functools.wraps(handler)
    def wrapper(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]) -> Any:
        if not _rbac_ok(hass, connection, msg):
            return None
        return handler(hass, connection, msg)
    return wrapper


def _sanitize_panel(p: dict[str, Any], current: dict[str, Any]) -> dict[str, Any]:
    out = dict(current)
    if "poll_interval_s" in p:
        try:
            out["poll_interval_s"] = max(2, min(60, int(p["poll_interval_s"])))
        except (TypeError, ValueError):
            pass
    if p.get("default_tab") in _PANEL_TABS:
        out["default_tab"] = p["default_tab"]
    if isinstance(p.get("hidden_tabs"), list):
        out["hidden_tabs"] = [t for t in p["hidden_tabs"] if t in _PANEL_TABS and t not in ("status", "panel")]
    return out


def _sanitize_rbac(r: dict[str, Any]) -> dict[str, Any]:
    levels = set(_LEVEL_RANK)
    dlevel = r.get("default_level", "none")
    out: dict[str, Any] = {
        "enabled": bool(r.get("enabled", False)),
        "default_level": dlevel if dlevel in levels else "none",
        "users": {},
    }
    for uid, u in (r.get("users") or {}).items():
        if not isinstance(u, dict):
            continue
        d = u.get("default", "none")
        devices = {str(eid): lvl for eid, lvl in (u.get("devices") or {}).items() if lvl in levels}
        out["users"][str(uid)] = {"default": d if d in levels else "none", "devices": devices}
    return out


# ─── Registration ─────────────────────────────────────────────────────────────

@callback
# ─── Community store (online features) ─────────────────────────────────────────

def _store_ctx(hass: HomeAssistant, entry_id: str) -> tuple[Any, dict[str, Any]] | None:
    """Return (manager, options) when online features are enabled (global), else None."""
    from .store import online_features_enabled
    entry = _get_entry(hass, entry_id)
    manager = _get_manager(hass, entry_id)
    if entry is None or manager is None:
        return None
    if not online_features_enabled(hass):
        return None
    return manager, dict(entry.options)


@websocket_api.websocket_command({vol.Required("type"): "ha_washdata/store_status", vol.Required("entry_id"): str})
@websocket_api.async_response
async def ws_store_status(hass, connection, msg):
    from .store import online_features_enabled
    manager = _get_manager(hass, msg["entry_id"])
    if manager is None:
        _err_not_found(connection, msg["id"], msg["entry_id"])
        return
    if not online_features_enabled(hass):
        _send_result(connection, msg["id"], "store_status", {"enabled": False})
        return
    _send_result(connection, msg["id"], "store_status", manager.store_bridge.status())


@websocket_api.websocket_command({
    vol.Required("type"): "ha_washdata/store_connect", vol.Required("entry_id"): str,
    vol.Required("refresh_token"): str, vol.Required("uid"): str, vol.Optional("name"): vol.Any(str, None),
})
@websocket_api.async_response
async def ws_store_connect(hass, connection, msg):
    ctx = _store_ctx(hass, msg["entry_id"])
    if ctx is None:
        _send_result(connection, msg["id"], "store_connect", {"disabled": True})
        return
    manager, _ = ctx
    res = await manager.store_bridge.connect(msg["refresh_token"], msg["uid"], msg.get("name"))
    _send_result(connection, msg["id"], "store_connect", res)


@websocket_api.websocket_command({vol.Required("type"): "ha_washdata/store_disconnect", vol.Required("entry_id"): str})
@websocket_api.async_response
async def ws_store_disconnect(hass, connection, msg):
    ctx = _store_ctx(hass, msg["entry_id"])
    if ctx is None:
        _send_result(connection, msg["id"], "store_disconnect", {"disabled": True})
        return
    manager, _ = ctx
    _send_result(connection, msg["id"], "store_disconnect", await manager.store_bridge.disconnect())


@websocket_api.websocket_command({
    vol.Required("type"): "ha_washdata/store_search_devices", vol.Required("entry_id"): str,
    vol.Optional("query"): vol.Any(str, None), vol.Optional("appliance_type"): vol.Any(str, None),
    vol.Optional("model_query"): vol.Any(str, None), vol.Optional("include_pending"): bool,
})
@websocket_api.async_response
async def ws_store_search_devices(hass, connection, msg):
    ctx = _store_ctx(hass, msg["entry_id"])
    if ctx is None:
        _send_result(connection, msg["id"], "store_search_devices", {"disabled": True})
        return
    manager, _ = ctx
    items = await manager.store_bridge.search_devices(
        msg.get("query"), msg.get("appliance_type"),
        model_query=msg.get("model_query"), include_pending=bool(msg.get("include_pending", False)),
    )
    _send_result(connection, msg["id"], "store_search_devices", {"items": items})


@websocket_api.websocket_command({
    vol.Required("type"): "ha_washdata/store_list_brands", vol.Required("entry_id"): str,
    vol.Optional("query"): vol.Any(str, None), vol.Optional("include_pending"): bool,
})
@websocket_api.async_response
async def ws_store_list_brands(hass, connection, msg):
    ctx = _store_ctx(hass, msg["entry_id"])
    if ctx is None:
        _send_result(connection, msg["id"], "store_list_brands", {"disabled": True})
        return
    manager, _ = ctx
    items = await manager.store_bridge.list_brands(msg.get("query"), include_pending=bool(msg.get("include_pending", True)))
    _send_result(connection, msg["id"], "store_list_brands", {"items": items})


@websocket_api.websocket_command({
    vol.Required("type"): "ha_washdata/store_get_device_quality", vol.Required("entry_id"): str,
    vol.Required("device_id"): str,
})
@websocket_api.async_response
async def ws_store_get_device_quality(hass, connection, msg):
    ctx = _store_ctx(hass, msg["entry_id"])
    if ctx is None:
        _send_result(connection, msg["id"], "store_get_device_quality", {"disabled": True})
        return
    manager, _ = ctx
    _send_result(connection, msg["id"], "store_get_device_quality", await manager.store_bridge.get_device_quality(msg["device_id"]))


@websocket_api.websocket_command({
    vol.Required("type"): "ha_washdata/store_get_catalog_entry", vol.Required("entry_id"): str,
    vol.Required("brand"): str, vol.Required("model"): str, vol.Required("appliance_type"): str,
})
@websocket_api.async_response
async def ws_store_get_catalog_entry(hass, connection, msg):
    """Resolve just this appliance's catalog brand + device documents, by id.

    Two point reads, replacing the brand list + device list the settings form used to
    download purely to locate these two rows (measured: 128 documents, 119 KB). The
    pickers still fetch the full lists, but only when the user opens one.
    """
    ctx = _store_ctx(hass, msg["entry_id"])
    if ctx is None:
        _send_result(connection, msg["id"], "store_get_catalog_entry", {"disabled": True})
        return
    manager, _ = ctx
    res = await manager.store_bridge.catalog_entry(msg["brand"], msg["model"], msg["appliance_type"])
    _send_result(connection, msg["id"], "store_get_catalog_entry", res)


@websocket_api.websocket_command({
    vol.Required("type"): "ha_washdata/store_refresh_catalog", vol.Required("entry_id"): str,
})
@websocket_api.async_response
async def ws_store_refresh_catalog(hass, connection, msg):
    """Drop the cached catalog so the next browse re-reads the store.

    The cache is deliberately long-lived (the catalog is near-static and every read is
    charged against a shared free-tier budget), so this is the escape hatch for "someone
    told me my brand was just approved".
    """
    ctx = _store_ctx(hass, msg["entry_id"])
    if ctx is None:
        _send_result(connection, msg["id"], "store_refresh_catalog", {"disabled": True})
        return
    manager, _ = ctx
    _send_result(connection, msg["id"], "store_refresh_catalog", manager.store_bridge.refresh_catalog())


@websocket_api.websocket_command({
    vol.Required("type"): "ha_washdata/store_get_device_profiles", vol.Required("entry_id"): str,
    vol.Required("brand"): str, vol.Required("model"): str, vol.Required("appliance_type"): str,
})
@websocket_api.async_response
async def ws_store_get_device_profiles(hass, connection, msg):
    ctx = _store_ctx(hass, msg["entry_id"])
    if ctx is None:
        _send_result(connection, msg["id"], "store_get_device_profiles", {"disabled": True})
        return
    manager, _ = ctx
    res = await manager.store_bridge.device_profiles(msg["brand"], msg["model"], msg["appliance_type"])
    _send_result(connection, msg["id"], "store_get_device_profiles", res)


@websocket_api.websocket_command({
    vol.Required("type"): "ha_washdata/store_confirm_device", vol.Required("entry_id"): str,
    vol.Required("device_id"): str,
})
@websocket_api.async_response
async def ws_store_confirm_device(hass, connection, msg):
    ctx = _store_ctx(hass, msg["entry_id"])
    if ctx is None:
        _send_result(connection, msg["id"], "store_confirm_device", {"disabled": True})
        return
    manager, _ = ctx
    _send_result(connection, msg["id"], "store_confirm_device", await manager.store_bridge.confirm_device(msg["device_id"]))


@websocket_api.websocket_command({
    vol.Required("type"): "ha_washdata/store_rate_device", vol.Required("entry_id"): str,
    vol.Required("device_id"): str,
    vol.Required("rating"): vol.All(int, vol.Range(min=1, max=5)),
})
@websocket_api.async_response
async def ws_store_rate_device(hass, connection, msg):
    ctx = _store_ctx(hass, msg["entry_id"])
    if ctx is None:
        _send_result(connection, msg["id"], "store_rate_device", {"disabled": True})
        return
    manager, _ = ctx
    _send_result(connection, msg["id"], "store_rate_device", await manager.store_bridge.rate_device(msg["device_id"], int(msg["rating"])))


@websocket_api.websocket_command({
    vol.Required("type"): "ha_washdata/store_set_online", vol.Required("entry_id"): str,
    vol.Required("enabled"): bool,
})
@websocket_api.async_response
async def ws_store_set_online(hass, connection, msg):
    """Enable/disable online features integration-wide (device-agnostic)."""
    from . import store_account
    await store_account.async_set_online(hass, bool(msg["enabled"]))
    manager = _get_manager(hass, msg["entry_id"])
    if manager is not None:
        manager.notify_update()
    _send_result(connection, msg["id"], "store_set_online", {"enabled": store_account.online_enabled(hass)})


@websocket_api.websocket_command({
    vol.Required("type"): "ha_washdata/store_set_prefs", vol.Required("entry_id"): str,
    vol.Required("prefs"): dict,
})
@websocket_api.async_response
async def ws_store_set_prefs(hass, connection, msg):
    """Merge integration-wide community-store preferences (only known keys)."""
    from . import store_account
    prefs = await store_account.async_set_prefs(hass, msg.get("prefs") or {})
    manager = _get_manager(hass, msg["entry_id"])
    if manager is not None:
        manager.notify_update()
    _send_result(connection, msg["id"], "store_set_prefs", {"prefs": prefs})


@websocket_api.websocket_command({
    vol.Required("type"): "ha_washdata/store_get_profiles", vol.Required("entry_id"): str,
    vol.Required("device_id"): str,
})
@websocket_api.async_response
async def ws_store_get_profiles(hass, connection, msg):
    ctx = _store_ctx(hass, msg["entry_id"])
    if ctx is None:
        _send_result(connection, msg["id"], "store_get_profiles", {"disabled": True})
        return
    manager, _ = ctx
    items = await manager.store_bridge.get_profiles(msg["device_id"])
    _send_result(connection, msg["id"], "store_get_profiles", {"items": items})


@websocket_api.websocket_command({
    vol.Required("type"): "ha_washdata/store_get_cycles", vol.Required("entry_id"): str,
    vol.Required("profile_id"): str,
})
@websocket_api.async_response
async def ws_store_get_cycles(hass, connection, msg):
    ctx = _store_ctx(hass, msg["entry_id"])
    if ctx is None:
        _send_result(connection, msg["id"], "store_get_cycles", {"disabled": True})
        return
    manager, _ = ctx
    items = await manager.store_bridge.get_cycles(msg["profile_id"])
    _send_result(connection, msg["id"], "store_get_cycles", {"items": items})


@websocket_api.websocket_command({
    vol.Required("type"): "ha_washdata/store_import_cycle", vol.Required("entry_id"): str,
    vol.Required("cycle_id"): str,
    vol.Optional("target_profile"): vol.Any(str, None), vol.Optional("new_profile_name"): vol.Any(str, None),
})
@websocket_api.async_response
async def ws_store_import_cycle(hass, connection, msg):
    ctx = _store_ctx(hass, msg["entry_id"])
    if ctx is None:
        _send_result(connection, msg["id"], "store_import_cycle", {"disabled": True})
        return
    manager, _ = ctx
    res = await manager.store_bridge.import_cycle(
        msg["cycle_id"], msg.get("target_profile"), msg.get("new_profile_name")
    )
    manager.notify_update()
    _send_result(connection, msg["id"], "store_import_cycle", res)


@websocket_api.websocket_command({
    vol.Required("type"): "ha_washdata/store_upload_cycle", vol.Required("entry_id"): str,
    vol.Required("local_cycle_id"): str, vol.Required("program"): str,
    vol.Optional("description"): vol.Any(str, None),
})
@websocket_api.async_response
async def ws_store_upload_cycle(hass, connection, msg):
    from .const import CONF_STORE_BRAND, CONF_STORE_MODEL, CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE
    ctx = _store_ctx(hass, msg["entry_id"])
    if ctx is None:
        _send_result(connection, msg["id"], "store_upload_cycle", {"disabled": True})
        return
    manager, opts = ctx
    brand = str(opts.get(CONF_STORE_BRAND) or "").strip()
    model = str(opts.get(CONF_STORE_MODEL) or "").strip()
    if not brand or not model:
        _send_result(connection, msg["id"], "store_upload_cycle", {"error": "no_appliance_declared"})
        return
    appliance = opts.get(CONF_DEVICE_TYPE, manager.config_entry.data.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE))
    res = await manager.store_bridge.share_cycle(
        msg["local_cycle_id"], msg["program"], brand, model, appliance,
        description=msg.get("description") or "",
    )
    _send_result(connection, msg["id"], "store_upload_cycle", res)


@websocket_api.websocket_command({
    vol.Required("type"): "ha_washdata/store_upload_device", vol.Required("entry_id"): str,
    vol.Required("items"): [dict], vol.Optional("include_phases"): [str],
    vol.Optional("include_settings"): bool,
})
@websocket_api.async_response
async def ws_store_upload_device(hass, connection, msg):
    """Share a whole-device bundle. Brand/model/type come from this device's options;
    ``items`` = the panel's tree selection ``[{local_cycle_id, program}]``;
    ``include_phases`` = programs whose phase map should ride along;
    ``include_settings`` bundles the allow-listed recognition/matching settings."""
    from .const import (
        CONF_STORE_BRAND, CONF_STORE_MODEL, CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE,
        SHAREABLE_SETTING_KEYS,
    )
    ctx = _store_ctx(hass, msg["entry_id"])
    if ctx is None:
        _send_result(connection, msg["id"], "store_upload_device", {"disabled": True})
        return
    manager, opts = ctx
    brand = str(opts.get(CONF_STORE_BRAND) or "").strip()
    model = str(opts.get(CONF_STORE_MODEL) or "").strip()
    if not brand or not model:
        _send_result(connection, msg["id"], "store_upload_device", {"error": "no_appliance_declared"})
        return
    appliance = opts.get(CONF_DEVICE_TYPE, manager.config_entry.data.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE))
    settings = None
    if msg.get("include_settings"):
        # Only the allow-listed numeric thresholds; the WS layer owns entry.options.
        settings = {k: opts[k] for k in SHAREABLE_SETTING_KEYS if k in opts}
    res = await manager.store_bridge.share_device(
        brand, model, appliance, msg["items"],
        include_phases=msg.get("include_phases"), settings=settings,
    )
    _send_result(connection, msg["id"], "store_upload_device", res)


@websocket_api.websocket_command({
    vol.Required("type"): "ha_washdata/store_download_device", vol.Required("entry_id"): str,
    vol.Required("device_id"): str, vol.Optional("include_settings"): bool,
})
@websocket_api.async_response
async def ws_store_download_device(hass, connection, msg):
    """Adopt a whole-device bundle into this device's reference cycles (merge/upsert).
    When ``include_settings`` is set, also apply the bundle's allow-listed
    recognition/matching settings onto this device's options (overwrites live tuning)."""
    from .const import CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE, SHAREABLE_SETTING_KEYS
    ctx = _store_ctx(hass, msg["entry_id"])
    if ctx is None:
        _send_result(connection, msg["id"], "store_download_device", {"disabled": True})
        return
    manager, opts = ctx
    device_type = opts.get(CONF_DEVICE_TYPE, manager.config_entry.data.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE))
    res = await manager.store_bridge.download_device(msg["device_id"], device_type)
    settings_applied = 0
    if msg.get("include_settings"):
        bundle_settings = res.get("settings") if isinstance(res.get("settings"), dict) else {}
        # Accept only allow-listed, numeric (non-bool) values - matching what the
        # upload side ever writes - so a malformed/hostile bundle can't inject a
        # string/list/bool into this device's live options.
        filtered = {
            k: v for k, v in bundle_settings.items()
            if k in SHAREABLE_SETTING_KEYS
            and isinstance(v, (int, float)) and not isinstance(v, bool)
        }
        entry = _get_entry(hass, msg["entry_id"])
        if filtered and entry is not None:
            hass.config_entries.async_update_entry(entry, options={**entry.options, **filtered})
            settings_applied = len(filtered)
    res = {**res, "settings_applied": settings_applied}
    manager.notify_update()
    _send_result(connection, msg["id"], "store_download_device", res)


@websocket_api.websocket_command({
    vol.Required("type"): "ha_washdata/get_shareable_cycles", vol.Required("entry_id"): str,
})
@websocket_api.async_response
async def ws_get_shareable_cycles(hass, connection, msg):
    """All recorded/golden reference cycles eligible to share (the share-device tree),
    plus the subset of programs that carry a local phase map (for the phase toggle)."""
    manager = _get_manager(hass, msg["entry_id"])
    if manager is None:
        _err_not_found(connection, msg["id"], msg["entry_id"])
        return
    store = manager.profile_store
    items = store.get_shareable_cycles()
    # All known profiles (not just those with shareable cycles) so the panel can show
    # profiles that exist but have no golden/recorded cycles yet, with guidance.
    all_programs = sorted(store.get_profiles().keys())
    # Phase toggle covers ALL profiles that have a phase map, not just those with
    # shareable cycles — so users with phases but no reference cycles yet still see
    # the toggle (shown as a dimmed no-cycle row in the share tree).
    phase_programs = sorted(p for p in all_programs if store.get_profile_phase_ranges(p))
    _send_result(connection, msg["id"], "get_shareable_cycles",
                 {"items": items, "phase_programs": phase_programs, "all_programs": all_programs})


def async_register_commands(hass: HomeAssistant) -> None:
    """Register all WebSocket commands for the WashData panel.

    Every handler is wrapped in _guard so RBAC is enforced centrally and no
    command can accidentally ship unprotected.
    """
    handlers = [
        ws_get_devices, ws_get_device_cycles,
        # Settings
        ws_get_options, ws_set_options, ws_get_settings_changelog,
        ws_get_setup_status,
        # Profiles
        ws_get_profiles, ws_create_profile, ws_rename_profile, ws_delete_profile,
        ws_rebuild_envelopes, ws_get_profile_phases, ws_set_profile_phases,
        # Profile groups (Stage 5)
        ws_get_profile_groups, ws_save_profile_group, ws_rename_profile_group, ws_delete_profile_group,
        # Maintenance log (Group E)
        ws_get_maintenance_log, ws_add_maintenance_event, ws_delete_maintenance_event,
        # Cycles
        ws_label_cycle, ws_delete_cycle, ws_auto_label_cycles,
        # Phase catalog
        ws_get_phase_catalog, ws_create_phase, ws_update_phase, ws_delete_phase,
        # Recording
        ws_get_recording_state, ws_start_recording, ws_stop_recording,
        ws_process_recording, ws_discard_recording,
        # Feedbacks
        ws_get_feedbacks, ws_resolve_feedback, ws_dismiss_all_feedbacks,
        # Diagnostics
        ws_get_diagnostics, ws_reprocess_history, ws_clear_debug_data,
        ws_wipe_history, ws_export_config, ws_import_config,
        # Selective export/import wizard (inventory + analyze + selective export/import)
        ws_get_export_inventory, ws_analyze_import,
        ws_export_config_selective, ws_import_config_selective,
        # Shared constants
        ws_get_constants,
        # Suggestions
        ws_get_suggestions, ws_apply_suggestions, ws_clear_suggestions, ws_run_suggestion_analysis,
        ws_set_suggestion_lock,
        # Cycle curve / interactive editing
        ws_get_cycle_power_data, ws_trim_cycle, ws_analyze_split, ws_apply_split, ws_apply_merge,
        # Profile envelope / member cycles
        ws_get_profile_envelope, ws_get_profile_cycles,
        # Panel config + RBAC
        ws_get_panel_config, ws_set_panel_config, ws_set_user_prefs,
        # Logs
        ws_get_logs,
        # Live power history
        ws_get_power_history,
        # Manual program selection
        ws_set_program,
        # Live match debug
        ws_get_match_debug,
        # ML Lab shadow-mode comparison
        ws_get_ml_comparison,
        # ML Lab review write-back (Stage 4b)
        ws_set_ml_review,
        # On-device ML training (status + manual trigger + matcher-tuning revert + models revert)
        ws_get_ml_training_status, ws_trigger_ml_training, ws_revert_matching_config,
        ws_revert_ml_models,
        # Cycle controls (pause / resume / force-stop)
        ws_pause_cycle, ws_resume_cycle, ws_terminate_cycle,
        # Playground (F3): DTW visualizer
        ws_get_dtw_debug,
        # Playground settings control panel: live values + named presets
        ws_get_playground_settings, ws_save_playground_preset, ws_delete_playground_preset,
        # Playground redesign: faithful single-cycle sim + history table + sweep
        ws_run_playground_cycle_detail, ws_run_playground_history,
        ws_run_playground_sweep,
        # Background-task registry (progress / cancel / reconnect-safe results)
        ws_list_tasks, ws_subscribe_tasks, ws_cancel_task, ws_get_task_result,
        # Playground batch/sweep as detached registry-tracked tasks
        ws_start_playground_history, ws_start_playground_sweep,
        ws_start_playground_cycle_detail,
        # Community store (online features): status/connect/disconnect/browse/import/upload
        ws_store_status, ws_store_connect, ws_store_disconnect,
        ws_store_search_devices, ws_store_get_profiles, ws_store_get_cycles,
        ws_store_import_cycle, ws_store_upload_cycle,
        # Device-bundle sharing (Stage 1): upload a whole device + adopt one
        ws_store_upload_device, ws_store_download_device,
        # Local reference cycles eligible to share (share-device tree source)
        ws_get_shareable_cycles,
        # Community catalog: brand list, device quality, confirm/rate, global online toggle
        ws_store_list_brands, ws_store_get_device_quality, ws_store_get_device_profiles,
        ws_store_confirm_device, ws_store_rate_device, ws_store_set_online,
        ws_store_set_prefs,
        # Catalog identity badges (point reads) + manual cache refresh
        ws_store_get_catalog_entry, ws_store_refresh_catalog,
        # Historical power-data import: staged ingest, background scan, apply
        ws_history_import_begin, ws_history_import_chunk, ws_history_import_recorder,
        ws_start_history_import_scan, ws_apply_history_import,
    ]
    for handler in handlers:
        websocket_api.async_register_command(hass, _guard(handler))


# ─── Devices ──────────────────────────────────────────────────────────────────

@websocket_api.websocket_command({vol.Required("type"): "ha_washdata/get_devices"})
@callback
def ws_get_devices(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Return all WashData config entries with their live state (RBAC-filtered)."""
    entries = hass.config_entries.async_entries(DOMAIN)
    domain_data: dict[str, Any] = hass.data.get(DOMAIN, {})
    user = getattr(connection, "user", None)
    devices: list[dict[str, Any]] = []

    for entry in entries:
        level = _effective_level(hass, user, entry.entry_id)
        if level == "none":
            continue  # device hidden from this user by RBAC
        manager = domain_data.get(entry.entry_id) if isinstance(domain_data, dict) else None

        info: dict[str, Any] = {
            "entry_id": entry.entry_id,
            "perm": level,
            "title": entry.title,
            "detector_state": "unknown",
            "sub_state": None,
            "current_program": None,
            "time_remaining_s": None,
            "total_duration_s": None,
            "current_power_w": None,
            "cycle_progress_pct": None,
            "suggestions_count": 0,
            # Which keys those are, so the panel can merge them with the
            # Calibrated (ML) recommendations it computes client-side without
            # double-counting a key both engines suggest.
            "suggestion_keys": [],
            "feedback_count": 0,
            "recording": False,
            "is_user_paused": False,
            "manual_program": False,
            "options": dict(entry.options),
            # Device-resolved defaults for the cadence/ratio fields (#396/#393) so the
            # device-list conflict/suggestion badges score an unset field against the
            # value the integration would actually use, matching the Settings tab.
            "option_defaults": _resolved_option_defaults(
                {**entry.data, **entry.options}.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE)
            ),
        }

        if manager is not None:
            try:
                detector = getattr(manager, "detector", None)
                if detector is not None:
                    info["detector_state"] = detector.state
                    info["sub_state"] = detector.sub_state

                program: str | None = getattr(manager, "_current_program", None)
                if program in (None, "off", "unknown", "detecting...", "restored..."):
                    program = None
                info["current_program"] = program
                info["manual_program"] = bool(getattr(manager, "manual_program_active", False))

                info["time_remaining_s"] = getattr(manager, "_time_remaining", None)
                info["total_duration_s"] = getattr(manager, "_total_duration", None)

                power = getattr(manager, "_current_power", None)
                info["current_power_w"] = round(float(power), 2) if power is not None else None

                progress = getattr(manager, "_cycle_progress", None)
                if progress is not None:
                    info["cycle_progress_pct"] = round(float(progress), 1)

                store = getattr(manager, "profile_store", None)
                if store is not None:
                    try:
                        # Same filters as ws_get_suggestions (muted keys and
                        # no-op values dropped) so the device-pill badge can
                        # never disagree with the Settings tab banner.
                        raw = store.get_suggestions() or {}
                        merged = {**entry.data, **entry.options}
                        try:
                            muted = set(store.get_locked_suggestions() or [])
                        except Exception:  # pylint: disable=broad-exception-caught
                            muted = set()
                        keys = [
                            k for k in _SUGGESTION_KEYS
                            if k not in muted
                            and isinstance(raw.get(k), dict)
                            and raw[k].get("value") is not None
                            # Coerce first, exactly like ws_get_suggestions, so the pill
                            # and the Settings list agree on int-key rounding.
                            and not _suggestion_equivalent(
                                _coerce_suggested(k, raw[k]["value"]), merged.get(k)
                            )
                        ]
                        info["suggestion_keys"] = keys
                        info["suggestions_count"] = len(keys)
                    except Exception:  # pylint: disable=broad-exception-caught
                        pass
                    try:
                        # Count only pending feedback whose cycle still exists, so the
                        # badge cannot outrun the review list after a cycle is deleted,
                        # merged or split (#362). Defense-in-depth on top of the prune in
                        # those mutation paths; also self-heals pre-existing orphans.
                        _pend = store.get_pending_feedback() or {}
                        _live = {c.get("id") for c in store.get_past_cycles()}
                        info["feedback_count"] = sum(1 for cid in _pend if cid in _live)
                    except Exception:  # pylint: disable=broad-exception-caught
                        pass
                info["is_user_paused"] = bool(getattr(manager, "is_user_paused", False))
                recorder = getattr(manager, "recorder", None)
                if recorder is not None:
                    info["recording"] = bool(getattr(recorder, "is_recording", False))
            except Exception as exc:  # pylint: disable=broad-exception-caught
                _LOGGER.debug("Error reading manager state for entry %s: %s", entry.entry_id, exc)

        devices.append(info)

    _send_result(connection, msg["id"], "get_devices", {"devices": devices})


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/get_device_cycles",
        vol.Required("entry_id"): str,
        vol.Optional("limit", default=50): vol.All(int, vol.Range(min=1, max=200)),
        vol.Optional("offset", default=0): vol.All(int, vol.Range(min=0)),
    }
)
@callback
def ws_get_device_cycles(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Return a page of recent cycles for a device, stripping large binary fields.

    Cycles are returned most-recent-first and sliced ``[offset : offset+limit]``
    so the panel can page. ``total`` is the device's full cycle count and
    ``has_more`` is True when cycles remain beyond the returned window.
    """
    entry_id: str = msg["entry_id"]
    limit: int = msg.get("limit", 50)
    offset: int = msg.get("offset", 0)

    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    cycles: list[dict[str, Any]] = []
    reference_cycles: list[dict[str, Any]] = []
    backfill_cycles: list[dict[str, Any]] = []
    total = 0
    try:
        store = getattr(manager, "profile_store", None)
        if store is not None:
            raw: list[Any] = store.get_past_cycles()
            total = len(raw)
            # History order is oldest-first in storage; present most-recent-first
            # and slice the requested page. offset=0 is identical to the legacy
            # reversed(raw[-limit:]) behaviour.
            ordered = list(reversed(raw))
            window = ordered[offset:offset + limit]
            for c in window:
                cycles.append(_strip_cycle(c))
            # Imported store recordings and cycles recovered from raw history are
            # bounded sets kept out of the paginated `cycles`/`total` (they never enter
            # usage stats). Return them once, on the first page, tagged so the panel can
            # badge them and route edits/deletes correctly. They travel in separate
            # arrays because they are separate categories: a curated community template
            # and an auto-detected segment from the user's own past are not the same
            # claim about a cycle.
            if offset == 0:
                for c in reversed(store.get_reference_cycles()):
                    ref = _strip_cycle(c)
                    ref.update(_cycle_capabilities(c, "reference"))
                    reference_cycles.append(ref)
                for c in reversed(store.get_backfill_cycles()):
                    item = _strip_cycle(c)
                    item.update(_cycle_capabilities(c, "backfill"))
                    backfill_cycles.append(item)
    except Exception as exc:  # pylint: disable=broad-exception-caught
        _LOGGER.debug("Error fetching cycles for entry %s: %s", entry_id, exc)

    has_more = (offset + len(cycles)) < total
    _send_result(connection, msg["id"], "get_device_cycles", {
            "entry_id": entry_id,
            "cycles": cycles,
            "reference_cycles": reference_cycles,
            "backfill_cycles": backfill_cycles,
            "total": total,
            "has_more": has_more,
        },
    )


# ─── Settings ─────────────────────────────────────────────────────────────────


def _resolved_option_defaults(device_type: str) -> dict[str, Any]:
    """Device-resolved defaults for the cadence/ratio settings whose default varies
    by device type (#396/#393).

    The panel uses these as the render/conflict-check/suggestion-comparison fallback
    for an unset field, so it shows - and validates against - the value the
    integration would actually use, not a static schema literal that would spuriously
    trip (or silently miss) the watchdog>=2*sampling / start_duration>=sampling rules
    on a coarse-sampling device type. Shared by ws_get_options (current device) and
    ws_get_devices (per device) so the two never diverge.
    """
    return {
        CONF_SAMPLING_INTERVAL: resolve_sampling_interval_default(device_type),
        CONF_WATCHDOG_INTERVAL: resolve_watchdog_interval_default(device_type),
        CONF_START_DURATION_THRESHOLD: resolve_start_duration_default(device_type),
        CONF_SMART_TERMINATION_DURATION_RATIO: (
            resolve_smart_termination_duration_ratio_default(device_type)
        ),
    }


@websocket_api.websocket_command(
    {vol.Required("type"): "ha_washdata/get_options", vol.Required("entry_id"): str}
)
@callback
def ws_get_options(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Return merged data+options for a config entry."""
    entry = _get_entry(hass, msg["entry_id"])
    if not entry:
        connection.send_error(msg["id"], "not_found", f"Entry {msg['entry_id']!r} not found")
        return
    options = {**entry.data, **entry.options}
    # Device-resolved defaults for the cadence settings whose defaults vary by
    # device type (#396). The panel uses these as the render/conflict-check
    # fallback for an unset field so it shows (and validates against) the value the
    # integration would actually use - not a static schema literal that would
    # spuriously trip the panel's own watchdog>=2*sampling / start_duration>=sampling
    # rules on a coarse-sampling device type.
    device_type = options.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE)
    _send_result(
        connection,
        msg["id"],
        "get_options",
        {"options": options, "defaults": _resolved_option_defaults(device_type)},
    )


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/set_options",
        vol.Required("entry_id"): str,
        vol.Required("options"): dict,
    }
)
@websocket_api.async_response
async def ws_set_options(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Persist updated options and trigger an entry reload."""
    entry = _get_entry(hass, msg["entry_id"])
    if not entry:
        connection.send_error(msg["id"], "not_found", f"Entry {msg['entry_id']!r} not found")
        return
    # Build the new options from the *existing* options plus the submitted
    # values only. Never spread entry.data in: that would copy identity and
    # data-only keys (name, initial_profile, stale creation-time identity) into
    # options where they don't belong. Tunables (including device_type /
    # power_sensor / min_power, which live in options post-3.6) are preserved
    # from entry.options and overridden by the submission.
    new_options = {**entry.options, **msg["options"]}

    # Capture the submitted display name for the entry title before it is
    # stripped out of options below.
    submitted_name = new_options.get(CONF_NAME)

    # Mirror the OptionsFlow save-time normalization so the panel can never
    # persist stale or invalid values:
    #  - a cleared selector (entity / linked device / trigger) becomes None so
    #    the link or subscription is removed rather than left dangling;
    #  - pump-only keys are dropped for non-pump device types;
    #  - the transient "apply suggestions" flag is never stored.
    for key in (
        CONF_EXTERNAL_END_TRIGGER,
        CONF_DOOR_SENSOR_ENTITY,
        CONF_LINKED_DEVICE,
        CONF_SWITCH_ENTITY,
        CONF_ENERGY_SENSOR,
    ):
        if key in new_options and not new_options[key]:
            new_options[key] = None

    # Resolve the effective device type option-first (submission -> existing
    # options -> data -> default) so the pump-only key is dropped correctly even
    # when the submission omits device_type.
    effective_device_type = new_options.get(
        CONF_DEVICE_TYPE, entry.data.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE)
    )
    if effective_device_type != DEVICE_TYPE_PUMP:
        new_options.pop(CONF_PUMP_STUCK_DURATION, None)

    # Numeric-finite validation for fields that the cycle-detector float()-casts at
    # build time; coerce bad submissions to the compiled default so storage stays clean.
    if CONF_DISHWASHER_END_SPIKE_QUIET_RELEASE in new_options:
        try:
            _qr = float(new_options[CONF_DISHWASHER_END_SPIKE_QUIET_RELEASE])
            if not math.isfinite(_qr):
                raise ValueError("non-finite")
            new_options[CONF_DISHWASHER_END_SPIKE_QUIET_RELEASE] = _qr
        except (TypeError, ValueError):
            new_options[CONF_DISHWASHER_END_SPIKE_QUIET_RELEASE] = (
                DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS
            )

    # Smart-Termination duration ratio (#393): fraction of expected duration, so it
    # is meaningless outside [0.50, 1.00] - clamp valid submissions to the range.
    # An empty or non-numeric value drops the key so the device-type default
    # (resolved in the config builder, 0.99 dishwasher / 0.98 other) applies again;
    # coercing to a single scalar default here would be wrong for dishwashers.
    if CONF_SMART_TERMINATION_DURATION_RATIO in new_options:
        _raw_str = new_options[CONF_SMART_TERMINATION_DURATION_RATIO]
        if _raw_str in (None, ""):
            new_options.pop(CONF_SMART_TERMINATION_DURATION_RATIO, None)
        else:
            try:
                _str = float(_raw_str)
                if not math.isfinite(_str):
                    raise ValueError("non-finite")
                new_options[CONF_SMART_TERMINATION_DURATION_RATIO] = min(
                    1.0, max(0.5, _str)
                )
            except (TypeError, ValueError):
                new_options.pop(CONF_SMART_TERMINATION_DURATION_RATIO, None)

    # A None outside the clearable selectors means "not set", not a value: the
    # per-setting Revert sends the changelog's `old`, which is null for a setting
    # never saved before. Stored, it would survive options.get(key, DEFAULT) and
    # break the float()/int() casts at setup, so drop the key and let the default
    # apply again; this also cleans nulls persisted by earlier builds.
    _pre_strip_keys = set(new_options)
    new_options = strip_null_options(new_options)
    dropped_null_keys = _pre_strip_keys - set(new_options)

    # Partition identity out of options: the display name is carried by the
    # entry title, never persisted in options (matches the config-flow invariant
    # that CONF_NAME is absent from options).
    for key in _OPTIONS_IDENTITY_KEYS:
        new_options.pop(key, None)

    update_kwargs: dict[str, Any] = {"options": new_options}
    if isinstance(submitted_name, str) and submitted_name.strip():
        update_kwargs["title"] = submitted_name.strip()

    # Settings change history (D7): diff the pre-update effective options against
    # the post-normalization values, but only for keys the user actually
    # submitted, and persist BEFORE async_update_entry (which schedules a reload
    # that rebuilds the store). A changelog failure must never block the save.
    try:
        old_effective = {**entry.data, **entry.options}
        # Keys dropped by the null-strip above are recorded as a change to None
        # ("reverted to unset") so the history still shows what happened; a
        # None -> None no-op is skipped by _diff_option_changes.
        submitted_post = {
            k: new_options.get(k)
            for k in msg["options"]
            if k in new_options or k in dropped_null_keys
        }
        changes = _diff_option_changes(old_effective, submitted_post)
        if changes:
            manager = _get_manager(hass, msg["entry_id"])
            store = getattr(manager, "profile_store", None) if manager else None
            if store is not None:
                await store.async_record_settings_changes(changes)
    except Exception as exc:  # pylint: disable=broad-exception-caught
        _LOGGER.debug(
            "Settings changelog recording failed for %s: %s", msg["entry_id"], exc
        )

    # When online features are disabled, clear the persisted store account so the
    # user's identity isn't silently retained after they opt out.
    from .const import CONF_ENABLE_ONLINE_FEATURES  # pylint: disable=import-outside-toplevel
    was_online = bool(entry.options.get(CONF_ENABLE_ONLINE_FEATURES, False))
    now_online = bool(new_options.get(CONF_ENABLE_ONLINE_FEATURES, False))
    if was_online and not now_online:
        try:
            manager = _get_manager(hass, msg["entry_id"])
            store = getattr(manager, "profile_store", None) if manager else None
            if store is not None:
                await store.clear_store_account()
                await store.async_save()
        except Exception:  # pylint: disable=broad-exception-caught
            pass

    hass.config_entries.async_update_entry(entry, **update_kwargs)
    _send_result(connection, msg["id"], "set_options", {"success": True})


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/get_settings_changelog",
        vol.Required("entry_id"): str,
    }
)
@websocket_api.async_response
async def ws_get_settings_changelog(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Return the settings-change history for a device (most-recent-first)."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    changelog: list[dict[str, Any]] = []
    try:
        store = getattr(manager, "profile_store", None)
        if store is not None:
            changelog = store.get_settings_changelog()
    except Exception as exc:  # pylint: disable=broad-exception-caught
        _LOGGER.debug(
            "Error fetching settings changelog for entry %s: %s", entry_id, exc
        )

    _send_result(connection, msg["id"], "get_settings_changelog", {"changelog": changelog})


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/get_setup_status",
        vol.Required("entry_id"): str,
    }
)
@websocket_api.async_response
async def ws_get_setup_status(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Return the current setup phase for the adoption guidance card."""
    manager = _get_manager(hass, msg["entry_id"])
    if not manager:
        connection.send_error(msg["id"], "not_found", "Device not found")
        return

    # Gather user's skipped steps from user prefs
    skipped_steps: dict[str, str | None] = {}
    user = getattr(connection, "user", None)
    if user:
        holder = hass.data.get(_PANEL_DATA_KEY)
        if holder:
            prefs = holder["data"].get("prefs", {}).get(user.id, {})
            for k, v in prefs.items():
                if k.startswith("setup_skip_"):
                    skipped_steps[k] = v

    # Gather store data (executor-safe reads)
    store = manager.profile_store
    profile_names = list(store._data.get("profiles", {}).keys())
    past_cycles = store._data.get("past_cycles", [])
    ref_names: set[str] = set()
    for rc in store._data.get("reference_cycles", []):
        if rc.get("profile_name"):
            ref_names.add(rc["profile_name"])

    coverage_gap = await hass.async_add_executor_job(store.suggest_coverage_gaps)
    # suggestions: read from the store (cheap dict lookup; heavy computation happens
    # in the SuggestionEngine background task, not here).
    suggestions = list((store.get_suggestions() or {}).values())
    pg_data = store._data.get("profile_groups", {})
    pending_groups = (pg_data.get("suggestions") or []) if isinstance(pg_data, dict) else []

    device_type = manager.device_type

    result = compute_setup_phase(
        device_type=device_type,
        profile_names=profile_names,
        past_cycles=past_cycles,
        ref_profile_names=ref_names,
        coverage_gap=coverage_gap,
        suggestions=suggestions,
        profile_groups=pending_groups,
        skipped_steps=skipped_steps,
        now=dt_util.now(),
    )

    _send_result(connection, msg["id"], "get_setup_status", {
        "phase": result.phase,
        "message_key": result.message_key,
        "message_params": result.message_params,
        "cta_label_key": result.cta_label_key,
        "cta_action": result.cta_action,
        "secondary_label_key": result.secondary_label_key,
        "secondary_action": result.secondary_action,
        "skippable": result.skippable,
        "dismissible": result.dismissible,
        "step_key": result.step_key,
    })


# ─── Profiles ─────────────────────────────────────────────────────────────────

@websocket_api.websocket_command(
    {vol.Required("type"): "ha_washdata/get_profiles", vol.Required("entry_id"): str}
)
@websocket_api.async_response
async def ws_get_profiles(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Return all profiles for a device."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    def _compute_stats() -> dict[str, Any]:
        profiles: list[dict[str, Any]] = []
        try:
            profiles = manager.profile_store.list_profiles()
        except Exception as exc:  # pylint: disable=broad-exception-caught
            _LOGGER.debug("Error listing profiles for %s: %s", entry_id, exc)

        health: dict[str, dict] = {}
        try:
            health = manager.profile_store.compute_profile_health()
        except Exception:  # pylint: disable=broad-exception-caught
            pass

        trends: dict[str, dict] = {}
        try:
            trends = manager.profile_store.compute_profile_trends()
        except Exception:  # pylint: disable=broad-exception-caught
            pass

        coverage_gaps: dict[str, Any] = {}
        try:
            coverage_gaps = manager.profile_store.suggest_coverage_gaps()
        except Exception:  # pylint: disable=broad-exception-caught
            pass

        advisories: list[dict] = []
        try:
            advisories = manager.profile_store.compute_profile_advisories()
        except Exception:  # pylint: disable=broad-exception-caught
            pass

        return {
            "profiles": profiles,
            "profile_health": health,
            "profile_trends": trends,
            "coverage_gaps": coverage_gaps,
            "profile_advisories": advisories,
        }

    stats = await hass.async_add_executor_job(_compute_stats)
    _send_result(connection, msg["id"], "get_profiles", stats)


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/create_profile",
        vol.Required("entry_id"): str,
        vol.Required("name"): str,
        vol.Optional("reference_cycle"): vol.Any(str, None),
        vol.Optional("manual_duration_min"): vol.Any(vol.Coerce(float), None),
    }
)
@websocket_api.async_response
async def ws_create_profile(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Create a new profile, optionally seeded from a cycle."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    name = str(msg["name"]).strip()
    if not name:
        connection.send_error(msg["id"], "invalid_format", "Profile name must not be empty")
        return

    ref_cycle = msg.get("reference_cycle")
    manual_mins = msg.get("manual_duration_min")
    avg_duration = float(manual_mins) * 60.0 if manual_mins and float(manual_mins) > 0 else None

    try:
        await manager.profile_store.create_profile_standalone(
            name,
            ref_cycle if ref_cycle not in (None, "none", "") else None,
            avg_duration=avg_duration,
        )
        manager.notify_update()
        _send_result(connection, msg["id"], "create_profile", {"success": True, "name": name})
    except ValueError as exc:
        connection.send_error(msg["id"], "profile_exists", str(exc))
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/rename_profile",
        vol.Required("entry_id"): str,
        vol.Required("profile_name"): str,
        vol.Required("new_name"): str,
        vol.Optional("manual_duration_min"): vol.Any(vol.Coerce(float), None),
    }
)
@websocket_api.async_response
async def ws_rename_profile(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Rename a profile and optionally update its manual duration."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    new_name = str(msg["new_name"]).strip()
    if not new_name:
        connection.send_error(msg["id"], "invalid_format", "New name must not be empty")
        return

    manual_mins = msg.get("manual_duration_min")
    avg_duration = float(manual_mins) * 60.0 if manual_mins and float(manual_mins) > 0 else None

    try:
        await manager.profile_store.update_profile(
            msg["profile_name"], new_name, avg_duration=avg_duration
        )
        manager.notify_update()
        _send_result(connection, msg["id"], "rename_profile", {"success": True})
    except ValueError as exc:
        connection.send_error(msg["id"], "rename_failed", str(exc))
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/delete_profile",
        vol.Required("entry_id"): str,
        vol.Required("profile_name"): str,
        vol.Optional("unlabel_cycles", default=True): bool,
    }
)
@websocket_api.async_response
async def ws_delete_profile(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Delete a profile, optionally removing cycle labels."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    try:
        await manager.profile_store.delete_profile(
            msg["profile_name"], msg.get("unlabel_cycles", True)
        )
        manager.notify_update()
        _send_result(connection, msg["id"], "delete_profile", {"success": True})
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


# ─── Profile groups (Stage 5) ──────────────────────────────────────────────

@websocket_api.websocket_command(
    {vol.Required("type"): "ha_washdata/get_profile_groups", vol.Required("entry_id"): str}
)
@websocket_api.async_response
async def ws_get_profile_groups(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Return profile groups with member lists, cohesion, and near-duplicate
    suggestions the user can act on."""
    from .const import GROUP_MIN_COHESION  # pylint: disable=import-outside-toplevel
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    store = manager.profile_store
    groups = []
    for name, g in store.get_profile_groups().items():
        members = list(g.get("members") or [])
        if len(members) >= 2:
            coh = await hass.async_add_executor_job(store.group_cohesion, members)
        else:
            coh = 1.0
        groups.append({
            "name": name,
            "members": members,
            "cohesion": round(coh, 3),
            "cohesive": coh >= GROUP_MIN_COHESION,  # False => not aggregated by matcher; UI warns
        })
    suggestions = await hass.async_add_executor_job(store.suggest_profile_groups)
    _send_result(connection, msg["id"], "get_profile_groups", {
        "groups": groups,
        "min_cohesion": GROUP_MIN_COHESION,
        "suggestions": suggestions,
    })


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/save_profile_group",
        vol.Required("entry_id"): str,
        vol.Required("name"): str,
        vol.Required("members"): [str],
    }
)
@websocket_api.async_response
async def ws_save_profile_group(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Create a group or replace an existing group's members."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    try:
        store = manager.profile_store
        if msg["name"] in store.get_profile_groups():
            await store.set_profile_group_members(msg["name"], msg["members"])
        else:
            await store.create_profile_group(msg["name"], msg["members"])
        manager.notify_update()
        _send_result(connection, msg["id"], "save_profile_group", {"success": True})
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/rename_profile_group",
        vol.Required("entry_id"): str,
        vol.Required("name"): str,
        vol.Required("new_name"): str,
    }
)
@websocket_api.async_response
async def ws_rename_profile_group(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    try:
        await manager.profile_store.rename_profile_group(msg["name"], msg["new_name"])
        manager.notify_update()
        _send_result(connection, msg["id"], "rename_profile_group", {"success": True})
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/delete_profile_group",
        vol.Required("entry_id"): str,
        vol.Required("name"): str,
    }
)
@websocket_api.async_response
async def ws_delete_profile_group(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    try:
        await manager.profile_store.delete_profile_group(msg["name"])
        manager.notify_update()
        _send_result(connection, msg["id"], "delete_profile_group", {"success": True})
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


@websocket_api.websocket_command(
    {vol.Required("type"): "ha_washdata/rebuild_envelopes", vol.Required("entry_id"): str}
)
@callback
def ws_rebuild_envelopes(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Rebuild power-profile envelopes for all profiles.

    Runs as a detached, registry-tracked task that rebuilds one profile per step
    (each DTW pass already offloads to the executor): rebuilding every profile
    serially inside the WS request stalled low-power hosts for the whole run
    (issue #311). Progress/result come via the task registry."""
    entry_id: str = msg["entry_id"]
    if _get_manager(hass, entry_id) is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    reg = task_registry.get_registry(hass)
    task = reg.create(
        entry_id, "rebuild", "Rebuilding envelopes",
        label_key="task.rebuild.envelopes", label_params={},
    )
    _raw = hass.async_create_task(_rebuild_envelopes_task(hass, task, entry_id))
    if _raw is not None:
        reg.link_asyncio_task(task.id, _raw)
    _send_result(connection, msg["id"], "rebuild_envelopes", {"task_id": task.id})


async def _rebuild_envelopes_task(hass: HomeAssistant, task: Any, entry_id: str) -> None:
    """Detached runner: rebuild every profile's envelope one at a time, reporting
    progress and honouring cancel, so the loop breathes between profiles."""
    reg = task_registry.get_registry(hass)
    manager = _get_manager(hass, entry_id)
    if manager is None:
        reg.finish(task, state=task_registry.STATE_ERROR, error="device unavailable")
        return
    store = manager.profile_store
    lock = _entry_write_lock(hass, entry_id)
    acquired = False
    try:
        await lock.acquire()
        acquired = True
        names = list(store.get_profiles().keys())
        reg.update(task, total=len(names), done=0)
        rebuilt = 0
        for i, name in enumerate(names):
            if task.cancel_requested:
                break
            try:
                if await store.async_rebuild_envelope(name):
                    rebuilt += 1
            except Exception as exc:  # pylint: disable=broad-exception-caught
                _LOGGER.debug("Envelope rebuild failed for %s/%s: %s", entry_id, name, exc)
            reg.update(task, done=i + 1)
        if _get_manager(hass, entry_id) is manager:
            manager.notify_update()
        reg.finish(
            task,
            state=task_registry.STATE_CANCELLED if task.cancel_requested else task_registry.STATE_DONE,
            result={"success": True, "rebuilt": rebuilt},
        )
    except asyncio.CancelledError:
        reg.finish(task, state=task_registry.STATE_CANCELLED)
        raise
    except Exception as exc:  # pylint: disable=broad-exception-caught
        _LOGGER.warning("Rebuild-envelopes task failed for %s: %s", entry_id, exc)
        reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))
    finally:
        if acquired:
            lock.release()


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/get_profile_phases",
        vol.Required("entry_id"): str,
        vol.Required("profile_name"): str,
    }
)
@callback
def ws_get_profile_phases(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Return phase ranges assigned to a profile."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    phases: list[dict[str, Any]] = []
    try:
        phases = manager.profile_store.get_profile_phase_ranges(msg["profile_name"])
    except Exception as exc:  # pylint: disable=broad-exception-caught
        _LOGGER.debug("Error getting profile phases: %s", exc)

    _send_result(connection, msg["id"], "get_profile_phases", {"phases": phases or []})


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/set_profile_phases",
        vol.Required("entry_id"): str,
        vol.Required("profile_name"): str,
        vol.Required("phases"): list,
    }
)
@websocket_api.async_response
async def ws_set_profile_phases(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Save phase ranges for a profile."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    try:
        await manager.profile_store.async_set_profile_phase_ranges(
            msg["profile_name"], msg["phases"]
        )
        _send_result(connection, msg["id"], "set_profile_phases", {"success": True})
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


# ─── Maintenance log (Group E) ──────────────────────────────────────────────

@websocket_api.websocket_command(
    {vol.Required("type"): "ha_washdata/get_maintenance_log", vol.Required("entry_id"): str}
)
@websocket_api.async_response
async def ws_get_maintenance_log(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Return the maintenance log, due reminders, event types, and reminder config."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    reminders = dict(DEFAULT_MAINTENANCE_REMINDER_CYCLES)
    cfg = manager.config_entry.options.get(CONF_MAINTENANCE_REMINDER_CYCLES)
    if isinstance(cfg, dict):
        reminders.update(cfg)
    _send_result(connection, msg["id"], "get_maintenance_log", {
        "log": manager.profile_store.get_maintenance_log(),
        "due": manager.maintenance_due,
        "event_types": list(MAINTENANCE_EVENT_TYPES),
        "reminders": reminders,
    })


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/add_maintenance_event",
        vol.Required("entry_id"): str,
        vol.Required("event_type"): str,
        vol.Optional("date"): vol.Any(str, None),
        vol.Optional("notes"): str,
    }
)
@websocket_api.async_response
async def ws_add_maintenance_event(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Log a maintenance event (requires edit access via central RBAC guard)."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    try:
        event = await manager.profile_store.async_add_maintenance_event(
            msg["event_type"], msg.get("date"), msg.get("notes", "")
        )
        manager.notify_update()
        _send_result(connection, msg["id"], "add_maintenance_event", {"success": True, "event": event})
    except ValueError as exc:
        connection.send_error(msg["id"], "invalid_format", str(exc))
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/delete_maintenance_event",
        vol.Required("entry_id"): str,
        vol.Required("event_id"): str,
    }
)
@websocket_api.async_response
async def ws_delete_maintenance_event(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Delete a maintenance event by id (requires edit access via central RBAC guard)."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    try:
        removed = await manager.profile_store.async_delete_maintenance_event(msg["event_id"])
        if removed:
            manager.notify_update()
        _send_result(connection, msg["id"], "delete_maintenance_event", {"success": removed})
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


# ─── Cycles ───────────────────────────────────────────────────────────────────

@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/label_cycle",
        vol.Required("entry_id"): str,
        vol.Required("cycle_id"): str,
        vol.Optional("profile_name"): vol.Any(str, None),
        vol.Optional("new_profile_name"): vol.Any(str, None),
    }
)
@websocket_api.async_response
async def ws_label_cycle(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Assign (or remove) a profile label from a cycle.

    profile_name=None removes the label.
    profile_name='__create_new__' + new_profile_name creates and assigns.
    """
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    cycle_id: str = msg["cycle_id"]
    profile_name: str | None = msg.get("profile_name")
    new_profile_name: str | None = msg.get("new_profile_name")

    try:
        if profile_name == "__create_new__":
            if not new_profile_name or not new_profile_name.strip():
                connection.send_error(msg["id"], "invalid_format", "New profile name required")
                return
            applied_profile: str | None = new_profile_name.strip()
            await manager.profile_store.create_profile(applied_profile, cycle_id)
        else:
            applied_profile = profile_name
            await manager.profile_store.assign_profile_to_cycle(cycle_id, profile_name)
        # Manually (re)labelling a cycle awaiting verification IS the user's answer,
        # so resolve any pending feedback and drop it from the review queue (#331).
        if hasattr(manager, "learning_manager"):
            await manager.learning_manager.async_resolve_pending_from_label(
                cycle_id, applied_profile
            )
        manager.notify_update()
        _send_result(connection, msg["id"], "label_cycle", {"success": True})
    except ValueError as exc:
        connection.send_error(msg["id"], "label_failed", str(exc))
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/delete_cycle",
        vol.Required("entry_id"): str,
        vol.Required("cycle_id"): str,
    }
)
@websocket_api.async_response
async def ws_delete_cycle(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Delete a single cycle."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    try:
        await manager.profile_store.delete_cycle(msg["cycle_id"])
        manager.notify_update()
        _send_result(connection, msg["id"], "delete_cycle", {"success": True})
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/auto_label_cycles",
        vol.Required("entry_id"): str,
        vol.Optional("confidence_threshold", default=0.75): vol.All(
            vol.Coerce(float), vol.Range(min=0.5, max=0.95)
        ),
    }
)
@websocket_api.async_response
async def ws_auto_label_cycles(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Auto-label all cycles with matched profiles above the confidence threshold."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    threshold: float = msg.get("confidence_threshold", 0.75)
    try:
        await manager.profile_store.auto_label_cycles(threshold, overwrite=True)
        manager.notify_update()
        _send_result(connection, msg["id"], "auto_label_cycles", {"success": True})
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


# ─── Phase catalog ────────────────────────────────────────────────────────────

@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/get_phase_catalog",
        vol.Required("entry_id"): str,
        vol.Optional("device_type"): vol.Any(str, None),
    }
)
@callback
def ws_get_phase_catalog(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Return phase catalog for a device type (or all types)."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    device_type: str | None = msg.get("device_type") or getattr(manager, "device_type", None)
    phases: list[dict[str, Any]] = []
    try:
        phases = manager.profile_store.list_phase_catalog(device_type or "")
    except Exception as exc:  # pylint: disable=broad-exception-caught
        _LOGGER.debug("Error listing phase catalog: %s", exc)

    _send_result(connection, msg["id"], "get_phase_catalog", {"phases": phases, "device_type": device_type})


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/create_phase",
        vol.Required("entry_id"): str,
        vol.Required("device_type"): str,
        vol.Required("name"): str,
        vol.Optional("description", default=""): str,
    }
)
@websocket_api.async_response
async def ws_create_phase(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Create a custom phase in the catalog."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    try:
        await manager.profile_store.async_create_custom_phase(
            msg["device_type"], msg["name"], msg.get("description", "")
        )
        _send_result(connection, msg["id"], "create_phase", {"success": True})
    except ValueError as exc:
        connection.send_error(msg["id"], "duplicate_phase", str(exc))
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/update_phase",
        vol.Required("entry_id"): str,
        vol.Required("phase_id"): str,
        vol.Required("new_name"): str,
        vol.Optional("description", default=""): str,
    }
)
@websocket_api.async_response
async def ws_update_phase(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Rename/update a phase in the catalog."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    try:
        await manager.profile_store.async_update_custom_phase(
            msg["phase_id"], msg["new_name"], msg.get("description", "")
        )
        _send_result(connection, msg["id"], "update_phase", {"success": True})
    except ValueError as exc:
        connection.send_error(msg["id"], "phase_not_found", str(exc))
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/delete_phase",
        vol.Required("entry_id"): str,
        vol.Required("phase_id"): str,
    }
)
@websocket_api.async_response
async def ws_delete_phase(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Delete a custom phase from the catalog."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    try:
        await manager.profile_store.async_delete_custom_phase(msg["phase_id"])
        _send_result(connection, msg["id"], "delete_phase", {"success": True})
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


# ─── Recording ────────────────────────────────────────────────────────────────

@websocket_api.websocket_command(
    {vol.Required("type"): "ha_washdata/get_recording_state", vol.Required("entry_id"): str}
)
@callback
def ws_get_recording_state(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Return current recording state for a device."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    recorder = getattr(manager, "recorder", None)
    if recorder is None:
        _send_result(connection, msg["id"], "get_recording_state", {"state": "unavailable"})
        return

    is_recording: bool = getattr(recorder, "is_recording", False)
    last_run: dict[str, Any] | None = getattr(recorder, "last_run", None)

    info: dict[str, Any] = {"state": "recording" if is_recording else "idle"}

    if is_recording:
        info["duration_s"] = int(getattr(recorder, "current_duration", 0))
        buf = getattr(recorder, "_buffer", [])
        info["sample_count"] = len(buf)
    elif last_run:
        info["state"] = "stopped"
        info["sample_count"] = len(last_run.get("data", []))
        info["start_time"] = last_run.get("start_time")
        info["end_time"] = last_run.get("end_time")
        try:
            start_str = last_run.get("start_time")
            end_str = last_run.get("end_time")
            if start_str and end_str:
                start = dt_util.parse_datetime(start_str)
                end = dt_util.parse_datetime(end_str)
                if start and end:
                    info["duration_s"] = int((end - start).total_seconds())
        except Exception:  # pylint: disable=broad-exception-caught
            pass

    _send_result(connection, msg["id"], "get_recording_state", info)


@websocket_api.websocket_command(
    {vol.Required("type"): "ha_washdata/start_recording", vol.Required("entry_id"): str}
)
@websocket_api.async_response
async def ws_start_recording(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Start manual recording mode."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    try:
        await manager.async_start_recording()
        _send_result(connection, msg["id"], "start_recording", {"success": True})
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


@websocket_api.websocket_command(
    {vol.Required("type"): "ha_washdata/stop_recording", vol.Required("entry_id"): str}
)
@websocket_api.async_response
async def ws_stop_recording(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Stop recording mode."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    try:
        await manager.async_stop_recording()
        _send_result(connection, msg["id"], "stop_recording", {"success": True})
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/process_recording",
        vol.Required("entry_id"): str,
        vol.Required("profile_name"): str,
        vol.Required("save_mode"): vol.In(["new_profile", "existing_profile"]),
        vol.Optional("head_trim", default=0.0): vol.Coerce(float),
        vol.Optional("tail_trim", default=0.0): vol.Coerce(float),
    }
)
@websocket_api.async_response
async def ws_process_recording(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Trim and save a completed recording to a profile."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    recorder = getattr(manager, "recorder", None)
    if not recorder:
        connection.send_error(msg["id"], "no_recording", "No completed recording to process")
        return

    head_trim: float = msg.get("head_trim", 0.0)
    tail_trim: float = msg.get("tail_trim", 0.0)
    profile_name: str = msg["profile_name"].strip()
    save_mode: str = msg["save_mode"]

    if not profile_name:
        connection.send_error(msg["id"], "invalid_format", "Profile name must not be empty")
        return

    # Serialize the whole claim+persist under the per-entry write lock so two
    # concurrent process_recording calls cannot both consume the same recording
    # (which would double-persist and collide on cycle IDs). The claim is the
    # read of ``last_run`` *inside* the lock; it is cleared only after a
    # successful persist, so a failure leaves the recording intact for retry.
    async with _entry_write_lock(hass, entry_id):
        last_run = getattr(recorder, "last_run", None)
        if not last_run:
            connection.send_error(msg["id"], "no_recording", "No completed recording to process")
            return
        data = last_run.get("data", [])

        try:
            rec_start_str = last_run.get("start_time")
            rec_end_str = last_run.get("end_time")

            parsed: list[tuple[float, float]] = []
            for item in data:
                t_str, p = (item[0], item[1]) if isinstance(item, (list, tuple)) else (None, None)
                if t_str:
                    t = dt_util.parse_datetime(str(t_str))
                    if t:
                        parsed.append((t.timestamp(), float(p or 0)))

            data_start_ts = parsed[0][0] if parsed else 0.0
            data_end_ts = parsed[-1][0] if parsed else 0.0

            if rec_start_str:
                parsed_dt = dt_util.parse_datetime(rec_start_str)
                if parsed_dt is None:
                    connection.send_error(msg["id"], "invalid_format", "Invalid recording start_time format")
                    return
                start_ts = parsed_dt.timestamp()
            else:
                start_ts = data_start_ts

            if rec_end_str:
                parsed_dt = dt_util.parse_datetime(rec_end_str)
                if parsed_dt is None:
                    connection.send_error(msg["id"], "invalid_format", "Invalid recording end_time format")
                    return
                end_ts = parsed_dt.timestamp()
            else:
                end_ts = data_end_ts

            if parsed:
                start_ts = min(start_ts, data_start_ts)
                end_ts = max(end_ts, data_end_ts)

            keep_start = start_ts + head_trim
            keep_end = end_ts - tail_trim
            duration = keep_end - keep_start

            trimmed_data = [
                (dt_util.utc_from_timestamp(t).isoformat(), p)
                for t, p in parsed
                if keep_start <= t <= keep_end
            ]

            # Reject a trim that removes everything before it can persist a
            # corrupt cycle: an inverted/empty window (end <= start) or a window
            # that keeps no samples.
            if keep_end <= keep_start:
                connection.send_error(
                    msg["id"], "invalid_format",
                    "Trim removes the entire recording (end is at or before start)",
                )
                return
            if not trimmed_data:
                connection.send_error(
                    msg["id"], "invalid_format",
                    "Trim leaves no power data",
                )
                return

            cycle_data: dict[str, Any] = {
                # High-entropy ID so two recordings processed in the same second
                # (or a retry) never collide on an existing cycle ID.
                "id": f"rec_{int(time.time())}_{uuid.uuid4().hex[:8]}",
                "start_time": dt_util.utc_from_timestamp(keep_start).isoformat(),
                "end_time": dt_util.utc_from_timestamp(keep_end).isoformat(),
                "duration": duration,
                "profile_name": profile_name,
                "power_data": trimmed_data,
                "status": "completed",
                "meta": {"source": "recorder", "original_samples": len(data)},
                # A recorded cycle is a hand-picked clean example -> it IS the golden
                # reference. Prefill the single ml_review flag (recorded == golden;
                # no separate "recorded" field) so it seeds matching immediately.
                "ml_review": {
                    "golden": True,
                    "quality": "good",
                    "reviewed_at": dt_util.now().isoformat(),
                },
            }

            if save_mode == "new_profile":
                await manager.profile_store.create_profile_standalone(profile_name)

            await manager.profile_store.async_add_cycle(cycle_data)
            await manager.profile_store.async_rebuild_envelope(profile_name)
            await manager.profile_store.async_save()
            # Clear only after a successful persist so the recording is consumed
            # exactly once; a failure above keeps it available for another try.
            await recorder.clear_last_run()

            # Re-validate the manager is still live after the awaited persist chain
            # (create/add/rebuild/save/clear): if the entry was reloaded meanwhile
            # the original manager is detached and its notify would target stale
            # state. Mirror the ws_reprocess_history guard.
            current_manager = _get_manager(hass, entry_id)
            if current_manager is not manager:
                _LOGGER.warning(
                    "Manager replaced during recording persist for %s; skipping notify",
                    entry_id,
                )
                _send_result(connection, msg["id"], "process_recording", {"success": True})
                return
            manager.notify_update()

            _send_result(connection, msg["id"], "process_recording", {"success": True})
        except Exception as exc:  # pylint: disable=broad-exception-caught
            connection.send_error(msg["id"], "unknown_error", str(exc))


@websocket_api.websocket_command(
    {vol.Required("type"): "ha_washdata/discard_recording", vol.Required("entry_id"): str}
)
@websocket_api.async_response
async def ws_discard_recording(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Discard the last completed recording."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    try:
        recorder = getattr(manager, "recorder", None)
        if recorder:
            await recorder.clear_last_run()
        _send_result(connection, msg["id"], "discard_recording", {"success": True})
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


# ─── Learning feedbacks ───────────────────────────────────────────────────────

@websocket_api.websocket_command(
    {vol.Required("type"): "ha_washdata/get_feedbacks", vol.Required("entry_id"): str}
)
@callback
def ws_get_feedbacks(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Return all pending learning feedbacks."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    feedbacks: list[dict[str, Any]] = []
    try:
        pending: dict[str, Any] = manager.profile_store.get_pending_feedback()
        feedbacks = sorted(
            [{"cycle_id": cid, **item} for cid, item in pending.items()],
            key=lambda x: x.get("created_at", ""),
            reverse=True,
        )
    except Exception as exc:  # pylint: disable=broad-exception-caught
        _LOGGER.debug("Error fetching feedbacks for %s: %s", entry_id, exc)

    _send_result(connection, msg["id"], "get_feedbacks", {"feedbacks": feedbacks})


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/resolve_feedback",
        vol.Required("entry_id"): str,
        vol.Required("cycle_id"): str,
        vol.Required("action"): vol.In(["confirm", "correct", "ignore", "delete"]),
        vol.Optional("corrected_profile"): vol.Any(str, None),
        vol.Optional("corrected_duration_min"): vol.Any(vol.Coerce(float), None),
    }
)
@websocket_api.async_response
async def ws_resolve_feedback(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Resolve a pending learning feedback."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    cycle_id: str = msg["cycle_id"]
    action: str = msg["action"]

    try:
        if action == "delete":
            await manager.profile_store.delete_cycle(cycle_id)
        elif hasattr(manager, "learning_manager"):
            corrected_duration_min = msg.get("corrected_duration_min")
            corrected_duration_s = (
                int(float(corrected_duration_min) * 60)
                if corrected_duration_min is not None
                else None
            )
            await manager.learning_manager.async_submit_cycle_feedback(
                cycle_id=cycle_id,
                user_confirmed=(action == "confirm"),
                corrected_profile=msg.get("corrected_profile") if action == "correct" else None,
                corrected_duration=corrected_duration_s,
                dismiss=(action == "ignore"),
            )
        else:
            connection.send_error(msg["id"], "not_available", "Learning manager not available")
            return
        manager.notify_update()
        _send_result(connection, msg["id"], "resolve_feedback", {"success": True})
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


@websocket_api.websocket_command(
    {vol.Required("type"): "ha_washdata/dismiss_all_feedbacks", vol.Required("entry_id"): str}
)
@websocket_api.async_response
async def ws_dismiss_all_feedbacks(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Dismiss all pending learning feedbacks."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    try:
        pending: dict[str, Any] = manager.profile_store.get_pending_feedback()
        if pending and hasattr(manager, "learning_manager"):
            cycle_ids = list(pending.keys())
            for cid in cycle_ids:
                await manager.learning_manager.async_submit_cycle_feedback(
                    cycle_id=cid,
                    user_confirmed=False,
                    corrected_profile=None,
                    corrected_duration=None,
                    dismiss=True,
                )
        manager.notify_update()
        _send_result(connection, msg["id"], "dismiss_all_feedbacks", {"success": True, "dismissed": len(pending)})
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


# ─── Diagnostics ──────────────────────────────────────────────────────────────

@websocket_api.websocket_command(
    {vol.Required("type"): "ha_washdata/get_diagnostics", vol.Required("entry_id"): str}
)
@websocket_api.async_response
async def ws_get_diagnostics(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Return storage statistics for a device."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    try:
        stats = await manager.profile_store.get_storage_stats()
        _send_result(connection, msg["id"], "get_diagnostics", {"stats": stats})
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


async def _reprocess_task(hass: HomeAssistant, task: Any, entry_id: str) -> None:
    """Detached runner for the full "Process history" pass, reporting phase-level
    progress to the task registry and storing the summary as the result. Survives
    a dropped socket; the panel re-attaches via the registry."""
    reg = task_registry.get_registry(hass)
    manager = _get_manager(hass, entry_id)
    if manager is None:
        reg.finish(task, state=task_registry.STATE_ERROR, error="device unavailable")
        return
    store = manager.profile_store
    summary: dict[str, Any] = {"success": True}
    # Serialize under the per-entry write lock: this multi-await pass rematches,
    # retrains and rewrites the store, and must not interleave with a concurrent
    # import / recording persist for the same entry.
    lock = _entry_write_lock(hass, entry_id)
    acquired = False
    try:
        await lock.acquire()
        acquired = True
        if task.cancel_requested:
            reg.finish(task, state=task_registry.STATE_CANCELLED)
            return

        reg.update(task, total=5, done=0, label="Reprocessing: matching cycles",
                   label_key="task.reprocess.matching")
        summary["count"] = await store.async_reprocess_all_data()

        if task.cancel_requested:
            reg.finish(task, state=task_registry.STATE_CANCELLED, result=summary)
            return

        reg.update(task, done=1, label="Reprocessing: backfilling golden",
                   label_key="task.reprocess.golden")
        try:
            summary["golden_backfilled"] = await store.async_backfill_recorded_golden()
        except Exception as exc:  # pylint: disable=broad-exception-caught
            _LOGGER.debug("golden backfill failed for %s: %s", entry_id, exc)

        if task.cancel_requested:
            reg.finish(task, state=task_registry.STATE_CANCELLED, result=summary)
            return

        reg.update(task, done=2, label="Reprocessing: suggestions",
                   label_key="task.reprocess.suggestions")
        learning = getattr(manager, "learning_manager", None)
        if learning is not None and hasattr(learning, "async_run_full_analysis"):
            try:
                res = await learning.async_run_full_analysis()
                summary["suggestions"] = (res or {}).get("count", 0)
            except Exception as exc:  # pylint: disable=broad-exception-caught
                _LOGGER.debug("suggestion analysis failed for %s: %s", entry_id, exc)

        reg.update(task, done=3, label="Reprocessing: ML training",
                   label_key="task.reprocess.ml_training")
        if ENABLE_ML_TRAINING and not task.cancel_requested:
            try:
                tr = await manager.async_run_ml_training(force=True)
                summary["ml_training"] = {
                    "ok": bool(tr.get("ok")),
                    "promoted": tr.get("promoted", []),
                    "reason": tr.get("reason"),
                }
            except Exception as exc:  # pylint: disable=broad-exception-caught
                _LOGGER.debug("ML training failed for %s: %s", entry_id, exc)

        if task.cancel_requested:
            reg.finish(task, state=task_registry.STATE_CANCELLED, result=summary)
            return

        reg.update(task, done=4, label="Reprocessing: cycle health",
                   label_key="task.reprocess.health")
        # Recompute per-cycle health against the (possibly retrained) model.
        # Skip when training already recomputed it (a promotion refreshes health).
        if not (summary.get("ml_training", {}) or {}).get("promoted"):
            try:
                summary["health_recomputed"] = await manager.async_recompute_cycle_health()
            except Exception as exc:  # pylint: disable=broad-exception-caught
                _LOGGER.debug("health recompute failed for %s: %s", entry_id, exc)

        reg.update(task, done=5)
        # Re-validate the manager is still live after a long chain of awaits; if the
        # entry was reloaded, the original manager is detached and must not notify.
        if _get_manager(hass, entry_id) is manager:
            manager.notify_update()
        reg.finish(task, state=task_registry.STATE_DONE, result=summary)
    except asyncio.CancelledError:
        reg.finish(task, state=task_registry.STATE_CANCELLED)
        raise
    except Exception as exc:  # pylint: disable=broad-exception-caught
        # Task-level failure: log at WARNING so it is visible in the default HA log
        # and the panel Logs view (sub-step failures above stay at debug on purpose).
        _LOGGER.warning("Reprocess task failed for %s: %s", entry_id, exc)
        reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))
    finally:
        if acquired:
            lock.release()


@websocket_api.websocket_command(
    {vol.Required("type"): "ha_washdata/reprocess_history", vol.Required("entry_id"): str}
)
@callback
def ws_reprocess_history(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Kick off the full "Process history" pass as a detached, registry-tracked
    task; returns its id immediately. Runs, in order: reprocess (rematch + rebuild
    envelopes) -> backfill golden -> refresh suggestions -> on-device ML training
    (when enabled) -> recompute cycle health. Progress + result via the registry."""
    entry_id: str = msg["entry_id"]
    if _get_manager(hass, entry_id) is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    reg = task_registry.get_registry(hass)
    task = reg.create(entry_id, "reprocess", "Reprocessing")
    _raw = hass.async_create_task(_reprocess_task(hass, task, entry_id))
    if _raw is not None:
        reg.link_asyncio_task(task.id, _raw)
    _send_result(connection, msg["id"], "reprocess_history", {"task_id": task.id})


@websocket_api.websocket_command(
    {vol.Required("type"): "ha_washdata/clear_debug_data", vol.Required("entry_id"): str}
)
@websocket_api.async_response
async def ws_clear_debug_data(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Clear stored debug traces."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    try:
        count = await manager.profile_store.async_clear_debug_data()
        _send_result(connection, msg["id"], "clear_debug_data", {"success": True, "count": count})
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


@websocket_api.websocket_command(
    {vol.Required("type"): "ha_washdata/wipe_history", vol.Required("entry_id"): str}
)
@websocket_api.async_response
async def ws_wipe_history(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Wipe all cycles and profiles (destructive)."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    try:
        await manager.profile_store.clear_all_data()
        manager.notify_update()
        _send_result(connection, msg["id"], "wipe_history", {"success": True})
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


@websocket_api.websocket_command(
    {vol.Required("type"): "ha_washdata/export_config", vol.Required("entry_id"): str}
)
@websocket_api.async_response
async def ws_export_config(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Export profiles and cycles as a JSON string."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    entry = _get_entry(hass, entry_id)
    try:
        payload = manager.profile_store.export_data(
            entry_data=dict(entry.data) if entry else {},
            entry_options=dict(entry.options) if entry else {},
        )
        # Offload serialization to executor — power traces can be megabytes
        json_str = await hass.async_add_executor_job(
            lambda: json.dumps(payload, indent=2)
        )
        _send_result(connection, msg["id"], "export_config", {"json_data": json_str})
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/import_config",
        vol.Required("entry_id"): str,
        vol.Required("json_data"): str,
    }
)
@websocket_api.async_response
async def ws_import_config(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Import profiles and cycles from a JSON string."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    # Serialize the whole import under the per-entry write lock so it cannot
    # interleave with a concurrent reprocess / recording persist (which would
    # corrupt the store the import is rewriting).
    async with _entry_write_lock(hass, entry_id):
        try:
            payload = await hass.async_add_executor_job(json.loads, msg["json_data"])
            config_updates = await manager.profile_store.async_import_data(payload)

            # Re-validate after the awaits — the entry may have been reloaded
            # (a new manager + store) during the import. Persisting through the
            # detached manager/entry would clobber the live one, so re-fetch both
            # and bail out if the manager is no longer the live one.
            current_manager = _get_manager(hass, entry_id)
            if current_manager is not manager:
                _LOGGER.warning(
                    "Manager replaced during import for %s; aborting notify", entry_id
                )
                _send_result(connection, msg["id"], "import_config", {"success": True})
                return

            entry = _get_entry(hass, entry_id)
            if entry and config_updates:
                entry_options_updates = dict(config_updates.get("entry_options", {}))
                # Identity must never be persisted into options; the display name
                # rides the entry title. device_type/power_sensor/min_power stay
                # in options and are applied as tunables.
                for key in _OPTIONS_IDENTITY_KEYS:
                    entry_options_updates.pop(key, None)
                if entry_options_updates:
                    # Apply the imported tunables on top of the current options;
                    # never spread entry.data into options. An import payload can
                    # carry a null (an export taken from an entry that still held
                    # one), and a persisted null survives options.get(key, DEFAULT)
                    # and breaks setup (#389), so the same write-boundary strip as
                    # ws_set_options applies here.
                    new_options = strip_null_options(
                        {**entry.options, **entry_options_updates}
                    )
                    hass.config_entries.async_update_entry(entry, options=new_options)
                # NB: config_updates["entry_data"] is intentionally NOT written to
                # entry.data. export_data ships the raw, un-redacted entry.data of
                # the *source* device (its power_sensor and other identity), so
                # blindly applying it would hijack this device's sensor binding.
                # Identity changes must go through the reconfigure flow.

            manager.notify_update()
            _send_result(connection, msg["id"], "import_config", {"success": True})
        except json.JSONDecodeError as exc:
            connection.send_error(msg["id"], "invalid_json", str(exc))
        except Exception as exc:  # pylint: disable=broad-exception-caught
            connection.send_error(msg["id"], "unknown_error", str(exc))


# Soft cap on a pasted/uploaded import blob so a huge string can't wedge the
# executor / event loop. Whole-store exports with full traces are a few MB; 64 MB
# is comfortably above any legitimate payload.
_MAX_IMPORT_JSON_BYTES = 64 * 1024 * 1024


@websocket_api.websocket_command(
    {vol.Required("type"): "ha_washdata/get_export_inventory", vol.Required("entry_id"): str}
)
@websocket_api.async_response
async def ws_get_export_inventory(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Return this device's per-category data inventory for the export wizard tree."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    entry = _get_entry(hass, entry_id)
    try:
        opts = dict(entry.options) if entry else {}
        # Run the inventory scan synchronously on the event loop. It's a fast walk
        # (counts + small per-cycle {id,date,duration} dicts) and staying on the loop
        # is inherently safe from concurrent mutation - offloading it to an executor
        # would read the live, mutable store lists from another thread and could raise
        # "list changed size during iteration" without a full (expensive) snapshot.
        manifest = manager.profile_store.get_export_inventory(opts)
        _send_result(connection, msg["id"], "get_export_inventory", {"manifest": manifest})
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/analyze_import",
        vol.Required("entry_id"): str,
        vol.Required("json_data"): str,
    }
)
@websocket_api.async_response
async def ws_analyze_import(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Parse a pasted/uploaded export and describe what it contains (no mutation).

    Synchronous (not a detached task): parsing + walking a few-MB file is tens of ms.
    Returns ``{"manifest": {...}}``; a structural problem is reported inside the
    manifest as ``manifest.error`` so the panel can render it inline.
    """
    from .const import CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE

    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    json_data: str = msg["json_data"]
    if len(json_data.encode("utf-8", "ignore")) > _MAX_IMPORT_JSON_BYTES:
        connection.send_error(msg["id"], "invalid_json", "Import payload is too large")
        return
    entry = _get_entry(hass, entry_id)
    device_type = ""
    if entry is not None:
        device_type = entry.options.get(
            CONF_DEVICE_TYPE, entry.data.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE)
        )
    try:
        from .profile_store import build_import_manifest

        local_names = list(manager.profile_store.get_profiles().keys())

        def _analyze() -> dict[str, Any]:
            payload = json.loads(json_data)
            return build_import_manifest(
                payload, local_device_type=device_type, local_profile_names=local_names
            )

        manifest = await hass.async_add_executor_job(_analyze)
        _send_result(connection, msg["id"], "analyze_import", {"manifest": manifest})
    except json.JSONDecodeError as exc:
        connection.send_error(msg["id"], "invalid_json", str(exc))
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/export_config_selective",
        vol.Required("entry_id"): str,
        vol.Required("selection"): dict,
    }
)
@websocket_api.async_response
async def ws_export_config_selective(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Export only the selected categories/items as a JSON string."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    entry = _get_entry(hass, entry_id)
    selection = msg["selection"]
    try:
        payload = manager.profile_store.export_data(
            entry_data=dict(entry.data) if entry else {},
            entry_options=dict(entry.options) if entry else {},
            selection=selection,
        )
        json_str = await hass.async_add_executor_job(
            lambda: json.dumps(payload, indent=2)
        )
        _send_result(
            connection, msg["id"], "export_config_selective", {"json_data": json_str}
        )
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/import_config_selective",
        vol.Required("entry_id"): str,
        vol.Required("json_data"): str,
        vol.Required("selection"): dict,
        vol.Optional("mode", default="merge"): vol.In(["merge", "replace"]),
        vol.Optional("conflict_resolutions", default=dict): dict,
        vol.Optional("cycle_destination", default="reference"): vol.In(
            ["reference", "real_history"]
        ),
        vol.Optional("apply_settings", default=True): bool,
    }
)
@websocket_api.async_response
async def ws_import_config_selective(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Selectively import chosen categories/items, merging into existing data."""
    from .const import CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE, SHAREABLE_SETTING_KEYS

    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    json_data: str = msg["json_data"]
    if len(json_data.encode("utf-8", "ignore")) > _MAX_IMPORT_JSON_BYTES:
        connection.send_error(msg["id"], "invalid_json", "Import payload is too large")
        return

    # Serialize under the per-entry write lock so the import can't interleave with a
    # concurrent reprocess / recording persist (which would corrupt the store).
    async with _entry_write_lock(hass, entry_id):
        try:
            entry = _get_entry(hass, entry_id)
            device_type = ""
            if entry is not None:
                device_type = entry.options.get(
                    CONF_DEVICE_TYPE, entry.data.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE)
                )
            payload = await hass.async_add_executor_job(json.loads, json_data)
            summary = await manager.profile_store.async_import_data_selective(
                payload,
                selection=msg["selection"],
                mode=msg["mode"],
                conflict_resolutions=msg["conflict_resolutions"],
                cycle_destination=msg["cycle_destination"],
                apply_settings=msg["apply_settings"],
                local_device_type=device_type,
            )

            # Re-validate after the awaits — the entry may have been reloaded (a new
            # manager + store) during the import. Persisting through the detached
            # manager/entry would clobber the live one, so bail out.
            current_manager = _get_manager(hass, entry_id)
            if current_manager is not manager:
                _LOGGER.warning(
                    "Manager replaced during selective import for %s; aborting notify",
                    entry_id,
                )
                _send_result(
                    connection, msg["id"], "import_config_selective",
                    {"success": True, "summary": summary},
                )
                return

            # Apply the imported settings subset onto this device's options. Only the
            # SHAREABLE numeric allow-list ever reaches here (the store already filters),
            # and identity keys are stripped for defense-in-depth. entry.data is never
            # written (no power_sensor/device_type hijack).
            entry = _get_entry(hass, entry_id)
            settings = summary.get("settings") if isinstance(summary.get("settings"), dict) else {}
            settings_applied = 0
            if entry is not None and settings:
                filtered = {
                    k: v for k, v in settings.items()
                    if k in SHAREABLE_SETTING_KEYS
                    and isinstance(v, (int, float)) and not isinstance(v, bool)
                }
                for key in _OPTIONS_IDENTITY_KEYS:
                    filtered.pop(key, None)
                if filtered:
                    hass.config_entries.async_update_entry(
                        entry, options={**entry.options, **filtered}
                    )
                    settings_applied = len(filtered)
            summary = {**summary, "settings_applied": settings_applied}

            manager.notify_update()
            _send_result(
                connection, msg["id"], "import_config_selective",
                {"success": True, "summary": summary},
            )
        except json.JSONDecodeError as exc:
            connection.send_error(msg["id"], "invalid_json", str(exc))
        except ValueError as exc:
            connection.send_error(msg["id"], "invalid_format", str(exc))
        except Exception as exc:  # pylint: disable=broad-exception-caught
            connection.send_error(msg["id"], "unknown_error", str(exc))


# ─── Shared constants ─────────────────────────────────────────────────────────

@websocket_api.websocket_command({vol.Required("type"): "ha_washdata/get_constants"})
@callback
def ws_get_constants(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Return shared display constants so the panel hardcodes nothing.

    Device-type and state labels are localised client-side via hass.localize
    against the integration translations; the values/colors here are the
    canonical fallback and the single source for state colors.
    """
    device_types = [
        {"id": key, "label": label}
        for key, label in DEVICE_TYPES.items()
    ]
    from .frontend import BRAND_ICON_URL as _BRAND_ICON_URL, BRAND_ICON_REGISTERED_KEY as _BRAND_ICON_KEY  # pylint: disable=import-outside-toplevel
    from .const import STORE_WEB_ORIGIN
    from . import store_account
    _send_result(connection, msg["id"], "get_constants", {
            "version": hass.data.get("ha_washdata_version", _INTEGRATION_VERSION),
            "icon_url": _BRAND_ICON_URL if hass.data.get(_BRAND_ICON_KEY) else None,
            "device_types": device_types,
            "state_colors": dict(STATE_COLORS),
            "ml_lab_enabled": SHOW_ML_LAB,
            "ml_suggestions_enabled": ENABLE_ML_SUGGESTIONS,
            "ml_training_available": ENABLE_ML_TRAINING,
            "PROFILE_MIN_WARMUP_CYCLES": CONF_PROFILE_MIN_WARMUP_CYCLES,
            # Canonical matcher defaults for the Playground's matcher-param fields, so
            # the panel's _PG_MATCH_DEFAULTS table cannot silently drift from const.py.
            # The panel keeps that table only as an offline fallback. Single source:
            # playground.MATCH_DEFAULTS_BY_OPTION (also used by effective_settings).
            "pg_match_defaults": dict(playground.MATCH_DEFAULTS_BY_OPTION),
            # Community store: the panel opens <origin>/connect.html for the GitHub
            # handoff and validates postMessage against new URL(origin).origin.
            "store_online_available": True,
            # Online features are integration-wide (device-agnostic), set in the gear menu.
            "store_online_enabled": store_account.online_enabled(hass),
            "store_web_origin": STORE_WEB_ORIGIN,
            # Community-store display/behaviour preferences (declarative; see
            # store_account._DEFAULT_PREFS + the panel's _STORE_PREFS list).
            "store_prefs": store_account.get_prefs(hass),
        },
    )


# ─── Suggestions ──────────────────────────────────────────────────────────────

@websocket_api.websocket_command(
    {vol.Required("type"): "ha_washdata/get_suggestions", vol.Required("entry_id"): str}
)
@callback
def ws_get_suggestions(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Return applicable tuning suggestions with current vs suggested values."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    entry = _get_entry(hass, entry_id)
    merged: dict[str, Any] = {**entry.data, **entry.options} if entry else {}

    out: list[dict[str, Any]] = []
    try:
        raw: dict[str, Any] = manager.profile_store.get_suggestions() or {}
        for key in _SUGGESTION_KEYS:
            item = raw.get(key)
            if not isinstance(item, dict) or item.get("value") is None:
                continue
            val = item["value"]
            suggested = _coerce_suggested(key, val)
            current = merged.get(key)
            # Hide suggestions that would not change the current value.
            if _suggestion_equivalent(suggested, current):
                continue
            out.append(
                {
                    "key": key,
                    "suggested": suggested,
                    "reason": item.get("reason", ""),
                    # Localization sidecars: panel renders _t(reason_key,
                    # reason_params, reason). Absent on old/reconciled entries.
                    "reason_key": item.get("reason_key"),
                    "reason_params": item.get("reason_params"),
                    # Structured excluded-cycle summary; the panel renders it as a
                    # localized note appended to the reason. Absent/empty on most.
                    "exclusions": item.get("exclusions"),
                    "current": current,
                    "updated": item.get("updated"),
                }
            )
    except Exception as exc:  # pylint: disable=broad-exception-caught
        _LOGGER.debug("Error reading suggestions for %s: %s", entry_id, exc)

    try:
        locked = manager.profile_store.get_locked_suggestions()
    except Exception:  # pylint: disable=broad-exception-caught
        locked = []
    _send_result(
        connection, msg["id"], "get_suggestions",
        {"suggestions": out, "locked_suggestions": locked},
    )


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/apply_suggestions",
        vol.Required("entry_id"): str,
        vol.Required("keys"): [str],
    }
)
@websocket_api.async_response
async def ws_apply_suggestions(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Stage selected suggested values into options, then clear suggestions."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    entry = _get_entry(hass, entry_id)
    if not entry:
        connection.send_error(msg["id"], "not_found", f"Entry {entry_id!r} not found")
        return

    try:
        raw: dict[str, Any] = manager.profile_store.get_suggestions() or {}
        updates: dict[str, Any] = {}
        for key in msg["keys"]:
            if key not in _SUGGESTION_KEYS:
                continue
            item = raw.get(key)
            if not isinstance(item, dict) or item.get("value") is None:
                continue
            val = item["value"]
            updates[key] = (
                int(float(val)) if key in _SUGGESTION_INT_KEYS else float(val)
            )

        if updates:
            # Clear before updating the entry: async_update_entry schedules a
            # reload that rebuilds the store, so persist the cleared state first.
            cycle_count = len(manager.profile_store.get_past_cycles())
            manager.profile_store.set_suggestion_apply_cycle_count(cycle_count)
            await manager.profile_store.clear_suggestions()
            # Suggested values are all tunables -> layer them onto the existing
            # options; never spread entry.data into options.
            new_options = {**entry.options, **updates}
            hass.config_entries.async_update_entry(entry, options=new_options)
            manager.notify_update()

        _send_result(connection, msg["id"], "apply_suggestions", {"success": True, "applied": list(updates.keys())}
        )
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


@websocket_api.websocket_command(
    {vol.Required("type"): "ha_washdata/clear_suggestions", vol.Required("entry_id"): str}
)
@websocket_api.async_response
async def ws_clear_suggestions(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Discard all pending tuning suggestions for a device."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    try:
        await manager.profile_store.clear_suggestions()
        manager.notify_update()
        _send_result(connection, msg["id"], "clear_suggestions", {"success": True})
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/set_suggestion_lock",
        vol.Required("entry_id"): str,
        vol.Required("key"): str,
        vol.Required("locked"): bool,
    }
)
@websocket_api.async_response
async def ws_set_suggestion_lock(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Lock or unlock a tuning suggestion key so the auto-tuner stops (or resumes)
    proposing it (#343)."""
    entry_id: str = msg["entry_id"]
    key: str = msg["key"]
    if key not in _SUGGESTION_KEYS:
        connection.send_error(msg["id"], "invalid_key", f"Unknown suggestion key: {key!r}")
        return
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    try:
        await manager.profile_store.set_suggestion_locked(key, bool(msg["locked"]))
        manager.notify_update()
        _send_result(
            connection, msg["id"], "set_suggestion_lock",
            {"success": True, "locked_suggestions": manager.profile_store.get_locked_suggestions()},
        )
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


@websocket_api.websocket_command(
    {vol.Required("type"): "ha_washdata/run_suggestion_analysis", vol.Required("entry_id"): str}
)
@websocket_api.async_response
async def ws_run_suggestion_analysis(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Run all suggestion passes on demand (manual 'analyze now' trigger)."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    learning = getattr(manager, "learning_manager", None)
    if learning is None or not hasattr(learning, "async_run_full_analysis"):
        connection.send_error(msg["id"], "unavailable", "Suggestion analysis unavailable")
        return
    try:
        result = await learning.async_run_full_analysis()
        manager.notify_update()
        _send_result(connection, msg["id"], "run_suggestion_analysis", {"success": True, **(result or {})})
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


# ─── Cycle curve / interactive editing ─────────────────────────────────────────

@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/get_cycle_power_data",
        vol.Required("entry_id"): str,
        vol.Required("cycle_id"): str,
    }
)
@websocket_api.async_response
async def ws_get_cycle_power_data(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Return a single cycle's downsampled power curve plus its metadata."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    cycle_id: str = msg["cycle_id"]
    samples: list[Any] = []
    meta: dict[str, Any] = {}
    try:
        store = manager.profile_store
        samples = store.get_cycle_power_data(cycle_id)
        cycle, origin = store.find_stored_cycle(cycle_id)
        if cycle:
            meta = {
                "start_time": cycle.get("start_time"),
                "end_time": cycle.get("end_time"),
                "duration": cycle.get("duration"),
                "profile_name": cycle.get("profile_name"),
                "status": cycle.get("status"),
                "energy_kwh": _cycle_kwh(cycle),
                **_cycle_capabilities(cycle, origin),
            }
            # Transient artifacts (door-open pauses, out-of-band dips/spikes) for
            # graph markers. Prefer the value frozen at cycle end; compute on the
            # fly for older cycles that predate artifact storage.
            artifacts = cycle.get("artifacts")
            if artifacts is None and cycle.get("profile_name") and samples:
                # Offload CPU-intensive NumPy work to executor thread
                artifacts = await hass.async_add_executor_job(
                    store.detect_cycle_artifacts, cycle["profile_name"], samples
                )
            meta["artifacts"] = artifacts or []
            # HA restart gaps recorded during this cycle (for panel shading).
            meta["restart_gaps"] = cycle.get("restart_gaps") or []
    except Exception as exc:  # pylint: disable=broad-exception-caught
        _LOGGER.debug("Error getting cycle power data %s: %s", cycle_id, exc)

    _ds = _downsample(samples)
    _send_result(connection, msg["id"], "get_cycle_power_data", {
            "cycle_id": cycle_id,
            "samples": _ds,
            # Declare thinning (#395) so a gap from decimation is not mistaken for
            # a gap from a sensor that stopped reporting: the panel can show
            # "N of M samples" and disambiguate the two.
            "sample_count": len(samples),
            "decimated": len(_ds) < len(samples),
            "full_duration_s": round(float(samples[-1][0]), 1) if samples else 0.0,
            **meta,
        },
    )


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/trim_cycle",
        vol.Required("entry_id"): str,
        vol.Required("cycle_id"): str,
        vol.Required("start_s"): vol.Coerce(float),
        vol.Required("end_s"): vol.Coerce(float),
    }
)
@callback
def ws_trim_cycle(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Trim a cycle's power data to the [start_s, end_s] offset window.

    Runs as a detached, registry-tracked task (returns a task_id immediately): the
    trim recomputes the signature and rebuilds the profile envelope, which stalls
    low-power hosts if held on the event loop for the whole WS request (issue #311).
    Progress/result come via subscribe_tasks/get_task_result."""
    entry_id: str = msg["entry_id"]
    if _get_manager(hass, entry_id) is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    reg = task_registry.get_registry(hass)
    task = reg.create(
        entry_id, "trim", "Trimming cycle", label_key="task.trim.apply", label_params={},
    )
    _raw = hass.async_create_task(_trim_task(
        hass, task, entry_id, msg["cycle_id"], float(msg["start_s"]), float(msg["end_s"]),
    ))
    if _raw is not None:
        reg.link_asyncio_task(task.id, _raw)
    _send_result(connection, msg["id"], "trim_cycle", {"task_id": task.id})


async def _trim_task(
    hass: HomeAssistant, task: Any, entry_id: str,
    cycle_id: str, start_s: float, end_s: float,
) -> None:
    """Detached runner for a cycle trim (recompute + single-profile envelope
    rebuild). Serialized under the per-entry write lock like reprocess."""
    reg = task_registry.get_registry(hass)
    manager = _get_manager(hass, entry_id)
    if manager is None:
        reg.finish(task, state=task_registry.STATE_ERROR, error="device unavailable")
        return
    store = manager.profile_store
    lock = _entry_write_lock(hass, entry_id)
    acquired = False
    try:
        await lock.acquire()
        acquired = True
        if task.cancel_requested:
            reg.finish(task, state=task_registry.STATE_CANCELLED)
            return
        reg.update(task, total=1, done=0)
        ok = await store.trim_cycle_power_data(cycle_id, start_s, end_s)
        reg.update(task, done=1)
        if not ok:
            reg.finish(task, state=task_registry.STATE_ERROR, error="trim_failed")
            return
        if _get_manager(hass, entry_id) is manager:
            manager.notify_update()
        reg.finish(task, state=task_registry.STATE_DONE, result={"success": True})
    except asyncio.CancelledError:
        reg.finish(task, state=task_registry.STATE_CANCELLED)
        raise
    except Exception as exc:  # pylint: disable=broad-exception-caught
        _LOGGER.warning("Trim task failed for %s: %s", entry_id, exc)
        reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))
    finally:
        if acquired:
            lock.release()


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/analyze_split",
        vol.Required("entry_id"): str,
        vol.Required("cycle_id"): str,
        vol.Optional("gap_seconds", default=900): vol.All(
            int, vol.Range(min=30, max=21600)
        ),
    }
)
@websocket_api.async_response
async def ws_analyze_split(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Auto-detect split boundaries for a cycle; return the curve and offsets."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    store = manager.profile_store
    cycle_id: str = msg["cycle_id"]
    gap: int = msg.get("gap_seconds", 900)
    try:
        cycle = next(
            (c for c in store.get_past_cycles() if c.get("id") == cycle_id), None
        )
        if not cycle:
            connection.send_error(msg["id"], "not_found", f"Cycle {cycle_id!r} not found")
            return

        segs = await hass.async_add_executor_job(
            store.analyze_split_sync, cycle, gap, 2.0
        )
        samples = store.get_cycle_power_data(cycle_id)
        split_offsets = (
            [round(float(s[1]), 1) for s in segs[:-1]] if segs and len(segs) > 1 else []
        )
        _ds = _downsample(samples)
        _send_result(connection, msg["id"], "analyze_split", {
                "segments": [
                    [round(float(a), 1), round(float(b), 1)] for a, b in (segs or [])
                ],
                "split_offsets": split_offsets,
                "samples": _ds,
                # Declare thinning (#395); see ws_get_cycle_power_data.
                "sample_count": len(samples),
                "decimated": len(_ds) < len(samples),
                "full_duration_s": round(float(samples[-1][0]), 1) if samples else 0.0,
            },
        )
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/apply_split",
        vol.Required("entry_id"): str,
        vol.Required("cycle_id"): str,
        vol.Required("split_offsets"): [vol.Coerce(float)],
        vol.Optional("segment_profiles"): list,
    }
)
@callback
def ws_apply_split(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Split a cycle at the given offsets, optionally labeling each segment.

    Cheap validation (cycle exists, at least two segments) runs synchronously so a
    bad request fails fast; the heavy apply (per-segment extraction + affected
    envelope rebuilds + save) then runs as a detached, registry-tracked task so it
    never holds the event loop for the whole request on low-power hosts (issue
    #311). Progress/result come via subscribe_tasks/get_task_result."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    store = manager.profile_store
    cycle_id: str = msg["cycle_id"]
    cycle = next(
        (c for c in store.get_past_cycles() if c.get("id") == cycle_id), None
    )
    if not cycle:
        connection.send_error(msg["id"], "not_found", f"Cycle {cycle_id!r} not found")
        return

    offsets = [float(o) for o in msg["split_offsets"]]
    seg_bounds = store.build_split_segments_from_offsets(cycle, offsets)
    if len(seg_bounds) < 2:
        connection.send_error(
            msg["id"],
            "split_failed",
            "Split points did not produce at least two segments",
        )
        return

    profiles = msg.get("segment_profiles") or []
    segments: list[dict[str, Any]] = []
    for i, (seg_start, seg_end) in enumerate(seg_bounds):
        prof = profiles[i] if i < len(profiles) else None
        if prof in ("", "none", "__none__"):
            prof = None
        segments.append(
            {"start": float(seg_start), "end": float(seg_end), "profile": prof}
        )

    reg = task_registry.get_registry(hass)
    task = reg.create(
        entry_id, "split", "Splitting cycle", label_key="task.split.apply", label_params={},
    )
    _raw = hass.async_create_task(_apply_split_task(hass, task, entry_id, cycle_id, segments))
    if _raw is not None:
        reg.link_asyncio_task(task.id, _raw)
    _send_result(connection, msg["id"], "apply_split", {"task_id": task.id})


async def _apply_split_task(
    hass: HomeAssistant, task: Any, entry_id: str,
    cycle_id: str, segments: list[dict[str, Any]],
) -> None:
    """Detached runner for a cycle split. Rebuilds only the affected profiles'
    envelopes (done inside apply_split_interactive). Serialized under the per-entry
    write lock like reprocess."""
    reg = task_registry.get_registry(hass)
    manager = _get_manager(hass, entry_id)
    if manager is None:
        reg.finish(task, state=task_registry.STATE_ERROR, error="device unavailable")
        return
    store = manager.profile_store
    lock = _entry_write_lock(hass, entry_id)
    acquired = False
    try:
        await lock.acquire()
        acquired = True
        if task.cancel_requested:
            reg.finish(task, state=task_registry.STATE_CANCELLED)
            return
        reg.update(task, total=1, done=0)
        new_ids = await store.apply_split_interactive(cycle_id, segments)
        reg.update(task, done=1)
        if _get_manager(hass, entry_id) is manager:
            manager.notify_update()
        reg.finish(
            task, state=task_registry.STATE_DONE,
            result={"success": True, "new_ids": new_ids},
        )
    except asyncio.CancelledError:
        reg.finish(task, state=task_registry.STATE_CANCELLED)
        raise
    except Exception as exc:  # pylint: disable=broad-exception-caught
        _LOGGER.warning("Apply-split task failed for %s: %s", entry_id, exc)
        reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))
    finally:
        if acquired:
            lock.release()


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/apply_merge",
        vol.Required("entry_id"): str,
        vol.Required("cycle_ids"): [str],
        vol.Optional("target_profile"): vol.Any(str, None),
        vol.Optional("new_profile_name"): vol.Any(str, None),
    }
)
@callback
def ws_apply_merge(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Merge two or more cycles into one, optionally labeling the result.

    Cheap validation runs synchronously; the merge (gap-fill + signature recompute
    + affected-profile envelope rebuilds + save) then runs as a detached,
    registry-tracked task so it never holds the event loop for the whole request
    on low-power hosts (issue #311). Progress/result via the task registry."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    ids: list[str] = msg["cycle_ids"]
    if len(ids) < 2:
        connection.send_error(
            msg["id"], "merge_failed", "Select at least two cycles to merge"
        )
        return

    target = msg.get("target_profile")
    new_name: str | None = None
    if target == "__create_new__":
        new_name = (msg.get("new_profile_name") or "").strip()
        if not new_name:
            connection.send_error(msg["id"], "invalid_format", "New profile name required")
            return
    elif target in ("", "none", "__none__"):
        target = None

    reg = task_registry.get_registry(hass)
    task = reg.create(
        entry_id, "merge", "Merging cycles", label_key="task.merge.apply", label_params={},
    )
    _raw = hass.async_create_task(_apply_merge_task(hass, task, entry_id, ids, target, new_name))
    if _raw is not None:
        reg.link_asyncio_task(task.id, _raw)
    _send_result(connection, msg["id"], "apply_merge", {"task_id": task.id})


async def _apply_merge_task(
    hass: HomeAssistant, task: Any, entry_id: str,
    ids: list[str], target: str | None, new_name: str | None,
) -> None:
    """Detached runner for a cycle merge. Rebuilds only the affected profiles'
    envelopes (done inside apply_merge_interactive). Serialized under the per-entry
    write lock like reprocess."""
    reg = task_registry.get_registry(hass)
    manager = _get_manager(hass, entry_id)
    if manager is None:
        reg.finish(task, state=task_registry.STATE_ERROR, error="device unavailable")
        return
    store = manager.profile_store
    lock = _entry_write_lock(hass, entry_id)
    acquired = False
    try:
        await lock.acquire()
        acquired = True
        if task.cancel_requested:
            reg.finish(task, state=task_registry.STATE_CANCELLED)
            return
        reg.update(task, total=1, done=0)
        created_new = False
        if new_name:
            # Raises ValueError if the name already exists, so reaching the next
            # line guarantees we created a brand-new (empty) profile that must be
            # rolled back if the merge below fails.
            await store.create_profile_standalone(new_name)
            created_new = True
            target = new_name
        new_id = await store.apply_merge_interactive(ids, target)
        reg.update(task, done=1)
        if not new_id:
            if created_new and new_name:
                # Merge rejected the cycle set (stale/invalid id, unparseable start
                # time, ...). Delete the empty profile we just created so a failed
                # merge doesn't orphan a cycle-less profile in the store.
                await store.delete_profile(new_name, unlabel_cycles=False)
            reg.finish(task, state=task_registry.STATE_ERROR, error="merge_failed")
            return
        if _get_manager(hass, entry_id) is manager:
            manager.notify_update()
        reg.finish(
            task, state=task_registry.STATE_DONE,
            result={"success": True, "new_id": new_id},
        )
    except asyncio.CancelledError:
        reg.finish(task, state=task_registry.STATE_CANCELLED)
        raise
    except Exception as exc:  # pylint: disable=broad-exception-caught
        _LOGGER.warning("Apply-merge task failed for %s: %s", entry_id, exc)
        reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))
    finally:
        if acquired:
            lock.release()


# ─── Profile envelope / member cycles ──────────────────────────────────────────

@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/get_profile_envelope",
        vol.Required("entry_id"): str,
        vol.Required("profile_name"): str,
    }
)
@callback
def ws_get_profile_envelope(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Return a profile's averaged power envelope (downsampled) and stats."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    env_out: dict[str, Any] | None = None
    try:
        env = manager.profile_store.get_envelope(msg["profile_name"])
        if env:
            env_out = {
                "avg": _downsample(env.get("avg") or []),
                "min": _downsample(env.get("min") or []),
                "max": _downsample(env.get("max") or []),
                "target_duration": env.get("target_duration"),
                "avg_energy": env.get("avg_energy"),
                "duration_std_dev": env.get("duration_std_dev"),
                "cycle_count": env.get("cycle_count"),
            }
    except Exception as exc:  # pylint: disable=broad-exception-caught
        _LOGGER.debug("Error getting envelope for %s: %s", msg.get("profile_name"), exc)

    _send_result(connection, msg["id"], "get_profile_envelope", {"envelope": env_out})


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/get_profile_cycles",
        vol.Required("entry_id"): str,
        vol.Required("profile_name"): str,
        vol.Optional("limit", default=150): vol.All(int, vol.Range(min=1, max=400)),
    }
)
@websocket_api.async_response
async def ws_get_profile_cycles(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Return cycles labeled with a profile, each with a downsampled curve.

    Powers the history-cleanup spaghetti view: the panel overlays every curve
    and lets the user delete outliers. Colors are assigned client-side.
    """
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    profile_name: str = msg["profile_name"]
    limit: int = msg.get("limit", 150)

    def _collect() -> list[dict[str, Any]]:
        out: list[dict[str, Any]] = []
        try:
            store = manager.profile_store
            matched = [
                c for c in store.get_past_cycles() if c.get("profile_name") == profile_name
            ]
            for c in matched[-limit:]:
                cid = c.get("id")
                samples = store.get_cycle_power_data(cid) if cid else []
                out.append(
                    {
                        "cycle_id": cid,
                        "start_time": c.get("start_time"),
                        "duration": c.get("duration"),
                        "status": c.get("status"),
                        "energy_kwh": _cycle_kwh(c),
                        "samples": _downsample(samples, 160),
                    }
                )
        except Exception as exc:  # pylint: disable=broad-exception-caught
            _LOGGER.debug("Error getting profile cycles for %s: %s", profile_name, exc)
        return out

    result = await hass.async_add_executor_job(_collect)
    _send_result(connection, msg["id"], "get_profile_cycles", {"cycles": result})


# ─── Panel config + RBAC commands ──────────────────────────────────────────────

@websocket_api.websocket_command({vol.Required("type"): "ha_washdata/get_panel_config"})
@websocket_api.async_response
async def ws_get_panel_config(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Return panel settings + the caller's prefs; admins also get RBAC + user list."""
    user = getattr(connection, "user", None)
    cfg = _panel_data(hass)
    is_admin = bool(getattr(user, "is_admin", False))
    uid = getattr(user, "id", "") or ""
    out: dict[str, Any] = {
        "panel": dict(cfg.get("panel", {})),
        "is_admin": is_admin,
        "user": {"id": uid, "name": getattr(user, "name", None)},
        "prefs": dict((cfg.get("prefs") or {}).get(uid, {})),
    }
    if is_admin:
        rbac = cfg.get("rbac", {})
        out["rbac"] = {
            "enabled": bool(rbac.get("enabled", False)),
            "default_level": rbac.get("default_level", "none"),
            "users": {
                k: {"default": v.get("default", "none"), "devices": dict(v.get("devices") or {})}
                for k, v in (rbac.get("users") or {}).items()
            },
        }
        users: list[dict[str, Any]] = []
        try:
            for u in await hass.auth.async_get_users():
                if u.system_generated or not u.is_active:
                    continue
                users.append({"id": u.id, "name": u.name or "Unnamed user", "is_admin": bool(u.is_admin)})
        except Exception as exc:  # pylint: disable=broad-exception-caught
            _LOGGER.debug("Could not list users: %s", exc)
        out["users"] = users
    _send_result(connection, msg["id"], "get_panel_config", out)


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/set_panel_config",
        vol.Optional("panel"): dict,
        vol.Optional("rbac"): dict,
    }
)
@websocket_api.async_response
async def ws_set_panel_config(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Persist panel settings and/or RBAC config (admin only; enforced by _guard)."""
    holder = hass.data.get(_PANEL_DATA_KEY)
    if not holder:
        await async_load_panel_config(hass)
        holder = hass.data.get(_PANEL_DATA_KEY)
    cfg = holder["data"]
    try:
        if isinstance(msg.get("panel"), dict):
            cfg["panel"] = _sanitize_panel(msg["panel"], cfg.get("panel", {}))
        if isinstance(msg.get("rbac"), dict):
            cfg["rbac"] = _sanitize_rbac(msg["rbac"])
        await _save_panel_data(hass)
        _send_result(connection, msg["id"], "set_panel_config", {"success": True})
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/set_user_prefs",
        vol.Required("prefs"): dict,
    }
)
@websocket_api.async_response
async def ws_set_user_prefs(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Persist the calling user's own view preferences (any authenticated user)."""
    user = getattr(connection, "user", None)
    if user is None:
        connection.send_error(msg["id"], "unauthorized", "No authenticated user")
        return
    holder = hass.data.get(_PANEL_DATA_KEY)
    if not holder:
        await async_load_panel_config(hass)
        holder = hass.data.get(_PANEL_DATA_KEY)
    cfg = holder["data"]
    prefs = cfg.setdefault("prefs", {})
    cur = dict(prefs.get(user.id, {}))
    p = msg["prefs"]
    if p.get("default_tab") in _PANEL_TABS:
        cur["default_tab"] = p["default_tab"]
    for k in ("show_expected", "show_raw", "show_raw_active", "show_debug", "onboarding_dismissed"):
        if k in p:
            cur[k] = bool(p[k])
    # F2: per-user Basic/Advanced settings disclosure level.
    if p.get("settings_level") in ("basic", "advanced"):
        cur["settings_level"] = p["settings_level"]
    # Panel font-size multiplier (accessibility). Coerce + clamp to safe bounds so a
    # bad value can never break rendering; without this it was silently dropped and
    # the setting vanished on refresh.
    if "font_scale" in p:
        try:
            cur["font_scale"] = max(0.7, min(2.0, float(p["font_scale"])))
        except (TypeError, ValueError):
            cur.pop("font_scale", None)
    # Display prefs: cycle date format + panel language override (paired with the
    # panel's save-prefs payload; without these they would be silently dropped).
    if p.get("date_format") in _PREF_DATE_FORMATS:
        cur["date_format"] = p["date_format"]
    if "lang_override" in p:
        lang = p["lang_override"]
        if lang == "":
            cur.pop("lang_override", None)  # empty clears -> system default
        elif isinstance(lang, str) and _PREF_LANG_TAG_RE.match(lang):
            cur["lang_override"] = lang
    # Allow setup guidance skip keys: setup_skip_<step_key> -> "never" | ISO timestamp
    for k, v in p.items():
        if isinstance(k, str) and k.startswith("setup_skip_"):
            if v is None:
                cur.pop(k, None)
            elif v == "never" or (isinstance(v, str) and len(v) <= 40):
                cur[k] = v
    prefs[user.id] = cur
    await _save_panel_data(hass)
    _send_result(connection, msg["id"], "set_user_prefs", {"success": True})


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/get_match_debug",
        vol.Required("entry_id"): str,
    }
)
@callback
def ws_get_match_debug(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Return the latest live match result for the Status debug panel.

    Confidence, ambiguity flag, and the ranked candidate list (from the last
    in-cycle match attempt). Empty until the first match runs.
    """
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    out: dict[str, Any] = {"confidence": None, "ambiguous": False, "candidates": []}
    try:
        mr = getattr(manager, "_last_match_result", None)
        conf = getattr(manager, "_last_match_confidence", None)
        out["confidence"] = round(float(conf), 4) if conf is not None else None
        out["ambiguous"] = bool(getattr(manager, "_last_match_ambiguous", False))
        if mr is not None:
            out["candidates"] = manager.profile_store.get_match_candidates_summary(mr, 5)
    except Exception as exc:  # pylint: disable=broad-exception-caught
        _LOGGER.debug("Error building match debug for %s: %s", entry_id, exc)

    _send_result(connection, msg["id"], "get_match_debug", out)


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/set_program",
        vol.Required("entry_id"): str,
        vol.Required("program"): vol.Any(str, None),
    }
)
@callback
def ws_set_program(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Manually set the active program, or clear back to auto-detect.

    Drives the same manager methods as the program-select entity.
    """
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    try:
        prog = msg.get("program")
        if not prog or prog in ("auto_detect", "__auto__", "none"):
            manager.clear_manual_program()
        else:
            manager.set_manual_program(prog)
        manager.notify_update()
        _send_result(connection, msg["id"], "set_program", {"success": True})
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/get_power_history",
        vol.Required("entry_id"): str,
        vol.Optional("with_raw", default=False): bool,
    }
)
@websocket_api.async_response
async def ws_get_power_history(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Return the live power trace for the status chart, held server-side.

    While a cycle runs ``live`` is the in-progress cycle trace (offsets from
    cycle start, so it lines up with the matched profile envelope); otherwise it
    is the recent readings. When ``with_raw`` is set and a cycle is running,
    ``raw`` is the configured power-sensor entity's recorder history over the
    cycle window, so the real socket data can be compared side by side with the
    integration's processed/sampled trace. Server-held so it survives a browser
    refresh, and the cycle trace survives an HA restart via state restore.
    """
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    with_raw = bool(msg.get("with_raw"))
    out: dict[str, Any] = {"cycle_active": False, "cycle_elapsed_s": 0.0, "live": [], "raw": [], "restart_gaps": []}
    try:
        detector = getattr(manager, "detector", None)
        diag = getattr(manager, "diag_buffer", None)
        trace = detector.get_power_trace() if detector else []
        cycle_start = getattr(detector, "current_cycle_start", None) if detector else None
        if cycle_start and trace:
            start_dt = trace[0][0]
            start_ts = start_dt.timestamp()
            live = [[round((t - start_dt).total_seconds(), 1), round(float(p), 1)] for t, p in trace]
            # Overlay any diag_buffer readings strictly newer than the last trace
            # point. During STATE_ENDING the detector trace can lag the raw sensor
            # by up to one sampling interval (readings are throttled; the diag_buffer
            # records every reading before the throttle), so the chart would appear
            # frozen at the last active reading until the next throttle-pass.
            if diag is not None and live:
                last_trace_ts = trace[-1][0].timestamp()
                for raw_ts, raw_w in diag.power_samples(last_trace_ts + 0.1):
                    offset_s = round(raw_ts - start_ts, 1)
                    if offset_s > live[-1][0]:
                        live.append([offset_s, round(float(raw_w), 1)])
            out["cycle_active"] = True
            out["live"] = _downsample(live)
            out["cycle_elapsed_s"] = live[-1][0] if live else 0.0
            out["cycle_start_iso"] = start_dt.isoformat()
            out["restart_gaps"] = list(getattr(manager, "_restart_gaps", []))
            if with_raw:
                ent = getattr(manager, "power_sensor_entity_id", None)
                if ent:
                    samples = await _recorder_power(hass, ent, start_dt)
                    start_ts = start_dt.timestamp()
                    out["raw"] = _downsample(
                        [[max(0.0, round(ts - start_ts, 1)), w] for ts, w in samples], 400
                    )
        elif diag is not None:
            recent = diag.power_samples(time.time() - 900.0)
            if recent:
                base = recent[0][0]
                out["live"] = _downsample([[round(ts - base, 1), round(float(w), 1)] for ts, w in recent])
    except Exception as exc:  # pylint: disable=broad-exception-caught
        _LOGGER.debug("Error building power history for %s: %s", entry_id, exc)

    _send_result(connection, msg["id"], "get_power_history", out)


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/get_logs",
        vol.Optional("level"): vol.Any(str, None),
        vol.Optional("limit", default=200): vol.All(int, vol.Range(min=1, max=500)),
    }
)
@callback
def ws_get_logs(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Return recent ha_washdata log records (admin only; enforced by _guard)."""
    handler = hass.data.get(_LOG_BUFFER_KEY)
    recs = list(handler.records) if handler else []
    level = msg.get("level")
    if level and level in _LOG_LEVELS:
        minl = _LOG_LEVELS[level]
        recs = [r for r in recs if _LOG_LEVELS.get(r["level"], 0) >= minl]
    limit = msg.get("limit", 200)
    _send_result(connection, msg["id"], "get_logs", {"logs": recs[-limit:]})


# ─── ML Lab (shadow-mode comparison) ──────────────────────────────────────────

def _ml_median(values: list[float]) -> float | None:
    if not values:
        return None
    s = sorted(values)
    n = len(s)
    return s[n // 2] if n % 2 else (s[n // 2 - 1] + s[n // 2]) / 2


def _compute_cycle_events(
    points: list[tuple[float, float]],
    expectation: dict[str, float],
    end_feat_fn: Any,
    end_score_fn: Any,
    max_events: int = 20,
) -> list[dict[str, Any]]:
    """Find all low-power events in a trace and score each with the ML end detector.

    Each event represents a contiguous below-threshold power segment.  Events that
    were followed by power resumption are classified as pauses (``is_end=False``);
    the last event is marked as the end trigger (``is_end=True``).

    Motor-cycling appliances (washing machines) produce tens of micro-dips per cycle
    from drum motor switching.  A 30 s minimum filters these while still capturing
    genuine pause events which are typically > 1 min.
    """
    _MIN_EVENT_S = 30.0
    if not points or len(points) < 4:
        return []
    powers = [p for _, p in points]
    peak = max(powers) if powers else 0.0
    low_thresh = max(1.0, 0.05 * peak)

    events: list[dict[str, Any]] = []
    in_low = False
    seg_start_s = 0.0

    for i, (offset_s, pwr) in enumerate(points):
        if not in_low and pwr < low_thresh:
            in_low = True
            seg_start_s = offset_s
        elif in_low and pwr >= low_thresh:
            seg_end_s = points[i - 1][0] if i > 0 else offset_s
            low_run_s = seg_end_s - seg_start_s
            if low_run_s >= _MIN_EVENT_S:
                ml_conf: float | None = None
                try:
                    feat = end_feat_fn(points[:i], expectation)
                    if feat is not None:
                        ml_conf = round(float(end_score_fn(feat)), 3)
                except Exception:  # pylint: disable=broad-exception-caught
                    pass
                events.append({"offset_s": float(round(seg_start_s, 0)), "low_run_s": float(round(low_run_s, 0)), "ml_end_confidence": ml_conf, "is_end": False})
            in_low = False
            if len(events) >= max_events:
                break

    if in_low:
        low_run_s = points[-1][0] - seg_start_s
        if low_run_s >= _MIN_EVENT_S:
            ml_conf = None
            try:
                feat = end_feat_fn(points, expectation)
                if feat is not None:
                    ml_conf = round(float(end_score_fn(feat)), 3)
            except Exception:  # pylint: disable=broad-exception-caught
                pass
            events.append({"offset_s": float(round(seg_start_s, 0)), "low_run_s": float(round(low_run_s, 0)), "ml_end_confidence": ml_conf, "is_end": True})

    if events and not events[-1]["is_end"]:
        events[-1]["is_end"] = True
    return events[:max_events]


def _health_model_sig(store: Any) -> str:
    """Signature of the active health/end models, so persisted per-cycle health
    can be invalidated (recomputed) whenever the model changes or is retrained.
    """
    versions = store.get_ml_model_versions() or {}
    parts = []
    for cap in ("quality", "end"):
        v = versions.get(cap)
        ts = v.get("trained_at") if isinstance(v, dict) else None
        parts.append(f"{cap}:{ts or 'base'}")
    return "|".join(parts)


def _compute_ml_comparison(
    store: Any, current_off_delay: int, *, force_recompute: bool = False
) -> dict[str, Any]:
    """Build the ML shadow-mode comparison report (CPU-intensive; runs in executor).

    Iterates all stored cycles, extracts features, and scores them against the
    embedded ML models.  Original detection logic is completely untouched; this
    is read-only analysis for the ML Lab panel page.

    Per-cycle health (quality + end scores, labels and the events timeline) is
    **persisted** on each cycle under ``ml_health`` keyed by the active model
    signature. Subsequent calls reuse the cached value instead of re-scoring on
    every panel load; it is only recomputed when the model changes (signature
    mismatch), when ``force_recompute`` is set (reprocess / maintenance /
    processing trigger), or for cycles that have never been assessed. The caller
    persists the store when ``result['_health_dirty']`` is true.
    """
    # Lazy imports so this module loads instantly even when ML deps are absent.
    try:
        from .ml.engine import resolve_scorer
        from .ml.feature_extraction import latest_end_event_features, quality_features
        from .profile_store import decompress_power_data
    except Exception:  # pylint: disable=broad-exception-caught
        return {"enabled": False, "error": "ML models not available", "cycles": [], "settings_comparison": {}}

    # Prefer on-device trained models when present, else the embedded baseline.
    quality_score_fn, quality_source = resolve_scorer("quality", store)
    end_score_fn, end_source = resolve_scorer("end", store)
    if quality_score_fn is None and end_score_fn is None:
        return {"enabled": False, "error": "ML models not available", "cycles": [], "settings_comparison": {}}

    model_sig = _health_model_sig(store)
    health_dirty = False
    health_updates: dict[str, Any] = {}
    cycles: list[Any] = store.get_past_cycles()

    # --- Build per-profile statistics from cycle history ---
    raw_stats: dict[str, dict[str, list[float]]] = {}
    for cycle in cycles:
        name = cycle.get("profile_name")
        if not name:
            continue
        s = raw_stats.setdefault(name, {"durations": [], "energies": [], "peaks": []})
        dur = cycle.get("duration")
        energy = cycle.get("energy_wh")
        peak = cycle.get("max_power")
        if dur is not None:
            s["durations"].append(float(dur))
        if energy is not None:
            s["energies"].append(float(energy))
        if peak is not None:
            s["peaks"].append(float(peak))

    profile_medians: dict[str, dict[str, float]] = {}
    for name, s in raw_stats.items():
        profile_medians[name] = {
            "duration_s": _ml_median(s["durations"]) or 1800.0,
            "energy_wh": _ml_median(s["energies"]) or 500.0,
            "peak_w": _ml_median(s["peaks"]) or 500.0,
            "count": len(s["durations"]),
        }

    # --- Evaluate cycles (newest 200 for scoring/display; all for pause analysis) ---
    # `cycles` is oldest-first (new cycles are appended at the end), so the newest
    # 200 are the tail. Score / persist health for only that window; the pause
    # scan below still runs over every cycle.
    intra_pauses: list[float] = []
    evaluated: list[dict[str, Any]] = []
    recent_start_idx = max(0, len(cycles) - 200)

    for idx, cycle in enumerate(cycles):
        profile_name: str | None = cycle.get("profile_name")
        duration: float = float(cycle.get("duration") or 0)
        energy_wh: float = float(cycle.get("energy_wh") or 0)
        status: str = cycle.get("status", "completed")

        # A stored confidence of 0 most likely means the field wasn't recorded
        # (manually-labeled or pre-confidence cycles), not that the match was bad.
        # Treat 0 as "unknown" so we don't poison every quality feature with
        # worst-case proxies.
        raw_conf = cycle.get("match_confidence")
        conf_known: bool = isinstance(raw_conf, (int, float)) and raw_conf > 0
        match_conf: float = float(raw_conf) if conf_known else 0.0

        # Proxy feature values for quality scoring.  When confidence is known,
        # derive distance/margin/fit from it.  When unknown, use neutral values
        # so the model scores on trace shape alone.
        if conf_known:
            proxy_dist = max(0.0, 1.0 - match_conf)
            proxy_margin = match_conf
            proxy_fit = match_conf
        else:
            proxy_dist, proxy_margin, proxy_fit = 0.25, 0.30, 0.75

        points = decompress_power_data(cycle)

        # Collect intra-cycle pauses for off_delay recommendation (all cycles).
        if points and len(points) >= 4 and status == "completed":
            powers = [p for _, p in points]
            peak = max(powers) if powers else 0.0
            low_thresh = max(1.0, 0.05 * peak)
            in_pause = False
            pause_start_s = 0.0
            for offset_s, pwr in points:
                if not in_pause and pwr < low_thresh:
                    in_pause = True
                    pause_start_s = offset_s
                elif in_pause and pwr >= low_thresh:
                    pause_dur = offset_s - pause_start_s
                    if 5.0 <= pause_dur <= 1800.0:
                        intra_pauses.append(pause_dur)
                    in_pause = False

        # Only score and display the newest 200 cycles (the tail of the
        # oldest-first list); older cycles still contributed pauses above.
        if idx < recent_start_idx:
            continue

        # Reuse persisted per-cycle health when it was computed against the
        # current model (unless a recompute is forced). This is what keeps the
        # panel from re-scoring every cycle on every load.
        cached = cycle.get("ml_health")
        if (
            not force_recompute
            and isinstance(cached, dict)
            and cached.get("model_sig") == model_sig
        ):
            ml_quality = cached.get("score")
            quality_label = cached.get("label", "no_data")
            ml_end_conf = cached.get("end_score")
            end_label = cached.get("end_label", "no_event")
            events = cached.get("events") or []
        else:
            ml_quality = None
            if profile_name and profile_name in profile_medians:
                pm = profile_medians[profile_name]
                try:
                    feat = quality_features(
                        points=points,
                        profile_median_duration_s=pm["duration_s"],
                        profile_median_energy_wh=pm["energy_wh"],
                        profile_median_peak_w=pm["peak_w"],
                        profile_distance=proxy_dist,
                        label_margin=proxy_margin,
                        profile_fit_score=proxy_fit,
                        flag_count=0,
                    )
                    if quality_score_fn is not None:
                        ml_quality = float(quality_score_fn(feat))
                except Exception:  # pylint: disable=broad-exception-caught
                    pass

            expectation: dict[str, float] = {}
            if profile_name and profile_name in profile_medians:
                pm = profile_medians[profile_name]
                expectation = {"duration": pm["duration_s"], "energy": pm["energy_wh"], "peak": pm["peak_w"]}

            ml_end_conf = None
            if expectation and points:
                try:
                    end_feat = latest_end_event_features(points, expectation)
                    if end_feat is not None and end_score_fn is not None:
                        ml_end_conf = float(end_score_fn(end_feat))
                except Exception:  # pylint: disable=broad-exception-caught
                    pass

            # Per-cycle events timeline for the modal
            events = []
            if expectation and points and end_score_fn is not None:
                events = _compute_cycle_events(points, expectation, latest_end_event_features, end_score_fn)

            if ml_quality is None:
                quality_label = "no_data"
            elif ml_quality < 0.3:
                quality_label = "ok"
            elif ml_quality < 0.6:
                quality_label = "uncertain"
            else:
                quality_label = "review"

            if ml_end_conf is None:
                end_label = "no_event"
            elif ml_end_conf >= 0.6:
                end_label = "likely_end"
            elif ml_end_conf >= 0.35:
                end_label = "uncertain"
            else:
                end_label = "likely_pause"

            # Collect freshly-computed health for the event-loop to apply
            # back to the live store dicts (avoids mutating from executor thread).
            cycle_id_key = cycle.get("id", "")
            if cycle_id_key:
                health_updates[cycle_id_key] = {
                    "score": round(ml_quality, 3) if ml_quality is not None else None,
                    "label": quality_label,
                    "end_score": round(ml_end_conf, 3) if ml_end_conf is not None else None,
                    "end_label": end_label,
                    "events": events,
                    "model_sig": model_sig,
                    "at": dt_util.now().isoformat(),
                }
            health_dirty = True

        start_raw = cycle.get("start_time", "")
        evaluated.append({
            "id": cycle.get("id", ""),
            "start_time": start_raw if isinstance(start_raw, str) else "",
            "duration_s": round(duration, 0),
            "status": status,
            "profile_name": profile_name,
            "match_confidence": round(match_conf, 3),
            "confidence_known": conf_known,
            "energy_wh": round(energy_wh, 1),
            "ml_quality_score": round(ml_quality, 3) if ml_quality is not None else None,
            "ml_quality_label": quality_label,
            "ml_end_confidence": round(ml_end_conf, 3) if ml_end_conf is not None else None,
            "ml_end_label": end_label,
            "has_power_data": len(points) > 0,
            "events": events,
            "ml_review": cycle.get("ml_review") or {},
        })

    # Panel expects most-recent-first ordering; the loop appended oldest-first.
    evaluated.reverse()

    # --- Settings comparison: off_delay ---
    settings_comparison: dict[str, Any] = {}
    ml_off_delay: int | None = None
    pause_p95: float | None = None
    if intra_pauses:
        intra_pauses.sort()
        p95_idx = min(int(len(intra_pauses) * 0.95), len(intra_pauses) - 1)
        pause_p95 = intra_pauses[p95_idx]
        ml_off_delay = max(60, int(pause_p95) + 60)

    original_suggestions = store.get_suggestions() or {}
    orig_off = original_suggestions.get(CONF_OFF_DELAY)
    settings_comparison["off_delay"] = {
        "key": CONF_OFF_DELAY,
        "label": "Off Delay",
        "unit": "s",
        "current_value": current_off_delay,
        "original_suggestion": orig_off.get("value") if isinstance(orig_off, dict) else None,
        "original_reason": orig_off.get("reason") if isinstance(orig_off, dict) else None,
        "ml_recommendation": ml_off_delay,
        "ml_reasoning": (
            f"p95 intra-cycle pause: {int(pause_p95)}s + 60s buffer = {ml_off_delay}s "
            f"(from {len(intra_pauses)} observed pauses)"
            if ml_off_delay is not None else "Not enough pause data to recommend"
        ),
        "pause_count": len(intra_pauses),
    }

    return {
        "enabled": True,
        "cycle_count": len(cycles),
        "evaluated_count": sum(1 for e in evaluated if e["ml_quality_score"] is not None),
        "cycles": evaluated,
        "settings_comparison": settings_comparison,
        "model_source": {"quality": quality_source, "end": end_source},
        "_health_dirty": health_dirty,
        "_health_updates": health_updates,
        "profile_stats": {
            name: {"count": int(m["count"]), "median_duration_s": int(m["duration_s"]), "median_energy_wh": round(m["energy_wh"], 1)}
            for name, m in profile_medians.items()
        },
    }


def _build_settings_comparison(
    manager: Any, merged: dict[str, Any], engine: Any = None
) -> dict[str, Any]:
    """Build the enriched Classic-vs-ML settings comparison (executor-safe).

    Runs both the classic :class:`SuggestionEngine` and the
    :class:`MLSuggestionEngine` against clean cycle history and merges their
    recommendations with the current option values. Gated by the caller behind
    ``ENABLE_ML_SUGGESTIONS``.
    """
    try:
        from .suggestion_engine import (  # pylint: disable=import-outside-toplevel
            MLSuggestionEngine,
            select_clean_cycles,
        )
    except Exception:  # pylint: disable=broad-exception-caught
        return {}

    # `engine` is a for_job() copy prepared on the event loop with the config
    # snapshot already bound; fall back to the shared engine only for callers that
    # did not supply one (its _entry_options() then does a live read).
    classic = engine
    if classic is None:
        learning = getattr(manager, "learning_manager", None)
        classic = getattr(learning, "suggestion_engine", None)
    if classic is None:
        return {}

    stop_thr = classic._current_stop_threshold(merged)  # pylint: disable=protected-access
    raw_cycles = classic.profile_store.get_past_cycles()

    # Classic recommendations pooled from every classic pass.
    classic_vals: dict[str, Any] = {}
    for producer in (
        classic.generate_detection_suggestions,
        classic.generate_model_suggestions,
    ):
        try:
            classic_vals.update(producer() or {})
        except Exception:  # pylint: disable=broad-exception-caught
            pass
    try:
        classic_vals.update(classic.run_batch_simulation(raw_cycles) or {})
    except Exception:  # pylint: disable=broad-exception-caught
        pass
    # Classic off_delay via the (Stage 2) pause analysis.
    try:
        clean, _excl = select_clean_cycles(raw_cycles[-200:], stop_threshold_w=stop_thr)
        device_floor = DEFAULT_OFF_DELAY_BY_DEVICE.get(
            classic.device_type, DEFAULT_OFF_DELAY
        ) if classic.device_type else DEFAULT_OFF_DELAY
        od = classic._suggest_off_delay_from_pauses(clean, stop_thr, device_floor)  # pylint: disable=protected-access
        if od is not None:
            classic_vals[CONF_OFF_DELAY] = {
                "value": od[0],
                "reason": od[1],
                "reason_key": od[2],
                "reason_params": od[3],
            }
    except Exception:  # pylint: disable=broad-exception-caught
        pass

    # ML recommendations.
    ml_vals: dict[str, Any] = {}
    try:
        ml_vals = MLSuggestionEngine(classic).generate_ml_suggestions() or {}
    except Exception:  # pylint: disable=broad-exception-caught
        pass

    def _val(entry: Any) -> Any:
        return entry.get("value") if isinstance(entry, dict) else None

    def _reason(entry: Any) -> str:
        return entry.get("reason", "") if isinstance(entry, dict) else ""

    def _reason_key(entry: Any) -> str | None:
        return entry.get("reason_key") if isinstance(entry, dict) else None

    def _reason_params(entry: Any) -> dict[str, Any] | None:
        return entry.get("reason_params") if isinstance(entry, dict) else None

    comparison: dict[str, Any] = {}
    for key, label, unit in _ML_COMPARE_SETTINGS:
        cv, mv = classic_vals.get(key), ml_vals.get(key)
        if cv is None and mv is None:
            continue
        current = merged.get(key)
        classic_value = _val(cv)
        ml_value = _val(mv)
        # Hide a recommendation that would not change the current value.
        if classic_value is not None and _suggestion_equivalent(classic_value, current):
            classic_value = None
        if ml_value is not None and _suggestion_equivalent(ml_value, current):
            ml_value = None
        if classic_value is None and ml_value is None:
            continue
        comparison[key] = {
            "key": key,
            # ``label`` is the English fallback; the panel renders the field's own
            # localized setting label (setting.<key>.label) so this is not shown
            # directly. Reasons carry _key/_params sidecars for _t() rendering.
            "label": label,
            "unit": unit,
            "current_value": current,
            "classic_value": classic_value,
            "classic_reason": _reason(cv) if classic_value is not None else "",
            "classic_reason_key": _reason_key(cv) if classic_value is not None else None,
            "classic_reason_params": _reason_params(cv) if classic_value is not None else None,
            "ml_value": ml_value,
            "ml_reason": _reason(mv) if ml_value is not None else "",
            "ml_reason_key": _reason_key(mv) if ml_value is not None else None,
            "ml_reason_params": _reason_params(mv) if ml_value is not None else None,
        }
    return comparison


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/get_ml_comparison",
        vol.Required("entry_id"): str,
    }
)
@websocket_api.async_response
async def ws_get_ml_comparison(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Return the ML shadow-mode comparison report for the ML Lab panel page.

    Runs the embedded ML models against stored cycle history and compares with
    the existing detection/suggestion logic.  Read-only: no side effects on the
    proven algorithms.
    """
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    try:
        store = manager.profile_store
        entry = _get_entry(hass, entry_id)
        merged: dict[str, Any] = {**(entry.data if entry else {}), **(entry.options if entry else {})}
        current_off_delay = int(merged.get(CONF_OFF_DELAY, 120))

        result = await hass.async_add_executor_job(
            _compute_ml_comparison, store, current_off_delay
        )
        # Apply any freshly-computed per-cycle health updates on the event loop
        # (the executor must not mutate live store dicts directly), then persist.
        health_updates = result.pop("_health_updates", {})
        result.pop("_health_dirty", False)
        if health_updates:
            for cycle in store.get_past_cycles():
                cid = cycle.get("id", "")
                if cid and cid in health_updates:
                    cycle["ml_health"] = health_updates[cid]
            await store.async_save()
        result["ml_suggestions_enabled"] = ENABLE_ML_SUGGESTIONS

        # Stage 3: replace the basic off_delay-only comparison with the full
        # Classic-vs-ML settings table when ML suggestions are unlocked.
        if ENABLE_ML_SUGGESTIONS:
            # Bind this handler's already-merged view to a throwaway engine copy
            # before the executor hop: reading the config entry is loop-affine, so
            # the generators must not re-read it from the worker thread, and a
            # copy keeps concurrent jobs from clobbering each other's snapshot.
            shared = getattr(
                getattr(manager, "learning_manager", None), "suggestion_engine", None
            )
            job_engine = shared.for_job(merged) if shared is not None else None
            enriched = await hass.async_add_executor_job(
                _build_settings_comparison, manager, merged, job_engine
            )
            if enriched:
                result["settings_comparison"] = enriched

        _send_result(connection, msg["id"], "get_ml_comparison", result)
    except Exception as exc:  # pylint: disable=broad-exception-caught
        _LOGGER.warning("ML comparison failed for %s: %s", entry_id, exc)
        connection.send_error(msg["id"], "unknown_error", str(exc))


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/get_ml_training_status",
        vol.Required("entry_id"): str,
    }
)
@websocket_api.async_response
async def ws_get_ml_training_status(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Return on-device ML training status for the Tuning > ML Training panel."""
    from .const import (  # pylint: disable=import-outside-toplevel
        CONF_ML_TRAINING_ENABLED,
        CONF_ML_TRAINING_HOUR,
        CONF_ML_TRAINING_INTERVAL_DAYS,
        CONF_ML_TRAINING_MIN_CYCLES,
        DEFAULT_ML_TRAINING_ENABLED,
        DEFAULT_ML_TRAINING_HOUR,
        DEFAULT_ML_TRAINING_INTERVAL_DAYS,
        DEFAULT_ML_TRAINING_MIN_CYCLES,
        MATCH_CORR_WEIGHT,
        MATCH_DTW_ENSEMBLE_W,
        MATCH_DURATION_WEIGHT,
        MATCH_ENERGY_WEIGHT,
    )

    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    entry = _get_entry(hass, entry_id)
    merged: dict[str, Any] = {**(entry.data if entry else {}), **(entry.options if entry else {})}
    store = manager.profile_store
    versions = store.get_ml_model_versions() or {}
    last = manager._last_ml_training_at()  # pylint: disable=protected-access
    # Plain-language names + a one-line "what it does" for each capability, so the
    # panel never has to show raw internal keys to non-ML users.
    _cap_labels = {
        "end": ("Cycle-end detection", "Knowing when a cycle has truly finished"),
        "quality": ("Cycle quality check", "Spotting mis-detected or corrupted cycles"),
        "live_match": ("Program matching", "Identifying the running program sooner"),
        "remaining_time": ("Time-remaining estimate", "Predicting how long is left"),
        "total_energy": ("Energy estimate", "Predicting total energy and cost"),
    }
    models: dict[str, Any] = {}
    for cap, v in versions.items():
        if not isinstance(v, dict):
            continue
        spec = v.get("spec") if isinstance(v.get("spec"), dict) else {}
        label, blurb = _cap_labels.get(cap, (cap, ""))
        info: dict[str, Any] = {
            "trained_at": v.get("trained_at"),
            "cycle_count": v.get("cycle_count"),
            "kind": spec.get("kind"),
            # ``label``/``blurb`` are the English fallbacks; the panel renders
            # _t(label_key/blurb_key, {}, fallback). Keyed by capability so a
            # missing key falls back cleanly to the English text.
            "label": label,
            "label_key": f"ml.cap_label.{cap}" if cap in _cap_labels else None,
            "blurb": blurb,
            "blurb_key": f"ml.cap_blurb.{cap}" if cap in _cap_labels else None,
        }
        # Raw metric numbers so the panel can render a humanized quality indicator
        # (a bar + word) with the exact figure on hover: held-out AUC for
        # classifiers, MAE-vs-naive for regressors.
        if v.get("new_auc") is not None:
            info["auc"] = round(float(v["new_auc"]), 4)
            info["metric"] = f"AUC {float(v['new_auc']):.2f} on held-out data"
            info["metric_key"] = "ml.metric_auc"
            info["metric_params"] = {"auc": f"{float(v['new_auc']):.2f}"}
        elif v.get("model_mae") is not None and v.get("naive_mae") is not None:
            info["model_mae"] = round(float(v["model_mae"]), 5)
            info["naive_mae"] = round(float(v["naive_mae"]), 5)
            info["metric"] = f"error {float(v['model_mae']):.3f} vs {float(v['naive_mae']):.3f} baseline"
            info["metric_key"] = "ml.metric_mae"
            info["metric_params"] = {
                "model": f"{float(v['model_mae']):.3f}",
                "baseline": f"{float(v['naive_mae']):.3f}",
            }
        models[cap] = info

    # Fit trend across recent training runs (drift): compare the mean held-out
    # score of the most-recent third of runs to the oldest third, respecting each
    # metric's direction. Only meaningful with a few runs of history.
    history = store.get_ml_training_history()
    for cap, info in models.items():
        series = history.get(cap) if isinstance(history, dict) else None
        if not isinstance(series, list) or len(series) < 4:
            continue
        scores = [float(e["score"]) for e in series if isinstance(e, dict) and "score" in e]
        if len(scores) < 4:
            continue
        higher_better = bool(series[-1].get("higher_better", True))
        third = max(1, len(scores) // 3)
        old_mean = sum(scores[:third]) / third
        new_mean = sum(scores[-third:]) / third
        if abs(old_mean) < 1e-9:
            continue
        rel = (new_mean - old_mean) / abs(old_mean)
        if not higher_better:
            rel = -rel  # for error metrics, a decrease is an improvement
        info["trend"] = "improving" if rel > 0.03 else "declining" if rel < -0.03 else "steady"

    # Matcher scoring-weight tuning (Stage 4/5): current shipped defaults, the
    # on-device tuned record (if promoted), and which set is actually in use.
    tuned_rec = store.get_matching_config()
    tuned_cfg = tuned_rec.get("config") if isinstance(tuned_rec, dict) else None
    matching = {
        "defaults": {
            "corr_weight": MATCH_CORR_WEIGHT,
            "duration_weight": MATCH_DURATION_WEIGHT,
            "energy_weight": MATCH_ENERGY_WEIGHT,
            "dtw_ensemble_w": MATCH_DTW_ENSEMBLE_W,
        },
        "tuned": tuned_rec or None,
        "active": "tuned" if tuned_cfg else "default",
    }
    _send_result(connection, msg["id"], "get_ml_training_status", {
            "available": ENABLE_ML_TRAINING,
            "enabled": bool(merged.get(CONF_ML_TRAINING_ENABLED, DEFAULT_ML_TRAINING_ENABLED)),
            "running": bool(getattr(manager, "_ml_training_running", False)),
            "last_trained": last.isoformat() if last else None,
            "cycle_count": len(store.get_past_cycles()),
            "min_cycles": int(merged.get(CONF_ML_TRAINING_MIN_CYCLES, DEFAULT_ML_TRAINING_MIN_CYCLES)),
            "interval_days": int(merged.get(CONF_ML_TRAINING_INTERVAL_DAYS, DEFAULT_ML_TRAINING_INTERVAL_DAYS)),
            "hour": int(merged.get(CONF_ML_TRAINING_HOUR, DEFAULT_ML_TRAINING_HOUR)),
            "on_device_models": models,
            "matching": matching,
        },
    )


async def _ml_training_task(hass: HomeAssistant, task: Any, entry_id: str) -> None:
    """Detached runner for on-device ML training; stores the summary as the result.

    NOT a WS handler: it is a plain coroutine kicked off via ``hass.async_create_task``
    by ``ws_trigger_ml_training``. It must carry no ``@websocket_command`` /
    ``@async_response`` decorators (those would rewrite it into a sync handler that
    returns ``None``, so the direct call would pass ``None`` to ``async_create_task``)."""
    reg = task_registry.get_registry(hass)
    manager = _get_manager(hass, entry_id)
    if manager is None:
        reg.finish(task, state=task_registry.STATE_ERROR, error="device unavailable")
        return
    # Serialize under the per-entry write lock (same lock _reprocess_task uses, and
    # reprocess itself runs ML training): training rewrites the store, so two runs
    # for the same entry must not interleave.
    lock = _entry_write_lock(hass, entry_id)
    acquired = False
    try:
        await lock.acquire()
        acquired = True
        summary = await manager.async_run_ml_training(force=True)
        reg.finish(task, state=task_registry.STATE_DONE, result=summary)
    except asyncio.CancelledError:
        reg.finish(task, state=task_registry.STATE_CANCELLED)
        raise
    except Exception as exc:  # pylint: disable=broad-exception-caught
        # Task-level failure: log at WARNING so it surfaces in the default HA log and
        # the panel Logs view (not swallowed at debug like a routine sub-step miss).
        _LOGGER.warning("ML training task failed for %s: %s", entry_id, exc)
        reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))
    finally:
        if acquired:
            lock.release()


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/trigger_ml_training",
        vol.Required("entry_id"): str,
    }
)
@callback
def ws_trigger_ml_training(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Kick off on-device ML training as a detached, registry-tracked task (manual,
    bypasses the schedule guards); returns its id. Result via the registry."""
    entry_id: str = msg["entry_id"]
    if _get_manager(hass, entry_id) is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    if not ENABLE_ML_TRAINING:
        connection.send_error(msg["id"], "not_available", "ML training is not enabled in this build")
        return
    reg = task_registry.get_registry(hass)
    task = reg.create(entry_id, "ml_training", "Learning")
    _raw = hass.async_create_task(_ml_training_task(hass, task, entry_id))
    if _raw is not None:
        reg.link_asyncio_task(task.id, _raw)
    _send_result(connection, msg["id"], "trigger_ml_training", {"task_id": task.id})


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/revert_matching_config",
        vol.Required("entry_id"): str,
    }
)
@websocket_api.async_response
async def ws_revert_matching_config(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Revert the matcher's scoring weights to the shipped defaults.

    Drops the on-device tuned override so matching falls back to the const
    defaults. The next training pass may re-promote a new one if it wins.
    """
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    await manager.profile_store.clear_matching_config()
    manager.notify_update()
    _send_result(connection, msg["id"], "revert_matching_config", {"success": True})


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/revert_ml_models",
        vol.Required("entry_id"): str,
    }
)
@websocket_api.async_response
async def ws_revert_ml_models(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Revert all on-device trained models to the shipped embedded baselines.

    Drops every promoted spec in ``ml_model_versions`` so ``resolve_scorer`` /
    ``resolve_regressor`` fall back to the baseline (or, for the baseline-less
    remaining-time regressor, become inert). The next training pass may
    re-promote models if they beat the baseline again. Mirrors
    ``revert_matching_config`` for the matcher weights.
    """
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    await manager.profile_store.clear_ml_model_versions()
    manager.notify_update()
    _send_result(connection, msg["id"], "revert_ml_models", {"success": True})


_ML_REVIEW_QUALITIES = {"", "good", "bad", "unusable"}


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/set_ml_review",
        vol.Required("entry_id"): str,
        vol.Required("cycle_id"): str,
        vol.Optional("quality"): vol.In(sorted(_ML_REVIEW_QUALITIES)),
        vol.Optional("golden"): bool,
        vol.Optional("tags"): [str],
        vol.Optional("notes"): str,
    }
)
@websocket_api.async_response
async def ws_set_ml_review(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Attach an ML-Lab review (quality / golden / tags / notes) to a cycle.

    This is the write-back that turns the read-only shadow view into a feedback
    loop: reviews become strong training labels for the on-device quality model.
    """
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    try:
        await manager.profile_store.set_cycle_review(
            msg["cycle_id"],
            quality=msg.get("quality"),
            golden=msg.get("golden"),
            tags=msg.get("tags"),
            notes=msg.get("notes"),
        )
        manager.notify_update()
        _send_result(connection, msg["id"], "set_ml_review", {"success": True})
    except ValueError as exc:
        connection.send_error(msg["id"], "not_found", str(exc))
    except Exception as exc:  # pylint: disable=broad-exception-caught
        connection.send_error(msg["id"], "unknown_error", str(exc))


# ── Cycle Controls ────────────────────────────────────────────────────────────

@websocket_api.websocket_command(
    {vol.Required("type"): "ha_washdata/pause_cycle", vol.Required("entry_id"): str}
)
@websocket_api.async_response
async def ws_pause_cycle(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """User-pause the active cycle."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    ok = await manager.async_pause_cycle()
    _send_result(connection, msg["id"], "pause_cycle", {"ok": bool(ok)})


@websocket_api.websocket_command(
    {vol.Required("type"): "ha_washdata/resume_cycle", vol.Required("entry_id"): str}
)
@websocket_api.async_response
async def ws_resume_cycle(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Resume a user-paused cycle."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    ok = await manager.async_resume_cycle()
    _send_result(connection, msg["id"], "resume_cycle", {"ok": bool(ok)})


@websocket_api.websocket_command(
    {vol.Required("type"): "ha_washdata/terminate_cycle", vol.Required("entry_id"): str}
)
@websocket_api.async_response
async def ws_terminate_cycle(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Force-terminate the active cycle."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    await manager.async_terminate_cycle()
    _send_result(connection, msg["id"], "terminate_cycle", {"ok": True})


# ─── Playground (F3): headless what-if replay + DTW visualizer ──────────────────


def _safe_float_finite(value: Any, default: float) -> float:
    """Convert ``value`` to a finite float, falling back to ``default`` on failure."""
    try:
        v = float(value)
        return v if math.isfinite(v) else default
    except (TypeError, ValueError):
        return default


def _playground_base_config(manager: Any, entry: Any) -> CycleDetectorConfig:
    """Resolve the device's live CycleDetectorConfig as the simulation base.

    Prefers the running detector's config (already merged with every default);
    falls back to a minimal config derived from the entry's effective options so
    the Playground still works if the detector is not yet initialised.
    """
    detector = getattr(manager, "detector", None)
    cfg = getattr(detector, "config", None)
    if isinstance(cfg, CycleDetectorConfig):
        return cfg
    opts: dict[str, Any] = {}
    if entry is not None:
        opts = {**getattr(entry, "data", {}), **getattr(entry, "options", {})}
    min_power = float(opts.get(CONF_MIN_POWER, DEFAULT_MIN_POWER) or DEFAULT_MIN_POWER)
    return CycleDetectorConfig(
        min_power=min_power,
        off_delay=int(opts.get(CONF_OFF_DELAY, DEFAULT_OFF_DELAY)),
        device_type=str(opts.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE)),
        completion_min_seconds=int(opts.get(CONF_COMPLETION_MIN_SECONDS, 600)),
        end_repeat_count=int(opts.get(CONF_END_REPEAT_COUNT, 1)),
        min_off_gap=int(opts.get(CONF_MIN_OFF_GAP, 60)),
        start_threshold_w=float(opts.get(CONF_START_THRESHOLD_W, min_power)),
        stop_threshold_w=float(
            opts.get(CONF_STOP_THRESHOLD_W, min_power * 0.6 if min_power else 2.0)
        ),
        dishwasher_end_spike_quiet_release=_safe_float_finite(
            opts.get(CONF_DISHWASHER_END_SPIKE_QUIET_RELEASE),
            DISHWASHER_END_SPIKE_QUIET_RELEASE_SECONDS,
        ),
        smart_termination_duration_ratio=_safe_float_finite(
            opts.get(CONF_SMART_TERMINATION_DURATION_RATIO),
            resolve_smart_termination_duration_ratio_default(
                str(opts.get(CONF_DEVICE_TYPE, DEFAULT_DEVICE_TYPE))
            ),
        ),
        # Match the live detector's tuned gate, not the dataclass 0.4, so the sim's
        # Smart-Termination / anti-crease confidence checks reproduce production.
        match_confidence_threshold=_safe_float_finite(
            opts.get(CONF_PROFILE_MATCH_THRESHOLD), DEFAULT_PROFILE_MATCH_THRESHOLD
        ),
    )


def _playground_context(hass: HomeAssistant, entry_id: str):
    """Return (manager, store, base_config, options, price) for a Playground call,
    or None (after sending the appropriate error) when unavailable."""
    entry = _get_entry(hass, entry_id)
    manager = _get_manager(hass, entry_id)
    if manager is None:
        return None
    store = getattr(manager, "profile_store", None)
    if store is None:
        return None
    base_config = _playground_base_config(manager, entry)
    options = {}
    if entry is not None:
        options = {**getattr(entry, "data", {}), **getattr(entry, "options", {})}
    try:
        price = manager._resolve_energy_price()  # noqa: SLF001
    except Exception:  # pylint: disable=broad-exception-caught
        price = None
    return manager, store, base_config, options, price


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/run_playground_cycle_detail",
        vol.Required("entry_id"): str,
        vol.Required("cycle_id"): str,
        vol.Optional("settings_override", default=dict): dict,
    }
)
@websocket_api.async_response
async def ws_run_playground_cycle_detail(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Faithful single-cycle simulation timeline (series + events + alerts +
    outcome) for the Playground "Simulate" view. Read-only what-if."""
    entry_id: str = msg["entry_id"]
    ctx = _playground_context(hass, entry_id)
    if ctx is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    _manager, store, base_config, options, price = ctx
    try:
        cycle_id = msg["cycle_id"]
        override = dict(msg.get("settings_override") or {})
        # The store lookup + replay run together in the executor so no store
        # access happens on the event loop.
        payload = await hass.async_add_executor_job(
            playground.simulate_cycle_detail_by_id,
            store, cycle_id, base_config, override, options, price,
        )
        if isinstance(payload, dict) and payload.get("error") == "not_found":
            connection.send_error(msg["id"], "not_found", "Cycle not found")
            return
        _send_result(connection, msg["id"], "run_playground_cycle_detail", payload)
    except Exception as exc:  # pylint: disable=broad-exception-caught
        _LOGGER.debug("Playground cycle detail failed for %s: %s", entry_id, exc)
        connection.send_error(msg["id"], "unknown_error", str(exc))


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/run_playground_history",
        vol.Required("entry_id"): str,
        vol.Optional("cycle_ids", default=list): [str],
        vol.Optional("settings_override", default=dict): dict,
        vol.Optional("concurrency", default=25): vol.Coerce(int),
    }
)
@websocket_api.async_response
async def ws_run_playground_history(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Per-cycle results table (+ before/after diff when settings_override is set)
    for the Playground "Test on history" view. Read-only what-if."""
    entry_id: str = msg["entry_id"]
    ctx = _playground_context(hass, entry_id)
    if ctx is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    _manager, store, base_config, options, price = ctx
    try:
        cycle_ids = list(msg.get("cycle_ids") or [])
        override = dict(msg.get("settings_override") or {})
        # Bound the batch size to the same safe cap as the backend (and sweep),
        # so an oversized caller value can't request unbounded replay work.
        concurrency = max(1, min(playground.MAX_BATCH_CYCLES, int(msg.get("concurrency", 25))))
        payload = await hass.async_add_executor_job(
            playground.run_playground_history,
            store, cycle_ids, base_config, override, options, price, concurrency,
        )
        _send_result(connection, msg["id"], "run_playground_history", payload)
    except Exception as exc:  # pylint: disable=broad-exception-caught
        _LOGGER.debug("Playground history failed for %s: %s", entry_id, exc)
        connection.send_error(msg["id"], "unknown_error", str(exc))


#: Max sweep points per axis - caps the grid so a caller can't request an
#: unbounded number of full-history replays (values x values_y x cycles).
_MAX_SWEEP_VALUES = 20


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/run_playground_sweep",
        vol.Required("entry_id"): str,
        vol.Required("param"): str,
        vol.Required("values"): vol.All([vol.Coerce(float)], vol.Length(min=1, max=_MAX_SWEEP_VALUES)),
        vol.Required("objective"): str,
        vol.Optional("cycle_ids", default=list): [str],
        vol.Optional("concurrency", default=15): vol.Coerce(int),
        vol.Optional("param_y"): str,
        vol.Optional("values_y"): vol.All([vol.Coerce(float)], vol.Length(max=_MAX_SWEEP_VALUES)),
    }
)
@websocket_api.async_response
async def ws_run_playground_sweep(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Objective-driven parameter sweep (1D curve or 2D heatmap) for the
    Playground "Sweep" view. Read-only what-if."""
    entry_id: str = msg["entry_id"]
    ctx = _playground_context(hass, entry_id)
    if ctx is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    _manager, store, base_config, options, price = ctx
    param_y = msg.get("param_y")
    values_y = list(msg["values_y"]) if msg.get("values_y") else None
    # A 2D sweep needs BOTH the second parameter and its values, or neither.
    if bool(param_y) != bool(values_y):
        connection.send_error(
            msg["id"], "invalid_format",
            "param_y and values_y must both be provided for a 2D sweep, or both omitted",
        )
        return
    try:
        cycle_ids = list(msg.get("cycle_ids") or [])
        concurrency = max(1, min(playground.MAX_BATCH_CYCLES, int(msg.get("concurrency", 15))))
        payload = await hass.async_add_executor_job(
            playground.run_playground_sweep,
            store,
            cycle_ids,
            base_config,
            msg["param"],
            list(msg.get("values") or []),
            msg["objective"],
            options,
            price,
            concurrency,
            param_y,
            values_y,
        )
        _send_result(connection, msg["id"], "run_playground_sweep", payload)
    except Exception as exc:  # pylint: disable=broad-exception-caught
        _LOGGER.debug("Playground sweep failed for %s: %s", entry_id, exc)
        connection.send_error(msg["id"], "unknown_error", str(exc))


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/get_dtw_debug",
        vol.Required("entry_id"): str,
        vol.Required("cycle_id"): str,
        vol.Optional("profile_name"): vol.Any(str, None),
    }
)
@websocket_api.async_response
async def ws_get_dtw_debug(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Return the Stage 2 / DTW / Stage 4 score breakdown + resampled traces +
    DTW warp path for one cycle vs one profile (defaults to the cycle's label)."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return

    store = getattr(manager, "profile_store", None)
    if store is None:
        connection.send_error(msg["id"], "unavailable", "Profile store unavailable")
        return

    try:
        payload = await hass.async_add_executor_job(
            playground.dtw_debug_payload,
            store,
            msg["cycle_id"],
            msg.get("profile_name"),
        )
        if isinstance(payload, dict) and payload.get("error"):
            connection.send_error(
                msg["id"], payload["error"], payload.get("detail", payload["error"])
            )
            return
        _send_result(connection, msg["id"], "get_dtw_debug", payload)
    except Exception as exc:  # pylint: disable=broad-exception-caught
        _LOGGER.debug("DTW debug failed for %s: %s", entry_id, exc)
        connection.send_error(msg["id"], "unknown_error", str(exc))


# ─── Playground settings control panel (live values + presets) ─────────────────


def _playground_preset_list(store: Any) -> list[dict[str, Any]]:
    """Presets as a name-sorted list for the panel dropdown."""
    presets = store.get_playground_presets()
    out: list[dict[str, Any]] = []
    for name, record in presets.items():
        if not isinstance(record, dict):
            continue
        out.append({
            "name": name,
            "values": dict(record.get("values") or {}),
            "created_at": record.get("created_at"),
            "updated_at": record.get("updated_at"),
        })
    out.sort(key=lambda p: str(p["name"]).lower())
    return out


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/get_playground_settings",
        vol.Required("entry_id"): str,
        vol.Optional("include_suggestions", default=True): bool,
    }
)
@websocket_api.async_response
async def ws_get_playground_settings(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Return the device's LIVE effective Playground settings plus saved presets.

    ``effective`` is read back off the same live detector/matcher config the
    simulation uses, so the control panel always opens on what the integration is
    really running - never on a stale schema default. ``publishable`` lists the
    keys the panel may write back to the config entry.

    ``include_suggestions=False`` skips the auto-tuner/ML suggestion blocks. They exist
    only to label the two "Load suggested" buttons, and the ML one runs statistics across
    every clean cycle - real work, on the critical path of opening the tab. The panel
    therefore opens without them and fetches them in the background; the buttons already
    render only once their count is non-zero, so they simply appear when the data lands.
    Defaults to True so every other caller is unaffected.
    """
    entry_id: str = msg["entry_id"]
    ctx = _playground_context(hass, entry_id)
    if ctx is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    _manager, store, base_config, options, _price = ctx
    try:
        match_config = playground._matching_config(store)  # noqa: SLF001
    except Exception:  # pylint: disable=broad-exception-caught
        match_config = {}

    include_suggestions = bool(msg.get("include_suggestions", True))

    # Classic suggestions from the store (periodic analysis results), filtered to
    # keys the Playground actually exposes — cheap dict read, no executor needed.
    raw_sugg: dict[str, Any] = {}
    try:
        raw_sugg = store.get_suggestions() if include_suggestions else {}
        raw_sugg = raw_sugg or {}
    except Exception as exc:  # pylint: disable=broad-exception-caught
        _LOGGER.debug(
            "Could not read Playground classic suggestions for %s: %s", entry_id, exc
        )
    classic_sugg: dict[str, Any] = {}
    for key in playground.SETTING_KEYS:
        item = raw_sugg.get(key)
        if isinstance(item, dict) and item.get("value") is not None:
            val = item["value"]
            try:
                classic_sugg[key] = int(float(val)) if key in _SUGGESTION_INT_KEYS else round(float(val), 4)
            except (TypeError, ValueError):
                pass

    # ML suggestions — computed on-demand; executor-offloaded because it runs
    # statistics across all clean cycles. None when the feature is disabled.
    ml_sugg: dict[str, Any] | None = None
    if ENABLE_ML_SUGGESTIONS and include_suggestions:
        ml_sugg = {}
        try:
            learning = getattr(_manager, "learning_manager", None)
            classic_engine = getattr(learning, "suggestion_engine", None)
            if classic_engine is not None:
                _pg_setting_keys = playground.SETTING_KEYS
                _pg_int_keys = _SUGGESTION_INT_KEYS
                job_engine = classic_engine.for_job(options)

                def _run_ml_sugg() -> dict[str, Any]:
                    from .suggestion_engine import MLSuggestionEngine as _ML  # pylint: disable=import-outside-toplevel
                    raw = _ML(job_engine).generate_ml_suggestions() or {}
                    out: dict[str, Any] = {}
                    for k in _pg_setting_keys:
                        entry = raw.get(k)
                        if isinstance(entry, dict) and entry.get("value") is not None:
                            v = entry["value"]
                            try:
                                out[k] = int(float(v)) if k in _pg_int_keys else round(float(v), 4)
                            except (TypeError, ValueError):
                                pass
                    return out

                ml_sugg = await hass.async_add_executor_job(_run_ml_sugg)
        except Exception as exc:  # pylint: disable=broad-exception-caught
            _LOGGER.debug(
                "Could not generate Playground ML suggestions for %s: %s", entry_id, exc
            )

    _send_result(connection, msg["id"], "get_playground_settings", {
        "effective": playground.effective_settings(base_config, match_config),
        "presets": _playground_preset_list(store),
        "publishable": sorted(playground.PUBLISHABLE_SETTING_KEYS),
        "preset_limit": PLAYGROUND_PRESET_MAX,
        "classic_suggestions": classic_sugg,
        "ml_suggestions": ml_sugg,
        "ml_suggestions_enabled": ENABLE_ML_SUGGESTIONS,
    })


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/save_playground_preset",
        vol.Required("entry_id"): str,
        vol.Required("name"): str,
        vol.Required("values"): dict,
    }
)
@websocket_api.async_response
async def ws_save_playground_preset(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Save (or overwrite) a named snapshot of the Playground's settings."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    store = getattr(manager, "profile_store", None) if manager else None
    if store is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    values = playground.sanitize_setting_values(msg["values"])
    try:
        await store.async_save_playground_preset(msg["name"], values)
    except ValueError as exc:
        connection.send_error(msg["id"], "invalid_format", str(exc))
        return
    except Exception as exc:  # pylint: disable=broad-exception-caught
        # The store write can fail (disk full, permissions). Without this the
        # exception escapes the handler and the client never gets a reply, so the
        # panel's save button spins forever instead of reporting the failure.
        _LOGGER.warning("Saving playground preset failed for %s: %s", entry_id, exc)
        connection.send_error(msg["id"], "unknown_error", str(exc))
        return
    _send_result(connection, msg["id"], "save_playground_preset", {
        "success": True,
        "presets": _playground_preset_list(store),
    })


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/delete_playground_preset",
        vol.Required("entry_id"): str,
        vol.Required("name"): str,
    }
)
@websocket_api.async_response
async def ws_delete_playground_preset(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Delete a saved Playground settings preset."""
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    store = getattr(manager, "profile_store", None) if manager else None
    if store is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    try:
        removed = await store.async_delete_playground_preset(msg["name"])
    except Exception as exc:  # pylint: disable=broad-exception-caught
        _LOGGER.warning("Deleting playground preset failed for %s: %s", entry_id, exc)
        connection.send_error(msg["id"], "unknown_error", str(exc))
        return
    _send_result(connection, msg["id"], "delete_playground_preset", {
        "success": removed,
        "presets": _playground_preset_list(store),
    })


# ─── Background-task registry (progress / cancel / reconnect-safe results) ──────


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/list_tasks",
        vol.Optional("entry_id"): vol.Any(str, None),
    }
)
@callback
def ws_list_tasks(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Snapshot of active + recently-finished background tasks (for reconnect
    rehydration / a one-shot refresh). Optionally filtered to one device."""
    reg = task_registry.get_registry(hass)
    _send_result(connection, msg["id"], "list_tasks", {"tasks": reg.snapshot(msg.get("entry_id"))})


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/subscribe_tasks",
        vol.Optional("entry_id"): vol.Any(str, None),
    }
)
@callback
def ws_subscribe_tasks(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Live push of task progress. Sends the current snapshot as `task` events on
    subscribe, then one event per change until the client unsubscribes / the
    socket closes. The client dedupes by id and keeps the latest `updated_at`."""
    reg = task_registry.get_registry(hass)
    entry_id = msg.get("entry_id")
    iden = msg["id"]

    @callback
    def _forward(snap: dict[str, Any]) -> None:
        if entry_id and snap.get("entry_id") != entry_id:
            return
        connection.send_message(
            websocket_api.event_message(iden, {"type": "task", "task": snap})
        )

    connection.subscriptions[iden] = reg.add_listener(_forward)
    connection.send_result(iden)
    for snap in reg.snapshot(entry_id):
        connection.send_message(
            websocket_api.event_message(iden, {"type": "task", "task": snap})
        )


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/cancel_task",
        vol.Required("task_id"): str,
    }
)
@callback
def ws_cancel_task(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Request cancellation of a running task (consumers stop at the next chunk)."""
    reg = task_registry.get_registry(hass)
    _send_result(connection, msg["id"], "cancel_task", {"cancelled": reg.cancel(msg["task_id"])})


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/get_task_result",
        vol.Required("task_id"): str,
    }
)
@callback
def ws_get_task_result(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Fetch a finished task's stored result (reloadable after a tab switch /
    reconnect until the task is evicted)."""
    reg = task_registry.get_registry(hass)
    task = reg.get(msg["task_id"])
    if task is None:
        connection.send_error(msg["id"], "not_found", "Task not found")
        return
    _send_result(connection, msg["id"], "get_task_result", task.snapshot(include_result=True))


# -- Playground batch/sweep as detached, registry-tracked tasks -----------------
# The heavy replay runs in the executor CHUNK-BY-CHUNK (awaiting between chunks so
# the event loop breathes and the executor thread is freed), updating the task's
# progress and checking its cancel flag. Because the task is detached
# (async_create_task), it survives a dropped socket; the panel re-attaches via
# subscribe_tasks and reads the result with get_task_result.

_PG_HISTORY_CHUNK = 2


async def _pg_history_task(
    hass: HomeAssistant, task: Any, entry_id: str,
    cycle_ids: list[str], override: dict[str, Any] | None,
) -> None:
    reg = task_registry.get_registry(hass)
    ctx = _playground_context(hass, entry_id)
    if ctx is None:
        reg.finish(task, state=task_registry.STATE_ERROR, error="device unavailable")
        return
    _manager, store, base_config, options, price = ctx
    try:
        past = await hass.async_add_executor_job(lambda: list(store.get_past_cycles() or []))
        by_id = {c.get("id"): c for c in past if isinstance(c, dict)}
        if cycle_ids:
            ids = [c for c in cycle_ids if c in by_id]
        else:
            ids = [c.get("id") for c in past[-playground.DEFAULT_RECENT_CYCLES:]]
        ids = [i for i in ids[:playground.MAX_BATCH_CYCLES] if i]
        # Build match snapshots once — they are store-derived and identical for
        # every chunk; rebuilding per chunk is O(n_profiles) wasted work.
        prebuilt = await hass.async_add_executor_job(playground._build_match_snapshots, store)
        reg.update(task, total=len(ids))
        rows: list[dict[str, Any]] = []
        base_rows: list[dict[str, Any]] = []
        for i in range(0, len(ids), _PG_HISTORY_CHUNK):
            if task.cancel_requested:
                break
            chunk = ids[i:i + _PG_HISTORY_CHUNK]
            r = await hass.async_add_executor_job(
                playground.run_playground_history,
                store, chunk, base_config, override, options, price, len(chunk), prebuilt,
            )
            rows.extend(r.get("rows") or [])
            base_rows.extend(r.get("baseline_rows") or [])
            reg.update(task, done=min(len(ids), i + len(chunk)))
        payload = playground.finalize_history(rows, base_rows, bool(override))
        payload["partial"] = task.cancel_requested
        reg.finish(
            task,
            state=task_registry.STATE_CANCELLED if task.cancel_requested else task_registry.STATE_DONE,
            result=payload,
        )
    except asyncio.CancelledError:
        reg.finish(task, state=task_registry.STATE_CANCELLED)
        raise
    except Exception as exc:  # pylint: disable=broad-exception-caught
        _LOGGER.debug("Playground history task failed for %s: %s", entry_id, exc)
        reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))


async def _pg_sweep_task(
    hass: HomeAssistant, task: Any, entry_id: str,
    param: str, values: list[float], objective: str,
    param_y: str | None, values_y: list[float] | None,
) -> None:
    reg = task_registry.get_registry(hass)
    ctx = _playground_context(hass, entry_id)
    if ctx is None:
        reg.finish(task, state=task_registry.STATE_ERROR, error="device unavailable")
        return
    _manager, store, base_config, options, price = ctx
    try:
        past = await hass.async_add_executor_job(lambda: list(store.get_past_cycles() or []))
        ids = [c.get("id") for c in past[-playground.DEFAULT_RECENT_CYCLES:] if isinstance(c, dict)]
        ids = [i for i in ids[:playground.MAX_BATCH_CYCLES] if i]
        n = max(1, len(ids))
        # Build match snapshots once — identical for every sweep value/cell.
        prebuilt = await hass.async_add_executor_job(playground._build_match_snapshots, store)
        if param_y and values_y:
            reg.update(task, total=len(values) * len(values_y))
            grid: list[list[float | None]] = [[None] * len(values) for _ in values_y]
            current: dict[str, Any] = {}
            done = 0
            cancelled = False
            for j, vy in enumerate(values_y):
                for i, vx in enumerate(values):
                    if task.cancel_requested:
                        cancelled = True
                        break
                    r = await hass.async_add_executor_job(
                        playground.run_playground_sweep,
                        store, ids, base_config, param, [vx], objective,
                        options, price, n, param_y, [vy], prebuilt,
                    )
                    cell = (r.get("grid") or [[None]])[0]
                    grid[j][i] = cell[0] if cell else None
                    if r.get("current"):
                        current = r["current"]
                    done += 1
                    reg.update(task, done=done)
                if cancelled:
                    break
            payload = playground.finalize_sweep_2d(param, param_y, objective, values, values_y, grid, current)
        else:
            reg.update(task, total=len(values))
            points: list[dict[str, Any]] = []
            current_value: Any = None
            for i, vx in enumerate(values):
                if task.cancel_requested:
                    break
                r = await hass.async_add_executor_job(
                    playground.run_playground_sweep,
                    store, ids, base_config, param, [vx], objective, options, price, n,
                    None, None, prebuilt,
                )
                points.extend(r.get("points") or [])
                if r.get("current_value") is not None:
                    current_value = r["current_value"]
                reg.update(task, done=i + 1)
            payload = playground.finalize_sweep_1d(param, objective, points, current_value)
        payload["partial"] = task.cancel_requested
        reg.finish(
            task,
            state=task_registry.STATE_CANCELLED if task.cancel_requested else task_registry.STATE_DONE,
            result=payload,
        )
    except asyncio.CancelledError:
        reg.finish(task, state=task_registry.STATE_CANCELLED)
        raise
    except Exception as exc:  # pylint: disable=broad-exception-caught
        _LOGGER.debug("Playground sweep task failed for %s: %s", entry_id, exc)
        reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/start_playground_history",
        vol.Required("entry_id"): str,
        vol.Optional("cycle_ids", default=list): [str],
        vol.Optional("settings_override", default=dict): dict,
    }
)
@callback
def ws_start_playground_history(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Kick off a detached, registry-tracked Test-on-history replay; returns the
    task id immediately. Progress/result come via subscribe_tasks/get_task_result."""
    entry_id = msg["entry_id"]
    if _playground_context(hass, entry_id) is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    reg = task_registry.get_registry(hass)
    task = reg.create(entry_id, "pg_history", "Test on history")
    override = dict(msg.get("settings_override") or {}) or None
    cycle_ids = list(msg.get("cycle_ids") or [])
    _raw = hass.async_create_task(_pg_history_task(hass, task, entry_id, cycle_ids, override))
    if _raw is not None:
        reg.link_asyncio_task(task.id, _raw)
    _send_result(connection, msg["id"], "start_playground_history", {"task_id": task.id})


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/start_playground_sweep",
        vol.Required("entry_id"): str,
        vol.Required("param"): str,
        vol.Required("values"): [vol.Coerce(float)],
        vol.Required("objective"): str,
        vol.Optional("param_y"): vol.Any(str, None),
        vol.Optional("values_y"): vol.Any([vol.Coerce(float)], None),
    }
)
@callback
def ws_start_playground_sweep(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Kick off a detached, registry-tracked Optimize sweep; returns the task id."""
    entry_id = msg["entry_id"]
    if _playground_context(hass, entry_id) is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    param_y = msg.get("param_y")
    values_y = msg.get("values_y")
    if bool(param_y) != bool(values_y):
        connection.send_error(msg["id"], "invalid_format", "param_y and values_y must be set together")
        return
    reg = task_registry.get_registry(hass)
    task = reg.create(
        entry_id, "pg_sweep", f"Optimize: {msg['param']}",
        label_key="task.pg_sweep.optimize", label_params={"param": msg["param"]},
    )
    _raw = hass.async_create_task(_pg_sweep_task(
        hass, task, entry_id, msg["param"], list(msg.get("values") or []),
        msg["objective"], param_y, list(values_y) if values_y else None,
    ))
    if _raw is not None:
        reg.link_asyncio_task(task.id, _raw)
    _send_result(connection, msg["id"], "start_playground_sweep", {"task_id": task.id})


# Readings replayed per executor job for the single-cycle detail sim. The event
# loop breathes between chunks; a ~233min/5s dishwasher cycle (~2800 readings)
# becomes ~11 short jobs instead of one multi-minute GIL-holding call (issue #311).
_PG_DETAIL_CHUNK = 250


async def _pg_detail_task(
    hass: HomeAssistant, task: Any, entry_id: str,
    cycle_id: str, override: dict[str, Any] | None,
    stress_tail: bool = False,
    stress_idle_w: float | None = None,
) -> None:
    reg = task_registry.get_registry(hass)
    ctx = _playground_context(hass, entry_id)
    if ctx is None:
        reg.finish(task, state=task_registry.STATE_ERROR, error="device unavailable")
        return
    _manager, store, base_config, options, price = ctx
    try:
        sim = await hass.async_add_executor_job(
            playground.build_cycle_detail_sim_by_id,
            store, cycle_id, base_config, override, options, price,
            stress_tail, stress_idle_w,
        )
        if isinstance(sim, dict):  # {"error": ...} marker (not_found / build failure)
            if sim.get("error") == "not_found":
                reg.finish(task, state=task_registry.STATE_ERROR, error="not_found")
            else:
                reg.finish(task, state=task_registry.STATE_ERROR, error=str(sim.get("error")))
            return
        if not sim.ready:
            reg.finish(task, state=task_registry.STATE_DONE, result=sim.empty_payload())
            return
        total = sim.n_readings
        reg.update(task, total=total)
        for i in range(0, total, _PG_DETAIL_CHUNK):
            if task.cancel_requested:
                break
            await hass.async_add_executor_job(sim.step, i, i + _PG_DETAIL_CHUNK)
            reg.update(task, done=min(total, i + _PG_DETAIL_CHUNK))
        if not task.cancel_requested:
            if sim.stress_tail:
                await hass.async_add_executor_job(sim.run_stress_tail)
            else:
                await hass.async_add_executor_job(sim.run_tail)
        payload = await hass.async_add_executor_job(sim.finalize)
        payload["partial"] = task.cancel_requested
        reg.finish(
            task,
            state=task_registry.STATE_CANCELLED if task.cancel_requested else task_registry.STATE_DONE,
            result=payload,
        )
    except asyncio.CancelledError:
        reg.finish(task, state=task_registry.STATE_CANCELLED)
        raise
    except Exception as exc:  # pylint: disable=broad-exception-caught
        _LOGGER.debug("Playground detail task failed for %s: %s", entry_id, exc)
        reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/start_playground_cycle_detail",
        vol.Required("entry_id"): str,
        vol.Required("cycle_id"): str,
        vol.Optional("settings_override", default=dict): dict,
        vol.Optional("stress_tail", default=False): bool,
        vol.Optional("stress_idle_w", default=None): vol.Any(vol.Coerce(float), None),
    }
)
@callback
def ws_start_playground_cycle_detail(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Kick off a detached, registry-tracked single-cycle "Simulate" replay;
    returns the task id immediately. The heavy per-5s replay runs chunk-by-chunk
    in the executor so a long cycle no longer stalls Home Assistant (issue #311).
    Progress/result come via subscribe_tasks/get_task_result."""
    entry_id = msg["entry_id"]
    if _playground_context(hass, entry_id) is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    reg = task_registry.get_registry(hass)
    task = reg.create(
        entry_id, "pg_detail", "Simulate cycle",
        label_key="task.pg_detail.simulate", label_params={},
    )
    override = dict(msg.get("settings_override") or {})
    stress_tail = bool(msg.get("stress_tail", False))
    stress_idle_w = msg.get("stress_idle_w")
    _raw = hass.async_create_task(
        _pg_detail_task(hass, task, entry_id, msg["cycle_id"], override, stress_tail, stress_idle_w)
    )
    if _raw is not None:
        reg.link_asyncio_task(task.id, _raw)
    _send_result(connection, msg["id"], "start_playground_cycle_detail", {"task_id": task.id})


# ─── Historical power-data import (issue #344) ─────────────────────────────────
#
# Four steps, because the data is large and the answer is the user's to approve:
#
#   1. ingest   - `history_import_begin` + `history_import_chunk` stage CSV text, or
#                 `history_import_recorder` fills the same buffer from the recorder.
#                 Chunked because Home Assistant builds its WebSocket with aiohttp's
#                 default 4 MiB frame cap, and ten days of 5-second data is 5-8 MB of
#                 text; an over-cap frame is not rejected, it closes the connection.
#   2. scan     - `start_history_import_scan` replays the stream through fresh detectors
#                 as a detached registry task, chunk by chunk (see history_import.py for
#                 why a raw stream cannot be fed to one detector).
#   3. review   - the panel shows one row per candidate; the whole traces never cross
#                 the wire (they would blow the same frame cap).
#   4. apply    - `apply_history_import` persists the accepted rows into
#                 `backfill_cycles`.
#
# Parsing lives in Python, not in the panel, so one implementation is under test.

_HISTORY_IMPORT_KEY = f"{DOMAIN}_history_import"


def _history_staging(hass: HomeAssistant) -> dict[str, dict[str, Any]]:
    """Per-entry staging area for an in-flight import, keyed by entry_id.

    One slot per entry: a new upload replaces the previous one, so an abandoned 8 MB
    paste cannot accumulate. Cleared by :func:`async_clear_history_import` on unload.
    """
    return hass.data.setdefault(_HISTORY_IMPORT_KEY, {})


def async_clear_history_import(hass: HomeAssistant, entry_id: str) -> None:
    """Drop any staged upload and scan result for an entry (called on unload)."""
    _history_staging(hass).pop(entry_id, None)


def _history_slot(hass: HomeAssistant, entry_id: str, token: str) -> dict[str, Any] | None:
    slot = _history_staging(hass).get(entry_id)
    if not isinstance(slot, dict) or slot.get("token") != token:
        return None
    return slot


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/history_import_begin",
        vol.Required("entry_id"): str,
    }
)
@callback
def ws_history_import_begin(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Open a staging slot for a CSV upload and return the token chunks must carry."""
    entry_id: str = msg["entry_id"]
    if _get_manager(hass, entry_id) is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    token = uuid.uuid4().hex
    _history_staging(hass)[entry_id] = {
        "token": token,
        "chunks": [],
        "bytes": 0,
        "next_seq": 0,
        "source": "csv",
    }
    _send_result(connection, msg["id"], "history_import_begin", {
        "token": token,
        "max_bytes": HISTORY_IMPORT_MAX_BYTES,
        "chunk_bytes": HISTORY_IMPORT_CHUNK_BYTES,
    })


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/history_import_chunk",
        vol.Required("entry_id"): str,
        vol.Required("token"): str,
        vol.Required("seq"): int,
        vol.Required("text"): str,
    }
)
@callback
def ws_history_import_chunk(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Append one chunk of CSV text to the staging slot.

    Sequence-checked: a dropped or re-ordered chunk would splice the file silently, so a
    mismatch is an error the panel can restart from rather than a corrupt import.
    """
    entry_id: str = msg["entry_id"]
    slot = _history_slot(hass, entry_id, msg["token"])
    if slot is None:
        connection.send_error(msg["id"], "not_found", "No upload in progress; start again")
        return
    if int(msg["seq"]) != slot["next_seq"]:
        connection.send_error(
            msg["id"], "invalid_format",
            f"Out-of-order chunk {msg['seq']}, expected {slot['next_seq']}",
        )
        return
    text: str = msg["text"]
    size = len(text.encode("utf-8", "ignore"))
    if slot["bytes"] + size > HISTORY_IMPORT_MAX_BYTES:
        _history_staging(hass).pop(entry_id, None)
        connection.send_error(msg["id"], "invalid_format", "Upload is too large")
        return
    slot["chunks"].append(text)
    slot["bytes"] += size
    slot["next_seq"] += 1
    _send_result(connection, msg["id"], "history_import_chunk", {
        "received_bytes": slot["bytes"],
        "next_seq": slot["next_seq"],
    })


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/history_import_recorder",
        vol.Required("entry_id"): str,
        # Either a start date (what the panel sends: "import since <date>") or a plain
        # day count. `start_date` wins when both are present.
        vol.Optional("start_date"): vol.Any(None, str),
        vol.Optional("days", default=10): vol.All(
            vol.Coerce(int), vol.Range(min=1, max=HISTORY_IMPORT_RECORDER_MAX_DAYS)
        ),
    }
)
@websocket_api.async_response
async def ws_history_import_recorder(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Fill the staging slot from Home Assistant's own recorder.

    The entity is always the device's configured power sensor, never client-supplied:
    this command reads arbitrary history, and letting the caller name the entity would
    make it an information-disclosure hole.

    Read one day at a time. `state_changes_during_period` has no row cap, and a single
    query over a long window materialises the whole result in one recorder-executor job.
    Home Assistant purges states after `purge_keep_days` (10 by default), so a request
    reaching further back simply returns fewer rows - that is reported, not an error.
    """
    entry_id: str = msg["entry_id"]
    manager = _get_manager(hass, entry_id)
    if manager is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    entity_id = getattr(manager, "power_sensor_entity_id", None)
    if not entity_id:
        connection.send_error(msg["id"], "not_found", "This device has no power sensor configured")
        return

    now = dt_util.now()
    days = int(msg.get("days") or 10)
    start_date = msg.get("start_date")
    if start_date:
        # "Import since <date>" - resolve to a day count so the windowed read below is
        # unchanged. The date is read as a LOCAL calendar day (it comes from a date
        # picker, where the user means their own midnight), and clamped to the supported
        # range: a future date reads today, an older one is capped at the maximum.
        try:
            parsed_date = dt_util.parse_date(str(start_date))
        except Exception:  # pylint: disable=broad-exception-caught
            parsed_date = None
        if parsed_date is None:
            connection.send_error(msg["id"], "invalid_format", "Not a valid start date")
            return
        span = (now.date() - parsed_date).days + 1
        days = max(1, min(HISTORY_IMPORT_RECORDER_MAX_DAYS, span))
    rows: list[tuple[float, float]] = []
    # Walk BACKWARDS from today, not forwards from the oldest requested day: the window can
    # now be years (a recorder configured to keep full-resolution states that long), and
    # starting at the far end would issue thousands of empty queries before reaching any
    # data - and, on truncation, would keep the OLDEST rows rather than the most recent.
    # `samples_from_readings` sorts, so the accumulation order does not matter downstream.
    empty_run = 0
    # The loop can stop early (empty-day run, or the row cap), so the requested `days` is
    # not what was read. Track the oldest window actually queried and report THAT, or the
    # panel would name a range it never looked at.
    oldest_queried = now
    queried_days = 0
    for day in range(1, days + 1):
        window_start = now - timedelta(days=day)
        window_end = now - timedelta(days=day - 1)
        day_rows = await _recorder_power(hass, entity_id, window_start, end_dt=window_end)
        oldest_queried = window_start
        queried_days += 1
        if day_rows:
            empty_run = 0
            rows.extend(day_rows)
        else:
            # Inside the retention window a day always yields at least the carried
            # start-time state, so a run of empty days means the recorder is purged past
            # here and every further query would be wasted.
            empty_run += 1
            if empty_run >= HISTORY_IMPORT_RECORDER_EMPTY_DAY_STOP:
                break
        if len(rows) > HISTORY_IMPORT_MAX_ROWS:
            break

    token = uuid.uuid4().hex
    _history_staging(hass)[entry_id] = {
        "token": token,
        "chunks": [],
        "bytes": 0,
        "next_seq": 0,
        "source": "recorder",
        "rows": rows[:HISTORY_IMPORT_MAX_ROWS],
        "entity_id": entity_id,
    }
    _send_result(connection, msg["id"], "history_import_recorder", {
        "token": token,
        "rows": len(rows[:HISTORY_IMPORT_MAX_ROWS]),
        "entity_id": entity_id,
        # Both describe the window actually read, not the one asked for: counted from
        # the loop itself, so an early stop cannot report days that were never queried.
        "days": queried_days,
        "start_date": oldest_queried.date().isoformat(),
        "truncated": len(rows) > HISTORY_IMPORT_MAX_ROWS,
    })


def _history_samples(slot: dict[str, Any], entity_id: str | None) -> Any:
    """Turn a staging slot into samples, whichever way it was filled.

    Executor-side: CSV parsing is pure Python over megabytes of text. Returns either a
    ``(samples, report)`` pair or an ``{"error": ...}`` marker.
    """
    if slot.get("source") == "recorder":
        samples = history_import.samples_from_readings(slot.get("rows") or [])
        if len(samples) < 2:
            return {"error": "no_readings"}
        return samples, {
            "rows_total": len(samples),
            "rows_parsed": len(samples),
            "entity_id": slot.get("entity_id"),
            "source": "recorder",
        }
    parsed = history_import.parse_history_csv(
        "".join(slot.get("chunks") or []), entity_id=entity_id
    )
    if isinstance(parsed, dict):
        return parsed
    report = parsed.report()
    report["source"] = "csv"
    return parsed.samples, report


async def _history_import_scan_task(
    hass: HomeAssistant, task: Any, entry_id: str, token: str
) -> None:
    """Replay a staged history stream, chunk by chunk, into candidate cycles.

    The full traces are held in the staging slot rather than in the task result: the
    result is served verbatim by `get_task_result`, and a few hundred traces would exceed
    the WebSocket frame cap and take the connection down. The result carries only preview
    rows, which is also all the review UI needs.
    """
    reg = task_registry.get_registry(hass)
    ctx = _playground_context(hass, entry_id)
    if ctx is None:
        reg.finish(task, state=task_registry.STATE_ERROR, error="device unavailable")
        return
    manager, _store, base_config, options, _price = ctx
    slot = _history_slot(hass, entry_id, token)
    if slot is None:
        reg.finish(task, state=task_registry.STATE_ERROR, error="upload expired")
        return
    # Segmentation is only as good as the thresholds it runs with, and
    # `_playground_base_config`'s fallback uses the *scalar* defaults (min_off_gap 60,
    # off_delay 180) rather than the per-device ones - which on a dishwasher would cut
    # the stream at every drying pause and produce nothing but fragments. Refuse rather
    # than scan against the wrong thresholds.
    if not isinstance(getattr(getattr(manager, "detector", None), "config", None), CycleDetectorConfig):
        reg.finish(task, state=task_registry.STATE_ERROR, error="detector_unavailable")
        return
    try:
        entity_id = getattr(manager, "power_sensor_entity_id", None)
        parsed = await hass.async_add_executor_job(_history_samples, slot, entity_id)
        if isinstance(parsed, dict):
            reg.finish(task, state=task_registry.STATE_ERROR, error=str(parsed.get("error")))
            return
        samples, report = parsed
        sampling_interval = options.get(CONF_SAMPLING_INTERVAL)
        runner = await hass.async_add_executor_job(
            functools.partial(
                history_import.build_scan,
                samples,
                base_config,
                sampling_interval_s=sampling_interval,
                parse_report=report,
            )
        )
        if isinstance(runner, dict):
            # A stream with nothing usable in it is a *result*, not a failure: the panel
            # explains which spans were skipped and why (six months of hourly averages
            # is the common case), so the user is not left staring at "0 cycles".
            reg.finish(task, state=task_registry.STATE_DONE, result={
                "segments": [],
                "skipped": runner.get("skipped") or [],
                "parse": runner.get("parse") or report,
                "found": 0,
                "error": runner.get("error"),
            })
            return
        reg.update(task, total=runner.total)
        while not runner.finished:
            if task.cancel_requested:
                break
            await hass.async_add_executor_job(runner.step, HISTORY_IMPORT_CHUNK_SAMPLES)
            reg.update(task, done=min(runner.total, runner.done))
        payload = await hass.async_add_executor_job(
            functools.partial(runner.finalize, partial=task.cancel_requested)
        )
        # Split the payload: traces stay server-side, keyed by this task so a reconnect
        # can still apply them; only the preview rows travel.
        cycles = payload.pop("cycles", [])
        current = _history_slot(hass, entry_id, token)
        if current is not None:
            current["scan_task_id"] = task.id
            current["cycles"] = cycles
            current.pop("chunks", None)  # the raw text is no longer needed
            current.pop("rows", None)
        payload["token"] = token
        payload["settings"] = {
            "min_power": base_config.min_power,
            "off_delay": base_config.off_delay,
            "min_off_gap": base_config.min_off_gap,
            "device_type": base_config.device_type,
        }
        reg.finish(
            task,
            state=task_registry.STATE_CANCELLED if task.cancel_requested else task_registry.STATE_DONE,
            result=payload,
        )
    except asyncio.CancelledError:
        reg.finish(task, state=task_registry.STATE_CANCELLED)
        raise
    except Exception as exc:  # pylint: disable=broad-exception-caught
        # Task-level failure at WARNING like the other task runners, so it lands in the
        # default HA log and the panel Logs view (sub-step failures stay at debug).
        _LOGGER.warning("History-import scan failed for %s: %s", entry_id, exc)
        reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/start_history_import_scan",
        vol.Required("entry_id"): str,
        vol.Required("token"): str,
    }
)
@callback
def ws_start_history_import_scan(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Kick off the replay as a detached, registry-tracked task."""
    entry_id: str = msg["entry_id"]
    if _get_manager(hass, entry_id) is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    if _history_slot(hass, entry_id, msg["token"]) is None:
        connection.send_error(msg["id"], "not_found", "No upload in progress; start again")
        return
    reg = task_registry.get_registry(hass)
    task = reg.create(
        entry_id, "history_import", "Scanning power history",
        label_key="task.history_import.scanning",
    )
    _raw = hass.async_create_task(
        _history_import_scan_task(hass, task, entry_id, msg["token"])
    )
    if _raw is not None:
        reg.link_asyncio_task(task.id, _raw)
    _send_result(connection, msg["id"], "start_history_import_scan", {"task_id": task.id})


async def _history_import_apply_task(
    hass: HomeAssistant, task: Any, entry_id: str, scan_task_id: str, accept: list[int]
) -> None:
    """Persist the accepted candidates into ``backfill_cycles``."""
    reg = task_registry.get_registry(hass)
    manager = _get_manager(hass, entry_id)
    if manager is None:
        reg.finish(task, state=task_registry.STATE_ERROR, error="device unavailable")
        return
    scan = reg.get(scan_task_id)
    # Ownership check: get_task_result is not entry-scoped, so without this a stale or
    # foreign task id could write another device's cycles into this store.
    if scan is None or scan.entry_id != entry_id or scan.kind != "history_import":
        reg.finish(task, state=task_registry.STATE_ERROR, error="scan_expired")
        return
    try:
        async with _entry_write_lock(hass, entry_id):
            if _get_manager(hass, entry_id) is not manager:
                reg.finish(task, state=task_registry.STATE_ERROR, error="device reloaded")
                return
            # Validate AND read the staging slot under the lock, not before it: two
            # concurrent applies must not both pass a pre-lock check and each persist the
            # same candidate (one whose dedup_key is None bypasses existing_dedup_keys),
            # and the slot must not be swapped between the check and the read.
            slot = _history_staging(hass).get(entry_id)
            if not isinstance(slot, dict) or slot.get("scan_task_id") != scan_task_id:
                # The registry keeps only the last 30 finished tasks per entry, and an
                # entry reload clears the staging area, so a scan can legitimately be
                # gone by now.
                reg.finish(task, state=task_registry.STATE_ERROR, error="scan_expired")
                return
            cycles: list[dict[str, Any]] = list(slot.get("cycles") or [])
            # Dedupe the client-supplied indices (order-preserving): a repeated index
            # would visit the same candidate twice, and one whose dedup_key is None is
            # not caught by the in-loop dedup set, so it would store a second copy.
            wanted = list(dict.fromkeys(i for i in accept if 0 <= i < len(cycles)))
            if not wanted:
                reg.finish(
                    task, state=task_registry.STATE_DONE,
                    result={"imported": 0, "duplicates": 0},
                )
                return
            store = manager.profile_store
            target = store.get_backfill_cycles()
            room = max(0, HISTORY_IMPORT_MAX_TOTAL_CYCLES - len(target))
            existing = history_import.existing_dedup_keys(store.iter_stored_cycles())
            id_pool = {c.get("id") for c in target if isinstance(c, dict)}
            imported = 0
            duplicates = 0
            reg.update(task, total=len(wanted))
            for done, index in enumerate(wanted, start=1):
                if task.cancel_requested:
                    break
                raw = cycles[index]
                key = history_import.dedup_key(raw.get("start_time"), raw.get("duration"))
                if key is not None and key in existing:
                    duplicates += 1
                    reg.update(task, done=done)
                    continue
                if imported >= room:
                    break
                store._add_cycle_data(  # noqa: SLF001 - the bulk insert primitive
                    history_import.build_backfill_cycle(raw),
                    target=target,
                    id_pool=id_pool,
                )
                if key is not None:
                    existing.add(key)
                imported += 1
                reg.update(task, done=done)
            if imported:
                await store.async_save()
            # "capped" means the cap decided where we stopped - not the user cancelling,
            # and not the duplicates that were legitimately skipped.
            capped = not task.cancel_requested and imported < (len(wanted) - duplicates)
            # Read the total inside the lock: another task could append to the same
            # backfill list after the lock releases, inflating a count read outside it.
            total_backfill = len(target)
            # Consume the slot we applied - but only if it is still that same object.
            # A fresh upload (which does not take this lock) can replace the slot during
            # the async_save() await above; clearing unconditionally would discard it.
            if _history_staging(hass).get(entry_id) is slot:
                async_clear_history_import(hass, entry_id)
        manager.notify_update()
        reg.finish(
            task,
            state=task_registry.STATE_CANCELLED if task.cancel_requested else task_registry.STATE_DONE,
            result={
                "imported": imported,
                "duplicates": duplicates,
                "capped": capped,
                "total_backfill": total_backfill,
            },
        )
    except asyncio.CancelledError:
        reg.finish(task, state=task_registry.STATE_CANCELLED)
        raise
    except Exception as exc:  # pylint: disable=broad-exception-caught
        # A failed apply is a failed data write; log at WARNING like the other task
        # runners so it is visible in the default HA log and the panel Logs view.
        _LOGGER.warning("History-import apply failed for %s: %s", entry_id, exc)
        reg.finish(task, state=task_registry.STATE_ERROR, error=str(exc))


@websocket_api.websocket_command(
    {
        vol.Required("type"): "ha_washdata/apply_history_import",
        vol.Required("entry_id"): str,
        vol.Required("scan_task_id"): str,
        vol.Required("accept"): list,
    }
)
@callback
def ws_apply_history_import(
    hass: HomeAssistant,
    connection: websocket_api.ActiveConnection,
    msg: dict[str, Any],
) -> None:
    """Persist the candidates the user kept, as a detached, registry-tracked task."""
    entry_id: str = msg["entry_id"]
    if _get_manager(hass, entry_id) is None:
        _err_not_found(connection, msg["id"], entry_id)
        return
    accept: list[int] = []
    for item in msg["accept"][:HISTORY_IMPORT_MAX_SEGMENTS]:
        try:
            accept.append(int(item))
        except (TypeError, ValueError):
            continue
    reg = task_registry.get_registry(hass)
    task = reg.create(
        entry_id, "history_import_apply", "Importing cycles",
        label_key="task.history_import.importing",
    )
    _raw = hass.async_create_task(
        _history_import_apply_task(hass, task, entry_id, msg["scan_task_id"], accept)
    )
    if _raw is not None:
        reg.link_asyncio_task(task.id, _raw)
    _send_result(connection, msg["id"], "apply_history_import", {"task_id": task.id})
