# 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/>.
"""Profile storage and matching logic for WashData."""

from __future__ import annotations

import contextlib
import dataclasses
import hashlib
import json
import logging
import math
import os
import re
import threading
import uuid
from datetime import datetime, timedelta
from typing import Any, TypeAlias, cast

import numpy as np

from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
from homeassistant.util import dt as dt_util

from .const import (
    CLUSTER_RESAMPLE_N,
    CLUSTER_SHAPE_SIMILARITY_THRESHOLD,
    GROUP_MIN_COHESION,
    MAINTENANCE_EVENT_TYPES,
    MAINTENANCE_RECENT_SUPPRESS_DAYS,
    MATCH_AMBIGUITY_MARGIN,
    PHASE_CONSISTENCY_MIN_CYCLES,
    PHASE_PROFILE_MIN_CYCLES,
    PHASE_HEAT_CV_WARN,
    PHASE_HEAT_OCC_MIXED_LO,
    PHASE_HEAT_OCC_MIXED_HI,
    PLAYGROUND_PRESET_MAX,
    PLAYGROUND_PRESET_NAME_MAX,
    REFERENCE_PROFILE_CURVE_POINTS,
    SHAPE_DRIFT_MIN_CYCLES,
    SHAPE_DRIFT_RESAMPLE_N,
    SHAPE_DRIFT_THRESHOLD,
    SHAREABLE_SETTING_KEYS,
    SMART_TERM_LANDSCAPE_RATIO,
    SMART_TERM_LANDSCAPE_MIN_SHAPE,
    SMART_TERM_PREFIX_MARGIN,
    SMART_TERM_PREFIX_MIN_RATIO,
    SMART_TERM_PREFIX_MIN_SHAPE,
    SMART_TERM_TAIL_WINDOW_FRAC,
    STORAGE_KEY,
    STORAGE_VERSION,
    DEFAULT_MAX_PAST_CYCLES,
    DEFAULT_MAX_FULL_TRACES_PER_PROFILE,
    DEFAULT_MAX_FULL_TRACES_UNLABELED,
    EVIDENCE_BACKFILL_CYCLES,
    EVIDENCE_REAL_CYCLES,
    EVIDENCE_REFERENCE_CYCLES,
    PROFILE_EVIDENCE_SOURCES,
    DEFAULT_DTW_BANDWIDTH,
)
from .features import compute_signature
from .signal_processing import resample_uniform, resample_adaptive, Segment, integrate_wh, energy_gap_threshold_s
from . import analysis
from .time_utils import (
    migrate_power_data_to_offsets,
    power_data_to_offsets,
)
from .phase_catalog import (
    DEFAULT_PHASES_BY_DEVICE,
    _builtin_phase_id,
    get_builtin_phase_by_id,
    merge_phase_catalog,
    normalize_phase_name,
)
from .phase_segmenter import phase_matching_live_supported, phase_model_for, segment_cycle
from .phase_match import (
    build_phase_profile,
    match_phase_profiles,
    phase_eta,
    phase_profile_from_dict,
    phase_profile_to_dict,
)
from .log_utils import DeviceLoggerAdapter

_LOGGER = logging.getLogger(__name__)

# Absolute peak-power floor below which a cycle trace is considered a
# mis-capture (e.g. the power sensor wasn't reporting), regardless of profile.
# Used together with a relative (10% of median peak) test in envelope rebuild
# and reference selection so degenerate cycles never become the matching template.
_DEGENERATE_POWER_FLOOR = 15.0  # watts

# How far outside the requested trim window a stored sample may still be snapped
# to. Trim boundaries arrive quantized to whole seconds (panel number input, the
# trim_cycle service), so one second is exactly the input's own resolution -- it
# absorbs that rounding without letting the kept window grow measurably.
_TRIM_SNAP_TOLERANCE_S = 1.0

JSONDict: TypeAlias = dict[str, Any]
CycleDict: TypeAlias = dict[str, Any]

# Label sources that mean "the matcher guessed this", as opposed to a user confirming it.
# Consulted before overwriting a label, so the original guess is preserved exactly once.
# `auto_label_backfill` is the #344 import's own marker: an auto-labelled backfilled cycle
# feeds its profile's envelope immediately, so a wrong guess shifts what later cycles match
# against and has to stay distinguishable from a confirmed label.
_AUTO_LABEL_SOURCES = (
    "auto_match", "auto_label_post", "auto_label_service", "auto_label_backfill",
)


def _is_recorded_cycle(cycle: dict[str, Any]) -> bool:
    """True when a cycle was produced by the manual recorder.

    Two independent signals, because the explicit marker did not always exist:

    1. **Explicit meta marker** (recent recorder builds): ``meta.source ==
       "recorder"`` and, equivalently, ``meta.original_samples`` (the pre-trim
       sample count). (In exported diagnostics ``meta.source`` is redacted, but
       the real stored value is ``"recorder"`` — the migration runs against the
       real store, not the export.)

    2. **Structural signature** (old recordings, ``meta`` is ``None``): the
       recorder builds the cycle dict directly (``ws_process_recording``) and
       never sets ``max_power`` or ``termination_reason``, whereas *every*
       auto-detected cycle goes through ``CycleDetector._finish_cycle`` which
       stamps ``max_power`` unconditionally (in every version — ``termination_reason``
       was added later, so old *auto* cycles can lack it but never lack
       ``max_power``). So a **completed** cycle missing *both* fields is a
       recording that predates the meta marker. Verified against real exports:
       this recovers old recordings that carry only ``meta: None`` — which the
       marker-only check silently missed.
    """
    meta = cycle.get("meta")
    if isinstance(meta, dict) and (
        meta.get("source") == "recorder" or "original_samples" in meta
    ):
        return True
    return (
        cycle.get("status") == "completed"
        and "max_power" not in cycle
        and "termination_reason" not in cycle
    )


def _flag_recorded_cycles_golden(cycles: list[dict[str, Any]]) -> int:
    """Mark manually-recorded cycles as golden references (recorded == golden).

    A recorded cycle (see :func:`_is_recorded_cycle`) is, by definition, a
    hand-picked clean example, so it becomes the profile's golden reference. The
    flag lives in the single ``ml_review`` dict (no duplicate ``recorded`` field).
    Idempotent: already-golden cycles are skipped, so it is safe to re-run from the
    migration and the diagnostics processing trigger.

    Returns the number of cycles newly flagged.
    """
    flagged = 0
    for cycle in cycles:
        if not isinstance(cycle, dict):
            continue
        review = cycle.get("ml_review")
        if isinstance(review, dict) and review.get("golden"):
            continue
        if not _is_recorded_cycle(cycle):
            continue
        review = dict(review) if isinstance(review, dict) else {}
        review["golden"] = True
        if not review.get("quality"):
            review["quality"] = "good"  # recorded cycles are clean by definition
        if not review.get("reviewed_at"):
            review["reviewed_at"] = dt_util.now().isoformat()
        cycle["ml_review"] = review
        flagged += 1
    return flagged


def _empty_ranking() -> list[dict[str, Any]]:
    """Typed default factory for ranking entries."""
    return []


def _parse_start_dt(value: Any) -> datetime | None:
    """Parse a start_time value (ISO string or numeric timestamp) into a datetime.

    Handles the case where legacy cycles stored numeric unix timestamps instead
    of ISO-formatted strings, which dt_util.parse_datetime cannot handle.
    """
    if isinstance(value, datetime):
        return value
    if isinstance(value, (int, float)) and not isinstance(value, bool):
        try:
            return datetime.fromtimestamp(float(value), tz=dt_util.UTC)
        except (OSError, OverflowError, ValueError):
            return None
    if isinstance(value, str) and value:
        parsed = dt_util.parse_datetime(value)
        if parsed is not None:
            return parsed
        try:
            return datetime.fromtimestamp(float(value), tz=dt_util.UTC)
        except (TypeError, ValueError):
            return None
    return None


def _parse_maintenance_dt(value: Any) -> datetime | None:
    """Parse a maintenance-log date (ISO date or datetime) into an aware datetime.

    Accepts both full ISO datetimes (as written by ``dt_util.now().isoformat()``)
    and bare ISO dates (``"2026-07-01"``) that the user may enter. Naive results are
    localised so recency/comparison math stays timezone-consistent. Never raises.
    """
    dt = _parse_start_dt(value)
    if dt is None and isinstance(value, str) and value:
        try:
            dt = datetime.fromisoformat(value)
        except ValueError:
            dt = None
    if dt is not None and dt.tzinfo is None:
        dt = dt.replace(tzinfo=dt_util.DEFAULT_TIME_ZONE or dt_util.UTC)
    return dt


def _empty_debug_details() -> dict[str, Any]:
    """Typed default factory for debug details."""
    return {}


def _value_to_timestamp(value: Any) -> float | None:
    """Parse supported datetime-like values into unix seconds."""
    if isinstance(value, datetime):
        return value.timestamp()
    if isinstance(value, (int, float)):
        return float(value)
    if isinstance(value, str) and value:
        parsed = dt_util.parse_datetime(value)
        if parsed is not None:
            return parsed.timestamp()
        try:
            return float(value)
        except (TypeError, ValueError):
            return None
    return None


def profile_sort_key(name: str) -> tuple[int, int, str]:
    """Sort key for profile names: numeric-prefixed first (by number), then alphabetically."""
    match = re.match(r'^(\d+)', name)
    if match:
        return (0, int(match.group(1)), name)
    return (1, 0, name)




def trim_zero_power_data(
    data: list[list[float]],
    threshold: float = 0.5
) -> list[list[float]]:
    """Trim leading/trailing zero/near-zero power readings from stored data.

    Args:
        data: List of [offset, power] pairs
        threshold: Power values <= this are considered "zero"

    Returns:
        Trimmed list with leading/trailing zeros removed
    """
    if not data:
        return data

    # Find first non-zero reading
    start_idx = 0
    for i, point in enumerate(data):
        if point[1] > threshold:
            start_idx = i
            break
    else:
        # All readings are zero - keep at least one
        return data[:1] if data else []

    # Find last non-zero reading
    end_idx = len(data) - 1
    for i in range(len(data) - 1, -1, -1):
        if data[i][1] > threshold:
            end_idx = i
            break

    # Return trimmed slice (inclusive of end)
    return data[start_idx:end_idx + 1]


def filter_duration_outliers(durations: list[float]) -> list[float]:
    """Return a robust duration set with extreme outliers removed.

    Uses Tukey IQR fences for normal spread and falls back to a MAD-based
    filter when IQR collapses (common when most cycles are identical).
    """
    if len(durations) < 4:
        return durations

    arr = np.array(durations, dtype=float)

    q1 = float(np.percentile(arr, 25))
    q3 = float(np.percentile(arr, 75))
    iqr = q3 - q1

    if iqr > 0:
        lower = max(60.0, q1 - 1.5 * iqr)
        upper = q3 + 1.5 * iqr
        filtered = arr[(arr >= lower) & (arr <= upper)]
    else:
        # Degenerate spread (e.g. many identical durations); keep values
        # close to median and drop only extreme anomalies.
        median = float(np.median(arr))
        abs_dev = np.abs(arr - median)
        mad = float(np.median(abs_dev))
        if mad == 0:
            tol = max(300.0, median * 0.15)
            filtered = arr[np.abs(arr - median) <= tol]
        else:
            robust_z = abs_dev / (1.4826 * mad)
            filtered = arr[robust_z <= 3.5]

    # Guardrail: do not over-filter sparse datasets.
    if len(filtered) >= max(3, int(len(arr) * 0.6)):
        return filtered.astype(float).tolist()

    return durations





@dataclasses.dataclass
class MatchResult:
    """Result of a profile matching attempt."""

    best_profile: str | None
    confidence: float
    expected_duration: float
    matched_phase: str | None
    candidates: list[dict[str, Any]]
    is_ambiguous: bool
    ambiguity_margin: float
    ranking: list[dict[str, Any]] = dataclasses.field(default_factory=_empty_ranking)
    debug_details: dict[str, Any] = dataclasses.field(default_factory=_empty_debug_details)
    is_confident_mismatch: bool = False
    mismatch_reason: str | None = None
    is_prefix_ambiguous: bool = False
    # The LEGACY (#288-only, full-envelope shape) half of the verdict above.
    # `is_prefix_ambiguous` is widened by #364's prefix scoring, which is safe for
    # the ENDING Smart-Termination gate (a false fire only delays the finish) but
    # NOT for the anti-crease finalize, where blocking can re-hang a cycle the way
    # #296 described. That consumer reads this narrower flag instead.
    is_prefix_ambiguous_full_shape: bool = False

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary with JSON-serializable types, excluding heavy arrays."""
        def _convert(obj: Any) -> Any:
            if isinstance(obj, np.generic):
                return cast(np.generic, obj).item()
            if isinstance(obj, np.ndarray):
                # Fallback for unexpected arrays: just describe shape
                arr = cast(np.ndarray[Any, Any], obj)
                return f"<array shape={arr.shape}>"
            if isinstance(obj, dict):
                # Exclude huge raw data arrays from cycle candidates
                obj_dict = cast(dict[str, Any], obj)
                return {
                    k: _convert(v)
                    for k, v in obj_dict.items()
                    if k not in ("current", "sample", "metrics", "warping_path")
                }
            if isinstance(obj, list):
                obj_list = cast(list[Any], obj)
                return [_convert(v) for v in obj_list]
            if dataclasses.is_dataclass(obj):
                return text_type_safe_asdict(obj)
            return obj

        def text_type_safe_asdict(d_obj: Any) -> dict[str, Any]:
            return {f.name: _convert(getattr(d_obj, f.name)) for f in dataclasses.fields(d_obj)}

        return text_type_safe_asdict(self)



def _safe_file_size_kb(path: str) -> float:
    """Return the size of ``path`` in KiB, or 0.0 if it cannot be read.

    Runs blocking os.path calls; intended to be offloaded to the executor.
    """
    try:
        return os.path.getsize(path) / 1024 if os.path.exists(path) else 0.0
    except OSError:
        return 0.0


def decompress_power_data(cycle: CycleDict) -> list[tuple[float, float]]:
    """Return power data as ``[(offset_seconds, power), ...]`` for a cycle.

    Handles both the current canonical ``[offset_float, power]`` format and the
    legacy ``(iso_str, power)`` format transparently.  Returns an empty list if
    data is missing or malformed.
    """
    raw = cycle.get("power_data", [])
    if not isinstance(raw, list) or not raw:
        return []

    start_time_raw = cycle.get("start_time")
    start_time_iso: str | None = start_time_raw if isinstance(start_time_raw, str) and start_time_raw else None
    if isinstance(start_time_raw, datetime):
        start_time_iso = start_time_raw.isoformat()
    offsets = power_data_to_offsets(cast(list[list[Any] | tuple[Any, ...]], raw), start_time_iso)
    return [(float(o), float(p)) for o, p in offsets]


def earliest_sustained_quiet_offset(
    cycles: list[CycleDict],
    stop_threshold_w: float,
    min_quiet_span_s: float,
    min_clean_cycles: int,
) -> float | None:
    """Smallest elapsed offset (s) at which any *completed* cycle first shows a
    sustained (>= ``min_quiet_span_s``) near-zero (< ``stop_threshold_w``) span.

    This is the device's learned "we have never legitimately been quiet before
    this many seconds into a cycle" baseline, used by the opt-in terminal-drop
    detector: a hard cliff-to-~0 that begins *earlier* than this is anomalous and
    almost certainly a real stop rather than a soak pause.

    Only ``completed`` cycles seed the baseline - interrupted / force-stopped /
    terminal-drop cycles are exactly the anomalies we are trying to catch, so
    including them would poison the baseline and suppress detection. Returns
    ``None`` when there are fewer than ``min_clean_cycles`` completed cycles with
    usable traces, so the caller keeps the proven slow end-detection.

    Using the strict *minimum* (rather than a higher percentile) is deliberately
    conservative: one cycle that happened to go quiet early only lowers the
    baseline, making the detector fire *less* often - never more.
    """
    if not cycles:
        return None
    earliest: float | None = None
    clean = 0
    for cycle in cycles:
        if cycle.get("status") != "completed":
            continue
        points = decompress_power_data(cycle)
        if not points:
            continue
        clean += 1
        span_start: float | None = None
        for offset, power in points:
            if power < stop_threshold_w:
                if span_start is None:
                    span_start = offset
                elif (offset - span_start) >= min_quiet_span_s:
                    if earliest is None or span_start < earliest:
                        earliest = span_start
                    break
            else:
                span_start = None
    if clean < min_clean_cycles:
        return None
    return earliest


def device_active_peak_range(
    cycles: list[CycleDict], min_clean_cycles: int
) -> tuple[float, float] | None:
    """(min_peak, max_peak) across the device's **completed** cycles.

    The peak of a cycle is its maximum power.  This is the device's learned
    "power levels we have produced before" band, used by the terminal-drop
    familiarity gate: a running cycle peaking outside this range (widened by a
    tolerance) is drawing power unlike anything in its history and may be a new
    program, so an early drop on it is deferred rather than assumed terminal.

    Returns ``None`` below ``min_clean_cycles`` completed cycles with usable
    traces (same guard as the quiet-offset baseline)."""
    peaks: list[float] = []
    for cycle in cycles:
        if cycle.get("status") != "completed":
            continue
        points = decompress_power_data(cycle)
        if not points:
            continue
        peaks.append(max(p for _, p in points))
    if len(peaks) < min_clean_cycles:
        return None
    return (min(peaks), max(peaks))


def terminal_drop_baseline(
    cycles: list[CycleDict],
    stop_threshold_w: float,
    min_quiet_span_s: float,
    min_clean_cycles: int,
) -> tuple[float | None, tuple[float, float] | None]:
    """Combined ``(earliest_quiet_offset, peak_range)`` baseline.

    Pure (no store / no I/O beyond decompressing the passed-in cycle traces), so
    the manager can offload the whole per-cycle scan to an executor thread in one
    hop instead of decompressing every trace on the event loop (issue #311)."""
    earliest = earliest_sustained_quiet_offset(
        cycles, stop_threshold_w, min_quiet_span_s, min_clean_cycles
    )
    peak_range = device_active_peak_range(cycles, min_clean_cycles)
    return earliest, peak_range


def is_terminal_drop(
    points: list[tuple[float, float]],
    earliest_quiet: float | None,
    peak_range: tuple[float, float] | None,
    stop_threshold_w: float,
    earliness_ratio: float,
    min_peak_ratio: float,
    peak_familiar_tol: float,
) -> bool:
    """Whether a running cycle's current low-power event is an anomalously-early
    terminal drop (plug pulled / cancelled) rather than a soak pause.

    Pure decision logic (no store / no I/O) so it is unit-testable in isolation;
    the manager wires the learned ``earliest_quiet`` / ``peak_range`` baselines
    and gates on the ML opt-in. All three checks must hold:

    * **Clearly ON** - the cycle peaked >= ``min_peak_ratio`` x stop-threshold
      (a low-power idling device can't trip it).
    * **Familiar** - the peak is within the device's historical peak range
      widened by ``peak_familiar_tol``.  A peak outside that band means the cycle
      is drawing power unlike anything seen before (possibly a new program), so
      we DEFER (return ``False``) rather than assume a stop.
    * **Anomalous** - the trailing sub-threshold span began at an offset
      ``< earliest_quiet * earliness_ratio`` - earlier than this device has ever
      legitimately gone quiet.

    Returns ``False`` (keep the proven slow path) whenever a baseline is missing
    or any check fails."""
    if not points or earliest_quiet is None or peak_range is None:
        return False
    peak = max(p for _, p in points)
    if peak < min_peak_ratio * max(stop_threshold_w, 1.0):
        return False
    lo, hi = peak_range
    if not (lo * (1.0 - peak_familiar_tol) <= peak <= hi * (1.0 + peak_familiar_tol)):
        return False
    drop_start: float | None = None
    for offset, power in points:
        if power < stop_threshold_w:
            if drop_start is None:
                drop_start = offset
        else:
            drop_start = None
    if drop_start is None:
        return False
    return drop_start < earliest_quiet * earliness_ratio


def compress_power_data(cycle: CycleDict) -> list[Any] | None:
    """Compress cycle power data to [offset, power] format (Module-level helper).

    Returns the compressed list structure or None if compression failed/not needed.
    """
    raw_data_raw = cycle.get("power_data")
    if not isinstance(raw_data_raw, list) or not raw_data_raw:
        return None
    raw_data = cast(list[Any], raw_data_raw)

    # Check if already compressed (first element is number or mixed format)
    first = raw_data[0]
    if isinstance(first, (int, float)):
        # Already flat list (very old format?) or specific compression
        return None
    if isinstance(first, (list, tuple)):
        first_seq = cast(list[Any] | tuple[Any, ...], first)
        if len(first_seq) == 2 and isinstance(first_seq[0], (int, float)):
            # Already compressed [offset, power]
            return None

    # Proceed with compression from [iso_string, power]
    if "start_time" not in cycle:
        return None

    try:
        start_ts = _value_to_timestamp(cycle.get("start_time"))
        if start_ts is None:
            return None
        compressed: list[list[float]] = []

        last_saved_p = -999.0
        last_saved_t = -999.0

        for i, entry in enumerate(raw_data):
            if isinstance(entry, (list, tuple)):
                entry_seq = cast(list[Any] | tuple[Any, ...], entry)
                if len(entry_seq) != 2:
                    continue
                t_str, p_val_raw = entry_seq
                try:
                    # Handle both ISO string and potential timestamp float
                    t = _value_to_timestamp(t_str)
                    if t is None:
                        continue

                    p_val = float(p_val_raw)
                    offset = round(t - start_ts, 1)
                    if offset < 0:
                        offset = 0.0

                    # Save first and last
                    is_endpoint = i == 0 or i == len(raw_data) - 1

                    # Downsample: change > 1W or gap > 60s
                    if (
                        is_endpoint
                        or abs(p_val - last_saved_p) > 1.0
                        or (offset - last_saved_t) > 60
                    ):
                        compressed.append([offset, round(p_val, 1)])
                        last_saved_p = p_val
                        last_saved_t = offset

                except (ValueError, TypeError):
                    continue
        return compressed
    except Exception:
        return None


class WashDataStore(Store[JSONDict]):
    """Store implementation with migration support."""

    async def _async_migrate_func(
        self,
        old_major_version: int,
        old_minor_version: int,  # pylint: disable=unused-argument
        old_data: JSONDict,
    ) -> JSONDict:
        """Migrate data to the new version."""
        if old_major_version < 2:
            _LOGGER.info("Migrating storage from v%s to v2", old_major_version)
            # Logic moved from ProfileStore._migrate_v1_to_v2
            cycles_raw = old_data.get("past_cycles", [])
            cycles = cast(list[dict[str, Any]], cycles_raw) if isinstance(cycles_raw, list) else []
            migrated_cycles = 0
            for cycle in cycles:
                if "signature" not in cycle and cycle.get("power_data"):
                    try:
                        # decompress_power_data now returns [(offset_seconds, power), ...]
                        tuples = decompress_power_data(cycle)
                        if tuples and len(tuples) > 10:
                            ts_arr = np.array([t for t, _ in tuples])
                            p_arr = np.array([p for _, p in tuples])
                            sig = compute_signature(ts_arr, p_arr)
                            cycle["signature"] = dataclasses.asdict(sig)
                            migrated_cycles += 1
                    except Exception as e:  # pylint: disable=broad-exception-caught
                        _LOGGER.warning(
                            "Failed to migrate signature for cycle %s: %s", cycle.get("id"), e
                        )

            _LOGGER.info(
                "Migration v1->v2: Computed signatures for %s cycles", migrated_cycles
            )

        if old_major_version < 3:
            _LOGGER.info("Migrating storage from v%s to v3", old_major_version)
            cycles_raw = old_data.get("past_cycles", [])
            profiles_raw = old_data.get("profiles", {})
            cycles = cast(list[dict[str, Any]], cycles_raw) if isinstance(cycles_raw, list) else []
            profiles = cast(dict[str, dict[str, Any]], profiles_raw) if isinstance(profiles_raw, dict) else {}
            migrated_count = 0

            # 1. Migrate Power Data to canonical offset format & ensure status
            for cycle in cycles:
                if "status" not in cycle:
                    cycle["status"] = "completed"

                if cycle.get("power_data") and isinstance(cycle["power_data"], list):
                    try:
                        if migrate_power_data_to_offsets(cycle):
                            migrated_count += 1
                    except Exception as e:
                        _LOGGER.warning(
                            "Failed to migrate power data for cycle %s: %s",
                            cycle.get("id"),
                            e,
                        )

            # 2. Ensure Device Type in Profiles
            for profile in profiles.values():
                if "device_type" not in profile:
                    profile["device_type"] = "washing_machine"

            _LOGGER.info(
                "Migration v2->v3: Migrated power data for %s cycles", migrated_count
            )

        if old_major_version < 4:
            _LOGGER.info("Migrating storage from v%s to v4", old_major_version)
            profiles = old_data.get("profiles", {})
            if isinstance(profiles, dict):
                for profile in cast(dict[str, dict[str, Any]], profiles).values():
                    phases = profile.get("phases")
                    if not isinstance(phases, list):
                        profile["phases"] = []

            custom = old_data.get("custom_phases")
            if not isinstance(custom, dict):
                old_data["custom_phases"] = {}

        if old_major_version < 5:
            _LOGGER.info("Migrating storage from v%s to v5", old_major_version)
            custom = old_data.get("custom_phases")
            if isinstance(custom, list):
                normalized: list[dict[str, Any]] = []
                seen: set[tuple[str, str]] = set()
                for item in cast(list[Any], custom):
                    if not isinstance(item, dict):
                        continue
                    item_dict = cast(dict[str, Any], item)
                    name = str(item_dict.get("name", "")).strip()
                    if not name:
                        continue
                    device_type = str(item_dict.get("device_type", "")).strip()
                    key = (name.casefold(), device_type.casefold())
                    if key in seen:
                        continue
                    seen.add(key)
                    normalized.append(
                        {
                            "name": name,
                            "description": str(item_dict.get("description", "")).strip(),
                            "device_type": device_type,
                            "created_at": item_dict.get("created_at") or dt_util.now().isoformat(),
                        }
                    )
                old_data["custom_phases"] = normalized
            elif isinstance(custom, dict):
                normalized: list[dict[str, Any]] = []
                seen: set[tuple[str, str]] = set()
                custom_dict = cast(dict[str, Any], custom)
                for legacy_device_type, phase_list in custom_dict.items():
                    if not isinstance(phase_list, list):
                        continue
                    for item in cast(list[Any], phase_list):
                        if not isinstance(item, dict):
                            continue
                        item_dict = cast(dict[str, Any], item)
                        name = str(item_dict.get("name", "")).strip()
                        if not name:
                            continue
                        device_type = str(legacy_device_type or "").strip()
                        key = (name.casefold(), device_type.casefold())
                        if key in seen:
                            continue
                        seen.add(key)
                        normalized.append(
                            {
                                "name": name,
                                "description": str(item_dict.get("description", "")).strip(),
                                "device_type": device_type,
                                "created_at": item_dict.get("created_at") or dt_util.now().isoformat(),
                            }
                        )
                old_data["custom_phases"] = normalized
            else:
                old_data["custom_phases"] = []

        if old_major_version < 6:
            _LOGGER.info("Migrating storage from v%s to v6", old_major_version)
            cycles_raw = old_data.get("past_cycles", [])
            cycles = cast(list[dict[str, Any]], cycles_raw) if isinstance(cycles_raw, list) else []
            flagged = _flag_recorded_cycles_golden(cycles)
            _LOGGER.info(
                "Migration v5->v6: flagged %s recorded cycle(s) as golden references", flagged
            )

        if old_major_version < 7:
            # The v6 step only ran when upgrading from below v6, so recorded
            # cycles on installs already at v6 (e.g. recorded by an older build
            # that didn't set the golden flag) were never tagged. Re-run the now
            # broadened backfill once so every historical recorded cycle is
            # recognised. Idempotent: already-golden cycles are skipped.
            _LOGGER.info("Migrating storage from v%s to v7", old_major_version)
            cycles_raw = old_data.get("past_cycles", [])
            cycles = cast(list[dict[str, Any]], cycles_raw) if isinstance(cycles_raw, list) else []
            flagged = _flag_recorded_cycles_golden(cycles)
            _LOGGER.info(
                "Migration v6->v7: tagged %s previously-unmarked recorded cycle(s) as golden",
                flagged,
            )

        if old_major_version < 8:
            # The v6->v7 backfill identified recordings only by the explicit
            # ``meta.source``/``original_samples`` marker, which OLD recordings
            # (made before that marker existed, ``meta: None``) do not carry — so
            # they stayed untagged. _is_recorded_cycle now also recognises them by
            # their structural signature (completed + no max_power/termination_reason).
            # Re-run the backfill once more so those old recordings are finally
            # tagged. Idempotent: already-golden cycles are skipped.
            _LOGGER.info("Migrating storage from v%s to v8", old_major_version)
            cycles_raw = old_data.get("past_cycles", [])
            cycles = cast(list[dict[str, Any]], cycles_raw) if isinstance(cycles_raw, list) else []
            flagged = _flag_recorded_cycles_golden(cycles)
            _LOGGER.info(
                "Migration v7->v8: tagged %s old recorded cycle(s) (no meta marker) as golden",
                flagged,
            )

        if old_major_version < 9:
            # Pre-initialize additive top-level keys so they are present from the
            # first load rather than appearing lazily on first use. Idempotent:
            # setdefault leaves existing values untouched.
            _LOGGER.info("Migrating storage from v%s to v9", old_major_version)
            old_data.setdefault("lifetime_energy_wh", 0.0)
            # Seed the monotonic lifetime cycle counter from the retained history so an
            # existing install does not start at 0 and re-fire past milestones (50/100/...)
            # on cycles it already completed. Matches the cycle_count fallback semantics.
            old_data.setdefault(
                "lifetime_cycle_count", len(old_data.get("past_cycles") or [])
            )
            old_data.setdefault("settings_changelog", [])
            old_data.setdefault("maintenance_log", [])

        if old_major_version < 10:
            # Reference cycles imported from the community store live in their own list,
            # never in past_cycles, so they can feed the envelope/matcher but can never
            # touch usage/energy stats. Additive + idempotent.
            _LOGGER.info("Migrating storage from v%s to v10", old_major_version)
            old_data.setdefault("reference_cycles", [])

        if old_major_version < 11:
            # Marker-only bump. Per-phase profiles (envelope["phase_profile"], used
            # by phase-segmented matching / phase-resolved ETA) are DERIVED CACHE
            # built by async_rebuild_envelope, not stored data - so there is nothing
            # to migrate. They self-populate on the next envelope rebuild (which
            # runs on every cycle end / label change); until then consumers fall
            # back to the existing estimator via lazy absent-key handling. No data
            # is added, removed, or altered here.
            _LOGGER.info("Migrating storage from v%s to v11 (phase-profile cache marker)",
                         old_major_version)

        if old_major_version < 12:
            # Cycles recovered from raw power history that predates the integration
            # (issue #344) get their own list. They are auto-detected and unverified, so
            # they belong in neither past_cycles (lifetime stats, ML training labels, the
            # feedback queue, retention eviction) nor reference_cycles (curated
            # community-store templates, golden by construction). Additive + idempotent.
            _LOGGER.info("Migrating storage from v%s to v12", old_major_version)
            old_data.setdefault("backfill_cycles", [])

        return old_data

def _ambiguity_from_candidates(candidates: list[dict]) -> tuple[float, bool]:
    """Top1-vs-top2 score margin and whether the match is ambiguous.

    Single source for the ambiguity rule shared by both match paths:
    ``is_ambiguous = margin < MATCH_AMBIGUITY_MARGIN``. ``margin`` defaults to
    1.0 when there is only one candidate.
    """
    margin = 1.0
    if len(candidates) > 1:
        margin = candidates[0]["score"] - candidates[1]["score"]
    return margin, margin < MATCH_AMBIGUITY_MARGIN


def _match_prefix_ambiguity(
    candidates: list[dict], best_duration: float
) -> tuple[bool, bool]:
    """``(full_shape_hit, prefix_fit_hit)`` for the prefix-landscape guard.

    Both terms answer the same question - "might this trace be a *prefix* of a
    longer programme rather than a complete short one?" - by two different routes,
    and either one blocks Smart Termination:

    * ``full_shape_hit`` (#288, unchanged): a non-winning candidate is at least
      ``SMART_TERM_LANDSCAPE_RATIO`` longer than the winner and still scored
      ``SMART_TERM_LANDSCAPE_MIN_SHAPE`` against its **full** envelope.
    * ``prefix_fit_hit`` (#364): a longer candidate's score against its curve
      **truncated to the elapsed duration** (``prefix_score``, computed in
      ``analysis.annotate_prefix_scores``) beats the winner's own score by
      ``SMART_TERM_PREFIX_MARGIN``. The margin is the load-bearing term - it is
      scale-free, and asks whether the longer programme explains the trace
      materially better than the short one does. The absolute floor only rejects
      candidates that fit nothing.

    The full-envelope term alone has three structural false negatives on real
    devices (see the #364 block in const.py); the prefix term exists because a
    trace part-way through a longer programme cannot score well against that
    programme's whole curve. Returned separately because the widened verdict is
    only safe for the ENDING gate - see ``MatchResult.is_prefix_ambiguous_full_shape``.

    Pure function, no I/O. ``prefix_score`` absent (Stage 4 skipped, a mocked
    executor, an older snapshot) degrades to the legacy term alone, so this can
    never fire *less* often than the #288 predicate did.
    """
    if best_duration <= 0 or len(candidates) < 2:
        return False, False
    # Compare against the winner's SHAPE score, not its blended final score: prefix_score
    # is a shape-scale value (Stage-2 + Stage-3, no Stage-4 duration/energy agreement), so
    # measuring the margin against the blended score mixed scales and made 0.15 too strict.
    # Fall back to the blended score only when shape_score is absent (older snapshot).
    _best_shape = candidates[0].get("shape_score")
    best_score = float(
        (_best_shape if _best_shape is not None else candidates[0].get("score")) or 0.0
    )
    full_shape_hit = False
    prefix_fit_hit = False
    for cand in candidates[1:]:
        prof_dur = float(cand.get("profile_duration") or 0)
        if prof_dur > best_duration * SMART_TERM_LANDSCAPE_RATIO and float(
            cand.get("shape_score", cand.get("score", 0))
        ) >= SMART_TERM_LANDSCAPE_MIN_SHAPE:
            full_shape_hit = True
        prefix_score = cand.get("prefix_score")
        if (
            prefix_score is not None
            and prof_dur > best_duration * SMART_TERM_PREFIX_MIN_RATIO
            and float(prefix_score) >= SMART_TERM_PREFIX_MIN_SHAPE
            and float(prefix_score) >= best_score + SMART_TERM_PREFIX_MARGIN
        ):
            prefix_fit_hit = True
        if full_shape_hit and prefix_fit_hit:
            break
    return full_shape_hit, prefix_fit_hit


# ── Selective export/import taxonomy ────────────────────────────────────────────
# One canonical description of the store's top-level data kinds, grouped into the
# user-facing categories offered by the export/import wizard. A single walker
# (``_scan_data``) drives BOTH the export inventory and the import manifest so the
# tree UI is identical on both sides, and the export filter + selective-import
# apply agree on what each category contains.
#
# Keys NOT listed here are handled specially and are never a selectable category:
#   - "envelopes": derived cache -- rebuilt on import for cycle-receiving profiles,
#     or carried verbatim for a profiles-only export so definition-only profiles
#     stay matchable without their raw traces.
#   - "active_cycle" / "last_active_save": transient in-flight state -- never exported.
#   - "store_account": credential (GitHub refresh token) -- never exported.
_EXPORT_CATEGORIES: dict[str, dict[str, Any]] = {
    "profiles":         {"keys": ["profiles"], "kind": "dict", "enumerable": True},
    "real_cycles":      {"keys": ["past_cycles"], "kind": "list", "enumerable": True,
                         "group_by": "profile_name"},
    "reference_cycles": {"keys": ["reference_cycles"], "kind": "list", "enumerable": True,
                         "group_by": "profile_name"},
    "custom_phases":    {"keys": ["custom_phases"], "kind": "list"},
    "profile_groups":   {"keys": ["profile_groups"], "kind": "dict"},
    "matching_config":  {"keys": ["matching_config"], "kind": "dict", "device_specific": True},
    "suggestions":      {"keys": ["suggestions", "suggestion_apply_cycle_count"], "kind": "mixed"},
    "feedback":         {"keys": ["feedback_history", "pending_feedback"], "kind": "dict"},
    "maintenance_log":  {"keys": ["maintenance_log"], "kind": "list"},
    "ml_models":        {"keys": ["ml_model_versions", "ml_training_history", "ml_last_training_run"],
                         "kind": "mixed", "device_specific": True},
    "history_logs":     {"keys": ["auto_adjustments", "match_ranking_history", "settings_changelog"],
                         "kind": "list"},
    "lifetime_stats":   {"keys": ["lifetime_energy_wh", "lifetime_cycle_count"], "kind": "scalar"},
    "settings":         {"keys": [], "kind": "options", "device_specific": True},
}

# Categories whose payload rides at the envelope level (entry_options), not inside "data".
_ENVELOPE_LEVEL_CATEGORIES = frozenset({"settings"})
# Enumerable/structural categories handled explicitly (not as plain leaf keys).
_STRUCTURAL_CATEGORIES = frozenset({"profiles", "real_cycles", "reference_cycles", "settings"})


def _strip_redacted_dict(d: Any) -> dict[str, Any]:
    """Drop diagnostic ``**REDACTED**`` sentinels so they never overwrite real settings."""
    if not isinstance(d, dict):
        return {}
    return {k: v for k, v in d.items() if v != "**REDACTED**"}


def _shareable_settings(opts: Any) -> dict[str, Any]:
    """Filter options to the SHAREABLE allow-list, numeric non-bool only.

    Mirrors the community-bundle settings filter (``store.py`` / ``ws_api.py``): the
    only options that may travel with an export are recognition/matching thresholds,
    never identity keys or the power-sensor binding.
    """
    if not isinstance(opts, dict):
        return {}
    return {
        k: opts[k]
        for k in SHAREABLE_SETTING_KEYS
        if k in opts and isinstance(opts[k], (int, float)) and not isinstance(opts[k], bool)
    }


def _selected_categories(selection: Any) -> set[str]:
    """Normalize a wizard ``selection`` into the set of enabled category ids.

    Accepts either ``{"categories": [ids...]}`` or ``{"categories": {id: bool}}``.
    Unknown category ids are ignored so a stale client can't inject junk keys.
    """
    if not isinstance(selection, dict):
        return set()
    cats = selection.get("categories")
    out: set[str] = set()
    if isinstance(cats, dict):
        for cid, on in cats.items():
            if on and cid in _EXPORT_CATEGORIES:
                out.add(str(cid))
    elif isinstance(cats, list):
        for cid in cats:
            if cid in _EXPORT_CATEGORIES:
                out.add(str(cid))
    return out


def _cycles_by_profile(seq: Any) -> dict[str, list[dict[str, Any]]]:
    """Group a cycle list by ``profile_name`` (unlabeled cycles under ``""``)."""
    groups: dict[str, list[dict[str, Any]]] = {}
    if not isinstance(seq, list):
        return groups
    for c in seq:
        if not isinstance(c, dict):
            continue
        name = str(c.get("profile_name") or "")
        groups.setdefault(name, []).append(c)
    return groups


def _scan_data(data_dict: Any, entry_options: Any = None) -> dict[str, dict[str, Any]]:
    """Walk a store ``data`` blob and return a per-category inventory.

    Pure and never raises. Drives both the export inventory (over ``self._data``)
    and the import manifest (over an unwrapped payload). Enumerable categories
    carry item/group detail so the panel can render a hierarchical tri-state tree.
    """
    out: dict[str, dict[str, Any]] = {}
    dd = data_dict if isinstance(data_dict, dict) else {}
    profiles = dd.get("profiles") if isinstance(dd.get("profiles"), dict) else {}
    past = dd.get("past_cycles") if isinstance(dd.get("past_cycles"), list) else []
    refs = dd.get("reference_cycles") if isinstance(dd.get("reference_cycles"), list) else []

    past_by = _cycles_by_profile(past)
    ref_by = _cycles_by_profile(refs)

    # profiles: each profile with its local real/reference cycle counts
    prof_items = [
        {
            "name": name,
            "real_cycles": len(past_by.get(name, [])),
            "reference_cycles": len(ref_by.get(name, [])),
        }
        for name in profiles
    ]
    out["profiles"] = {"present": bool(profiles), "count": len(profiles), "items": prof_items}

    def _groups(groups: dict[str, list[dict[str, Any]]]) -> list[dict[str, Any]]:
        result: list[dict[str, Any]] = []
        for name in sorted(groups):
            cs = groups[name]
            result.append(
                {
                    "profile": name,
                    "count": len(cs),
                    "cycles": [
                        {
                            "id": c.get("id"),
                            "date": c.get("start_time"),
                            "duration": c.get("duration"),
                        }
                        for c in cs
                        if isinstance(c, dict)
                    ],
                }
            )
        return result

    out["real_cycles"] = {"present": bool(past), "count": len(past), "groups": _groups(past_by)}
    out["reference_cycles"] = {"present": bool(refs), "count": len(refs), "groups": _groups(ref_by)}

    # leaf categories (everything except the structural ones above + settings)
    for cat_id, spec in _EXPORT_CATEGORIES.items():
        if cat_id in _STRUCTURAL_CATEGORIES:
            continue
        present = False
        count = 0
        for key in spec.get("keys", []):
            if key not in dd:
                continue
            val = dd.get(key)
            if isinstance(val, (dict, list)):
                if val:
                    present = True
                    count += len(val)
            elif val not in (None, 0, 0.0, ""):
                present = True
                count += 1
        out[cat_id] = {"present": present, "count": count}

    # settings: the shareable numeric subset of entry_options
    shareable = _shareable_settings(entry_options)
    out["settings"] = {
        "present": bool(shareable),
        "count": len(shareable),
        "keys": sorted(shareable.keys()),
    }
    return out


def unwrap_import_payload(payload: Any) -> tuple[dict[str, Any], dict[str, Any]]:
    """Normalize any supported export/import wrapper into ``(data_dict, meta)``.

    Handles the four shapes the integration can be handed:
      * HA diagnostics download   -> ``{"home_assistant": ..., "data": {...}}``
      * integration diagnostics   -> ``{..., "store_export": {...}}``
      * v1 flat export            -> ``{"profiles": ..., "past_cycles": ...}``
      * v2+ nested export         -> ``{"version": N, "data": {...}, ...}``

    Returns a shallow-copied, shape-repaired ``data_dict`` (so the caller can mutate
    or assign it without aliasing the input) plus a ``meta`` dict carrying format,
    version, redaction-stripped ``entry_data``/``entry_options``, the device
    fingerprint and the export timestamp. Raises ``ValueError`` on a payload that
    is not a usable object.
    """
    if isinstance(payload, dict) and "home_assistant" in payload and "data" in payload:
        payload = payload["data"]
    if isinstance(payload, dict) and "store_export" in payload:
        payload = payload["store_export"]
    if not isinstance(payload, dict):
        raise ValueError("Invalid export payload (not a JSON object)")

    version = payload.get("version", 1)
    if version == 1 or "data" not in payload:
        data_dict: dict[str, Any] = {
            "profiles": payload.get("profiles", {}),
            "past_cycles": payload.get("past_cycles", []),
            "envelopes": payload.get("envelopes", {}),
        }
        fmt = "v1"
    else:
        data = payload.get("data")
        if not isinstance(data, dict):
            raise ValueError("Invalid export payload (missing or invalid 'data' key)")
        data_dict = dict(data)  # shallow copy: caller may assign/mutate freely
        fmt = "v2"

    # Repair top-level shape so downstream code can assume the core keys.
    if not isinstance(data_dict.get("profiles"), dict):
        data_dict["profiles"] = {}
    if not isinstance(data_dict.get("past_cycles"), list):
        data_dict["past_cycles"] = []
    if not isinstance(data_dict.get("reference_cycles"), list):
        data_dict["reference_cycles"] = []
    if not isinstance(data_dict.get("backfill_cycles"), list):
        data_dict["backfill_cycles"] = []
    data_dict.setdefault("envelopes", {})

    fingerprint = payload.get("device_fingerprint")
    meta: dict[str, Any] = {
        "format": fmt,
        "version": version,
        "entry_data": _strip_redacted_dict(payload.get("entry_data", {})),
        "entry_options": _strip_redacted_dict(payload.get("entry_options", {})),
        "device_fingerprint": fingerprint if isinstance(fingerprint, dict) else {},
        "exported_at": payload.get("exported_at"),
    }
    return data_dict, meta


def build_import_manifest(
    payload: Any, *, local_device_type: str, local_profile_names: Any
) -> dict[str, Any]:
    """Analyze an import payload and describe what it contains + what can be imported.

    Pure (no store mutation). Returns ``{"error": msg}`` on an unparseable payload so
    the WS layer / panel can render a friendly message instead of failing. Otherwise
    returns the format/version, source-vs-local device type compatibility, per-category
    inventory (with per-profile ``conflict`` flags and ``importable`` gating), and any
    warnings the panel should surface.
    """
    try:
        data_dict, meta = unwrap_import_payload(payload)
    except ValueError as exc:
        return {"error": str(exc)}

    scan = _scan_data(data_dict, entry_options=meta.get("entry_options"))
    source_dt = str((meta.get("device_fingerprint") or {}).get("device_type") or "") or None
    device_type_match = (not source_dt) or (source_dt == local_device_type)

    local_names = {str(n).casefold() for n in (local_profile_names or [])}
    for item in scan.get("profiles", {}).get("items", []):
        item["conflict"] = str(item.get("name", "")).casefold() in local_names

    for cat_id, info in scan.items():
        spec = _EXPORT_CATEGORIES.get(cat_id, {})
        importable = bool(info.get("present"))
        if spec.get("device_specific") and not device_type_match:
            importable = False
        info["importable"] = importable

    warnings: list[str] = []
    if not device_type_match:
        warnings.append("device_type_mismatch")

    return {
        "format": meta.get("format"),
        "version": meta.get("version"),
        "exported_at": meta.get("exported_at"),
        "source_device_type": source_dt,
        "local_device_type": local_device_type,
        "device_type_match": device_type_match,
        "real_history_allowed": device_type_match,
        "categories": scan,
        "warnings": warnings,
    }


def _unique_profile_name(existing: dict[str, Any], base: str) -> str:
    """Return ``base`` or ``"base 2"``/``"base 3"``… so it doesn't collide in ``existing``."""
    if base not in existing:
        return base
    i = 2
    while f"{base} {i}" in existing:
        i += 1
    return f"{base} {i}"


def _usable_reference_pairs(points: Any) -> list[list[float]] | None:
    """Validate a raw ``[[offset, watts], ...]`` trace for reference-cycle import: drop
    non-finite/non-numeric samples, require >= 2 points and a positive time span. Returns the
    sorted ``[offset, watts]`` pairs, or ``None`` if the trace is unusable (would no-op).

    Single source of the "is this trace importable" rule, shared by ``_add_reference_cycle_nosave``
    (which mutates) and the selective-import replace guard (which must not wipe a destination
    when every selected cycle would silently no-op)."""
    pairs: list[list[float]] = []
    for p in (points if isinstance(points, (list, tuple)) else []):
        if not isinstance(p, (list, tuple)) or len(p) < 2:
            continue
        try:
            x, y = float(p[0]), float(p[1])
        except (TypeError, ValueError):
            continue
        if np.isfinite(x) and np.isfinite(y):
            pairs.append([x, y])
    if len(pairs) < 2:
        return None
    pairs.sort(key=lambda q: q[0])  # defensive: normalize any out-of-order offsets
    if float(pairs[-1][0] - pairs[0][0]) <= 0:
        return None
    return pairs


def _merge_list_dedup(base: list[Any], incoming: list[Any]) -> None:
    """Append items from ``incoming`` to ``base`` in place, skipping duplicates.

    Dedupes by ``id`` when items carry one, else by a stable JSON signature so
    re-importing the same log file is idempotent for id-less list entries.
    """
    seen_ids = {str(x.get("id")) for x in base if isinstance(x, dict) and x.get("id") is not None}
    seen_sigs: set[str] = set()
    for x in base:
        try:
            seen_sigs.add(json.dumps(x, sort_keys=True, default=str))
        except (TypeError, ValueError):
            pass
    for item in incoming:
        if isinstance(item, dict) and item.get("id") is not None:
            key = str(item.get("id"))
            if key in seen_ids:
                continue
            seen_ids.add(key)
            base.append(item)
            continue
        try:
            sig = json.dumps(item, sort_keys=True, default=str)
        except (TypeError, ValueError):
            sig = None
        if sig is not None:
            if sig in seen_sigs:
                continue
            seen_sigs.add(sig)
        base.append(item)


class ProfileStore:
    """Manages storage of washer profiles and past cycles."""

    def __init__(
        self,
        hass: HomeAssistant,
        entry_id: str,
        min_duration_ratio: float = 0.50,
        max_duration_ratio: float = 1.50,
        save_debug_traces: bool = False,
        match_threshold: float = 0.4,
        unmatch_threshold: float = 0.35,
        device_name: str = "",
    ) -> None:
        """Initialize the profile store."""
        self.hass = hass
        self.entry_id = entry_id
        self._logger = DeviceLoggerAdapter(_LOGGER, device_name)
        self._min_duration_ratio = min_duration_ratio
        self._max_duration_ratio = max_duration_ratio
        self._match_threshold = match_threshold
        self._unmatch_threshold = unmatch_threshold
        self.dtw_bandwidth: float = DEFAULT_DTW_BANDWIDTH
        # Stage-4 energy-agreement mode ("mean"|"integrated"); the manager sets it
        # from the device type via analysis.stage4_energy_mode. Default "mean"
        # keeps behaviour byte-identical until wired.
        self.energy_mode: str = "mean"
        # Which cycle categories may shape a profile (see CONF_PROFILE_EVIDENCE_SOURCES).
        # Pushed in by the manager from entry options, the way energy_mode is; the store
        # has no access to them itself. Defaults to all three, i.e. pre-setting behaviour.
        self._evidence_sources: tuple[str, ...] = tuple(PROFILE_EVIDENCE_SOURCES)
        self._save_debug_traces = save_debug_traces

        # Cache for resampled sample segments: key=(cycle_id, dt)
        self._cached_sample_segments: dict[tuple[str, float], Segment] = {}
        # Cache for group cohesion scores to avoid re-running DTW on the event loop
        # every 5 minutes.  Keyed by sorted-members tuple; invalidated when profile_groups
        # content changes (tracked by a simple generation counter).
        self._cohesion_cache: dict[tuple[str, ...], float] = {}
        self._cohesion_cache_generation: int = 0
        self._cohesion_cache_generation_checked: int = -1
        # group_cohesion runs in executor threads (live matching + Playground can
        # touch the same store concurrently); serialize the read-clear-compute-store
        # sequence so a concurrent call can't clear a half-populated cache or
        # duplicate the DTW work.  A plain thread lock, never held across an await.
        self._cohesion_cache_lock = threading.Lock()
        # Profile duration tolerance (set by manager; reserved for duration-based heuristics)
        self._duration_tolerance: float = 0.25
        # Retention policy: cap total cycles and number of full-resolution traces per profile
        self._max_past_cycles = DEFAULT_MAX_PAST_CYCLES
        self._max_full_traces_per_profile = DEFAULT_MAX_FULL_TRACES_PER_PROFILE
        self._max_full_traces_unlabeled = DEFAULT_MAX_FULL_TRACES_UNLABELED
        # Separate store for each entry to avoid giant files
        # Use WashDataStore to handle migration
        self._store: Store[JSONDict] = WashDataStore(
            hass, STORAGE_VERSION, f"{STORAGE_KEY}.{entry_id}"
        )
        self._data: JSONDict = {
            "profiles": {},
            "past_cycles": [],
            "reference_cycles": [],  # Imported store cycles: envelope/matcher only, never usage stats
            "backfill_cycles": [],  # Cycles recovered from raw history (#344): unverified,
                                    # envelope/matcher only, never stats/ML/shareable
            "envelopes": {},  # Cached statistical envelopes per profile
            "auto_adjustments": [],  # Log of automatic setting changes
            "suggestions": {},  # Suggested settings (do NOT change user options)
            "feedback_history": {},  # Persisted user feedback (cycle_id -> record)
            "pending_feedback": {},  # Persisted pending feedback requests
            "custom_phases": [],  # Shared custom phase catalog
            "ml_model_versions": {},  # On-device trained model specs (Stage 4)
            "profile_groups": {},  # Named groups of near-duplicate profiles (Stage 5)
            "maintenance_log": [],  # User-logged maintenance events (Group E)
            "playground_presets": {},  # Named Playground setting snapshots (sandbox only)
        }




    def set_suggestion(
        self,
        key: str,
        value: Any,
        reason: str | None = None,
        reason_key: str | None = None,
        reason_params: dict[str, Any] | None = None,
    ) -> None:
        """Store a suggested setting value without changing config entry options.

        ``reason`` is the English fallback text; ``reason_key`` + ``reason_params``
        are the localization key + interpolation values the panel resolves via
        ``_t(reason_key, reason_params, reason)``. Both localization fields are
        optional so old callers / persisted entries keep working.
        """
        suggestions: JSONDict = self._data.setdefault("suggestions", {})
        entry: dict[str, Any] = {
            "value": value,
            "reason": reason,
            "updated": dt_util.now().isoformat(),
        }
        # Store localization sidecars only when present so we never overwrite an
        # existing key with a stale ``None`` (keeps the shape lean for old data).
        if reason_key is not None:
            entry["reason_key"] = reason_key
        if reason_params is not None:
            entry["reason_params"] = reason_params
        suggestions[key] = entry

    def get_suggestions(self) -> dict[str, Any]:
        """Return current suggestion map."""
        raw = self._data.get("suggestions")
        if isinstance(raw, dict):
            suggestions = cast(JSONDict, raw)
            return suggestions.copy()
        return {}

    def delete_suggestion(self, key: str) -> None:
        """Remove a single suggestion entry by key."""
        suggestions: JSONDict = self._data.setdefault("suggestions", {})
        suggestions.pop(key, None)

    def get_locked_suggestions(self) -> list[str]:
        """Return the setting keys the user has locked against auto-tuning (#343).

        A locked key is never re-surfaced as a suggestion, so the auto-tuner stops
        repeatedly proposing a value the user has rejected (e.g. a stop/start
        threshold that breaks an anti-crease-tuned device). Never raises.
        """
        raw = self._data.get("locked_suggestions")
        return [str(k) for k in raw] if isinstance(raw, list) else []

    async def set_suggestion_locked(self, key: str, locked: bool) -> None:
        """Lock or unlock a suggestion key and persist (#343).

        Locking also drops any currently-pending suggestion for the key so it
        disappears immediately; unlocking lets the auto-tuner surface it again.
        """
        cur = set(self.get_locked_suggestions())
        if locked:
            cur.add(key)
            self.delete_suggestion(key)
        else:
            cur.discard(key)
        self._data["locked_suggestions"] = sorted(cur)
        await self.async_save()

    async def clear_suggestions(self) -> None:
        """Clear all pending suggestions and persist."""
        self._data["suggestions"] = {}
        await self.async_save()

    def get_suggestion_apply_cycle_count(self) -> int:
        """Return the total cycle count recorded at the last user suggestion apply."""
        return int(self._data.get("suggestion_apply_cycle_count", 0))

    def set_suggestion_apply_cycle_count(self, count: int) -> None:
        """Record the total cycle count at the moment the user applies suggestions."""
        self._data["suggestion_apply_cycle_count"] = count

    # ─── Settings changelog (Group D7) ─────────────────────────────────────────

    #: Maximum settings-changelog entries retained per device (newest kept).
    SETTINGS_CHANGELOG_MAX = 50

    def get_settings_changelog(self) -> list[dict[str, Any]]:
        """Return the recorded settings-change history (most-recent-first).

        Never raises: returns an empty list when no changelog exists yet.
        """
        try:
            raw = self._data.get("settings_changelog", [])
            if isinstance(raw, list):
                return raw
        except Exception:  # pylint: disable=broad-exception-caught
            pass
        return []

    async def async_record_settings_changes(self, changes: list[dict]) -> None:
        """Append settings-change entries and persist (capped at the newest 50).

        Each entry is normalized to ``{"key", "old", "new", "timestamp"}`` and
        prepended so the list stays most-recent-first. No-op on empty/invalid
        input.
        """
        if not changes:
            return
        log = self._data.setdefault("settings_changelog", [])
        if not isinstance(log, list):
            log = []
            self._data["settings_changelog"] = log

        now_iso = dt_util.now().isoformat()
        added = False
        for ch in changes:
            if not isinstance(ch, dict) or "key" not in ch:
                continue
            log.insert(
                0,
                {
                    "key": str(ch.get("key")),
                    "old": ch.get("old"),
                    "new": ch.get("new"),
                    "timestamp": ch.get("timestamp") or now_iso,
                },
            )
            added = True

        if not added:
            return

        # Keep only the newest entries (list is most-recent-first).
        if len(log) > self.SETTINGS_CHANGELOG_MAX:
            del log[self.SETTINGS_CHANGELOG_MAX:]
        self._data["settings_changelog"] = log
        await self.async_save()

    # ─── On-device ML model versions (Stage 4) ────────────────────────────────

    def get_ml_model_versions(self) -> dict[str, Any]:
        """Return the map of on-device trained model specs (capability -> record).

        Each record is ``{spec, trained_at, cycle_count, metrics, baseline_auc}``
        where ``spec`` is a NumPy-only standardized-logistic model produced by
        :mod:`.ml.trainer`. Empty when nothing has been trained on-device.
        """
        raw = self._data.get("ml_model_versions")
        if isinstance(raw, dict):
            return cast(JSONDict, raw).copy()
        return {}

    async def set_ml_model_version(self, capability: str, record: dict[str, Any]) -> None:
        """Persist a trained model record for a capability (quality/live_match/end)."""
        versions: JSONDict = self._data.setdefault("ml_model_versions", {})
        versions[capability] = record
        await self.async_save()

    async def clear_ml_model_versions(self) -> None:
        """Drop all on-device trained models (reverts to embedded baselines)."""
        self._data["ml_model_versions"] = {}
        await self.async_save()

    def get_ml_last_training_run(self) -> str | None:
        """ISO timestamp of the last *completed* on-device training run, or None.

        Distinct from each model's ``trained_at`` (the promotion time): this
        advances on every run even when no model beat the baseline, so the panel's
        "Last trained" reflects that training actually ran.
        """
        ts = self._data.get("ml_last_training_run")
        return ts if isinstance(ts, str) else None

    async def set_ml_last_training_run(self, iso: str) -> None:
        """Record when on-device training last completed (regardless of promotion)."""
        self._data["ml_last_training_run"] = iso
        await self.async_save()

    def get_ml_training_history(self) -> dict[str, list[dict[str, Any]]]:
        """Per-capability held-out-score history across training runs.

        ``{capability: [{"ts": iso, "score": float, "higher_better": bool}, ...]}``
        oldest-first. Lets the panel show whether a model's fit is improving,
        steady, or declining over time. Empty until training has run.
        """
        raw = self._data.get("ml_training_history")
        return cast(dict[str, list[dict[str, Any]]], raw) if isinstance(raw, dict) else {}

    async def append_ml_training_history(self, run_iso: str, results: list[dict[str, Any]]) -> None:
        """Append each capability's held-out score from a training run.

        ``results`` is ``train_from_cycles``'s per-capability records; classifiers
        report ``new_auc`` (higher is better), regressors ``model_mae`` (lower is
        better). Capabilities without a held-out metric this run (insufficient
        data) are skipped. Retains at most ``ML_TRAINING_HISTORY_MAX`` runs each.
        """
        from .const import ML_TRAINING_HISTORY_MAX  # noqa: PLC0415

        hist: JSONDict = self._data.setdefault("ml_training_history", {})
        changed = False
        for rec in results or []:
            if not isinstance(rec, dict):
                continue
            cap = rec.get("capability")
            if not isinstance(cap, str) or not cap:
                continue
            if rec.get("new_auc") is not None:
                score, higher_better = float(rec["new_auc"]), True
            elif rec.get("model_mae") is not None:
                score, higher_better = float(rec["model_mae"]), False
            else:
                continue
            series = hist.setdefault(cap, [])
            if not isinstance(series, list):
                series = hist[cap] = []
            series.append({"ts": run_iso, "score": round(score, 5), "higher_better": higher_better})
            del series[:-ML_TRAINING_HISTORY_MAX]
            changed = True
        if changed:
            await self.async_save()

    # ─── On-device matching-config tuning (Stage 4/5, opt-in) ──────────────────

    #: Only these bounded scoring weights (all in [0, 1]) may be overridden
    #: on-device, so a stored record can never change structural matching
    #: behaviour. Mirrors ml.matching_tuner.OVERRIDE_KEYS.
    _MATCHING_OVERRIDE_KEYS = ("corr_weight", "duration_weight", "energy_weight", "dtw_ensemble_w")

    def get_matching_config(self) -> dict[str, Any]:
        """Return the on-device tuned matcher scoring-weight record, if any.

        Record shape: ``{config, trained_at, cycle_count, baseline_test_top1,
        tuned_test_top1}`` where ``config`` holds the bounded scoring weights
        (``corr_weight``/``duration_weight``/``energy_weight``). Empty when the
        matcher is using the shipped defaults.
        """
        raw = self._data.get("matching_config")
        if isinstance(raw, dict):
            return cast(JSONDict, raw).copy()
        return {}

    async def set_matching_config(self, record: dict[str, Any]) -> None:
        """Persist the tuned matcher scoring-weight override + metadata."""
        self._data["matching_config"] = record
        await self.async_save()

    async def clear_matching_config(self) -> None:
        """Revert the matcher to the shipped default scoring weights."""
        self._data.pop("matching_config", None)
        await self.async_save()

    def _matching_overrides(self) -> dict[str, float]:
        """Bounded scoring-weight overrides to merge into the matcher config.

        Only the whitelisted keys are honoured, each clamped to ``[0, 1]``, so a
        stored record can never alter structural matching behaviour - only the
        emphasis between shape, level and energy agreement.
        """
        rec = self._data.get("matching_config")
        cfg = rec.get("config") if isinstance(rec, dict) else None
        out: dict[str, float] = {}
        if isinstance(cfg, dict):
            for k in self._MATCHING_OVERRIDE_KEYS:
                try:
                    out[k] = min(1.0, max(0.0, float(cfg[k])))
                except (KeyError, TypeError, ValueError):
                    continue
        return out

    async def set_cycle_review(
        self,
        cycle_id: str,
        *,
        quality: str | None = None,
        golden: bool | None = None,
        tags: list[str] | None = None,
        notes: str | None = None,
    ) -> bool:
        """Attach an ML-Lab review to a cycle (Stage 4b).

        The review (``quality``/``golden``/``tags``/``notes``) is stored under the
        cycle's ``ml_review`` key and becomes a strong training label for the
        on-device quality model. Only the provided fields are updated. Returns
        True when the cycle was found and updated.
        """
        cycle = next(
            (c for c in self._data.get("past_cycles", []) if c.get("id") == cycle_id),
            None,
        )
        if not cycle:
            raise ValueError(f"Cycle {cycle_id} not found")
        prev_review = cycle.get("ml_review")
        prev_golden = bool(prev_review.get("golden")) if isinstance(prev_review, dict) else False
        review = dict(prev_review or {})
        if quality is not None:
            review["quality"] = quality
        if golden is not None:
            review["golden"] = bool(golden)
        if tags is not None:
            review["tags"] = list(tags)
        if notes is not None:
            review["notes"] = notes
        review["reviewed_at"] = dt_util.now().isoformat()
        cycle["ml_review"] = review
        # Golden cycles seed the profile's matching reference/envelope. When the
        # golden flag actually flips (either direction), rebuild the affected
        # profile so its envelope + reference cycle + cohesion cache reflect the
        # new golden set. Only when the value changed — an unchanged save stays
        # idempotent and skips the (executor-bound) rebuild.
        golden_changed = golden is not None and bool(golden) != prev_golden
        profile_name = cycle.get("profile_name")
        if golden_changed and isinstance(profile_name, str) and profile_name:
            await self.async_rebuild_envelope(profile_name)
        await self.async_save()
        # Don't log the full review (notes/tags are user-entered) — only field names.
        _changed = [
            n for n, v in (("quality", quality), ("golden", golden), ("tags", tags), ("notes", notes))
            if v is not None
        ]
        self._logger.info(
            "Recorded ML review for cycle %s (updated: %s)", cycle_id, ", ".join(_changed) or "none"
        )
        return True

    async def async_backfill_recorded_golden(self) -> int:
        """Flag manually-recorded cycles (meta.source == 'recorder') as golden
        references. Recorded == golden: a single stored flag, no duplicate field.
        Idempotent; saves only when something changed. Returns the count flagged.
        """
        cycles = self._data.get("past_cycles", [])
        if not isinstance(cycles, list):
            return 0
        # Snapshot each cycle's golden state (aligned by list position, which
        # _flag_recorded_cycles_golden never reorders) so we can rebuild exactly
        # the profiles whose cycles get newly flagged — golden cycles seed the
        # matching reference/envelope.
        before_golden = [
            bool(c.get("ml_review", {}).get("golden"))
            if isinstance(c, dict) and isinstance(c.get("ml_review"), dict)
            else False
            for c in cycles
        ]
        flagged = _flag_recorded_cycles_golden(cycles)
        if flagged:
            affected: set[str] = set()
            for was_golden, cycle in zip(before_golden, cycles):
                if was_golden or not isinstance(cycle, dict):
                    continue
                review = cycle.get("ml_review")
                if isinstance(review, dict) and review.get("golden"):
                    pname = cycle.get("profile_name")
                    if isinstance(pname, str) and pname:
                        affected.add(pname)
            for pname in affected:
                await self.async_rebuild_envelope(pname)
            await self.async_save()
            self._logger.info("Backfilled golden flag on %s recorded cycle(s)", flagged)
        return flagged

    def get_feedback_history(self) -> dict[str, dict[str, Any]]:
        """Return mutable feedback history mapping (cycle_id -> record)."""
        raw = self._data.setdefault("feedback_history", {})
        if isinstance(raw, dict):
            return cast(dict[str, dict[str, Any]], raw)
        return {}

    def get_pending_feedback(self) -> dict[str, dict[str, Any]]:
        """Return mutable pending feedback mapping (cycle_id -> request)."""
        raw = self._data.setdefault("pending_feedback", {})
        if isinstance(raw, dict):
            return cast(dict[str, dict[str, Any]], raw)
        return {}

    def add_pending_feedback(self, cycle_id: str, request_data: dict[str, Any]) -> None:
        """Add a pending feedback request (sync wrapper, does not save immediately)."""
        feedbacks = self.get_pending_feedback()
        feedbacks[cycle_id] = request_data
        # Caller must ensure save is called eventually

    def prune_orphaned_feedback(self) -> int:
        """Drop pending-feedback entries whose cycle no longer exists.

        A cycle that was deleted/merged/trimmed can leave its pending feedback
        behind, so the device's "needs review" count stays >0 while the Cycles
        review filter (which matches feedback ids against real cycles) shows
        nothing. Returns the number pruned; never raises. Caller saves.
        """
        try:
            pending = self._data.get("pending_feedback")
            if not isinstance(pending, dict) or not pending:
                return 0
            live_ids = {c.get("id") for c in self.get_past_cycles()}
            orphans = [cid for cid in list(pending) if cid not in live_ids]
            for cid in orphans:
                pending.pop(cid, None)
            return len(orphans)
        except Exception:  # noqa: BLE001
            return 0


    def get_profile(self, name: str) -> JSONDict | None:
        """Return a single profile by name with calculated stats (via list_profiles)."""
        # Reuse list_profiles logic to ensure consistency and avoid duplication
        all_profiles = self.list_profiles()
        return next((p for p in all_profiles if p["name"] == name), None)

    def get_profiles(self) -> dict[str, JSONDict]:
        """Return mutable profiles mapping (profile_name -> profile data)."""
        raw = self._data.setdefault("profiles", {})
        if isinstance(raw, dict):
            return cast(dict[str, JSONDict], raw)
        return {}

    def get_past_cycles(self) -> list[CycleDict]:
        """Return mutable list of stored cycles."""
        raw = self._data.setdefault("past_cycles", [])
        if isinstance(raw, list):
            return cast(list[CycleDict], raw)
        return []

    # ── Community-store account (connect handoff) ─────────────────────────────
    def get_store_account(self) -> dict[str, Any]:
        """Full persisted store account incl. the refresh token (credential)."""
        raw = self._data.get("store_account")
        return dict(raw) if isinstance(raw, dict) else {}

    def get_store_identity(self) -> dict[str, Any]:
        """Safe account view for status/UI - never includes the refresh token."""
        a = self.get_store_account()
        return {
            "connected": bool(a.get("refresh_token")),
            "uid": a.get("uid"),
            "name": a.get("name"),
            "brand": a.get("brand"),
            "model": a.get("model"),
        }

    async def set_store_account(self, account: dict[str, Any]) -> None:
        """Persist/merge the store account (refresh_token, uid, name, brand, model)."""
        cur = self.get_store_account()
        cur.update({k: v for k, v in account.items() if v is not None})
        self._data["store_account"] = cur
        await self.async_save()

    async def clear_store_account(self) -> None:
        self._data.pop("store_account", None)
        await self.async_save()

    def get_reference_cycles(self) -> list[CycleDict]:
        """Return the imported-store reference cycles.

        These are NOT in ``past_cycles``: they feed only the envelope shape and the
        matcher template, never usage/energy/count/trend stats.
        """
        raw = self._data.setdefault("reference_cycles", [])
        if isinstance(raw, list):
            return cast(list[CycleDict], raw)
        return []

    def get_backfill_cycles(self) -> list[CycleDict]:
        """Return the cycles recovered from raw power history (issue #344).

        A third list, deliberately neither of the other two. Like ``reference_cycles``
        these shape the envelope and can serve as a matching template once labelled, and
        never touch usage/energy/count stats. Unlike them they are **not** curated: they
        were auto-detected by replaying a history export, nothing has verified them, so
        they are never golden, never shareable, and never training data.
        """
        raw = self._data.setdefault("backfill_cycles", [])
        if isinstance(raw, list):
            return cast(list[CycleDict], raw)
        return []

    def iter_stored_cycles(self) -> list[CycleDict]:
        """Every stored cycle, from all three lists.

        The single source for "find a cycle by id" and "is this id still real?". Profile
        garbage collection and sample repair delete a profile whose ``sample_cycle_id``
        resolves to nothing, so a read path that forgets one of the lists silently
        destroys an import-only profile. Route those lookups through here rather than
        open-coding the union.
        """
        return [
            *self.get_past_cycles(),
            *self.get_reference_cycles(),
            *self.get_backfill_cycles(),
        ]

    @property
    def evidence_sources(self) -> tuple[str, ...]:
        """Categories currently allowed to shape a profile."""
        return self._evidence_sources

    @evidence_sources.setter
    def evidence_sources(self, value: Any) -> None:
        """Accept a user selection, falling back to all categories.

        An empty or unrecognised selection is treated as "all": with nothing allowed
        every envelope would be empty and every profile unmatchable, which is worse than
        ignoring the setting. Unknown names are dropped rather than trusted.
        """
        wanted = tuple(
            name for name in PROFILE_EVIDENCE_SOURCES
            if isinstance(value, (list, tuple, set, frozenset)) and name in value
        )
        if not wanted:
            if value:
                self._logger.warning(
                    "Ignoring profile-evidence selection %s: no known category left, "
                    "falling back to all", value,
                )
            wanted = tuple(PROFILE_EVIDENCE_SOURCES)
        self._evidence_sources = wanted

    def iter_evidence_cycles(self) -> list[CycleDict]:
        """Stored cycles the user allows to shape a profile.

        The gated twin of :meth:`iter_stored_cycles`. Use this for the envelope, the
        matcher's snapshot pool and the matching template - anything that answers "what
        does this profile look like". Never use it for a lookup by id or a validity check:
        an excluded cycle still exists, and profile garbage collection reading this view
        would delete a profile whose only cycles the user had merely stopped trusting.
        """
        allowed = self._evidence_sources
        out: list[CycleDict] = []
        if EVIDENCE_REAL_CYCLES in allowed:
            out.extend(self.get_past_cycles())
        if EVIDENCE_REFERENCE_CYCLES in allowed:
            out.extend(self.get_reference_cycles())
        if EVIDENCE_BACKFILL_CYCLES in allowed:
            out.extend(self.get_backfill_cycles())
        return out

    def find_stored_cycle(self, cycle_id: str) -> tuple[CycleDict | None, str]:
        """Locate a cycle by id, returning it with the name of the list holding it.

        The origin is what decides capability: ``past`` cycles are fully editable,
        ``reference`` and ``backfill`` cycles can be labelled but not trimmed or split.
        """
        for origin, cycles in (
            ("past", self.get_past_cycles()),
            ("reference", self.get_reference_cycles()),
            ("backfill", self.get_backfill_cycles()),
        ):
            match = next((c for c in cycles if c.get("id") == cycle_id), None)
            if match is not None:
                return match, origin
        return None, ""

    def get_shareable_cycles(self) -> list[dict[str, Any]]:
        """Recorded/golden reference cycles eligible to share to the community store.

        A cycle qualifies when it is hand-flagged golden (``ml_review.golden``) or a
        manual recording (``meta.source == "recorder"``) and carries a program label.
        Imported ``reference_cycles`` are intentionally excluded (you do not re-share
        what you downloaded). Returns light summaries (no traces), most-recent-first;
        the whole set (not a page) so the panel's share tree can offer all of them.
        Never raises.
        """
        out: list[dict[str, Any]] = []
        for c in self.get_past_cycles():
            if not isinstance(c, dict):
                continue
            program = str(c.get("profile_name") or "").strip()
            if not program:
                continue
            review = c.get("ml_review") if isinstance(c.get("ml_review"), dict) else {}
            meta = c.get("meta") if isinstance(c.get("meta"), dict) else {}
            if not (review.get("golden") or meta.get("source") == "recorder"):
                continue
            out.append({
                "id": c.get("id"),
                "profile_name": program,
                "start_time": c.get("start_time"),
                "duration": c.get("duration"),
                "source": meta.get("source"),
            })
        out.sort(key=lambda r: r.get("start_time") or "", reverse=True)
        return out

    def _add_reference_cycle_nosave(
        self, profile_name: str, points: list[list[float]], meta: dict[str, Any],
        *, id_pool: set[Any] | None = None,
    ) -> str:
        """Validate + append one reference cycle. NO envelope rebuild, NO save.

        The mutation core shared by ``add_reference_cycle`` (single-cycle: rebuild +
        save after) and ``async_import_data_selective`` (bulk: rebuild each touched
        profile once + a single save at the end). Returns the new cycle id, or ``""``
        when the trace is unusable (mutating nothing).
        """
        # Validate the trace BEFORE creating any persistent state (profile entry / reference
        # cycle): a garbage trace returns "" and mutates nothing. Same rule the import guard
        # uses to decide whether a replace has anything usable to refill a destination with.
        pairs = _usable_reference_pairs(points)
        if pairs is None:
            return ""
        duration = float(pairs[-1][0] - pairs[0][0])
        # Re-base to offset 0 so envelope reconstruction and DTW work correctly.
        if pairs[0][0] != 0.0:
            origin = pairs[0][0]
            pairs = [[p[0] - origin, p[1]] for p in pairs]
        now = dt_util.as_utc(dt_util.now())
        store_id = str(meta.get("store_cycle_id") or "")
        cycle: CycleDict = {
            "profile_name": profile_name,
            "power_data": pairs,
            "start_time": now.isoformat(),
            # Keep the timestamp interval consistent with the imported duration
            # (both were previously "now", giving a zero-length interval).
            "end_time": (now + timedelta(seconds=duration)).isoformat(),
            "duration": duration,
            "status": "completed",
            "ml_review": {"golden": True},
            "meta": {
                "source": f"store:{store_id}" if store_id else "store",
                "store_uploaded_at": meta.get("store_uploaded_at"),
            },
        }
        if meta.get("sampling_interval"):
            # ignore a malformed sampling_interval rather than raise mid-import
            with contextlib.suppress(TypeError, ValueError):
                cycle["sampling_interval"] = float(meta["sampling_interval"])
        # A reference cycle implies its program exists locally; create a minimal profile
        # entry if absent so the matcher iterates it and the rebuild can set its template.
        profiles = self._data.setdefault("profiles", {})
        if profile_name not in profiles:
            profiles[profile_name] = {"avg_duration": duration}
        self._add_cycle_data(cycle, target=self._data.setdefault("reference_cycles", []), id_pool=id_pool)
        return str(cycle.get("id", ""))

    async def add_reference_cycle(
        self, profile_name: str, points: list[list[float]], meta: dict[str, Any]
    ) -> str:
        """Import a reference cycle downloaded from the store into ``reference_cycles``.

        ``points`` is a raw trace of ``[offset_seconds, watts]`` pairs. ``meta`` may carry
        ``store_cycle_id`` (-> ``meta.source = "store:<id>"``), ``store_uploaded_at`` and
        ``sampling_interval``. The cycle is stamped with import-time timestamps (its real
        run time is meaningless locally), forced ``status="completed"`` and
        ``ml_review.golden=True`` so it seeds the envelope shape, then the envelope is
        rebuilt. Never accumulates lifetime energy or touches ``past_cycles``.
        """
        cid = self._add_reference_cycle_nosave(profile_name, points, meta)
        if not cid:
            return ""
        await self.async_rebuild_envelope(profile_name)
        await self.async_save()
        return cid

    # ── Profile groups (Stage 5: near-duplicate variants) ──────────────────────

    def get_profile_groups(self) -> dict[str, JSONDict]:
        """Return mutable profile-groups mapping (group_name -> {members, created_at}).

        A group bundles near-duplicate profiles (e.g. the same program at
        different temperature/spin). The matcher treats the group as one
        aggregate candidate and only picks the exact member once the group wins.
        Groups with fewer than 2 present members are ignored by the matcher.
        """
        raw = self._data.setdefault("profile_groups", {})
        if not isinstance(raw, dict):
            self._data["profile_groups"] = {}
            return self._data["profile_groups"]
        # Drop members that no longer exist as profiles (kept consistent lazily).
        profiles = self.get_profiles()
        for g in raw.values():
            if isinstance(g, dict) and isinstance(g.get("members"), list):
                g["members"] = [m for m in g["members"] if m in profiles]
        return cast(dict[str, JSONDict], raw)

    def _members_in_other_groups(self, members: list[str], exclude: str | None) -> dict[str, str]:
        """Map any member already assigned to a DIFFERENT group -> that group name."""
        owner: dict[str, str] = {}
        for gname, g in self.get_profile_groups().items():
            if gname == exclude or not isinstance(g, dict):
                continue
            for m in (g.get("members") or []):
                owner.setdefault(m, gname)
        return {m: owner[m] for m in members if m in owner}

    async def create_profile_group(self, name: str, members: list[str]) -> bool:
        """Create (or overwrite) a group with the given member profile names."""
        name = (name or "").strip()
        if not name:
            raise ValueError("Group name is required")
        profiles = self.get_profiles()
        members = [m for m in dict.fromkeys(members or []) if m in profiles]
        # A profile may belong to at most one group (else _grouped_snapshots would
        # collapse it inconsistently). Reject up front — no partial mutation.
        conflicts = self._members_in_other_groups(members, exclude=name)
        if conflicts:
            raise ValueError(
                "Already in another group: "
                + ", ".join(f"{m} ({g})" for m, g in conflicts.items())
            )
        groups = self.get_profile_groups()
        groups[name] = {"members": members, "created_at": dt_util.now().isoformat()}
        self._cohesion_cache_generation += 1
        await self.async_save()
        self._logger.info("Created profile group %r with %d members", name, len(members))
        return True

    async def set_profile_group_members(self, name: str, members: list[str]) -> bool:
        """Replace a group's member list. Deletes the group if left empty."""
        groups = self.get_profile_groups()
        if name not in groups:
            raise ValueError(f"Group {name} not found")
        profiles = self.get_profiles()
        members = [m for m in dict.fromkeys(members or []) if m in profiles]
        conflicts = self._members_in_other_groups(members, exclude=name)
        if conflicts:
            raise ValueError(
                "Already in another group: "
                + ", ".join(f"{m} ({g})" for m, g in conflicts.items())
            )
        if members:
            groups[name]["members"] = members
        else:
            groups.pop(name, None)
        self._cohesion_cache_generation += 1
        await self.async_save()
        return True

    async def rename_profile_group(self, name: str, new_name: str) -> bool:
        groups = self.get_profile_groups()
        new_name = (new_name or "").strip()
        if name not in groups or not new_name:
            raise ValueError("Group not found or new name empty")
        if new_name != name:
            if new_name in groups:
                raise ValueError(f"A group named {new_name!r} already exists")
            groups[new_name] = groups.pop(name)
        self._cohesion_cache_generation += 1
        await self.async_save()
        return True

    async def delete_profile_group(self, name: str) -> bool:
        groups = self.get_profile_groups()
        if groups.pop(name, None) is None:
            return False
        self._cohesion_cache_generation += 1
        await self.async_save()
        self._logger.info("Deleted profile group %r", name)
        return True

    def _profile_curve(self, name: str, n: int = 150) -> np.ndarray | None:
        """A profile's envelope average resampled to n points, or None."""
        env = self._data.get("envelopes", {}).get(name) if isinstance(self._data.get("envelopes"), dict) else None
        avg = env.get("avg") if isinstance(env, dict) else None
        if not avg or not isinstance(avg[0], (list, tuple)):
            return None
        ys = np.asarray([float(p[1]) for p in avg], dtype=float)
        if ys.size < 4:
            return None
        return np.interp(np.linspace(0, 1, n), np.linspace(0, 1, ys.size), ys)

    @staticmethod
    def _shape_similarity(a: np.ndarray, b: np.ndarray) -> float:
        """Duration- and amplitude-tolerant shape similarity in (0,1].

        Peak-normalises both curves (so temperature/spin amplitude differences
        don't count against membership) then aligns them with a Sakoe-Chiba-band
        DTW (so a longer heating or draining phase at a different setting warps
        into alignment instead of being penalised as a different shape). 1.0 =
        identical shape; genuinely different programs score low even after warping.
        """
        na = a / (float(a.max()) or 1.0)
        nb = b / (float(b.max()) or 1.0)
        dist = analysis.compute_dtw_lite(na, nb, band_width_ratio=0.2)
        norm = dist / max(len(na), 1)  # mean per-sample distance on [0,1] curves
        return 1.0 / (1.0 + norm / 0.15)

    def group_cohesion(self, members: list[str]) -> float:
        """Minimum pairwise shape similarity among a group's members (1.0 =
        identical shapes; see _shape_similarity for the DTW/peak-normalised
        metric). Low cohesion means the members are not really the same program,
        so their averaged aggregate would be too generic and could out-match
        unrelated profiles - the matcher refuses to aggregate below
        GROUP_MIN_COHESION and the UI warns the user.

        Results are cached per member-set and invalidated via
        ``_cohesion_cache_generation`` (incremented by group mutation methods) so
        the DTW pairwise comparison does not run on the event loop every 5 minutes.
        """
        key = tuple(sorted(members))
        with self._cohesion_cache_lock:
            if self._cohesion_cache_generation != self._cohesion_cache_generation_checked:
                self._cohesion_cache.clear()
                self._cohesion_cache_generation_checked = self._cohesion_cache_generation
            if key in self._cohesion_cache:
                return self._cohesion_cache[key]
            curves = [c for c in (self._profile_curve(m) for m in members) if c is not None]
            if len(members) < 2:
                # A genuinely single-member group is trivially cohesive (and is never
                # collapsed anyway — nothing to aggregate).
                result = 1.0
            elif len(curves) < 2:
                # Multi-member but too few built curves -> insufficient evidence, treat as
                # NOT cohesive so the group isn't collapsed into a blurry aggregate yet.
                result = 0.0
            else:
                result = 1.0
                for i in range(len(curves)):
                    for j in range(i + 1, len(curves)):
                        result = min(result, self._shape_similarity(curves[i], curves[j]))
            self._cohesion_cache[key] = result
            return result

    def _grouped_snapshots(
        self, snapshots: list[dict[str, Any]]
    ) -> tuple[list[dict[str, Any]], dict[str, list[str]], dict[str, dict[str, Any]]]:
        """Collapse each *cohesive* group into one aggregate candidate snapshot.

        Returns (snapshots, group_members, member_snaps). Groups with <2 present
        members, or cohesion below GROUP_MIN_COHESION (too generic an aggregate),
        are left as individual member snapshots. Aggregate name is prefixed
        ``__group__`` and mapped to its member profile names in group_members.
        """
        groups = self.get_profile_groups()
        if not groups:
            return snapshots, {}, {}
        by_name = {s["name"]: s for s in snapshots}
        member_to_group: dict[str, str] = {}
        for gname, g in groups.items():
            present = [m for m in (g.get("members") or []) if m in by_name]
            if len(present) < 2 or self.group_cohesion(present) < GROUP_MIN_COHESION:
                continue  # keep members individual
            for m in present:
                member_to_group[m] = gname
        if not member_to_group:
            return snapshots, {}, {}
        n = 200
        agg_curves: dict[str, list[np.ndarray]] = {}
        agg_durs: dict[str, list[float]] = {}
        agg_spans: dict[str, list[float]] = {}
        member_snaps: dict[str, dict[str, Any]] = {}
        out: list[dict[str, Any]] = []
        for s in snapshots:
            g = member_to_group.get(s["name"])
            if g is None:
                out.append(s)
                continue
            member_snaps[s["name"]] = s
            sp = s.get("sample_power") or []
            if len(sp) >= 2:
                arr = np.asarray(sp, dtype=float)
                agg_curves.setdefault(g, []).append(
                    np.interp(np.linspace(0, 1, n), np.linspace(0, 1, arr.size), arr)
                )
                _dur = float(s.get("avg_duration") or 0.0)
                agg_durs.setdefault(g, []).append(_dur)
                # Members are resampled onto a normalized 0..1 axis above, so the
                # aggregate's own time span is the mean of its members' (#364).
                agg_spans.setdefault(g, []).append(float(s.get("sample_span_s") or _dur))
        group_members: dict[str, list[str]] = {}
        for g, curves in agg_curves.items():
            key = f"__group__{g}"
            durs = [d for d in agg_durs[g] if d > 0]
            spans = [v for v in agg_spans.get(g, []) if v > 0]
            out.append({
                "name": key,
                "avg_duration": float(np.mean(durs)) if durs else 0.0,
                "sample_power": np.mean(np.array(curves), axis=0).tolist(),
                "sample_span_s": float(np.mean(spans)) if spans else 0.0,
            })
            group_members[key] = [m for m in member_to_group if member_to_group[m] == g and m in member_snaps]
        return out, group_members, member_snaps

    def _stage5_pick_member(
        self, current_power: list[float], current_duration: float,
        members: list[str], member_snaps: dict[str, dict[str, Any]],
    ) -> tuple[str, float | None, float | None]:
        """Within a winning group, pick the member whose integrated ENERGY best
        matches the cycle.

        Group members already share shape and duration (that is what the cohesion
        gate collapses them for), so the within-group discriminator is temperature
        / spin, and the clean signal for that is integrated energy (Sum P*dt) --
        NOT whole-cycle mean power (diluted, because hotter cycles also run longer)
        nor peak (dominated by the ~constant heating-element draw, so it is flat
        across variants). Validated on real store data (leave-one-cycle-out member
        pick, 196 cycles): energy-only 73.3% vs the old duration*mean*peak product
        63.3%; adding any duration term back regressed it (grouped members are
        duration-cohesive, so duration only adds noise). Duration is still returned
        (for ETA and the overrun guard) but is intentionally not part of selection.

        Returns (member_name, individual_fit_score, member_avg_duration). The fit
        score is the chosen member's own alignment score, used as a sanity check."""
        cur = np.asarray(current_power, dtype=float)
        if cur.size == 0 or not members:
            return (members[0] if members else ""), None, None
        # Energy proxy = mean power * duration; the 1/3600 Wh factor cancels in the
        # log-ratio, so this is exact up to that constant.  This proxy equals the
        # true integral only because `current_power` is already resampled onto a
        # uniform grid by the matcher; raw or irregular traces must use
        # signal_processing.integrate_wh instead.
        cur_energy = float(cur.mean()) * float(current_duration)

        def agree(a: float, b: float, scale: float) -> float:
            if a <= 0 or b <= 0:
                return 0.0
            return 1.0 / (1.0 + abs(math.log(a / b)) / scale)

        best_m, best_sc, best_dur = members[0], -1.0, None
        for m in members:
            snap = member_snaps.get(m)
            if not snap:
                continue
            sp = np.asarray(snap.get("sample_power") or [], dtype=float)
            if sp.size == 0:
                continue
            md = float(snap.get("avg_duration") or 0.0)
            mem_energy = float(sp.mean()) * md
            # Scale 0.30 is validated as insensitive over 0.25-0.40 on real data.
            sc = agree(cur_energy, mem_energy, 0.30)
            if sc > best_sc:
                best_sc, best_m, best_dur = sc, m, md
        fit = None
        snap = member_snaps.get(best_m)
        if snap and snap.get("sample_power"):
            try:
                fit = float(analysis.find_best_alignment(current_power, snap["sample_power"], 1.0)[0])
            except Exception:  # pylint: disable=broad-exception-caught
                fit = None
        return best_m, fit, best_dur

    def suggest_profile_groups(self, dur_tol: float = 0.60, sim_min: float = 0.85) -> list[dict[str, Any]]:
        """Detect clusters of near-duplicate profiles not already fully grouped.

        Two profiles cluster when their durations are within a (loose) ``dur_tol``
        sanity bound AND their DTW/peak-normalised shape similarity exceeds
        ``sim_min`` (same program shape; they may differ in temperature/spin and in
        phase length, which the DTW alignment tolerates). The duration bound is
        loose because higher-temp/lower-rpm variants legitimately run longer - it
        only rules out grouping, say, a 20-min rinse with a 3-hour cotton.
        Returns {"members": [...], "existing_group": name|None} suggestions the
        user confirms. Never mutates state.
        """
        profiles = self.get_profiles()
        envelopes = self._data.get("envelopes", {})
        # Build per-profile (avg curve resampled to N, avg duration).
        N = 150
        reps: dict[str, tuple[np.ndarray, float]] = {}
        for name, prof in profiles.items():
            env = envelopes.get(name) if isinstance(envelopes, dict) else None
            avg = env.get("avg") if isinstance(env, dict) else None
            dur = float(prof.get("avg_duration") or 0.0)
            if not avg or dur <= 0 or not isinstance(avg[0], (list, tuple)):
                continue
            ys = np.asarray([float(p[1]) for p in avg], dtype=float)
            if ys.size < 4:
                continue
            curve = np.interp(np.linspace(0, 1, N), np.linspace(0, 1, ys.size), ys)
            reps[name] = (curve, dur)
        names = list(reps)
        # Union-find near-duplicates.
        parent = {n: n for n in names}
        def find(x: str) -> str:
            while parent[x] != x:
                parent[x] = parent[parent[x]]; x = parent[x]
            return x
        lim = math.log(1.0 + dur_tol)
        for i in range(len(names)):
            for j in range(i + 1, len(names)):
                a, b = names[i], names[j]
                (ca, da), (cb, db) = reps[a], reps[b]
                if da <= 0 or db <= 0 or abs(math.log(da / db)) > lim:
                    continue
                if self._shape_similarity(ca, cb) > sim_min:
                    parent[find(a)] = find(b)
        clusters: dict[str, list[str]] = {}
        for n in names:
            clusters.setdefault(find(n), []).append(n)
        # Only clusters of >=2; annotate with any existing group overlap; skip
        # clusters already fully contained in one group.
        out: list[dict[str, Any]] = []
        groups = self.get_profile_groups()
        for members in clusters.values():
            if len(members) < 2:
                continue
            existing = None
            for gname, g in groups.items():
                gmembers = set(g.get("members") or [])
                if gmembers & set(members):
                    existing = gname
                    if set(members) <= gmembers:
                        members = []  # already grouped
                    break
            if members:
                out.append({"members": sorted(members), "existing_group": existing})
        return out

    # ------------------------------------------------------------------
    # A1: Underrun anomaly helpers
    # ------------------------------------------------------------------

    def get_profile_median_duration(self, profile_name: str) -> float | None:
        """Median cycle duration (s) for this profile across all labeled cycles. Never raises."""
        try:
            durations = [
                float(c["duration"])
                for c in self.get_past_cycles()
                if c.get("profile_name") == profile_name and c.get("duration")
            ]
            return float(np.median(durations)) if len(durations) >= 2 else None
        except Exception:  # noqa: BLE001
            return None

    # ------------------------------------------------------------------
    # A2: Energy anomaly helpers
    # ------------------------------------------------------------------

    def get_profile_energy_stats(self, profile_name: str) -> dict[str, float] | None:
        """Energy stats {avg_wh, std_wh, n} for this profile. None if fewer than 3 cycles. Never raises."""
        try:
            energies = [
                float(c["energy_wh"])
                for c in self.get_past_cycles()
                if c.get("profile_name") == profile_name and c.get("energy_wh")
            ]
            if len(energies) < 3:
                return None
            arr = np.asarray(energies, dtype=float)
            return {"avg_wh": float(np.mean(arr)), "std_wh": float(np.std(arr)), "n": len(energies)}
        except Exception:  # noqa: BLE001
            return None

    # ------------------------------------------------------------------
    # A4: Profile warm-up mode helper
    # ------------------------------------------------------------------

    def get_profile_labeled_count(self, profile_name: str) -> int:
        """Number of labeled cycles for this profile. Never raises."""
        try:
            return sum(1 for c in self.get_past_cycles() if c.get("profile_name") == profile_name)
        except Exception:  # noqa: BLE001
            return 0

    def profile_has_reference_cycles(self, profile_name: str) -> bool:
        """True if this profile was seeded from imported store reference cycle(s).

        Such a profile is a trusted, community-shared template that the user
        downloaded to match immediately, so it is exempt from the local warm-up
        gate (which exists to stabilise profiles built from a few local cycles).
        """
        try:
            return any(
                c.get("profile_name") == profile_name
                for c in self.get_reference_cycles()
            )
        except Exception:  # noqa: BLE001
            return False

    # ------------------------------------------------------------------
    # B1: Lifetime energy accumulator (HA Energy dashboard)
    # ------------------------------------------------------------------

    def get_lifetime_energy_wh(self) -> float:
        """Total accumulated lifetime energy (Wh). Pure getter — never persists. Never raises."""
        try:
            return float(self._data.get("lifetime_energy_wh", 0.0))
        except Exception:  # noqa: BLE001
            return 0.0

    async def async_add_lifetime_energy_wh(self, wh: float) -> None:
        """Add *wh* (clamped >= 0) to the lifetime energy total and persist.

        Called exactly once per completed cycle so it never double-counts. Does
        not backfill from history — the meter starts at 0 and accumulates forward.
        Ignores non-numeric input.
        """
        try:
            add = max(0.0, float(wh))
        except (ValueError, TypeError):
            return
        base = self.get_lifetime_energy_wh()
        self._data["lifetime_energy_wh"] = round(base + add, 3)
        await self.async_save()

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

        Only ever increments (never regresses when history is trimmed/merged), so it
        is the correct basis for milestone crossings. Pure getter - never persists.
        Never raises; returns 0 when unset.
        """
        try:
            return int(self._data.get("lifetime_cycle_count", 0) or 0)
        except Exception:  # noqa: BLE001
            return 0

    def set_lifetime_cycle_count(self, count: int) -> None:
        """Set the lifetime cycle count in memory WITHOUT persisting.

        The cycle-end path batches this with the immediately-following lifetime
        energy save (:meth:`async_add_lifetime_energy_wh`) so the store is written
        once per cycle. Encapsulates the storage key so callers need not touch
        ``_data`` directly. Ignores non-integer input.
        """
        try:
            self._data["lifetime_cycle_count"] = int(count)
        except (TypeError, ValueError):
            pass

    # ------------------------------------------------------------------
    # E1: Maintenance log & predictive-maintenance reminders (Group E)
    # ------------------------------------------------------------------

    def get_maintenance_log(self) -> list[dict[str, Any]]:
        """Return logged maintenance events, most-recent-first. Never raises.

        Each entry is ``{"id", "date", "event_type", "notes"}``. Entries with an
        unparseable date sort last.
        """
        try:
            raw = self._data.get("maintenance_log", [])
            if not isinstance(raw, list):
                return []
            entries = [dict(e) for e in raw if isinstance(e, dict)]
            _floor = datetime.min.replace(tzinfo=dt_util.UTC)
            entries.sort(
                key=lambda e: _parse_maintenance_dt(e.get("date")) or _floor,
                reverse=True,
            )
            return entries
        except Exception:  # noqa: BLE001
            return []

    async def async_add_maintenance_event(
        self, event_type: str, date: str | None = None, notes: str = ""
    ) -> dict[str, Any]:
        """Append a maintenance event and persist. Returns the created entry.

        ``event_type`` must be one of :data:`MAINTENANCE_EVENT_TYPES` (else raises
        ``ValueError``). ``date`` defaults to the current timestamp; a short unique
        id is generated for the entry.
        """
        if event_type not in MAINTENANCE_EVENT_TYPES:
            raise ValueError(f"Unknown maintenance event_type: {event_type!r}")
        entry: dict[str, Any] = {
            "id": uuid.uuid4().hex[:12],
            "date": date if isinstance(date, str) and date else dt_util.now().isoformat(),
            "event_type": event_type,
            "notes": str(notes or ""),
        }
        log = self._data.setdefault("maintenance_log", [])
        if not isinstance(log, list):
            log = []
            self._data["maintenance_log"] = log
        log.append(entry)
        await self.async_save()
        return entry

    async def async_delete_maintenance_event(self, event_id: str) -> bool:
        """Remove a maintenance event by id, persist, and report whether removed."""
        log = self._data.get("maintenance_log")
        if not isinstance(log, list):
            return False
        remaining = [e for e in log if not (isinstance(e, dict) and e.get("id") == event_id)]
        if len(remaining) == len(log):
            return False
        self._data["maintenance_log"] = remaining
        await self.async_save()
        return True

    # ─── Playground presets ────────────────────────────────────────────────────
    # Named snapshots of the Playground's settings control panel. Sandbox data:
    # nothing here ever reaches the live detector or matcher - the user publishes
    # individual values to entry.options explicitly (ws_set_options) if they want
    # them live. Stored per device because the values are device-scale (watts,
    # seconds tuned for THIS appliance).

    def get_playground_presets(self) -> dict[str, JSONDict]:
        """Return the mutable playground-presets mapping (name -> record).

        Each record is ``{"values": {...}, "created_at": iso, "updated_at": iso}``.
        Self-heals a corrupt/missing key to an empty mapping. Never raises.
        """
        raw = self._data.setdefault("playground_presets", {})
        if not isinstance(raw, dict):
            self._data["playground_presets"] = {}
            return cast(dict[str, JSONDict], self._data["playground_presets"])
        return cast(dict[str, JSONDict], raw)

    @staticmethod
    def _playground_preset_key(name: str) -> str:
        """Canonical storage key for a Playground preset name.

        Save and delete MUST derive the key the same way: truncating only on save
        meant a name longer than the cap was stored truncated but looked up in
        full, leaving a preset that could never be deleted. The trailing strip()
        runs after the clamp so a cut landing mid-space cannot bake a trailing
        space into the key.
        """
        return (name or "").strip()[:PLAYGROUND_PRESET_NAME_MAX].strip()

    async def async_save_playground_preset(
        self, name: str, values: dict[str, Any]
    ) -> JSONDict:
        """Create or overwrite a named Playground preset and persist it.

        ``values`` must already be sanitized by ``playground.sanitize_setting_values``
        (the store deliberately does not import the playground module - that would
        be a cycle). Raises ``ValueError`` for an empty name, an empty value map, or
        when the per-device preset cap is reached by a NEW name.
        """
        name = self._playground_preset_key(name)
        if not name:
            raise ValueError("Preset name is required")
        if not isinstance(values, dict) or not values:
            raise ValueError("Preset has no settings to save")
        presets = self.get_playground_presets()
        existing = presets.get(name)
        if existing is None and len(presets) >= PLAYGROUND_PRESET_MAX:
            raise ValueError(
                f"Preset limit reached ({PLAYGROUND_PRESET_MAX}); delete one first"
            )
        now = dt_util.now().isoformat()
        created = now
        if isinstance(existing, dict) and isinstance(existing.get("created_at"), str):
            created = existing["created_at"]
        record: JSONDict = {
            "values": dict(values),
            "created_at": created,
            "updated_at": now,
        }
        presets[name] = record
        await self.async_save()
        self._logger.info(
            "Saved playground preset %r with %d values", name, len(values)
        )
        return record

    async def async_delete_playground_preset(self, name: str) -> bool:
        """Remove a Playground preset by name; report whether one was removed."""
        presets = self.get_playground_presets()
        if presets.pop(self._playground_preset_key(name), None) is None:
            return False
        await self.async_save()
        self._logger.info("Deleted playground preset %r", name)
        return True

    def cycles_since_maintenance(self, event_type: str) -> int:
        """Count completed cycles since the most recent maintenance event of a type.

        Counts completed cycles (``status == "completed"``) whose ``start_time`` is
        after the most recent maintenance event of ``event_type``. If no such event
        was ever logged, returns the total completed-cycle count. Never raises.
        """
        try:
            completed = [
                c for c in self.get_past_cycles()
                if isinstance(c, dict) and c.get("status") == "completed"
            ]
            latest_dt: datetime | None = None
            for e in self._data.get("maintenance_log", []) or []:
                if not isinstance(e, dict) or e.get("event_type") != event_type:
                    continue
                dt = _parse_maintenance_dt(e.get("date"))
                if dt is not None and (latest_dt is None or dt > latest_dt):
                    latest_dt = dt
            if latest_dt is None:
                return len(completed)
            count = 0
            for c in completed:
                start = _parse_start_dt(c.get("start_time"))
                if start is not None and start > latest_dt:
                    count += 1
            return count
        except Exception:  # noqa: BLE001
            return 0

    def get_maintenance_due(self, reminder_cfg: dict[str, Any] | None) -> list[str]:
        """Return event types whose cycles-since threshold has been reached.

        For each event type with a positive integer threshold in ``reminder_cfg``,
        includes it when ``cycles_since_maintenance(event_type) >= threshold``.
        Never raises.
        """
        try:
            if not isinstance(reminder_cfg, dict):
                return []
            due: list[str] = []
            for event_type, threshold in reminder_cfg.items():
                try:
                    thr = int(threshold)
                except (TypeError, ValueError):
                    continue
                if thr <= 0:
                    continue
                if self.cycles_since_maintenance(str(event_type)) >= thr:
                    due.append(str(event_type))
            return due
        except Exception:  # noqa: BLE001
            return []

    def has_recent_maintenance(
        self, event_type: str, days: int = MAINTENANCE_RECENT_SUPPRESS_DAYS
    ) -> bool:
        """True if a matching maintenance event was logged within ``days``. Never raises."""
        try:
            cutoff = dt_util.now() - timedelta(days=max(0, int(days)))
            for e in self._data.get("maintenance_log", []) or []:
                if not isinstance(e, dict) or e.get("event_type") != event_type:
                    continue
                dt = _parse_maintenance_dt(e.get("date"))
                if dt is not None and dt >= cutoff:
                    return True
            return False
        except Exception:  # noqa: BLE001
            return False

    def compute_profile_health(self) -> dict[str, dict[str, Any]]:
        """Compute per-profile health indicators from labeled cycle history.

        For each profile that has at least 3 labeled cycles, returns a dict with:
          ``cycle_count``      – number of labeled cycles used in the calculation
          ``confidence_mean``  – mean match_confidence across those cycles (0–1)
          ``duration_cv``      – coefficient of variation of cycle durations (lower = consistent)
          ``health_score``     – composite 0–1 health score (1 = healthy, 0 = needs attention)
          ``health_status``    – "healthy" / "fair" / "poor" / "unknown"
          ``shape_drift``      – True when the early/recent envelope correlation is below
                                 SHAPE_DRIFT_THRESHOLD (only present when >= SHAPE_DRIFT_MIN_CYCLES
                                 labeled cycles with power_data exist)
          ``shape_drift_correlation`` – Pearson r between early and recent average envelopes

        Profiles with fewer than 3 labeled cycles return ``health_status="unknown"``.
        Never raises — returns an empty dict on any error.
        """
        try:
            cycles = self.get_past_cycles()
            from collections import defaultdict  # pylint: disable=import-outside-toplevel
            by_profile: dict[str, list[dict[str, Any]]] = defaultdict(list)
            for c in cycles:
                name = c.get("profile_name")
                if name:
                    by_profile[str(name)].append(c)

            result: dict[str, dict[str, Any]] = {}
            for name, pcy in by_profile.items():
                count = len(pcy)
                durations = [float(c["duration"]) for c in pcy if c.get("duration")]
                confidences = [
                    float(c["match_confidence"])
                    for c in pcy
                    if c.get("match_confidence") is not None  # keep genuine 0.0, drop only absent
                ]

                if count < 3 or not durations:
                    result[name] = {"cycle_count": count, "health_status": "unknown"}
                    continue

                dur_arr = np.asarray(durations, dtype=float)
                dur_mean = float(np.mean(dur_arr))
                dur_cv = float(np.std(dur_arr) / dur_mean) if dur_mean > 0 else 0.0

                conf_mean = float(np.mean(confidences)) if confidences else 0.5

                # consistency: 1 at CV=0, linearly decays to 0 at CV=0.5+
                consistency = max(0.0, 1.0 - dur_cv / 0.5)
                health_score = round(0.5 * consistency + 0.5 * conf_mean, 3)

                if health_score >= 0.65:
                    status = "healthy"
                elif health_score >= 0.40:
                    status = "fair"
                else:
                    status = "poor"

                # A5: Shape drift — compare early vs recent power curve envelopes.
                _sd: dict[str, Any] = {}
                _traced = [c for c in pcy if c.get("power_data")]
                if len(_traced) >= SHAPE_DRIFT_MIN_CYCLES:
                    _third = len(_traced) // 3
                    _early = _traced[:_third]
                    _recent = _traced[-_third:]
                    try:
                        from .signal_processing import resample_to_n as _resamp  # noqa: PLC0415

                        def _avg_env(cycles: list[dict[str, Any]]) -> "np.ndarray | None":
                            _traces = []
                            for _c in cycles:
                                _raw = decompress_power_data(_c)
                                if not _raw:
                                    continue
                                _pwr = [float(_p) for _, _p in _raw]
                                if len(_pwr) < 5:
                                    continue
                                _t = np.asarray(_resamp(_pwr, SHAPE_DRIFT_RESAMPLE_N), dtype=float)
                                _mx = _t.max()
                                if _mx > 0:
                                    _t = _t / _mx
                                _traces.append(_t)
                            if len(_traces) < 2:
                                return None
                            return np.mean(np.stack(_traces), axis=0)

                        _early_env = _avg_env(_early)
                        _rec_env = _avg_env(_recent)
                        if _early_env is not None and _rec_env is not None:
                            _corr = float(np.corrcoef(_early_env, _rec_env)[0, 1])
                            if not np.isfinite(_corr):
                                _corr = 1.0
                            _sd = {
                                "shape_drift": _corr < SHAPE_DRIFT_THRESHOLD,
                                "shape_drift_correlation": round(_corr, 3),
                            }
                    except Exception:  # noqa: BLE001
                        pass

                result[name] = {
                    "cycle_count": count,
                    "confidence_mean": round(conf_mean, 3),
                    "duration_cv": round(dur_cv, 3),
                    "health_score": health_score,
                    "health_status": status,
                    **_sd,   # merges shape_drift and shape_drift_correlation when available
                }
            return result
        except Exception:  # noqa: BLE001
            return {}

    # ------------------------------------------------------------------
    # Coverage gap detection (unmatched cycle clustering)
    # ------------------------------------------------------------------

    def suggest_coverage_gaps(
        self,
        recent_window: int = 30,
        min_unmatched: int = 5,
        min_unmatched_rate: float = 0.20,
        low_confidence_threshold: float = 0.40,
        duration_bucket_s: float = 900.0,
    ) -> dict[str, Any]:
        """Detect gaps in profile coverage from recent unlabelled cycles.

        Looks at the ``recent_window`` most recent cycles.  When there are
        enough unmatched (no ``profile_name``) or low-confidence cycles,
        returns a record with:

          ``unmatched_count``       – number of cycles with no profile label
          ``low_confidence_count``  – number of labelled cycles below the confidence
                                      threshold (potential mis-matches)
          ``unmatched_rate``        – fraction of recent cycles that are unmatched
          ``suggest_create``        – True when unmatched_count >= min_unmatched
                                      AND unmatched_rate >= min_unmatched_rate
          ``duration_clusters``     – list of duration-cluster dicts for unmatched
                                      cycles: ``{"duration_bucket_min": int,
                                      "count": int, "avg_duration_s": float}``
                                      sorted descending by count. Only clusters
                                      with ≥ 2 members are returned.

        Returns an empty dict when there are fewer than ``min_unmatched``
        unmatched cycles or on any error (never raises).
        """
        try:
            cycles = self.get_past_cycles()
            recent = cycles[-recent_window:] if len(cycles) > recent_window else cycles
            if not recent:
                return {}

            unmatched: list[dict[str, Any]] = []
            low_conf: list[dict[str, Any]] = []
            for c in recent:
                name = c.get("profile_name")
                if not name:
                    unmatched.append(c)
                else:
                    conf = c.get("match_confidence")
                    if (
                        isinstance(conf, (int, float))
                        and not isinstance(conf, bool)
                        and conf < low_confidence_threshold
                    ):
                        low_conf.append(c)

            n_unmatched = len(unmatched)
            n_total = len(recent)
            rate = n_unmatched / n_total if n_total > 0 else 0.0

            if n_unmatched < min_unmatched:
                return {}

            # Cluster unmatched cycles by duration bucket (simple histogram)
            bucket_dur: dict[int, list[float]] = {}
            for c in unmatched:
                dur = c.get("duration")
                if isinstance(dur, (int, float)) and not isinstance(dur, bool) and dur > 0:
                    bucket = int(dur // duration_bucket_s)
                    bucket_dur.setdefault(bucket, []).append(float(dur))

            clusters = []
            for bucket, durs in sorted(bucket_dur.items(), key=lambda kv: -len(kv[1])):
                if len(durs) >= 2:
                    clusters.append({
                        "duration_bucket_min": int(bucket * duration_bucket_s / 60),
                        "count": len(durs),
                        "avg_duration_s": round(float(np.mean(durs)), 1),
                    })

            # A3: Shape-similarity clustering within duration buckets
            profile_suggestions: list[dict[str, Any]] = []
            for bucket, durs in sorted(bucket_dur.items(), key=lambda kv: -len(kv[1])):
                if len(durs) < 2:
                    continue
                bucket_cycles = [
                    c for c in unmatched
                    if c.get("power_data")
                    and c.get("duration")
                    and int(float(c["duration"]) // duration_bucket_s) == bucket
                ]
                if len(bucket_cycles) < 2:
                    continue
                try:
                    from .signal_processing import resample_to_n  # noqa: PLC0415
                    traces: list[np.ndarray] = []
                    ids: list[str] = []
                    for c in bucket_cycles[:5]:  # cap at 5 per bucket for performance
                        raw = decompress_power_data(c)
                        if not raw:
                            continue
                        pwr = [float(p) for _, p in raw]
                        if len(pwr) < 5:
                            continue
                        t = np.asarray(resample_to_n(pwr, CLUSTER_RESAMPLE_N), dtype=float)
                        mx = t.max()
                        if mx > 0:
                            t = t / mx
                        traces.append(t)
                        ids.append(str(c.get("id", "")))
                    if len(traces) < 2:
                        continue
                    corrs = [
                        float(np.corrcoef(traces[i], traces[j])[0, 1])
                        for i in range(len(traces))
                        for j in range(i + 1, len(traces))
                    ]
                    valid_corrs = [r for r in corrs if np.isfinite(r)]
                    if not valid_corrs:
                        continue
                    avg_corr = float(np.mean(valid_corrs))
                    if avg_corr >= CLUSTER_SHAPE_SIMILARITY_THRESHOLD:
                        avg_dur_s = float(np.mean(durs))
                        profile_suggestions.append({
                            "suggested_name": f"~{int(avg_dur_s // 60)} min program",
                            "cycle_ids": [cid for cid in ids if cid],
                            "avg_duration_s": round(avg_dur_s, 1),
                            "count": len(ids),
                            "similarity": round(avg_corr, 3),
                        })
                except Exception:  # noqa: BLE001
                    continue

            # Most-recent unmatched cycle id, so the setup advisor's phase-2
            # "unmatched" nudge can deep-link straight to it (open_cycle:<id>)
            # instead of always falling back to the whole unlabelled list.
            last_unmatched_cycle_id = None
            for c in reversed(unmatched):
                cid = c.get("id")
                if cid:
                    last_unmatched_cycle_id = cid
                    break

            return {
                "unmatched_count": n_unmatched,
                "low_confidence_count": len(low_conf),
                "unmatched_rate": round(rate, 3),
                "suggest_create": bool(n_unmatched >= min_unmatched and rate >= min_unmatched_rate),
                "duration_clusters": clusters,
                "profile_suggestions": profile_suggestions,   # NEW (A3)
                "last_unmatched_cycle_id": last_unmatched_cycle_id,
            }
        except Exception:  # noqa: BLE001
            return {}

    # ------------------------------------------------------------------
    # Profile trend analysis (pure stats, appliance performance drift)
    # ------------------------------------------------------------------

    def compute_profile_trends(
        self,
        min_cycles: int = 12,
        recent_window: int = 8,
        slope_threshold_pct: float = 0.08,
    ) -> dict[str, dict[str, Any]]:
        """Detect per-profile duration / energy drift using ordinary least-squares.

        Uses all labeled cycles in chronological order.  For each profile with at
        least ``min_cycles`` labeled cycles:

        * Fits a linear trend (OLS via NumPy) to the most-recent N=``len(cycles)``
          values (not just the window — the window is used only for the *recent
          mean* display; the slope is computed on all data so short histories don't
          give noisy slopes).
        * Expresses the slope as percent-change-per-cycle relative to the mean,
          so the magnitude is comparable across low- and high-duration profiles.
        * Classifies as ``"up"`` / ``"down"`` / ``"stable"`` per metric when the
          normalized slope exceeds ``slope_threshold_pct`` (default 8%/cycle).

        Returns a dict mapping profile_name → trend record:
          ``duration_trend``            – "up" / "down" / "stable"
          ``duration_slope_pct``        – normalized slope (%/cycle)
          ``duration_recent_mean_s``    – mean duration of the last N cycles (s)
          ``energy_trend``              – "up" / "down" / "stable" (if available)
          ``energy_slope_pct``          – normalized slope (%/cycle)
          ``energy_recent_mean_wh``     – mean energy of the last N cycles (Wh)
          ``cycle_count``               – number of labeled cycles used
          ``recent_window``             – how many cycles the recent means cover

        Profiles with fewer than ``min_cycles`` labeled cycles are omitted.
        Never raises — returns ``{}`` on any error.
        """
        try:
            cycles = self.get_past_cycles()
            from collections import defaultdict  # pylint: disable=import-outside-toplevel
            by_profile: dict[str, list[dict[str, Any]]] = defaultdict(list)
            for c in cycles:
                name = c.get("profile_name")
                if name:
                    by_profile[str(name)].append(c)

            result: dict[str, dict[str, Any]] = {}
            for name, pcy in by_profile.items():
                if len(pcy) < min_cycles:
                    continue
                # Preserve insertion order (chronological from get_past_cycles)
                durations = [float(c["duration"]) for c in pcy if c.get("duration")]
                energies = [float(c["energy_wh"]) for c in pcy if c.get("energy_wh")]

                if len(durations) < min_cycles:
                    continue

                def _slope_pct(values: list[float]) -> float:
                    """OLS slope as % of mean per cycle."""
                    arr = np.asarray(values, dtype=float)
                    n = len(arr)
                    x = np.arange(n, dtype=float)
                    x -= x.mean()
                    denom = float(np.dot(x, x))
                    if denom < 1e-9:
                        return 0.0
                    slope = float(np.dot(x, arr)) / denom
                    mean_val = float(np.mean(arr))
                    return (slope / mean_val) if abs(mean_val) > 1e-9 else 0.0

                def _classify(slope_pct: float) -> str:
                    if slope_pct > slope_threshold_pct:
                        return "up"
                    if slope_pct < -slope_threshold_pct:
                        return "down"
                    return "stable"

                dur_slope = _slope_pct(durations)
                dur_recent = float(np.mean(durations[-recent_window:]))

                rec: dict[str, Any] = {
                    "cycle_count": len(pcy),
                    "recent_window": min(recent_window, len(durations)),
                    "duration_trend": _classify(dur_slope),
                    "duration_slope_pct": round(dur_slope * 100, 2),
                    "duration_recent_mean_s": round(dur_recent, 1),
                }
                if len(energies) >= min_cycles:
                    en_slope = _slope_pct(energies)
                    en_recent = float(np.mean(energies[-recent_window:]))
                    rec["energy_trend"] = _classify(en_slope)
                    rec["energy_slope_pct"] = round(en_slope * 100, 2)
                    rec["energy_recent_mean_wh"] = round(en_recent, 1)
                result[name] = rec
            return result
        except Exception:  # noqa: BLE001
            return {}

    def compute_profile_advisories(self) -> list[dict[str, Any]]:
        """Actionable per-profile maintenance advisories from existing signals.

        Pure statistics (no ML): consolidates :meth:`compute_profile_health` and
        :meth:`compute_profile_trends` into a small ranked list of human-readable,
        actionable recommendations (e.g. "durations trending longer -> re-record").
        These surface as recommendations in the Profiles tab - never a
        notification. Each item is ``{profile, severity, code, message}``; severity
        is ``"warning"`` or ``"info"``. Returns ``[]`` on error (never raises).
        """
        try:
            advisories: list[dict[str, Any]] = []
            health = self.compute_profile_health() or {}
            trends = self.compute_profile_trends() or {}

            for name, h in health.items():
                if h.get("health_status") == "poor":
                    advisories.append({
                        "profile": name, "severity": "warning", "code": "poor_health",
                        "message": (
                            f"'{name}' has a low fit score - its recent cycles vary "
                            "a lot or match weakly. Review its cycles or re-record "
                            "the profile so matching and time estimates stay accurate."
                        ),
                        # Localization: panel renders _t(message_key, message_params,
                        # message). The English `message` above is the fallback.
                        "message_key": "msg.advisory_poor_health",
                        "message_params": {"name": name},
                    })
                elif h.get("shape_drift"):
                    corr = h.get("shape_drift_correlation")
                    corr_str = f" (correlation {corr:.2f})" if corr is not None else ""
                    advisories.append({
                        "profile": name, "severity": "info", "code": "shape_drift",
                        "message": (
                            f"'{name}' has drifted significantly from its original "
                            f"power shape{corr_str}. The appliance may have changed "
                            "behaviour over time (e.g. limescale, wear). Consider "
                            "re-recording this profile with recent cycles."
                        ),
                        "message_key": (
                            "msg.advisory_shape_drift_corr"
                            if corr is not None
                            else "msg.advisory_shape_drift"
                        ),
                        "message_params": (
                            {"name": name, "corr": f"{corr:.2f}"}
                            if corr is not None
                            else {"name": name}
                        ),
                    })

            for name, t in trends.items():
                # Skip profiles already flagged poor to avoid double advice.
                if health.get(name, {}).get("health_status") == "poor":
                    continue
                if t.get("duration_trend") == "up":
                    # duration_slope_pct is already expressed as %/cycle by
                    # compute_profile_trends (dur_slope * 100); do NOT re-scale.
                    pct = abs(float(t.get("duration_slope_pct") or 0.0))
                    advisories.append({
                        "profile": name, "severity": "info", "code": "duration_trend_up",
                        "message": (
                            f"'{name}' cycles are running progressively longer "
                            f"(about +{pct:.0f}% per cycle). If the appliance's "
                            "behaviour changed, re-record or rebuild this profile."
                        ),
                        "message_key": "msg.advisory_duration_trend_up",
                        "message_params": {"name": name, "pct": f"{pct:.0f}"},
                    })
                elif t.get("energy_trend") == "up":
                    # energy_slope_pct is already %/cycle (en_slope * 100); no re-scale.
                    pct = abs(float(t.get("energy_slope_pct") or 0.0))
                    advisories.append({
                        "profile": name, "severity": "info", "code": "energy_trend_up",
                        "message": (
                            f"'{name}' is drawing progressively more energy "
                            f"(about +{pct:.0f}% per cycle) - worth checking the "
                            "appliance if that is unexpected."
                        ),
                        "message_key": "msg.advisory_energy_trend_up",
                        "message_params": {"name": name, "pct": f"{pct:.0f}"},
                    })

            # Phase-structure consistency (phase-matching device types only). Uses
            # the cached per-role phase profile: a profile whose member cycles heat
            # for wildly different times, or where only some cycles heat at all,
            # most likely mixes different programs/temperatures under one label -
            # which hurts both matching and the phase-resolved ETA. Advisory only
            # (Profiles tab); no relabeling (phase matching does not label better).
            _sd = getattr(self, "_data", None)
            _envs = _sd.get("envelopes") if isinstance(_sd, dict) else None
            for pname, penv in (_envs if isinstance(_envs, dict) else {}).items():
                if health.get(pname, {}).get("health_status") == "poor":
                    continue  # avoid double advice
                pp = penv.get("phase_profile") if isinstance(penv, dict) else None
                if not isinstance(pp, dict):
                    continue
                try:
                    if int(pp.get("n_cycles") or 0) < PHASE_CONSISTENCY_MIN_CYCLES:
                        continue
                    heat = (pp.get("roles") or {}).get("heating") or {}
                    heat_mean = float(heat.get("dur_mean") or 0.0)
                    heat_std = float(heat.get("dur_std") or 0.0)
                    heat_occ = float(heat.get("occurrence") or 0.0)
                    heat_cv = (heat_std / heat_mean) if heat_mean > 60.0 else 0.0
                    mixed_temp = heat_cv > PHASE_HEAT_CV_WARN
                    mixed_prog = PHASE_HEAT_OCC_MIXED_LO <= heat_occ <= PHASE_HEAT_OCC_MIXED_HI
                    if not (mixed_temp or mixed_prog):
                        continue
                    advisories.append({
                        "profile": pname, "severity": "warning",
                        "code": "phase_inconsistent",
                        "message": (
                            f"'{pname}' looks like it mixes different programs or "
                            "temperatures - its cycles heat for very different lengths "
                            "of time. Splitting it into separate profiles (e.g. per "
                            "temperature) will improve matching and time estimates."
                        ),
                        "message_key": "msg.advisory_phase_inconsistent",
                        "message_params": {"name": pname},
                    })
                except (TypeError, ValueError):
                    continue

            # E1: suppress the "needs maintenance" nag (duration-trending-longer /
            # shape-drift/poor-fit) when the user recently logged a descale, filter
            # clean, or drum clean — any recent maintenance clears the reminder.
            try:
                # ``is True`` (not truthiness) so a mocked store whose method
                # returns a MagicMock does not accidentally suppress advisories.
                if any(
                    self.has_recent_maintenance(evt) is True
                    for evt in ("descale", "filter_clean", "drum_clean")
                ):
                    _nag_codes = {"duration_trend_up", "poor_health", "shape_drift"}
                    advisories = [a for a in advisories if a.get("code") not in _nag_codes]
            except Exception:  # noqa: BLE001
                pass

            order = {"warning": 0, "info": 1}
            advisories.sort(key=lambda a: order.get(a["severity"], 2))
            return advisories
        except Exception:  # noqa: BLE001
            return []

    # ------------------------------------------------------------------
    # Match ranking history (per-cycle ML commit snapshots)
    # ------------------------------------------------------------------

    def record_match_ranking_snapshot(
        self,
        start_time_iso: str,
        features: dict[str, float],
        top1_profile: str,
        top1_score: float,
        top2_score: float | None,
        candidate_count: int,
        cycle_id: str = "",
    ) -> None:
        """Append a live-match ranking snapshot for the active cycle.

        Snapshots are keyed by ``start_time_iso`` (and optionally ``cycle_id``) so
        that ``confirm_match_ranking_snapshots`` can back-fill the confirmed label
        when the cycle ends.  Pre-computed feature scalars are stored (not raw traces)
        to keep footprint small.  The store is NOT persisted here — the caller must
        schedule ``async_save``.
        """
        from .const import MATCH_RANKING_HISTORY_MAX  # pylint: disable=import-outside-toplevel
        history: list[dict[str, Any]] = self._data.setdefault("match_ranking_history", [])
        history.append({
            "start_time_iso": start_time_iso,
            "cycle_id": str(cycle_id) if cycle_id else "",
            "features": dict(features),
            "top1_profile": str(top1_profile),
            "top1_score": round(float(top1_score), 4),
            "top2_score": round(float(top2_score), 4) if top2_score is not None else None,
            "candidate_count": int(candidate_count),
            "confirmed_label": None,
        })
        # Trim to the most recent N snapshots.
        if len(history) > MATCH_RANKING_HISTORY_MAX:
            del history[: len(history) - MATCH_RANKING_HISTORY_MAX]

    def confirm_match_ranking_snapshots(
        self,
        start_time_iso: str,
        confirmed_label: str,
        cycle_id: str = "",
    ) -> int:
        """Back-fill the confirmed label for all snapshots belonging to a cycle.

        When ``cycle_id`` is provided (and the snapshot was recorded with one), matches
        by ``cycle_id`` to avoid cross-contamination between cycles that share the same
        second-resolution ``start_time_iso``.  Falls back to ``start_time_iso`` matching
        for snapshots recorded without a cycle_id (backward compatibility).
        Returns the number of snapshots updated.
        """
        history = self._data.get("match_ranking_history")
        if not isinstance(history, list):
            return 0
        updated = 0
        for snap in history:
            if not isinstance(snap, dict):
                continue
            snap_cid = snap.get("cycle_id") or ""
            if cycle_id and snap_cid:
                # Both sides have an ID — match by ID for precision.
                if snap_cid == cycle_id:
                    snap["confirmed_label"] = str(confirmed_label)
                    updated += 1
            else:
                # Legacy path: match by timestamp (no cycle_id on one or both sides).
                if snap.get("start_time_iso") == start_time_iso:
                    snap["confirmed_label"] = str(confirmed_label)
                    updated += 1
        return updated

    def get_match_ranking_history(self) -> list[dict[str, Any]]:
        """Return all ranking snapshots (confirmed and pending).

        Callers that build a training dataset should filter to snapshots where
        ``confirmed_label`` is not None.
        """
        raw = self._data.get("match_ranking_history")
        return list(raw) if isinstance(raw, list) else []

    def _get_shared_custom_phases(self) -> list[dict[str, Any]]:
        """Return mutable shared custom phase list with legacy flattening."""
        raw = self._data.setdefault("custom_phases", [])
        if isinstance(raw, list):
            return cast(list[dict[str, Any]], raw)

        # Legacy format: {device_type: [phase, ...]}. Flatten to shared list.
        flattened: list[dict[str, Any]] = []
        seen: set[tuple[str, str]] = set()
        if isinstance(raw, dict):
            raw_dict = cast(dict[str, Any], raw)
            for legacy_device_type, phase_list in raw_dict.items():
                if not isinstance(phase_list, list):
                    continue
                for item in cast(list[Any], phase_list):
                    if not isinstance(item, dict):
                        continue
                    item_dict = cast(dict[str, Any], item)
                    name = str(item_dict.get("name", "")).strip()
                    if not name:
                        continue
                    device_type = str(legacy_device_type or "").strip()
                    key = (name.casefold(), device_type.casefold())
                    if key in seen:
                        continue
                    seen.add(key)
                    flattened.append(
                        {
                            "name": name,
                            "description": str(item_dict.get("description", "")).strip(),
                            "device_type": device_type,
                            "created_at": item_dict.get("created_at") or dt_util.now().isoformat(),
                        }
                    )

        self._data["custom_phases"] = flattened
        return self._data["custom_phases"]

    def list_custom_phases(self, device_type: str) -> list[dict[str, Any]]:
        """Return shared custom phases relevant to the requested device type."""

        def applies_to_device(item_device_type: str, target_device_type: str) -> bool:
            if not item_device_type:
                return True
            return item_device_type == target_device_type

        target = str(device_type or "").strip()
        phases = self._get_shared_custom_phases()
        return [
            {
                "id": str(p.get("id", "")),
                "name": str(p.get("name", "")).strip(),
                "description": str(p.get("description", "")).strip(),
                "device_type": str(p.get("device_type", "")).strip(),
                "is_default": False,
            }
            for p in phases
            if p.get("name")
            and applies_to_device(str(p.get("device_type", "")).strip(), target)
        ]

    def list_phase_catalog(self, device_type: str) -> list[dict[str, Any]]:
        """Return merged shared default + custom phase catalog."""
        return merge_phase_catalog(device_type, self.list_custom_phases(device_type))

    async def async_create_custom_phase(
        self,
        device_type: str,
        phase_name: str,
        description: str = "",
    ) -> None:
        """Create a custom phase in the shared catalog."""
        target_device_type = str(device_type or "").strip()
        name = normalize_phase_name(phase_name)
        desc = str(description or "").strip()
        catalog = self.list_phase_catalog(target_device_type)
        if any(str(p.get("name", "")).casefold() == name.casefold() for p in catalog):
            raise ValueError("duplicate_phase")

        self._get_shared_custom_phases().append(
            {
                "id": str(uuid.uuid4()),
                "name": name,
                "description": desc,
                "device_type": target_device_type,
                "created_at": dt_util.now().isoformat(),
            }
        )
        await self.async_save()

    async def async_update_custom_phase(
        self,
        phase_id: str,
        new_name: str,
        description: str = "",
    ) -> None:
        """Update a phase by id, propagating rename to profile assignments.

        If phase_id matches a built-in, a custom override is created using the
        built-in's id so the merge can replace it in-place.
        """
        target_name = normalize_phase_name(new_name)
        desc = str(description or "").strip()
        phases = self._get_shared_custom_phases()

        # Look for an existing custom entry with this id.
        found: dict[str, Any] | None = next(
            (p for p in phases if str(p.get("id", "")) == phase_id), None
        )
        creating_new = found is None

        if creating_new:
            builtin = get_builtin_phase_by_id(phase_id)
            if builtin is None:
                raise ValueError("phase_not_found")
            candidate: dict[str, Any] = {
                "id": phase_id,
                "name": str(builtin.get("name", "")),
                "description": "",
                "device_type": str(builtin.get("device_type", "")),
                "created_at": dt_util.now().isoformat(),
            }
        else:
            candidate = found  # type: ignore[assignment]

        old_name = str(candidate.get("name", ""))
        target_device_type = str(candidate.get("device_type", "")).strip()

        # Duplicate-name check before any mutation.
        for p in self.list_phase_catalog(target_device_type):
            pname = str(p.get("name", ""))
            if pname.casefold() == target_name.casefold() and pname.casefold() != old_name.casefold():
                raise ValueError("duplicate_phase")

        if creating_new:
            phases.append(candidate)
            found = candidate

        found["name"] = target_name  # type: ignore[index]
        found["description"] = desc  # type: ignore[index]

        # Propagate rename to profile assignments.
        for profile in self.get_profiles().values():
            profile_device_type = str(profile.get("device_type", "")).strip()
            if target_device_type and profile_device_type != target_device_type:
                continue
            phases_assigned = profile.get("phases", [])
            if not isinstance(phases_assigned, list):
                continue
            for assigned in cast(list[dict[str, Any]], phases_assigned):
                if str(assigned.get("name", "")).casefold() == old_name.casefold():
                    assigned["name"] = target_name

        await self.async_save()

    async def async_delete_custom_phase(self, phase_id: str) -> int:
        """Delete a custom phase by id and remove matching profile assignments.

        Returns number of removed assignments.
        Raises ValueError('phase_not_found') if no custom phase has this id.
        Raises ValueError('cannot_delete_builtin') if the id is a built-in phase.
        """
        phases = self._get_shared_custom_phases()
        found = next((p for p in phases if str(p.get("id", "")) == phase_id), None)
        if found is None:
            raise ValueError("phase_not_found")
        if get_builtin_phase_by_id(phase_id) is not None:
            raise ValueError("cannot_delete_builtin")

        phase_name = str(found.get("name", ""))
        phase_scope = str(found.get("device_type", "")).strip()
        self._data["custom_phases"] = [p for p in phases if str(p.get("id", "")) != phase_id]

        removed_assignments = 0
        for profile in self.get_profiles().values():
            profile_device_type = str(profile.get("device_type", "")).strip()
            if phase_scope and profile_device_type != phase_scope:
                continue
            assigned = profile.get("phases", [])
            if not isinstance(assigned, list):
                continue
            assigned_list = cast(list[dict[str, Any]], assigned)
            before = len(assigned_list)
            profile["phases"] = [
                p for p in assigned_list
                if str(p.get("name", "")).casefold() != phase_name.casefold()
            ]
            removed_assignments += before - len(profile["phases"])

        await self.async_save()
        return removed_assignments

    def get_profile_phase_ranges(self, profile_name: str) -> list[dict[str, Any]]:
        """Return assigned phase ranges for a profile."""
        profiles = self._data.get("profiles", {})
        if not isinstance(profiles, dict):
            return []
        profile_raw = cast(dict[str, Any], profiles).get(profile_name)
        profile = cast(dict[str, Any], profile_raw) if isinstance(profile_raw, dict) else None
        if not isinstance(profile, dict):
            return []
        phases = profile.get("phases", [])
        if not isinstance(phases, list):
            return []
        phases_list = cast(list[dict[str, Any]], phases)
        cleaned: list[dict[str, Any]] = []
        for phase in phases_list:
            try:
                start = float(phase.get("start", 0.0))
                end = float(phase.get("end", 0.0))
            except (TypeError, ValueError):
                continue
            if end <= start:
                continue
            cleaned.append(
                {
                    "name": str(phase.get("name", "")).strip(),
                    "start": start,
                    "end": end,
                    "description": str(phase.get("description", "")).strip(),
                }
            )
        return sorted(cleaned, key=lambda x: (x["start"], x["end"], x["name"]))

    def get_profile_phase_ranges_for_device(
        self, profile_name: str, device_type: str
    ) -> list[dict[str, Any]]:
        """Return assigned ranges enriched with catalog descriptions."""
        ranges = self.get_profile_phase_ranges(profile_name)
        catalog = self.list_phase_catalog(device_type)
        desc_map = {
            str(p.get("name", "")).casefold(): str(p.get("description", "")).strip()
            for p in catalog
        }
        enriched: list[dict[str, Any]] = []
        for row in ranges:
            name = str(row.get("name", "")).strip()
            enriched.append(
                {
                    "name": name,
                    "start": float(row.get("start", 0.0)),
                    "end": float(row.get("end", 0.0)),
                    "description": desc_map.get(name.casefold(), ""),
                }
            )
        return enriched

    async def async_set_profile_phase_ranges(
        self, profile_name: str, ranges: list[dict[str, Any]]
    ) -> None:
        """Replace assigned phase ranges for a profile."""
        profile = self._data.get("profiles", {}).get(profile_name)
        if not isinstance(profile, dict):
            raise ValueError("profile_not_found")

        normalized: list[dict[str, Any]] = []
        for item in ranges:
            name = normalize_phase_name(str(item.get("name", "")))
            try:
                start = float(item.get("start", 0.0))
                end = float(item.get("end", 0.0))
            except (TypeError, ValueError) as e:
                raise ValueError("invalid_phase_range") from e
            if end <= start:
                raise ValueError("invalid_phase_range")
            normalized.append({"name": name, "start": start, "end": end})

        normalized.sort(key=lambda x: (x["start"], x["end"]))
        prev_end = None
        for row in normalized:
            if prev_end is not None and row["start"] < prev_end:
                raise ValueError("overlapping_phase_ranges")
            prev_end = row["end"]

        profile["phases"] = normalized
        await self.async_save()

    def set_duration_tolerance(self, tolerance: float) -> None:
        """Set the profile duration tolerance used by matching heuristics."""
        try:
            self._duration_tolerance = float(tolerance)
        except (TypeError, ValueError):
            pass

    def set_retention_limits(
        self,
        *,
        max_past_cycles: int,
        max_full_traces_per_profile: int,
        max_full_traces_unlabeled: int,
    ) -> None:
        """Set retention caps for stored cycles and full-resolution traces."""
        try:
            self._max_past_cycles = int(max_past_cycles)
            self._max_full_traces_per_profile = int(max_full_traces_per_profile)
            self._max_full_traces_unlabeled = int(max_full_traces_unlabeled)
        except (TypeError, ValueError):
            pass

    def get_duration_ratio_limits(self) -> tuple[float, float]:
        """Return (min_duration_ratio, max_duration_ratio) used for duration matching."""
        return (float(self._min_duration_ratio), float(self._max_duration_ratio))

    def set_duration_ratio_limits(self, *, min_ratio: float, max_ratio: float) -> None:
        """Update duration ratio bounds used for duration matching."""
        try:
            self._min_duration_ratio = float(min_ratio)
            self._max_duration_ratio = float(max_ratio)
        except (TypeError, ValueError):
            pass

    def _migrate_phase_ids(self) -> bool:
        """Assign ids to any custom phase missing one. Returns True if anything changed."""
        phases = self._data.get("custom_phases", [])
        if not isinstance(phases, list):
            return False
        changed = False
        for phase in cast(list[dict[str, Any]], phases):
            if phase.get("id"):
                continue
            dt = str(phase.get("device_type", "")).strip()
            name = str(phase.get("name", "")).strip()
            matched_id: str | None = None
            for bdt, bphases in DEFAULT_PHASES_BY_DEVICE.items():
                if dt and bdt != dt:
                    continue
                for bp in bphases:
                    if str(bp.get("name", "")).strip().casefold() == name.casefold():
                        matched_id = _builtin_phase_id(bdt, str(bp.get("name", "")))
                        break
                if matched_id:
                    break
            phase["id"] = matched_id if matched_id else str(uuid.uuid4())
            changed = True
        return changed

    async def async_load(self) -> None:
        """Load data from storage with migration."""
        # WashDataStore handles migration internally via _async_migrate_func
        data = await self._store.async_load()
        if data:
            self._data = data
        # Ensure legacy custom phase formats are normalized in-memory.
        self._get_shared_custom_phases()
        # Assign ids to any custom phase missing one.
        if self._migrate_phase_ids():
            await self.async_save()
        # Repair cycles whose power_data was corrupted by the double-subtract bug.
        if self.repair_corrupted_power_data():
            await self.async_save()
            await self.async_rebuild_all_envelopes()
            await self.async_save()
        # Prune pending feedback whose cycle no longer exists, so the device's
        # "needs review" count can't disagree with the Cycles review filter.
        if self.prune_orphaned_feedback():
            await self.async_save()

    # _migrate_v1_to_v2 and _decompress_power_from_raw removed; logic moved to WashDataStore

    async def async_repair_profile_samples(self) -> dict[str, int]:
        """Repair profile sample references after retention or migrations.

        Ensures each profile's sample_cycle_id points to an existing cycle that still
        has full-resolution power_data. If missing, picks the newest available cycle
        with power_data and assigns it as the sample (and labels that cycle to the
        profile if it was unlabeled).

        Returns stats dict.
        """
        stats = {
            "profiles_checked": 0,
            "profiles_repaired": 0,
            "cycles_labeled_as_sample": 0,
        }

        profiles: dict[str, dict[str, Any]] = self._data.get("profiles", {}) or {}
        cycles: list[dict[str, Any]] = self._data.get("past_cycles", []) or []
        if not profiles or not cycles:
            return stats

        # Sample validity must recognise imported and backfilled cycles: an import-only
        # profile legitimately points its sample at one of those. Without this, such a
        # sample looks "missing" and the repair below would steal an unrelated unlabeled
        # real cycle into the imported profile.
        by_id: dict[str, dict[str, Any]] = {
            c["id"]: c for c in self.iter_stored_cycles() if c.get("id")
        }

        def newest_unlabeled_with_power_data() -> dict[str, Any] | None:
            candidates: list[dict[str, Any]] = [
                c for c in cycles if c.get("power_data") and not c.get("profile_name")
            ]
            if not candidates:
                return None
            try:
                return max(candidates, key=lambda c: c.get("start_time", ""))
            except Exception:  # pylint: disable=broad-exception-caught
                return candidates[-1]

        for profile_name, profile in profiles.items():
            stats["profiles_checked"] += 1
            sample_id = profile.get("sample_cycle_id")
            sample = by_id.get(sample_id) if sample_id else None

            # Sample is valid only if it exists and still has power_data
            if sample and sample.get("power_data"):
                continue

            # Prefer newest already-labeled cycle for this profile that still has power_data
            labeled_candidates = [
                c
                for c in cycles
                if c.get("profile_name") == profile_name and c.get("power_data")
            ]
            if labeled_candidates:
                try:
                    chosen = max(
                        labeled_candidates, key=lambda c: c.get("start_time", "")
                    )
                except Exception:  # pylint: disable=broad-exception-caught
                    chosen = labeled_candidates[-1]
            else:
                # Fallback: pick newest UNLABELED cycle with power_data
                chosen = newest_unlabeled_with_power_data()

            if not chosen:
                continue

            profile["sample_cycle_id"] = chosen.get("id")
            if chosen.get("duration"):
                profile["avg_duration"] = chosen["duration"]

            # If chosen cycle is unlabeled, label it to this profile to bootstrap matching
            if not chosen.get("profile_name"):
                chosen["profile_name"] = profile_name
                stats["cycles_labeled_as_sample"] += 1

            stats["profiles_repaired"] += 1
            try:
                await self.async_rebuild_envelope(profile_name)
            except Exception:  # pylint: disable=broad-exception-caught
                pass

        return stats

    async def async_save(self) -> None:
        """Save data to storage."""
        await self._store.async_save(self._data)

    async def async_save_active_cycle(self, detector_snapshot: JSONDict) -> None:
        """Save the active cycle state to storage (throttled by Manager)."""
        self._data["active_cycle"] = detector_snapshot
        self._data["last_active_save"] = dt_util.now().isoformat()
        await self._store.async_save(self._data)

    def get_active_cycle(self) -> JSONDict | None:
        """Get the saved active cycle."""
        raw = self._data.get("active_cycle")
        if isinstance(raw, dict):
            return cast(JSONDict, raw)
        return None

    def get_last_active_save(self) -> datetime | None:
        """Return the last time the active cycle snapshot was persisted."""
        raw = self._data.get("last_active_save")
        if not isinstance(raw, str) or not raw:
            return None
        try:
            return dt_util.parse_datetime(raw)
        except ValueError:
            return None

    async def async_clear_active_cycle(self) -> None:
        """Clear the active cycle snapshot from storage."""
        if "active_cycle" in self._data:
            del self._data["active_cycle"]
            await self._store.async_save(self._data)

    def add_cycle(self, cycle_data: CycleDict) -> None:
        """Add a completed cycle to history (sync wrapper, schedules async tasks)."""
        self._add_cycle_data(cycle_data)
        self.hass.async_create_task(self.async_enforce_retention())

    async def async_add_cycle(self, cycle_data: CycleDict) -> None:
        """Add a completed cycle to history asynchronously."""
        self._add_cycle_data(cycle_data)
        await self.async_enforce_retention()

    def _add_cycle_data(
        self,
        cycle_data: CycleDict,
        target: list[CycleDict] | None = None,
        *,
        id_pool: set[Any] | None = None,
    ) -> None:
        """Internal logic to add cycle data to storage.

        ``target`` defaults to ``past_cycles``; ``add_reference_cycle`` passes the
        separate ``reference_cycles`` list so imported cycles never enter usage stats.

        ``id_pool`` lets a bulk caller (e.g. selective import) hand in a reusable set of
        already-assigned ids so the SHA-id uniqueness check stays O(1) amortized instead
        of rebuilding the id set from the growing destination list on every call (which is
        O(N^2) over a large import and would block the event loop). When supplied it is
        mutated in place with the newly-minted id.
        """
        dest = self._data["past_cycles"] if target is None else target
        # Generate SHA256 ID — dedup suffix avoids collisions when two cycles share
        # an identical raw start_time + duration (e.g. bulk reference-cycle imports).
        existing_ids = id_pool if id_pool is not None else {c.get("id") for c in dest if isinstance(c, dict)}
        unique_str = f"{cycle_data['start_time']}_{cycle_data['duration']}"
        candidate = hashlib.sha256(unique_str.encode()).hexdigest()[:12]
        suffix = 0
        while candidate in existing_ids:
            suffix += 1
            candidate = hashlib.sha256(f"{unique_str}_{suffix}".encode()).hexdigest()[:12]
        cycle_data["id"] = candidate
        if id_pool is not None:
            id_pool.add(candidate)

        # Preserve profile_name if already set by manager; default to None otherwise
        if "profile_name" not in cycle_data:
            cycle_data["profile_name"] = None  # Initially unknown

        # Store power data at native sampling resolution
        # Format: [seconds_offset, power] preserves actual sample rate from device
        # (e.g., 3s intervals from test socket, 60s intervals from real socket)
        raw_data: list[Any] = cycle_data.get("power_data", []) or []
        self._logger.debug("add_cycle: raw_data has %s points", len(raw_data))

        if raw_data:
            start_time_raw = cycle_data.get("start_time")
            start_time_iso: str | None = None
            if start_time_raw is not None:
                parsed_dt = _parse_start_dt(start_time_raw)
                if parsed_dt is not None:
                    start_time_iso = parsed_dt.isoformat()
                    # Keep original ISO string as-is if it was already a valid ISO string
                    if isinstance(start_time_raw, str) and dt_util.parse_datetime(start_time_raw) is not None:
                        start_time_iso = start_time_raw
                else:
                    try:
                        ts = float(start_time_raw)
                        start_time_iso = dt_util.utc_from_timestamp(ts).isoformat()
                    except (ValueError, OSError):
                        self._logger.debug(
                            "add_cycle: unparseable string start_time %r, falling back",
                            start_time_raw,
                        )
            elif isinstance(start_time_raw, datetime):
                start_time_iso = start_time_raw.isoformat()
            elif isinstance(start_time_raw, (int, float)):
                try:
                    start_time_iso = dt_util.utc_from_timestamp(float(start_time_raw)).isoformat()
                except (ValueError, OSError):
                    pass
            if start_time_iso is not None:
                cycle_data["start_time"] = start_time_iso
            if start_time_iso is None and _value_to_timestamp(start_time_raw) is None:
                self._logger.debug("add_cycle: invalid start_time %r, skipping power_data normalization", start_time_raw)
                if hasattr(self, "_save_debug_traces") and not self._save_debug_traces:
                    cycle_data.pop("debug_data", None)
                dest.append(cycle_data)
                return

            # Use unified normalizer: handles offset, ISO-string, and datetime formats
            pairs = power_data_to_offsets(
                cast(list[list[Any] | tuple[Any, ...]], raw_data), start_time_iso
            )
            stored: list[list[float]] = [[round(p[0], 1), round(p[1], 1)] for p in pairs]
            offsets: list[float] = [p[0] for p in stored]

            # Calculate average sampling interval (in seconds)
            if len(offsets) > 1:
                intervals = np.diff(offsets)
                positive_intervals = intervals[intervals > 0]
                sampling_interval = float(np.median(positive_intervals)) if positive_intervals.size > 0 else 0.0
            else:
                sampling_interval = 1.0  # Default fallback

            # Trim leading/trailing zero readings for cleaner data
            # SKIP for completed cycles to preserve end spike data
            if cycle_data.get("status") in ("completed", "force_stopped"):
                # Only trim leading zeros for completed cycles, keep trailing data
                start_idx = 0
                for i, point in enumerate(stored):
                    if point[1] > 1.0:
                        start_idx = i
                        break
                stored = stored[start_idx:]
                self._logger.debug("add_cycle: Skipping trailing trim for completed cycle")
            else:
                stored = trim_zero_power_data(stored, threshold=1.0)

            cycle_data["power_data"] = stored
            cycle_data["sampling_interval"] = round(sampling_interval, 1)

            # Helper to get arrays for signature (use stored data for consistency)
            ts_arr = np.array([t for t, _ in stored])
            p_arr = np.array([p for _, p in stored])

            # Compute and store signature
            if len(ts_arr) > 1 and len(ts_arr) == len(p_arr):
                sig = compute_signature(ts_arr, p_arr)
                cycle_data["signature"] = dataclasses.asdict(sig)

            # Compute and store energy (Wh) if not already set (e.g. by manager).
            # Shared trapezoidal integrator + data-driven outage gap (single source
            # with manager._on_cycle_end).
            if "energy_wh" not in cycle_data and len(ts_arr) > 1:
                sort_idx = np.argsort(ts_arr)
                ts_s = ts_arr[sort_idx]
                p_s = p_arr[sort_idx]
                cycle_data["energy_wh"] = round(
                    integrate_wh(ts_s, p_s, max_gap_s=energy_gap_threshold_s(ts_s)), 3
                )

            self._logger.debug(
                "add_cycle: stored %s samples at %.1fs intervals",
                len(stored),
                sampling_interval,
            )

        # 4. Handle Debug Data (Strip if not enabled)
        if hasattr(self, "_save_debug_traces") and not self._save_debug_traces:
            if "debug_data" in cycle_data:
                del cycle_data["debug_data"]

        dest.append(cycle_data)
        # Apply retention after adding


    async def async_enforce_retention(self) -> None:
        """Apply retention policy asynchronously."""
        affected = self._enforce_retention_data()
        for p in affected:
            try:
                # Use async rebuild task
                self.hass.async_create_task(self.async_rebuild_envelope(p))
            except Exception as e: # pylint: disable=broad-exception-caught
                self._logger.warning("Failed to schedule envelope rebuild for %s: %s", p, e)

    def _enforce_retention_data(self) -> set[str]:
        """Internal retention logic (data operations only).
        Returns set of affected profile names."""
        raw_cycles = self._data.get("past_cycles", [])
        cycles: list[CycleDict] = (
            cast(list[CycleDict], raw_cycles) if isinstance(raw_cycles, list) else []
        )
        if not cycles:
            return set()

        def _start_time(cycle: CycleDict) -> str:
            return str(cycle.get("start_time", ""))

        affected_profiles: set[str] = set()

        # 1) Cap total cycles
        if len(cycles) > self._max_past_cycles:
            # Sort by start_time and drop oldest beyond cap
            try:
                cycles.sort(key=_start_time)
            except Exception:  # pylint: disable=broad-exception-caught
                pass
            drop_count = len(cycles) - self._max_past_cycles
            to_drop = cycles[:drop_count]

            # Maintain profile sample references when dropping
            sample_refs = {
                name: p.get("sample_cycle_id")
                for name, p in self._data.get("profiles", {}).items()
            }
            for cy in to_drop:
                # Track affected profile
                p_name = cy.get("profile_name")
                if p_name:
                    affected_profiles.add(p_name)

                cy_id = cy.get("id")
                # If a profile sample points here, try to move to most recent cycle of that profile
                for name, ref_id in list(sample_refs.items()):
                    if ref_id == cy_id:
                        # find newest cycle for that profile
                        newest = next(
                            (
                                c
                                for c in reversed(cycles)
                                if c.get("profile_name") == name and c not in to_drop
                            ),
                            None,
                        )
                        if newest:
                            self._data["profiles"][name]["sample_cycle_id"] = (
                                newest.get("id")
                            )
                        else:
                            # No replacement available
                            self._data["profiles"][name].pop("sample_cycle_id", None)
            # Actually drop
            del cycles[:drop_count]

        # 2) Strip older full traces per profile
        by_profile: dict[str | None, list[CycleDict]] = {}
        for cy in cycles:
            key_any = cy.get("profile_name")  # None for unlabeled
            key: str | None = key_any if isinstance(key_any, str) and key_any else None
            by_profile.setdefault(key, []).append(cy)

        # Collect cycle IDs that have pending feedback - never strip their power_data
        pending_feedback_ids: set[str] = set(self._data.get("pending_feedback", {}).keys())

        for key, group in by_profile.items():
            # newest first based on start_time
            try:
                group.sort(key=_start_time)
            except Exception: # pylint: disable=broad-exception-caught
                pass
            # determine cap
            cap = (
                self._max_full_traces_unlabeled
                if key
                in (
                    None,
                    "",
                )
                else self._max_full_traces_per_profile
            )
            # count existing full traces
            full_indices = [i for i, c in enumerate(group) if c.get("power_data")]
            if len(full_indices) > cap:
                # preserve last 'cap' full traces (newest at end after sort), strip older ones
                keep_set = set(full_indices[-cap:])

                # Get sample cycle ID for this profile
                sample_id: str | None = None
                if key and key in self._data.get("profiles", {}):
                    sample_id = self._data["profiles"][key].get("sample_cycle_id")

                for i, c in enumerate(group):
                    if i in keep_set:
                        continue

                    # EXEMPTION: Never strip power data from the profile's sample cycle!
                    if sample_id and c.get("id") == sample_id:
                        continue

                    # EXEMPTION: Never strip power data from cycles awaiting feedback review
                    if c.get("id") in pending_feedback_ids:
                        continue

                    # EXEMPTION: Never strip power data from user-pinned "golden" cycles.
                    # The matcher uses a golden trace as the sharp single-cycle template
                    # (has_golden in the snapshot builder), and golden_profiles membership
                    # is gated on the trace still being present — trimming it would flip
                    # has_golden false and silently drop the profile back to the smeared
                    # envelope average.
                    rev = c.get("ml_review")
                    if isinstance(rev, dict) and rev.get("golden"):
                        continue

                    if c.get("power_data"):
                        c.pop("power_data", None)
                        c.pop("sampling_interval", None)
                        if key:
                            affected_profiles.add(key)

        return affected_profiles



    def cleanup_orphaned_profiles(self) -> int:
        """Remove profiles that reference non-existent cycles.
        Returns number of profiles removed."""
        # Imported reference cycles and backfilled history cycles are valid sample
        # targets too (an import-only profile points its sample at one), so every list
        # counts here or such profiles would be wrongly deleted as orphans.
        cycle_ids = {c["id"] for c in self.iter_stored_cycles() if c.get("id")}
        orphaned: list[str] = []
        for name, profile in self._data["profiles"].items():
            ref = profile.get("sample_cycle_id")
            # Only delete if it references a non-existent cycle ID (Broken Link)
            # Creating a profile without a sample (None) is allowed (Pending State)
            if ref and ref not in cycle_ids:
                orphaned.append(name)

        for name in orphaned:
            del self._data["profiles"][name]
            self._logger.info(
                "Cleaned up orphaned profile '%s' (cycle no longer exists)", name
            )

        return len(orphaned)

    async def async_run_maintenance(self) -> dict[str, int]:
        """Run full maintenance: cleanup orphans, merge fragments, trim old cycles.

        Also rebuilds envelopes. Returns stats dict with counts of actions taken.
        """
        stats = {
            "orphaned_profiles": 0,
            "merged_cycles": 0,
            "split_cycles": 0,
            "rebuilt_envelopes": 0,
        }

        # 1. Clean up orphaned profiles
        stats["orphaned_profiles"] = self.cleanup_orphaned_profiles()

        # 2. Auto-Label missed cycles (retroactive matching)
        # Use overwrite=False to respect existing manual/confident labels
        label_stats = await self.auto_label_cycles(confidence_threshold=0.75, overwrite=False)
        stats["labeled_cycles"] = label_stats.get("labeled", 0)

        # 3. Smart Process History (Merge/Split/Rebuild)
        proc_stats = await self.async_smart_process_history()
        stats["merged_cycles"] = proc_stats.get("merged", 0)
        stats["split_cycles"] = proc_stats.get("split", 0)

        # 4. Rebuild every profile's envelope so bands stay fresh, and report the
        # real count of successful rebuilds (not an approximation).
        rebuilt = 0
        for profile_name in list(self._data.get("profiles", {}).keys()):
            try:
                # Count only real rebuilds; a no-op/failed rebuild returns False.
                if await self.async_rebuild_envelope(profile_name):
                    rebuilt += 1
            except Exception:  # pylint: disable=broad-exception-caught
                self._logger.debug(
                    "Envelope rebuild failed for %s during maintenance", profile_name, exc_info=True
                )
        stats["rebuilt_envelopes"] = rebuilt

        # 4. Save if any changes made (smart process saves internally if needed, but explicit save safe)
        if any(stats.values()):
            await self.async_save()
            self._logger.info("Maintenance completed: %s", stats)

        return stats

    def _reprocess_all_data_sync(self) -> int:
        """Synchronous implementation of reprocessing logic (run in executor)."""
        cycles_raw = self._data.get("past_cycles", [])
        cycles = cast(list[CycleDict], cycles_raw) if isinstance(cycles_raw, list) else []
        if not cycles:
            return 0

        processed_count = 0

        # 1. Update Signatures & Optimize Data
        for cycle in cycles:
            # Data Optimization: Trim leading/trailing zeros (0W)
            # Only apply to compressed data to avoid breaking legacy format
            p_data = cycle.get("power_data")
            if (
                p_data
                and isinstance(p_data, list)
                and p_data
                and isinstance(p_data[0], (list, tuple))
            ):
                first_point = cast(list[Any] | tuple[Any, ...], p_data[0])
                # Only trim offset-format data (numeric offsets). Legacy ISO-format
                # cycles skip trimming but still reach the signature block below.
                if len(first_point) == 2 and isinstance(first_point[0], (int, float)):
                    p_data_list = cast(list[list[float]], p_data)
                    # Apply trim helper
                    original_len = len(p_data_list)

                    # Logic: For completed cycles, only trim leading zeros.
                    # For others, trim both ends.
                    if cycle.get("status") in ("completed", "force_stopped"):
                        # Only trim leading
                        start_idx = 0
                        for i, point in enumerate(p_data_list):
                            if point[1] > 1.0: # Match threshold below
                                start_idx = i
                                break
                        trimmed: list[list[float]] = p_data_list[start_idx:]
                    else:
                        trimmed = trim_zero_power_data(p_data_list, threshold=1.0) # Conservative 1W threshold

                    if trimmed and len(trimmed) < original_len:
                        # Data was trimmed - check for start time shift
                        first_offset = trimmed[0][0]

                        if first_offset > 0:
                            # Leading zeros removed - Must shift start_time forward
                            try:
                                start_dt = _parse_start_dt(cycle["start_time"])
                                if start_dt is None:
                                    raise ValueError("unparseable start_time")
                                new_start = start_dt + timedelta(seconds=first_offset)
                                cycle["start_time"] = dt_util.as_utc(new_start).isoformat()

                                # Re-normalize offsets to 0
                                shifted_data: list[list[float]] = []
                                for row in trimmed:
                                    # row is [offset, power]
                                    shifted_data.append([round(row[0] - first_offset, 1), row[1]])
                                cycle["power_data"] = shifted_data
                                processed_count += 1
                            except (ValueError, TypeError) as e:
                                self._logger.warning("Failed to shift start_time for trimmed cycle: %s", e)
                        else:
                            # Only trailing trimmed or no shift needed
                            cycle["power_data"] = trimmed
                            processed_count += 1

                        # Update duration to match new data length
                        # If we only trimmed the head, the new duration is old_duration - first_offset
                        # This preserves trailing silence.
                        if cycle.get("power_data"):
                            old_dur = float(cycle.get("duration", 0.0) or 0.0)
                            # If we shifted (first_offset > 0), new duration is old_dur - first_offset
                            # Otherwise if we only trimmed tail, we might want to snap,
                            # but for completed cycles we don't trim tail in this loop.
                            if first_offset > 0:
                                cycle["duration"] = max(0.0, old_dur - first_offset)
                            else:
                                # Only trailing was trimmed (not expected for completed cycles here)
                                # or no trim happened.
                                # If trailing was trimmed, we SHOULD snap.
                                if len(trimmed) < original_len:
                                    cycle["duration"] = cycle["power_data"][-1][0]

                    # Self-heal duration/end_time drift.  A freshly-finalized cycle
                    # always has last_offset == duration (the finalizer appends a
                    # terminal point at end_time, and add_cycle keeps the tail for
                    # completed cycles), so this is a no-op for healthy records.  It
                    # repairs older / manually-edited cycles whose trace was trimmed
                    # without updating duration + end_time - e.g. a legacy record
                    # whose drying tail was dropped while duration kept the pre-trim
                    # value (duration says 8820s but the trace and end_time end at
                    # 6845s).  Snap both to the trace so the three always agree.
                    pd_now = cycle.get("power_data")
                    if (
                        isinstance(pd_now, list)
                        and pd_now
                        and isinstance(pd_now[-1], (list, tuple))
                        and len(pd_now[-1]) == 2
                    ):
                        trace_end = float(pd_now[-1][0])
                        stored_dur = float(cycle.get("duration", 0.0) or 0.0)
                        si = float(cycle.get("sampling_interval", 0.0) or 0.0)
                        # Tolerance above rounding + one sampling gap; the drifts we
                        # repair are large (minutes), so this never churns healthy data.
                        tol = max(5.0, 2.0 * si)
                        if trace_end > 0 and abs(stored_dur - trace_end) > tol:
                            cycle["duration"] = round(trace_end, 1)
                            start_ts = _value_to_timestamp(cycle.get("start_time"))
                            if start_ts is not None:
                                cycle["end_time"] = dt_util.utc_from_timestamp(
                                    start_ts + trace_end
                                ).isoformat()
                            processed_count += 1
                            self._logger.info(
                                "Reprocess self-heal: cycle %s duration %.0fs -> %.0fs "
                                "(snapped to trace; end_time realigned)",
                                cycle.get("id"),
                                stored_dur,
                                trace_end,
                            )

            if cycle.get("power_data"):
                try:
                    tuples = decompress_power_data(cycle)
                    if tuples and len(tuples) > 10:
                        ts_arr: list[float] = []
                        p_arr: list[float] = []
                        for offset_sec, p in tuples:
                            ts_arr.append(float(offset_sec))
                            p_arr.append(float(p))

                        sig = compute_signature(np.array(ts_arr, dtype=float), np.array(p_arr, dtype=float))
                        cycle["signature"] = dataclasses.asdict(sig)
                        processed_count += 1
                except Exception as e: # pylint: disable=broad-exception-caught
                    self._logger.warning("Failed to reprocess signature: %s", e)

        # 2. Rebuild Envelopes


        return processed_count

    async def async_reprocess_all_data(self) -> int:
        """Reprocess all historical data to update signatures and rebuild envelopes.

        This is a non-destructive operation for raw cycle data. It:
        1. Recalculates signatures for ALL past cycles using current logic.
        2. Rebuilds all profile envelopes from scratch.
        3. Updates global stats.

        Returns total number of cycles processed.
        """
        self._logger.info("Starting reprocessing (offloaded)...")

        # Offload heavy synchronous work
        processed_count = await self.hass.async_add_executor_job(
            self._reprocess_all_data_sync
        )

        # 2. Rebuild Envelopes (Using new async infrastructure)
        await self.async_rebuild_all_envelopes()

        await self.async_save()

        return processed_count

    async def get_storage_stats(self) -> dict[str, Any]:
        """Get storage usage stats.

        The on-disk size lookup uses blocking os.path calls, so it is offloaded
        to the executor. We deliberately never json.dumps(self._data) as a
        fallback: with power_data included the dataset can be many megabytes and
        serialising it on the event loop previously stalled the loop long enough
        that the diagnostics view appeared to hang on a spinner.
        """
        cycles = self._data.get("past_cycles", [])
        profiles = self._data.get("profiles", {})
        debug_traces_count = sum(1 for c in cycles if c.get("debug_data"))

        file_size_kb = 0.0
        path = getattr(self._store, "path", None)
        if path:
            file_size_kb = await self.hass.async_add_executor_job(
                _safe_file_size_kb, path
            )

        return {
            "file_size_kb": round(file_size_kb, 1),
            "total_cycles": len(cycles),
            "total_profiles": len(profiles),
            "debug_traces_count": debug_traces_count,
        }

    async def async_clear_debug_data(self) -> int:
        """Clear debug data from all cycles."""
        cycles = self._data.get("past_cycles", [])
        count = 0
        for cycle in cycles:
            if "debug_data" in cycle:
                del cycle["debug_data"]
                count += 1

        if count > 0:
            await self.async_save()
            self._logger.info("Cleared debug data from %s cycles", count)

        return count



    def _rebuild_envelope_sync(
        self, labeled_cycles: list[CycleDict]
    ) -> tuple[Any, list[float]] | None:
        """Sync worker to parse data and build envelope (run in executor).

        Degenerate cycles (a near-flat trace whose peak is a tiny fraction of the
        profile's typical peak - e.g. a run where the power sensor wasn't
        reporting) are excluded so they cannot pollute the envelope average that
        the live matcher scores against, or drag ``avg_duration`` around.
        User-pinned golden cycles are always kept.
        """
        # First pass: decompress everything and record each cycle's peak so we
        # can judge degeneracy relative to the profile (works for both a 2000W
        # dishwasher and a low-power pump).
        parsed: list[tuple[list[float], list[float], float, bool, float]] = []
        for cycle in labeled_cycles:
            pairs = decompress_power_data(cycle)
            if len(pairs) < 3:
                continue
            offsets = [p[0] for p in pairs]
            values = [p[1] for p in pairs]
            stored_dur = float(cycle.get("duration", 0.0) or 0.0)
            authoritative_dur = float(max(offsets[-1], stored_dur))
            man_dur = cycle.get("manual_duration")
            final_dur = float(man_dur) if man_dur else authoritative_dur
            review = cycle.get("ml_review")
            is_golden = bool(review.get("golden")) if isinstance(review, dict) else False
            peak = max(values) if values else 0.0
            parsed.append((offsets, values, final_dur, is_golden, peak))

        if not parsed:
            return None

        # Degeneracy floor: below max(_DEGENERATE_POWER_FLOOR, 10% of the median
        # peak) a cycle is treated as a mis-capture and dropped (unless golden).
        peaks = sorted(p[4] for p in parsed)
        median_peak = peaks[len(peaks) // 2] if peaks else 0.0
        degen_floor = max(_DEGENERATE_POWER_FLOOR, 0.10 * median_peak)

        raw_cycles_data: list[tuple[list[float], list[float], float]] = []
        durations: list[float] = []
        golden_mask: list[bool] = []
        dropped = 0
        for offsets, values, final_dur, is_golden, peak in parsed:
            if peak < degen_floor and not is_golden:
                dropped += 1
                continue
            raw_cycles_data.append((offsets, values, final_dur))
            durations.append(final_dur)
            golden_mask.append(is_golden)

        # Never drop everything: if the filter removed all cycles (e.g. a truly
        # low-power profile misjudged), fall back to using them all.
        if not raw_cycles_data:
            for offsets, values, final_dur, is_golden, _peak in parsed:
                raw_cycles_data.append((offsets, values, final_dur))
                durations.append(final_dur)
                golden_mask.append(is_golden)
        elif dropped:
            self._logger.debug(
                "Envelope rebuild: excluded %d degenerate cycle(s) below %.0fW "
                "(median peak %.0fW)", dropped, degen_floor, median_peak,
            )

        if not raw_cycles_data:
            return None

        # Run Heavy Computation. When the profile has user-verified "golden"
        # cycles, they define the reference shape (see compute_envelope_worker).
        result = analysis.compute_envelope_worker(
            cast(Any, raw_cycles_data),
            self.dtw_bandwidth,
            reference_mask=golden_mask if any(golden_mask) else None,
        )

        if not result:
            return None

        return result, durations

    def _cycle_peak(self, cycle: CycleDict) -> float:
        """Peak power of a cycle's trace (0.0 if it has none)."""
        pairs = decompress_power_data(cycle)
        return max((p[1] for p in pairs), default=0.0) if pairs else 0.0

    def _select_reference_cycle_id(
        self, profile_name: str, target_duration: float | None = None
    ) -> str | None:
        """Choose the best reference cycle for a profile's matching template.

        Preference order: user-pinned golden cycles first, then non-degenerate
        cycles (peak not a tiny fraction of the profile's median peak - this
        rejects mis-captured near-flat traces), and among those the one closest
        to the profile's representative duration (so a truncated half-cycle is
        not chosen). Returns a cycle id, or None if there are no usable cycles.
        """
        cands = [
            c for c in self.iter_evidence_cycles()
            if c.get("profile_name") == profile_name
            and c.get("status") in ("completed", "force_stopped")
            and isinstance(c.get("power_data"), list) and len(c["power_data"]) >= 3
        ]
        if not cands:
            return None
        peaks = {c["id"]: self._cycle_peak(c) for c in cands}
        med_peak = sorted(peaks.values())[len(peaks) // 2] if peaks else 0.0
        degen_floor = max(_DEGENERATE_POWER_FLOOR, 0.10 * med_peak)

        golden = [
            c for c in cands
            if isinstance(c.get("ml_review"), dict) and c["ml_review"].get("golden")
        ]
        if golden:
            pool = golden
        else:
            pool = [c for c in cands if peaks.get(c["id"], 0.0) >= degen_floor] or cands

        if target_duration and target_duration > 0:
            best = min(pool, key=lambda c: abs(float(c.get("duration") or 0.0) - target_duration))
        else:
            # No duration hint: prefer the longest (avoids truncated half-cycles).
            best = max(pool, key=lambda c: float(c.get("duration") or 0.0))
        return best.get("id")

    async def async_rebuild_all_envelopes(self) -> int:
        """Rebuild envelopes for all profiles. Returns count of envelopes rebuilt."""
        count = 0
        for profile_name in list(self._data["profiles"].keys()):
            if await self.async_rebuild_envelope(profile_name):
                count += 1
        return count

    def repair_corrupted_power_data(self) -> int:
        """Fix cycles whose power_data offsets were corrupted by the double-subtract bug.

        The bug caused ``offset = small_float - unix_timestamp`` to be stored instead of
        just ``small_float``.  Corrupted cycles have a first-offset < -1e8 (a value that
        can never occur for a real appliance cycle offset).  Recovery: add ``start_ts``
        back to every offset in the affected cycle.

        Returns the number of cycles repaired.
        """
        repaired = 0
        for cycle in self._data.get("past_cycles", []):
            power_data = cycle.get("power_data")
            if not isinstance(power_data, list) or not power_data:
                continue
            first = power_data[0]
            if not isinstance(first, (list, tuple)) or len(first) < 2:
                continue
            first_offset = first[0]
            if not isinstance(first_offset, (int, float)) or first_offset > -1e8:
                continue  # Not corrupted

            start_ts = _value_to_timestamp(cycle.get("start_time"))
            if start_ts is None:
                continue

            repaired_rows: list[list[float]] = []
            for pt in power_data:
                if not isinstance(pt, (list, tuple)) or len(pt) < 2:
                    continue
                try:
                    repaired_rows.append([round(float(pt[0]) + start_ts, 1), round(float(pt[1]), 1)])
                except (TypeError, ValueError):
                    continue
            if not repaired_rows:
                continue  # all rows malformed - leave original trace untouched
            cycle["power_data"] = repaired_rows
            repaired += 1
            repaired_data = cycle["power_data"]
            if len(repaired_data) > 1:
                r_offsets = [pt[0] for pt in repaired_data]
                r_intervals = np.diff(r_offsets)
                r_pos = r_intervals[r_intervals > 0]
                r_si = float(np.median(r_pos)) if len(r_pos) > 0 else 1.0
                cycle["sampling_interval"] = round(r_si, 1)
                # duration = last sample offset from cycle start (not span between
                # first and last sample, which would be wrong when leading zeros
                # were trimmed before storage)
                r_duration = round(r_offsets[-1], 1)
                cycle["duration"] = r_duration
                cycle["end_time"] = dt_util.utc_from_timestamp(
                    start_ts + r_duration
                ).isoformat()
                r_ts = np.array(r_offsets, dtype=float)
                r_p = np.array([pt[1] for pt in repaired_data], dtype=float)
                r_sig = compute_signature(r_ts, r_p)
                cycle["signature"] = dataclasses.asdict(r_sig)
            elif len(repaired_data) == 1:
                cycle["sampling_interval"] = 1.0
                cycle["duration"] = 0.0
                cycle["end_time"] = dt_util.utc_from_timestamp(start_ts).isoformat()
                cycle["signature"] = None

        if repaired:
            self._logger.warning(
                "Repaired corrupted power_data offsets in %d cycle(s)", repaired
            )
        return repaired

    async def async_rebuild_envelope(self, profile_name: str) -> bool:
        """
        Build/rebuild statistical envelope for a profile asynchronously.
        Offloads heavy DTW/normalization to executor.
        """
        # A rebuild changes this profile's curve, which feeds group cohesion, so
        # invalidate the cohesion cache (not only on group mutations) to avoid stale
        # cohesion approving/rejecting a collapse against outdated shapes.
        self._cohesion_cache_generation += 1
        # 1. Gather Data (Main Thread)
        def _eligible(seq: list[CycleDict]) -> list[CycleDict]:
            return [
                c
                for c in seq
                if c.get("profile_name") == profile_name
                and c.get("status") in ("completed", "force_stopped")
                and c.get("duration", 0) > 60
            ]

        # Real cycles drive usage stats (energy/count). Imported reference cycles and
        # backfilled history cycles additionally shape the curves + matching duration,
        # but never usage stats.
        #
        # Which of the three may shape the curve is the user's choice
        # (CONF_PROFILE_EVIDENCE_SOURCES); usage stats are not evidence and keep counting
        # real cycles either way, so unticking "real cycles" removes them from the curve
        # without making the profile claim it has never run.
        allowed = self._evidence_sources
        real_cycles = _eligible(self._data["past_cycles"])
        shape_real = real_cycles if EVIDENCE_REAL_CYCLES in allowed else []
        ref_cycles: list[CycleDict] = []
        if EVIDENCE_REFERENCE_CYCLES in allowed:
            ref_cycles += _eligible(self._data.get("reference_cycles", []))
        if EVIDENCE_BACKFILL_CYCLES in allowed:
            ref_cycles += _eligible(self._data.get("backfill_cycles", []))
        shape_cycles = shape_real + ref_cycles

        if not shape_cycles:
            if profile_name in self._data.get("envelopes", {}):
                del self._data["envelopes"][profile_name]
            return False

        # Kept for the fallback path + duration-stat code below (behaviour is
        # byte-identical to before when there are no reference cycles).
        labeled_cycles = shape_cycles

        # 2. Run Heavy Computation in Executor (Parsing + DTW)
        result_pkg = await self.hass.async_add_executor_job(
            self._rebuild_envelope_sync,
            shape_cycles
        )

        if not result_pkg:
            # Envelope shape couldn't be built (no power data / too few points).
            # Still update profile min/max/avg from raw cycle durations so that
            # a duration correction via feedback is immediately reflected in stats.
            if labeled_cycles and profile_name in self._data.get("profiles", {}):
                raw_durs = [
                    float(c.get("manual_duration") or c.get("duration", 0))
                    for c in labeled_cycles
                ]
                raw_durs = [d for d in raw_durs if d > 60]
                if raw_durs:
                    raw_arr_fallback = np.array(raw_durs, dtype=float)
                    self._data["profiles"][profile_name]["min_duration"] = float(np.min(raw_arr_fallback))
                    self._data["profiles"][profile_name]["max_duration"] = float(np.max(raw_arr_fallback))
                    self._data["profiles"][profile_name]["avg_duration"] = float(np.mean(raw_arr_fallback))
            if profile_name in self._data.get("envelopes", {}):
                del self._data["envelopes"][profile_name]
            return False

        result, durations = result_pkg

        # Update profile stats in storage (Fast metadata update)
        if durations and profile_name in self._data.get("profiles", {}):
            stats_durations = filter_duration_outliers(durations)
            raw_arr = np.array(durations, dtype=float)
            # min/max reflect the actual observed range (including outliers)
            # avg uses the outlier-filtered set for a robust representative value
            min_duration = float(np.min(raw_arr))
            max_duration = float(np.max(raw_arr))
            avg_duration = float(np.mean(stats_durations))
            self._data["profiles"][profile_name]["min_duration"] = min_duration
            self._data["profiles"][profile_name]["max_duration"] = max_duration
            self._data["profiles"][profile_name]["avg_duration"] = avg_duration

            # Re-point the matching reference to a golden / non-degenerate cycle
            # near the representative duration, so a mis-captured (flat) or
            # truncated cycle can never remain the profile's template.
            ref_id = self._select_reference_cycle_id(profile_name, avg_duration)
            if ref_id and self._data["profiles"][profile_name].get("sample_cycle_id") != ref_id:
                old = self._data["profiles"][profile_name].get("sample_cycle_id")
                self._data["profiles"][profile_name]["sample_cycle_id"] = ref_id
                self._logger.debug(
                    "Re-selected reference cycle for %s: %s -> %s",
                    profile_name, old, ref_id,
                )

        if not result:
            if profile_name in self._data.get("envelopes", {}):
                del self._data["envelopes"][profile_name]
            return False

        time_grid, min_curve, max_curve, avg_curve, std_curve, target_duration = result

        # 3. Update Storage
        # Convert to list of points [[x, y], ...]
        def to_points(y_vals: list[float]) -> list[list[float]]:
            return [[round(t, 1), round(y, 1)] for t, y in zip(time_grid, y_vals)]

        # Calculate scalar stats
        duration_std_dev = float(np.std(durations)) if durations else 0.0

        # Usage stats (avg_energy, cycle_count) come from REAL cycles only, so imported
        # reference cycles can shape the curve without ever inflating energy/count.
        # When there are no reference cycles this is byte-identical to the prior behaviour
        # (avg_energy from the avg curve, cycle_count = len(durations)).
        if ref_cycles:
            real_energies = [
                float(c["energy_wh"])
                for c in real_cycles
                if isinstance(c.get("energy_wh"), (int, float))
            ]
            avg_energy = (sum(real_energies) / len(real_energies) / 1000.0) if real_energies else 0.0
            cycle_count = len(real_cycles)
        else:
            # Average-curve energy (kWh) via the shared trapezoidal integrator.
            # avg_curve is in Watts, time_grid in seconds; integrate_wh returns Wh.
            avg_energy = integrate_wh(time_grid, avg_curve) / 1000.0
            cycle_count = len(durations)

        envelope_data: dict[str, Any] = {
            "time_grid": time_grid,  # Time grid used by manager for phase estimation
            "target_duration": target_duration,  # Target duration for phase estimation
            "min": to_points(min_curve),
            "max": to_points(max_curve),
            "avg": to_points(avg_curve),
            "std": to_points(std_curve),
            "cycle_count": cycle_count,
            "avg_energy": avg_energy,
            "duration_std_dev": duration_std_dev,
            "updated": dt_util.now().isoformat(),
        }

        # Derived cache: per-phase profile (per-role duration/energy priors) used by
        # phase-segmented matching / phase-resolved ETA. Built only for device types
        # phase matching is live-supported for; absent otherwise (consumers fall back
        # to the whole-cycle pipeline). Pure/cheap - segmentation is O(samples).
        device_type = str(
            self._data.get("profiles", {}).get(profile_name, {}).get("device_type") or ""
        )
        # Offload the per-cycle segmentation to the executor (it can be tens of ms
        # for very long traces; keep it off the event loop, like the envelope DTW).
        phase_profile = await self.hass.async_add_executor_job(
            self._compute_phase_profile, profile_name, shape_cycles, device_type
        )
        if phase_profile is not None:
            envelope_data["phase_profile"] = phase_profile

        if "envelopes" not in self._data:
            self._data["envelopes"] = {}
        self._data["envelopes"][profile_name] = envelope_data

        return True

    def _compute_phase_profile(
        self, profile_name: str, cycles: list[CycleDict], device_type: str
    ) -> dict[str, Any] | None:
        """Segment each member cycle and aggregate a per-role phase profile.

        Returns a JSON-safe dict for ``envelope["phase_profile"]`` or ``None`` when
        phase matching is not live-supported for this device type or no cycle could
        be segmented. Never raises (phase support must never break envelope rebuild).
        """
        try:
            if not phase_matching_live_supported(device_type):
                return None
            model = phase_model_for(device_type)
            if model is None:
                return None
            segmented: list = []
            for cycle in cycles:
                offsets = power_data_to_offsets(cycle.get("power_data") or [])
                if len(offsets) < 4:
                    continue
                t = [float(o) for o, _ in offsets]
                w = [float(p) for _, p in offsets]
                segs = segment_cycle(t, w, model)
                if segs:
                    segmented.append(segs)
            if not segmented:
                return None
            profile = build_phase_profile(profile_name, segmented)
            return phase_profile_to_dict(profile) if profile is not None else None
        except Exception:  # noqa: BLE001 - phase caching must never break rebuild
            self._logger.debug("phase-profile build failed for %s", profile_name, exc_info=True)
            return None

    def _group_scope(self, program: str) -> set[str] | None:
        """Phase-narrowing scope for the matched ``program``:

        * If ``program`` is in a group with >= 2 members, return that group's
          members - narrow WITHIN the family (design §9). This is both coherent
          (same program family as the displayed program) and accurate (picks the
          right temperature/spin variant among siblings).
        * Otherwise return ``None`` = no scope filter (consider ALL of the
          device's phase profiles). The Phase-0 gate showed that constraining an
          UNGROUPED cycle to only the whole-cycle-matched program regresses the
          ETA whenever that match is wrong (common on mislabeled data): the best
          ETA comes from letting the phase matcher pick the best-fitting profile,
          bounded by the ambiguity gate + cold-start floor. Grouping variants is
          the recommended workflow and restores full coherence.
        """
        try:
            for grp in self.get_profile_groups().values():
                members = grp.get("members") if isinstance(grp, dict) else None
                if isinstance(members, list) and program in members:
                    sib = {m for m in members if isinstance(m, str)}
                    if len(sib) >= 2:
                        return sib
        except Exception:  # noqa: BLE001
            self._logger.debug("_group_scope failed for %r", program, exc_info=True)
        return None

    def _candidate_phase_profiles(self, scope: set[str] | None = None) -> list:
        """Cached per-profile PhaseProfiles (from envelope['phase_profile']).

        Restricted to ``scope`` (profile names) when given, and always filtered to
        profiles with >= ``PHASE_PROFILE_MIN_CYCLES`` member cycles so a noisy
        single-cycle prior can't drive the ETA (cold-start floor).
        """
        out = []
        for name, env in (self._data.get("envelopes") or {}).items():
            if scope is not None and name not in scope:
                continue
            if isinstance(env, dict):
                pp = phase_profile_from_dict(env.get("phase_profile"))
                if pp is not None and pp.n_cycles >= PHASE_PROFILE_MIN_CYCLES:
                    out.append(pp)
        return out

    def phase_remaining(
        self,
        power_data: list,
        device_type: str,
        program: str | None = None,
    ) -> dict[str, Any] | None:
        """Phase-resolved remaining-time for a running cycle. Never raises.

        Segments the observed-so-far trace and matches it against the matched
        ``program``'s phase profile (and its group siblings, design §9), returning
        the winning member's per-role budget remaining. Returns ``None`` (caller
        keeps the current estimate) when: phase matching is not live-supported for
        the device type; ``program`` is unknown / has no cached phase profile
        (or too few cycles - cold-start floor); segmentation is degenerate; or the
        top two candidates are within ``MATCH_AMBIGUITY_MARGIN`` (ambiguous - do
        not commit a variant, design §7).

        This is the *phase* half of the blended ETA; the blend with the current
        estimator lives in ``progress.compute_progress`` (single source of truth).
        Pure and cheap (segmentation + per-role agreement, no DTW) - safe to call
        inline from the async matching path.
        """
        try:
            if not phase_matching_live_supported(device_type):
                return None
            model = phase_model_for(device_type)
            if model is None or not program:
                return None
            candidates = self._candidate_phase_profiles(self._group_scope(program))
            if not candidates:
                return None
            offsets = power_data_to_offsets(power_data or [])
            if len(offsets) < 4:
                return None
            t = [float(o) for o, _ in offsets]
            w = [float(p) for _, p in offsets]
            segs = segment_cycle(t, w, model, partial=True)
            if not segs:
                return None
            ranked = match_phase_profiles(segs, candidates, {})
            if not ranked:
                return None
            # Ambiguity gate: a near-tie among group members is not a confident
            # variant call - fall back rather than swing the ETA between budgets.
            if (len(ranked) >= 2
                    and (ranked[0].score - ranked[1].score) < MATCH_AMBIGUITY_MARGIN):
                return None
            best = next((c for c in candidates if c.name == ranked[0].name), None)
            remaining = phase_eta(segs, best) if best is not None else None
            if remaining is None:
                return None
            return {
                "remaining_s": float(remaining),
                "matched": ranked[0].name,
                "score": float(ranked[0].score),
            }
        except Exception:  # noqa: BLE001 - phase ETA must never break the estimate
            self._logger.debug("phase_remaining failed", exc_info=True)
            return None






    def get_envelope(self, profile_name: str) -> JSONDict | None:
        """Get cached envelope for a profile, or None if not available."""
        envelopes = self._data.get("envelopes", {})
        if isinstance(envelopes, dict):
            envelopes_map = cast(dict[str, Any], envelopes)
            env = envelopes_map.get(profile_name)
            return cast(JSONDict, env) if isinstance(env, dict) else None
        return None

    @staticmethod
    def _envelope_time_power(
        envelope: JSONDict | None,
    ) -> tuple[list[float], list[float]] | None:
        """Extract (time_grid, power) from an envelope's ``avg`` curve, or None.

        Handles both the new ``[[t, p], ...]`` and legacy ``[p, ...]`` formats
        (reconstructing the grid from ``time_grid`` / ``target_duration`` / a 60 s
        fallback). Single source of the envelope time axis for both the alignment
        worker and the Smart-Termination release span (#348), so the mapped position
        and the span it is compared against always live on the same grid.
        """
        if not envelope:
            return None
        env_avg_raw = envelope.get("avg", [])
        if not env_avg_raw:
            return None
        try:
            if isinstance(env_avg_raw[0], (list, tuple)) and len(env_avg_raw[0]) >= 2:
                env_points = cast(list[list[Any] | tuple[Any, ...]], env_avg_raw)
                env_time = [float(p[0]) for p in env_points]
                env_power = [float(p[1]) for p in env_points]
            else:
                env_values = cast(list[float | int], env_avg_raw)
                env_power = [float(p) for p in env_values]
                env_time_raw = envelope.get("time_grid")
                env_time = cast(list[float], env_time_raw) if isinstance(env_time_raw, list) else None
                if not env_time or len(env_time) != len(env_power):
                    target_dur = float(envelope.get("target_duration", 0.0) or 0.0)
                    if target_dur > 0:
                        env_time = cast(list[float], np.linspace(0, target_dur, len(env_power)).tolist())
                    else:
                        env_time = [float(i * 60) for i in range(len(env_power))]
            # Inside the try: a stored time_grid with non-numeric entries must
            # yield the safe None fallback, not propagate to async_verify_alignment.
            return [float(t) for t in env_time], env_power
        except (TypeError, ValueError, IndexError):
            return None

    @staticmethod
    def _resample_trace_to_grid(
        offsets: list[float], powers: list[float], env_time: list[float]
    ) -> list[float]:
        """Resample a live ``(offsets, powers)`` trace onto the envelope's uniform
        time step, so its sample index maps to elapsed seconds on the envelope grid
        (#350). Linear interpolation (matching the envelope build side), bounded at
        2x the envelope length. Falls back to the raw powers when a grid cannot be
        formed (too few points / non-positive step), leaving behavior unchanged.
        """
        if len(offsets) < 2 or len(env_time) < 2:
            return list(powers)
        diffs = np.diff(np.asarray(env_time, dtype=float))
        pos = diffs[diffs > 0]
        if pos.size == 0:
            return list(powers)
        step = float(np.median(pos))
        span = float(env_time[-1])
        # Sort and deduplicate before computing t_max so the grid span reflects the
        # true maximum offset even when the raw trace is non-monotonic.
        off_arr = np.asarray(offsets, dtype=float)
        pow_arr = np.asarray(powers, dtype=float)
        order = np.argsort(off_arr, kind="stable")
        off_arr, pow_arr = off_arr[order], pow_arr[order]
        # Remove duplicate offsets; np.unique keeps the first occurrence after sorting.
        off_arr, unique_idx = np.unique(off_arr, return_index=True)
        pow_arr = pow_arr[unique_idx]
        t_max = min(float(off_arr[-1]), 2.0 * span) if span > 0 else float(off_arr[-1])
        if step <= 0 or t_max <= 0:
            return list(powers)
        n = max(2, round(t_max / step) + 1)
        grid = np.arange(n, dtype=float) * step
        return np.interp(grid, off_arr, pow_arr).tolist()

    def envelope_time_span(self, profile_name: str) -> float:
        """Total time span (seconds) of a profile's envelope grid, 0 if unavailable.

        This is exactly the maximum value ``async_verify_alignment`` maps onto, so
        dividing a mapped position by it yields a true 0..1 fraction through the
        cycle - unlike ``avg_duration`` (a differently-derived trimmed mean) which
        could push the 0.95 Smart-Termination release threshold out of reach (#348).
        """
        curves = self._envelope_time_power(self.get_envelope(profile_name))
        if not curves or not curves[0]:
            return 0.0
        return float(curves[0][-1])

    def profile_tail_power(
        self,
        profile_name: str,
        window_frac: float = SMART_TERM_TAIL_WINDOW_FRAC,
    ) -> float | None:
        """Mean power (W) a profile draws over the last ``window_frac`` of its own
        run, or None when the profile has no usable trace.  Never raises.

        This is the reference level the Smart-Termination power-plausibility guard
        compares the live trailing power against (#364).  Both Smart-Termination
        paths key on ``elapsed >= 0.98 * expected``; when the matcher has locked onto
        a *shorter* look-alike profile that anchor lands mid-wash, and neither path
        asks whether the appliance is still working.  "Several times what this
        programme draws at its own end" is the signal that it is.

        Prefers the profile's envelope average curve; falls back to the sample
        cycle's raw trace, because a thinly-trained profile (one labelled cycle, so
        no envelope) is still a match candidate and would otherwise get no guard at
        all.  Pure statistics, no ML - same never-raises contract as
        ``compute_envelope_conformance``.
        """
        try:
            frac = min(max(float(window_frac), 0.01), 1.0)

            curves = self._envelope_time_power(self.get_envelope(profile_name))
            if curves and len(curves[0]) >= 2:
                env_time, env_power = curves
                cutoff = float(env_time[-1]) * (1.0 - frac)
                tail = [p for t, p in zip(env_time, env_power) if t >= cutoff]
                if tail:
                    return float(np.mean(tail))

            profile = self.get_profiles().get(profile_name)
            if not isinstance(profile, dict):
                return None
            sample_id = profile.get("sample_cycle_id")
            if not sample_id:
                return None
            cycle, _ = self.find_stored_cycle(str(sample_id))
            if not cycle:
                return None
            # Through decompress_power_data, not raw power_data: a legacy cycle stores
            # (iso_string, power) pairs, and float() on the ISO string would raise into
            # the broad except below, silently leaving the #364 guard inert for that
            # profile. The isinstance check does not catch it - [iso_str, power] is a list.
            points = decompress_power_data(cycle)
            if len(points) < 2:
                return None
            offsets = [float(pt[0]) for pt in points]
            powers = [float(pt[1]) for pt in points]
            cutoff = offsets[-1] * (1.0 - frac)
            tail = [p for t, p in zip(offsets, powers) if t >= cutoff]
            if not tail:
                return None
            return float(np.mean(tail))
        except Exception:  # noqa: BLE001
            return None

    def reference_curve(
        self, profile_name: str, n: int = REFERENCE_PROFILE_CURVE_POINTS
    ) -> JSONDict | None:
        """Compact, read-only reference power curve for a matched profile.

        Downsamples the profile envelope's average power-over-time shape to at
        most ``n`` points so it can be exposed as an entity attribute for
        consumers (e.g. home energy managers) that want the *forward-looking
        load shape* rather than a scalar ETA - to anticipate, say, a heating
        spike later in the cycle. Shape::

            {
                "points": [[offset_s, watts], ...],  # <= n samples
                "duration_s": float,                 # profile target duration
                "cycle_count": int,                  # cycles behind the average
            }

        Offsets are absolute seconds from cycle start (0 .. ``duration_s``); a
        consumer derives the *remaining* curve by slicing from the live progress
        position (already exposed as the progress sensor). The curve is static
        per profile - it only changes when the profile is re-learned - so it can
        be surfaced as an attribute without recorder churn.

        Pure statistics (no ML); never raises - returns ``None`` when the
        envelope is missing or too short to be meaningful.
        """
        try:
            env = self.get_envelope(profile_name)
            if not isinstance(env, dict):
                return None
            avg = env.get("avg")
            if (
                not isinstance(avg, list)
                or len(avg) < 2
                or not isinstance(avg[0], (list, tuple))
                or len(avg[0]) < 2
            ):
                return None
            ts = np.asarray([float(p[0]) for p in avg], dtype=float)
            ws = np.asarray([float(p[1]) for p in avg], dtype=float)
            if ts.size < 2 or not (np.all(np.isfinite(ts)) and np.all(np.isfinite(ws))):
                return None
            if ts[-1] <= ts[0]:
                return None
            duration = float(env.get("target_duration") or 0.0)
            if duration <= 0:
                duration = float(ts[-1])
            count = max(2, min(int(n), ts.size))
            grid = np.linspace(float(ts[0]), float(ts[-1]), count)
            vals = np.interp(grid, ts, ws)
            points = [
                [int(round(float(g))), round(float(v), 1)]
                for g, v in zip(grid, vals)
            ]
            return {
                "points": points,
                "duration_s": round(duration, 1),
                "cycle_count": int(env.get("cycle_count") or 0),
            }
        except Exception:  # pragma: no cover - defensive; never break the sensor
            return None

    def get_profile_power_profile(
        self, profile_name: str, interval_s: float = 900.0
    ) -> list[float]:
        """Average power (W) per fixed interval across a profile's learned shape.

        Resamples the profile envelope's average power-over-time curve into
        consecutive ``interval_s`` buckets (default 15 min) and returns the mean
        watts in each, e.g. ``[2200, 2200, 800, 800, 1500, 500, 400, 200]`` - the
        flat per-slot array external planners such as tibber_prices'
        ``power_profile`` consume to pick the cheapest window to run the appliance
        (issue #272). Unlike :meth:`reference_curve` (a downsampled time/watt shape
        surfaced on the running-program sensor), this is a fixed-interval average
        exposed per profile so it can be read for planning before a cycle starts.

        The final bucket is averaged only over the part of the cycle that actually
        falls inside it. Pure statistics; never raises. Returns an empty list when
        the profile has no learned envelope yet.
        """
        try:
            if interval_s <= 0:
                return []
            env = self.get_envelope(profile_name)
            if not isinstance(env, dict):
                return []
            avg = env.get("avg")
            if (
                not isinstance(avg, list)
                or len(avg) < 2
                or not isinstance(avg[0], (list, tuple))
                or len(avg[0]) < 2
            ):
                return []
            ts = np.asarray([float(p[0]) for p in avg], dtype=float)
            ws = np.asarray([float(p[1]) for p in avg], dtype=float)
            if ts.size < 2 or not (np.all(np.isfinite(ts)) and np.all(np.isfinite(ws))):
                return []
            if ts[-1] <= ts[0]:
                return []
            total = float(env.get("target_duration") or 0.0)
            if total <= 0:
                total = float(ts[-1])
            if total <= 0:
                return []
            n_buckets = int(math.ceil(total / interval_s))
            out: list[float] = []
            for k in range(n_buckets):
                lo = k * interval_s
                hi = min(lo + interval_s, total)
                if hi <= lo:
                    break
                # Time-average the curve over the half-open slot [lo, hi) on a fine
                # interpolated grid, so the result is independent of the envelope's
                # grid spacing. endpoint=False keeps a slot boundary from being
                # double-counted into the adjacent slot.
                fine = np.linspace(lo, hi, 16, endpoint=False)
                out.append(round(float(np.mean(np.interp(fine, ts, ws))), 1))
            return out
        except Exception:  # pragma: no cover - defensive; never break the sensor
            return []

    def compute_envelope_conformance(
        self,
        profile_name: str,
        points: list[tuple[float, float]],
    ) -> dict[str, Any] | None:
        """Score how well the current power trace conforms to the profile envelope band.

        Resamples ``points`` to the envelope's time grid (scaling by the ratio of
        the current elapsed time to the envelope duration) and computes the fraction
        of samples that land within the [lower, upper] band.  Returns a dict:

          ``conformance``     – fraction of samples inside the envelope band (0–1)
          ``outside_frac``    – fraction outside the band (1 - conformance)
          ``samples``         – number of samples compared
          ``envelope_name``   – profile_name used

        Returns ``None`` if the envelope or points are unavailable / too short.
        Never raises.

        This is a complementary signal to match confidence: confidence measures
        shape correlation, while conformance measures absolute level/spread.  A
        high-confidence but low-conformance score may indicate the cycle was
        mis-detected (merged cycles, offset start) or the appliance is running
        anomalously.
        """
        try:
            if not points or len(points) < 4:
                return None
            env = self.get_envelope(profile_name)
            if not env:
                return None
            time_grid = env.get("time_grid")
            lower_raw = env.get("min")
            upper_raw = env.get("max")
            if not time_grid or not lower_raw or not upper_raw:
                return None

            tg = np.asarray(time_grid, dtype=float)
            # Envelope curves are stored as [[t, y], ...]; extract the y column.
            lo = np.asarray(
                [p[1] if isinstance(p, (list, tuple)) else p for p in lower_raw],
                dtype=float,
            )
            hi = np.asarray(
                [p[1] if isinstance(p, (list, tuple)) else p for p in upper_raw],
                dtype=float,
            )
            env_duration = float(tg[-1]) if len(tg) > 1 else 1.0

            # Normalise observed trace to [0, env_duration] time range
            t_obs = np.asarray([t for t, _ in points], dtype=float)
            p_obs = np.asarray([p for _, p in points], dtype=float)
            obs_duration = float(t_obs[-1]) if len(t_obs) > 1 else 1.0
            t_scaled = t_obs * (env_duration / obs_duration) if obs_duration > 0 else t_obs

            # Interpolate envelope bounds at scaled observed time points (clamp ends)
            t_clamped = np.clip(t_scaled, tg[0], tg[-1])
            lo_interp = np.interp(t_clamped, tg, lo)
            hi_interp = np.interp(t_clamped, tg, hi)

            inside = np.sum((p_obs >= lo_interp) & (p_obs <= hi_interp))
            n = len(p_obs)
            conformance = float(inside) / n if n > 0 else 0.0

            return {
                "conformance": round(conformance, 3),
                "outside_frac": round(1.0 - conformance, 3),
                "samples": n,
                "envelope_name": profile_name,
            }
        except Exception:  # noqa: BLE001
            return None

    def detect_cycle_artifacts(
        self,
        profile_name: str,
        points: list[tuple[float, float]],
    ) -> list[dict[str, Any]]:
        """Detect transient artifacts in a cycle by comparing it to the profile band.

        Walks the trace against the matched profile's envelope and flags contiguous
        segments that deviate from what the program normally does:

          * ``pause``  – power fell to ~0 where the profile expects activity and then
            resumed (e.g. the door was opened mid-cycle to add an item, or a manual
            pause); excluded at the very end (that's just the cycle finishing).
          * ``dip``    – ran sustainedly below the usual power band.
          * ``spike``  – ran sustainedly above the usual power band.

        Returns a chronological list of ``{type, start_s, end_s, detail, severity}``
        in the trace's own time offsets (so the panel can mark them on the graph),
        capped to the most significant few. ``[]`` when unavailable. Pure statistics
        (no ML), never raises — and the events double as candidate labels for a
        future supervised anomaly model.
        """
        try:
            if not points or len(points) < 6:
                return []
            env = self.get_envelope(profile_name)
            if not env:
                return []
            tg = np.asarray(env.get("time_grid") or [], dtype=float)
            # Envelope curves are stored as [[t, y], ...]; extract the y column.
            def _extract_y(raw: list) -> np.ndarray:
                if not raw:
                    return np.array([], dtype=float)
                return np.asarray(
                    [p[1] if isinstance(p, (list, tuple)) else p for p in raw],
                    dtype=float,
                )
            lo = _extract_y(env.get("min") or [])
            hi = _extract_y(env.get("max") or [])
            avg = _extract_y(env.get("avg") or [])
            if tg.size < 2 or lo.size != tg.size or hi.size != tg.size:
                return []

            t_obs = np.asarray([t for t, _ in points], dtype=float)
            p_obs = np.asarray([max(0.0, float(p)) for _, p in points], dtype=float)
            obs_dur = float(t_obs[-1]) if t_obs.size > 1 else 1.0
            env_dur = float(tg[-1]) if tg.size > 1 else 1.0
            if obs_dur <= 0 or env_dur <= 0:
                return []

            # Map each observed time to the envelope grid (same scaling as
            # compute_envelope_conformance) so the band is aligned to progress.
            t_scaled = np.clip(t_obs * (env_dur / obs_dur), tg[0], tg[-1])
            lo_i = np.interp(t_scaled, tg, lo)
            hi_i = np.interp(t_scaled, tg, hi)
            avg_i = np.interp(t_scaled, tg, avg) if avg.size == tg.size else (lo_i + hi_i) / 2.0

            peak = max(float(np.max(hi)), 1.0)
            active_thr = max(5.0, 0.05 * peak)   # profile expects real power here
            pause_thr = max(2.0, 0.03 * peak)    # observed effectively off
            margin = max(10.0, 0.12 * peak)      # band slack to ignore edge noise

            # Pre-check: if the cycle's linear-resampled alignment is too poor
            # (e.g. duration or phase structure doesn't match the profile), the
            # envelope comparison produces unreliable artifacts.  Compute tight
            # conformance (no margin) over the active region; bail out when more
            # than 45 % of expected-active samples are outside the raw band.
            active_mask = avg_i > active_thr
            n_active = int(np.sum(active_mask))
            if n_active > 10:
                outside_tight = int(np.sum(
                    (p_obs[active_mask] < lo_i[active_mask]) |
                    (p_obs[active_mask] > hi_i[active_mask])
                ))
                if outside_tight / n_active > 0.45:
                    return []

            states: list[str] = []
            for i in range(len(p_obs)):
                expects_power = avg_i[i] > active_thr
                if expects_power and p_obs[i] <= pause_thr:
                    states.append("pause")
                elif expects_power and p_obs[i] < lo_i[i] - margin:
                    states.append("dip")
                elif p_obs[i] > hi_i[i] + margin:
                    states.append("spike")
                else:
                    states.append("ok")

            min_dur = {"pause": 25.0, "dip": 45.0, "spike": 30.0}
            sev_scale = {"pause": 300.0, "dip": 600.0, "spike": 300.0}
            events: list[dict[str, Any]] = []
            n = len(states)
            i = 0
            while i < n:
                s = states[i]
                if s == "ok":
                    i += 1
                    continue
                j = i
                while j + 1 < n and states[j + 1] == s:
                    j += 1
                start_s, end_s = float(t_obs[i]), float(t_obs[j])
                dur = end_s - start_s
                resumes = (j + 1 < n) and float(np.max(p_obs[j + 1:])) > active_thr
                if dur >= min_dur[s] and (s != "pause" or resumes):
                    if s == "pause":
                        detail = (f"Power dropped to near zero for ~{int(dur)}s then resumed — "
                                  "likely the door was opened mid-cycle or the cycle was paused.")
                        detail_key = "msg.artifact_pause_detail"
                    elif s == "dip":
                        detail = f"Drew below the usual power band for ~{int(dur)}s."
                        detail_key = "msg.artifact_dip_detail"
                    else:
                        detail = f"Drew above the usual power band for ~{int(dur)}s."
                        detail_key = "msg.artifact_spike_detail"
                    events.append({
                        "type": s,
                        "start_s": round(start_s, 1),
                        "end_s": round(end_s, 1),
                        "detail": detail,
                        "detail_key": detail_key,
                        "detail_params": {"n": int(dur)},
                        "severity": round(min(1.0, dur / sev_scale[s]), 3),
                    })
                i = j + 1

            events.sort(key=lambda e: -e["severity"])
            events = events[:6]
            events.sort(key=lambda e: e["start_s"])
            return events
        except Exception:  # noqa: BLE001
            return []

    def get_match_candidates_summary(
        self, match_result: MatchResult, limit: int = 3
    ) -> list[dict[str, Any]]:
        """Extract top candidates from query result for UI display.

        Args:
            match_result: MatchResult from profile matching
            limit: Number of top candidates to return

        Returns:
            List of dicts with keys: profile_name, confidence_pct, mae, correlation, duration_ratio
        """
        candidates: list[dict[str, Any]] = []

        for candidate in match_result.ranking[:limit]:
            try:
                confidence_pct = round(candidate.get("score", 0.0) * 100, 1)
                metrics = candidate.get("metrics", {})
                mae = round(metrics.get("mae", 0.0), 2)
                corr = round(metrics.get("corr", 0.0), 3)

                profile_duration = candidate.get("profile_duration", 0.0)
                actual_duration = match_result.expected_duration
                duration_ratio = (
                    round((actual_duration / profile_duration - 1.0) * 100, 1)
                    if profile_duration > 0
                    else 0.0
                )

                candidates.append({
                    "profile_name": candidate.get("name", "Unknown"),
                    "confidence_pct": confidence_pct,
                    "mae": mae,
                    "correlation": corr,
                    "duration_ratio": duration_ratio,  # ±% from expected
                })
            except (TypeError, ValueError, KeyError):
                continue

        return candidates

    def _get_cached_sample_segment(
        self, sample_cycle: dict[str, Any], dt: float
    ) -> Segment | None:
        """Get or compute resampled segment for a sample cycle, using cache."""
        cycle_id = sample_cycle.get("id")
        if not cycle_id:
            return None

        # Round dt to avoid float cache misses
        dt_key = float(round(dt, 2))
        key = (cycle_id, dt_key)

        if key in self._cached_sample_segments:
            return self._cached_sample_segments[key]

        # Miss: Compute
        sample_data = sample_cycle.get("power_data")
        if not sample_data:
            return None

        try:
            if len(sample_data) > 0 and isinstance(sample_data[0], (list, tuple)):
                s_ts = np.array([x[0] for x in sample_data])
                s_p = np.array([x[1] for x in sample_data])
            else:
                return None

            s_segments = resample_uniform(s_ts, s_p, dt_s=dt, gap_s=21600.0)
            if not s_segments:
                return None

            sample_seg = max(s_segments, key=lambda s: len(s.power))

            # Store
            self._cached_sample_segments[key] = sample_seg
            return sample_seg
        except Exception as e: # pylint: disable=broad-exception-caught
            self._logger.warning("Error caching sample segment %s: %s", cycle_id, e)
            return None

    async def async_match_profile(
        self,
        current_power_data: list[tuple[str, float]] | list[tuple[datetime, float]] | list[tuple[float, float]] | list[list[float]],
        current_duration: float,
    ) -> MatchResult:
        """Run profile matching asynchronously in executor."""
        # 1. Prepare data in main thread (Access ProfileStore state safely)
        group_members: dict[str, list[str]] = {}
        member_snaps: dict[str, dict[str, Any]] = {}

        # Convert to list of floats for current power (uniform resampling)
        if not current_power_data:
            return MatchResult(None, 0.0, 0.0, None, [], False, 0.0)

        # Pre-process current data
        try:
            # Normalize input format
            first_elem = current_power_data[0][0]
            if isinstance(first_elem, datetime):
                # datetime objects: compute relative timestamps
                t_start = first_elem.timestamp()
                ts_arr = np.array([(x[0].timestamp() - t_start) for x in cast(list[tuple[datetime, float]], current_power_data)])
            elif isinstance(first_elem, (int, float)):
                # Already offset timestamps (from compressed format)
                ts_arr = np.array([float(x[0]) for x in cast(list[tuple[float, float]], current_power_data)])
            else:
                # ISO format strings
                t_start = datetime.fromisoformat(first_elem).timestamp()
                ts_arr = np.array(
                    [
                        (datetime.fromisoformat(x[0]).timestamp() - t_start)
                        for x in cast(list[tuple[str, float]], current_power_data)
                    ]
                )

            p_arr = np.array([float(x[1]) for x in current_power_data])

            # Resample current
            segments, used_dt = resample_adaptive(ts_arr, p_arr, min_dt=5.0, gap_s=21600.0)
            if not segments:
                return MatchResult(None, 0.0, 0.0, None, [], False, 0.0)
            current_seg = max(segments, key=lambda s: len(s.power))
            if len(current_seg.power) < 12:
                return MatchResult(None, 0.0, 0.0, None, [], False, 0.0)

            current_power_list = current_seg.power.tolist()

            # Prepare Snapshots. Imported reference cycles and backfilled history
            # cycles are eligible as matching templates alongside real cycles (so an
            # import-only profile can match) - subject to the user's evidence choice, so
            # the pool and the envelope always agree about what a profile looks like.
            all_cycles = self.iter_evidence_cycles()
            # Precompute per-profile lookups ONCE so the loop below is O(profiles),
            # not O(profiles x cycles). Rescanning all_cycles with next()/any() for
            # every profile made matching quadratic and stalled low-power hosts on
            # auto-label (many matches x many cycles) - issue #311. Selections are
            # byte-identical: cycles_by_id keeps the FIRST occurrence (== next()),
            # labeled_by_profile keeps the first eligible cycle in all_cycles order,
            # and golden_profiles mirrors the any(...) golden test.
            cycles_by_id: dict[str, CycleDict] = {}
            labeled_by_profile: dict[str, CycleDict] = {}
            golden_profiles: set[str] = set()
            for c in all_cycles:
                cid = c.get("id")
                if cid is not None and cid not in cycles_by_id:
                    cycles_by_id[cid] = c
                pname = c.get("profile_name")
                if not pname or not c.get("power_data"):
                    continue
                if (
                    pname not in labeled_by_profile
                    and c.get("status") in ("completed", "force_stopped")
                ):
                    labeled_by_profile[pname] = c
                rev = c.get("ml_review")
                if isinstance(rev, dict) and rev.get("golden"):
                    golden_profiles.add(pname)

            snapshots: list[dict[str, Any]] = []
            skipped_profiles: list[str] = []
            for name, profile in self._data["profiles"].items():
                # Try sample_cycle_id first, fall back to any labeled cycle
                sample_id = profile.get("sample_cycle_id")
                sample_cycle = cycles_by_id.get(sample_id) if sample_id else None
                # Fallback: find ANY completed cycle labeled with this profile
                if not sample_cycle:
                    sample_cycle = labeled_by_profile.get(name)
                # Boost user-pinned "golden" cycles: when a profile has one, use
                # its sharp single-cycle trace as the matching template instead
                # of the envelope average. The envelope average smears the
                # wash-phase peaks (each cycle's spikes land at slightly
                # different times), which hurts correlation for sharply-shaped
                # programs; a trusted golden cycle preserves that shape.
                has_golden = name in golden_profiles

                # Prefer envelope avg curve when ≥2 labeled cycles have been
                # confirmed - it gives a more representative reference signal
                # than the original sample alone, so confidence improves over
                # time as the user keeps confirming correct detections. Skipped
                # when a golden cycle is pinned (see above).
                envelope = self._data.get("envelopes", {}).get(name)
                _env_avg = envelope.get("avg") if envelope else None
                if (
                    not has_golden
                    and envelope
                    and envelope.get("cycle_count", 0) >= 2
                    and _env_avg
                    and isinstance(_env_avg[0], (list, tuple))
                    and len(_env_avg[0]) >= 2
                ):
                    avg_y = [float(p[1]) for p in _env_avg]
                    _env_ts_duration = (
                        float(_env_avg[-1][0]) - float(_env_avg[0][0])
                        if len(_env_avg) > 1 else 0.0
                    )
                    avg_duration = (
                        envelope.get("target_duration") or
                        profile.get("avg_duration") or
                        _env_ts_duration or
                        None
                    )
                    if not avg_duration:
                        skipped_profiles.append(
                            f"{name}: no valid duration (envelope has no target_duration, avg_duration, or timestamp span)"
                        )
                        continue
                    snapshots.append({
                        "name": name,
                        "avg_duration": float(avg_duration),
                        "sample_power": avg_y,
                        # True wall-clock span of `sample_power` (#364). NOT the same
                        # as avg_duration, which prefers target_duration / the
                        # profile's rolling mean - so index fraction only equals time
                        # fraction against this. Needed to truncate the curve to an
                        # elapsed duration for prefix scoring.
                        "sample_span_s": float(_env_ts_duration or avg_duration),
                    })
                    continue

                if not sample_cycle:
                    skipped_profiles.append(
                        f"{name}: no sample cycle (sample_id={sample_id})"
                    )
                    continue

                # Prepare sample segment (using cache)
                sample_seg = self._get_cached_sample_segment(sample_cycle, used_dt)
                if not sample_seg:
                    skipped_profiles.append(
                        f"{name}: failed to resample cycle {sample_cycle.get('id')}"
                    )
                    continue
                # avg_duration preference order:
                #   1. profile["avg_duration"] (rolling average, most accurate)
                #   2. sample_cycle["duration"] (raw cycle field)
                #   3. timestamp span of sample_seg (estimate from the resampled data)
                # Profiles created before avg_duration tracking was added may have
                # 0 or a missing value; falling back to the segment estimate prevents
                # update_match() from always seeing expected_duration=0, which
                # silences time-remaining estimates and logs a misleading warning.
                _seg_ts_duration = (
                    float(sample_seg.timestamps[-1]) - float(sample_seg.timestamps[0])
                    if len(sample_seg.timestamps) > 1 else 0.0
                )
                avg_dur = (
                    profile.get("avg_duration") or
                    sample_cycle.get("duration") or
                    _seg_ts_duration
                )
                if not avg_dur:
                    skipped_profiles.append(
                        f"{name}: no valid duration (avg_duration, cycle duration, and timestamp span all zero/missing)"
                    )
                    continue
                snapshots.append({
                    "name": name,
                    "avg_duration": float(avg_dur),
                    "sample_power": sample_seg.power.tolist(),
                    "sample_dt": used_dt,
                    # True wall-clock span of `sample_power` (#364). _get_cached_sample_segment
                    # keeps only the LONGEST gap-free segment, so a cycle with an internal
                    # outage yields a curve covering less than avg_dur - truncating by a
                    # fraction of avg_dur would then cut the wrong place.
                    "sample_span_s": float(_seg_ts_duration or avg_dur),
                })

            if skipped_profiles:
                self._logger.debug(
                    "Profile matching skipped %d profiles: %s",
                    len(skipped_profiles),
                    "; ".join(skipped_profiles)
                )

            # Stage 5: collapse cohesive near-duplicate groups into one aggregate
            # candidate each (loose groups stay individual). No-op without groups.
            snapshots, group_members, member_snaps = self._grouped_snapshots(snapshots)

            config = {
                "min_duration_ratio": self._min_duration_ratio,
                "max_duration_ratio": self._max_duration_ratio,
                "dtw_bandwidth": self.dtw_bandwidth,
                "energy_mode": self.energy_mode,
                # On-device tuned scoring weights (opt-in); empty = shipped defaults.
                **self._matching_overrides(),
            }

        except Exception as e:  # pylint: disable=broad-exception-caught
            self._logger.error("Preparation for async match failed: %s", e)
            return MatchResult(None, 0.0, 0.0, None, [], False, 0.0)

        # 2. Run Heavy Logic in Executor
        candidates = await self.hass.async_add_executor_job(
            analysis.compute_matches_worker,
            current_power_list,
            current_duration,
            cast(Any, snapshots),
            config
        )

        # 3. Process Result (Main Thread)
        if not candidates:
            profiles_count = len(self._data.get("profiles", {}))
            snapshots_count = len(snapshots) if 'snapshots' in dir() else 0
            self._logger.debug(
                "No profile match candidates: profiles=%d, snapshots=%d, "
                "duration=%.0fs. Possible reasons: duration ratio filter, "
                "no labeled cycles, or no profiles defined.",
                profiles_count,
                snapshots_count,
                current_duration
            )
            return MatchResult(None, 0.0, 0.0, None, [], False, 0.0, [], {}, is_confident_mismatch=True, mismatch_reason="all_rejected")

        best = candidates[0]

        # Reconstruct MatchResult. Top-level ambiguity (safeguard #1): a close
        # group-vs-runner-up call is flagged so it goes to the uncertain/feedback
        # path, not a confident commit.
        margin, is_ambiguous = _ambiguity_from_candidates(candidates)

        best_name = best["name"]
        best_duration = best["profile_duration"]
        # Stage 5: if a group won, pick the best-fitting member.
        if best_name in group_members:
            chosen, member_fit, member_dur = self._stage5_pick_member(
                current_power_list, current_duration, group_members[best_name], member_snaps
            )
            best_name = chosen
            if member_dur:
                best_duration = member_dur
            # Safeguard #2: the group aggregate matched but if the chosen member
            # does not individually fit reasonably (vs the group score), the real
            # program may be a different single profile -> treat as uncertain.
            # member_fit is a Stage-2-only score; best["score"] includes DTW-blend +
            # duration/energy agreement (typically 25-30% higher). Use 0.55× to avoid
            # the threshold being effectively too strict for DTW-boosted groups.
            if member_fit is not None and best["score"] > 0 and member_fit < 0.55 * best["score"]:
                is_ambiguous = True
            # Overrun guard: if the cycle has already outlasted the chosen member's
            # expected duration we may be running the longer group member — downgrade
            # to ambiguous so Smart Termination falls back to the power timeout.
            if best_duration and current_duration > best_duration * 1.05:
                is_ambiguous = True
            # Relabel the winning candidate for the ranking / diagnostics.
            candidates = [{**best, "name": best_name, "profile_duration": best_duration}, *candidates[1:]]

        matched_phase = None
        if best_name:
            # Always resolve phase for the matched profile so phase sensors can
            # show user-assigned phase names even when confidence is moderate.
            matched_phase = self.check_phase_match(best_name, current_duration)

        # Detect "prefix ambiguity": a non-winning candidate whose duration is
        # significantly longer than the matched profile AND whose shape matched
        # well before Stage-4 penalised its duration. When this is true the
        # current trace may be a prefix of that longer program, not a complete
        # short cycle. Signal cycle_detector to block Smart Termination; the
        # power-based fallback timeout will decide instead.
        full_shape_hit, prefix_fit_hit = _match_prefix_ambiguity(
            candidates, best_duration or 0.0
        )
        is_prefix_ambiguous = full_shape_hit or prefix_fit_hit

        return MatchResult(
            best_name,
            best["score"],
            best_duration,
            matched_phase,
            candidates[:5],
            is_ambiguous,
            margin,
            ranking=candidates[:5],  # populate ranking (consumed for training snapshots)
            is_prefix_ambiguous=is_prefix_ambiguous,
            is_prefix_ambiguous_full_shape=full_shape_hit,
        )

    async def async_verify_alignment(
        self,
        profile_name: str,
        current_power_data: list[list[float]] | list[tuple[Any, ...]],
    ) -> tuple[bool, float, float]:
        """
        Verify if the current power trace aligns with an expected low-power region in the envelope.
        Returns: (is_confirmed_low_power, mapped_envelope_time, mapped_envelope_power)
        """
        if not current_power_data:
            return False, 0.0, 9999.0

        # "avg" can be a list of [t, p] (new) or [p, ...] (legacy); the shared helper
        # normalizes both to (time_grid, power) - identical to envelope_time_span so
        # the mapped position and the release span live on the same grid (#348).
        envelope = self.get_envelope(profile_name)
        curves = self._envelope_time_power(envelope)
        if curves is None:
            if envelope and envelope.get("avg"):
                self._logger.error(
                    "Malformed envelope 'avg' data for %s", profile_name
                )
            return False, 0.0, 9999.0
        env_time, env_power = curves

        # Resample the live trace onto the envelope's own time step BEFORE alignment,
        # so its sample index corresponds to elapsed SECONDS, not sample count (#350).
        # The worker warps power-vs-power; with no shared time axis an irregularly or
        # sparsely sampled tail (e.g. 0 W keepalives every off_delay) maps one grid
        # step per sample regardless of wall-clock, so the position crawls - and a
        # dense short prefix instead races to the end. Linear interpolation matches
        # the envelope build side so the two curves stay comparable.
        try:
            offsets = [float(x[0]) for x in current_power_data]
            powers = [float(x[1]) for x in current_power_data]
        except (TypeError, ValueError, IndexError):
            # A legacy ISO-timestamped trace (x[0] is a string) lands here. The
            # signature admits ``list[tuple[Any, ...]]``, so normalize through the
            # shared helper instead of bailing out: returning early skipped
            # alignment entirely, which silently reduced the legacy-envelope guard
            # in tests/repro/test_issue_112.py to a no-op. Production callers pass
            # offsets and never reach this branch, so the fast path is unchanged.
            normalized = power_data_to_offsets(
                cast(list[list[Any] | tuple[Any, ...]], current_power_data)
            )
            if not normalized:
                return False, 0.0, 9999.0
            try:
                offsets = [float(x[0]) for x in normalized]
                powers = [float(x[1]) for x in normalized]
            except (TypeError, ValueError, IndexError):
                return False, 0.0, 9999.0
        current_power_list = self._resample_trace_to_grid(offsets, powers, env_time)

        # Offload to worker
        mapped_time, mapped_power, score = await self.hass.async_add_executor_job(
            analysis.verify_profile_alignment_worker,
            current_power_list,
            env_power,
            env_time,
            self.dtw_bandwidth
        )

        # Verify if mapped power and alignment score indicate an expected low-power region.
        # Thresholds: Expected power < 15W, Alignment score > 0.4
        is_confirmed = (mapped_power < 15.0) and (score > 0.4)

        return is_confirmed, mapped_time, mapped_power


    def check_phase_match(self, profile_name: str, duration: float) -> str | None:
        """
        Check if the current duration aligns with a known phase in the profile.
        Returns the phase name (e.g., 'Rinse', 'Spin') or None.
        """
        profile = self._data["profiles"].get(profile_name)
        if not profile:
            return None

        phases = profile.get("phases", [])
        if not phases:
            return None

        # Guard against non-dict entries (legacy/malformed storage) and
        # None/non-numeric start values before sorting.
        phases_sorted = sorted(
            [p for p in phases if isinstance(p, dict)],
            key=lambda p: float(p.get("start") or 0),
        )

        last_matched: str | None = None
        for phase in phases_sorted:
            p_start = float(phase.get("start") or 0)
            p_end = float(phase.get("end") or 0)
            if p_end <= p_start:
                # Missing or zero end — phase range is invalid; skip silently.
                continue
            if p_start <= duration <= p_end:
                return str(phase.get("name", "Unknown"))
            if p_start <= duration:
                # Track the last phase whose start we have passed; used for gap fallback.
                last_matched = str(phase.get("name", "Unknown"))

        # Duration is outside explicit bounds — keep a label so entities avoid
        # falling back to generic running/starting states.
        if phases_sorted:
            if duration < float(phases_sorted[0].get("start") or 0):
                return str(phases_sorted[0].get("name", "Unknown"))
            # Return the phase that just ended (last_matched) when in a gap, or the
            # final phase when past all ranges, rather than always the absolute last.
            return last_matched or str(phases_sorted[-1].get("name", "Unknown"))

        return None



    async def create_profile(self, name: str, source_cycle_id: str) -> None:
        """Create a new profile from a cycle, real or imported.

        ``reference_cycles`` are searched too: an imported cycle (community store, or a
        historical import per issue #344) is a legitimate source for a brand-new profile,
        and for a history import it is the *primary* one - the whole point is to name the
        programs found in months of past data. Looking only in ``past_cycles`` made
        "Label -> Create new profile..." fail outright on those cycles.

        The envelope is rebuilt here rather than left for the next match or maintenance
        pass, so the profile is usable the moment it is created (mirroring
        :meth:`assign_profile_to_cycle`).
        """
        cycle, _origin = self.find_stored_cycle(source_cycle_id)
        if not cycle:
            raise ValueError("Cycle not found")

        cycle["profile_name"] = name

        self._data.setdefault("profiles", {})[name] = {
            "avg_duration": cycle["duration"],
            "sample_cycle_id": source_cycle_id,
        }

        await self.async_rebuild_envelope(name)
        # Save to persist the label
        await self.async_save()

    @property
    def has_real_profiles(self) -> bool:
        """True if at least one stored profile is backed by a cycle that counts as evidence.

        A profile with no cycle behind it cannot be matched, so this gates matching and
        the setup notifications. Imported reference cycles and backfilled history cycles
        count: an import-only install has zero `past_cycles` and is still fully matchable.
        Evidence-gated, so a profile whose only cycles the user has excluded reads as not
        matchable - which is what excluding them means.
        """
        profile_names = self._data.get("profiles", {}).keys()
        if not profile_names:
            return False
        assigned = {
            c.get("profile_name")
            for c in self.iter_evidence_cycles()
            if c.get("profile_name")
        }
        return bool(assigned.intersection(profile_names))

    def list_profiles(self) -> list[dict[str, Any]]:
        """List all profiles with metadata."""
        profiles: list[JSONDict] = []
        raw_profiles = self._data.get("profiles", {})
        profiles_map = (
            cast(dict[str, Any], raw_profiles) if isinstance(raw_profiles, dict) else {}
        )
        for name, data in profiles_map.items():
            profile_meta = cast(JSONDict, data) if isinstance(data, dict) else {}

            # Calculate count and last_run
            p_cycles = [
                c for c in self._data.get("past_cycles", [])
                if c.get("profile_name") == name
            ]
            cycle_count = len(p_cycles)

            last_run = None
            if p_cycles:
                last_c = max(p_cycles, key=lambda x: x.get("start_time", ""))
                last_run = last_c.get("start_time")

            # Fetch envelope stats
            envelope = self.get_envelope(name)
            avg_energy = envelope.get("avg_energy") if envelope else None
            duration_std_dev = envelope.get("duration_std_dev") if envelope else None

            # Per-profile cost aggregates from frozen per-cycle costs. Never raises;
            # both default to None when no cycle carries a cost.
            avg_cost: float | None = None
            total_cost: float | None = None
            try:
                costs = [float(c["cost"]) for c in p_cycles if c.get("cost") is not None]
                if costs:
                    total_cost = round(sum(costs), 4)
                    avg_cost = round(sum(costs) / len(costs), 4)
            except Exception:  # noqa: BLE001
                avg_cost = None
                total_cost = None

            # Downsampled power signature (the profile's real average power curve),
            # for the profile-card mini graph. Small (<=40 pts), power values only.
            sig_curve: list[float] = []
            try:
                avg_pts = (envelope or {}).get("avg") or []
                ps = [
                    float(p[1]) for p in avg_pts
                    if isinstance(p, (list, tuple)) and len(p) >= 2
                ]
                if not ps:  # legacy flat [p, ...] envelopes
                    ps = [float(x) for x in avg_pts if isinstance(x, (int, float))]
                if len(ps) > 40:
                    step = (len(ps) - 1) / 39.0
                    sig_curve = [ps[round(i * step)] for i in range(40)]
                else:
                    sig_curve = ps
            except Exception:  # noqa: BLE001
                sig_curve = []

            profiles.append(
                {
                    "name": name,
                    "avg_duration": profile_meta.get("avg_duration", 0),
                    "min_duration": profile_meta.get("min_duration", 0),
                    "max_duration": profile_meta.get("max_duration", 0),
                    "sample_cycle_id": profile_meta.get("sample_cycle_id"),
                    "cycle_count": cycle_count,
                    "last_run": last_run,
                    "avg_energy": avg_energy,
                    "duration_std_dev": duration_std_dev,
                    "avg_cost": avg_cost,
                    "total_cost": total_cost,
                    "signature_curve": sig_curve,
                    "is_imported": self.profile_has_reference_cycles(name),
                    # Cycles recovered from imported history (#344). Reported separately
                    # from cycle_count, which counts only real observed cycles, so a
                    # profile built purely from backfilled history does not look empty.
                    "backfill_count": sum(
                        1 for c in self.get_backfill_cycles()
                        if c.get("profile_name") == name
                    ),
                }
            )
        return sorted(profiles, key=lambda p: profile_sort_key(p.get("name", "")))

    async def create_profile_standalone(
        self,
        name: str,
        reference_cycle_id: str | None = None,
        avg_duration: float | None = None,
    ) -> None:
        """Create a profile without immediately labeling a cycle.
        If reference_cycle_id is provided, use that cycle's characteristics.
        If avg_duration is provided (and no reference cycle), use it as baseline."""
        if name in self._data.get("profiles", {}):
            raise ValueError(f"Profile '{name}' already exists")

        profile_data: JSONDict = {}
        if reference_cycle_id:
            cycle = next(
                (c for c in self.iter_stored_cycles() if c.get("id") == reference_cycle_id),
                None,
            )
            if cycle:
                profile_data = {
                    "avg_duration": cycle["duration"],
                    "sample_cycle_id": reference_cycle_id,
                }
                # Label the reference cycle with the new profile so that
                # statistics are immediately populated after creation.
                if not cycle.get("profile_name"):
                    cycle["profile_name"] = name
        elif avg_duration is not None and avg_duration > 0:
            profile_data = {
                "avg_duration": float(avg_duration),
            }

        # Create profile with minimal data (will be updated when cycles are labeled)
        profile_data.setdefault("phases", [])
        self._data.setdefault("profiles", {})[name] = profile_data

        # Build the envelope from any already-labeled cycles (e.g. reference cycle above)
        await self.async_rebuild_envelope(name)

        await self.async_save()
        self._logger.info("Created standalone profile '%s'", name)

    async def update_profile(
        self, old_name: str, new_name: str, avg_duration: float | None = None
    ) -> int:
        """Update a profile's name and/or average duration.
        Returns number of cycles updated (if renamed)."""
        profiles = self._data.get("profiles", {})
        if old_name not in profiles:
            raise ValueError(f"Profile '{old_name}' not found")

        # Handle Rename
        renamed = False
        if new_name != old_name:
            if new_name in profiles:
                raise ValueError(f"Profile '{new_name}' already exists")

            # Rename in profiles dict
            profiles[new_name] = profiles.pop(old_name)

            # Rename in envelopes
            if "envelopes" in self._data and old_name in self._data["envelopes"]:
                self._data["envelopes"][new_name] = self._data["envelopes"].pop(
                    old_name
                )

            renamed = True

        target_name = new_name if renamed else old_name

        # Handle Duration Update
        if avg_duration is not None and avg_duration > 0:
            profiles[target_name]["avg_duration"] = float(avg_duration)
            # If there's an envelope, we ideally update its target_duration too,
            # but envelope is usually rebuilt from data.
            # However, for manual profiles, envelope might be empty or theoretical.
            # Let's log it.
            self._logger.info(
                "Updated baseline duration for '%s' to %ss",
                target_name,
                avg_duration,
            )

        # Update cycles and feedback if renamed
        count = 0
        if renamed:
            # 1. Update every stored cycle (imports and backfills carry profile_name
            #    too; leaving them under the old name orphans them from the matcher).
            for cycle in self.iter_stored_cycles():
                if cycle.get("profile_name") == old_name:
                    cycle["profile_name"] = new_name
                    count += 1

            # 2. Update pending feedback
            pending = self.get_pending_feedback()
            for req in pending.values():
                if req.get("detected_profile") == old_name:
                    req["detected_profile"] = new_name

            # 3. Update feedback history
            history = self.get_feedback_history()
            for record in history.values():
                if record.get("original_detected_profile") == old_name:
                    record["original_detected_profile"] = new_name
                if record.get("corrected_profile") == old_name:
                    record["corrected_profile"] = new_name

            # 4. Update group membership (rename the member in any group)
            for g in self._data.get("profile_groups", {}).values():
                mems = g.get("members") if isinstance(g, dict) else None
                if isinstance(mems, list) and old_name in mems:
                    g["members"] = [new_name if m == old_name else m for m in mems]

            self._logger.info(
                "Renamed profile '%s' to '%s', updated %s cycles and associated feedback",
                old_name,
                new_name,
                count,
            )

        await self.async_save()
        return count

    async def delete_profile(self, name: str, unlabel_cycles: bool = True) -> int:
        """Delete a profile.
        If unlabel_cycles=True, removes profile label from cycles.
        If unlabel_cycles=False, cycles keep the label (orphaned).
        Returns number of cycles affected."""
        if name not in self._data.get("profiles", {}):
            raise ValueError(f"Profile '{name}' not found")

        # Delete profile
        del self._data["profiles"][name]

        # Handle cycles from every list: imported and backfilled cycles carry
        # profile_name too, so they would otherwise keep a dangling label for a
        # deleted profile.
        count = 0
        for cycle in self.iter_stored_cycles():
            if cycle.get("profile_name") == name:
                if unlabel_cycles:
                    cycle["profile_name"] = None
                count += 1

        await self.async_save()
        action = "unlabeled" if unlabel_cycles else "orphaned"
        self._logger.info("Deleted profile '%s', %s %s cycles", name, action, count)
        return count

    async def clear_all_data(self) -> None:
        """Clear all profiles, cycle data, and derived state."""
        self._data["past_cycles"] = []
        self._data["reference_cycles"] = []
        self._data["backfill_cycles"] = []
        self._data["profiles"] = {}
        self._data["envelopes"] = {}
        self._data["suggestions"] = {}
        self._data["locked_suggestions"] = []
        self._data["feedback_history"] = {}
        self._data["pending_feedback"] = {}
        self._data["auto_adjustments"] = []
        self._data["active_cycle"] = None
        self._data["last_active_save"] = None
        # Newer persisted state must also be wiped, else a "wipe all" leaves trained
        # models, groups, matcher tuning, histories, and counters behind.
        self._data["custom_phases"] = []
        self._data["ml_model_versions"] = {}
        self._data["profile_groups"] = {}
        self._data["maintenance_log"] = []
        self._data["playground_presets"] = {}
        self._data["matching_config"] = {}
        self._data["match_ranking_history"] = []
        self._data["ml_last_training_run"] = None
        self._data["ml_training_history"] = {}
        self._data["lifetime_energy_wh"] = 0.0
        self._data["lifetime_cycle_count"] = 0
        self._data["settings_changelog"] = []
        self._data["suggestion_apply_cycle_count"] = 0
        self._cached_sample_segments = {}
        self._cohesion_cache = {}
        self._cohesion_cache_generation += 1
        await self.async_save()
        self._logger.info("Cleared all WashData storage")

    async def assign_profile_to_cycle(
        self, cycle_id: str, profile_name: str | None
    ) -> None:
        """Assign an existing profile to a cycle. Rebuilds envelope."""
        old_profile = None
        found, origin = self.find_stored_cycle(cycle_id)
        if found is None:
            raise ValueError(f"Cycle {cycle_id} not found")
        if origin != "past":
            # An imported store recording or a backfilled history cycle (separate lists,
            # never in usage stats): relabelling just moves which profile's template it
            # seeds.
            await self._assign_reference_cycle_profile(found, profile_name)
            return
        cycle = found

        # Track old profile for envelope rebuild
        old_profile = cycle.get("profile_name")

        if profile_name and profile_name not in self._data.get("profiles", {}):
            raise ValueError(f"Profile '{profile_name}' not found. Create it first.")

        # Preserve original auto-assigned label before first manual relabeling
        if profile_name and old_profile and not cycle.get("original_auto_label"):
            orig_src = cycle.get("label_source", "")
            if orig_src in _AUTO_LABEL_SOURCES:
                cycle["original_auto_label"] = old_profile

        # Update cycle
        cycle["profile_name"] = profile_name if profile_name else None
        cycle["label_source"] = "manual" if profile_name else None

        # Update profile metadata if this is the first cycle
        if profile_name:
            profile = self._data["profiles"][profile_name]
            if not profile.get("sample_cycle_id"):
                profile["sample_cycle_id"] = cycle_id
                profile["avg_duration"] = cycle["duration"]

        # Rebuild envelopes for affected profiles
        if old_profile and old_profile != profile_name:
            await self.async_rebuild_envelope(old_profile)  # Old profile lost a cycle
        if profile_name:
            await self.async_rebuild_envelope(profile_name)  # New profile gained a cycle
            # Apply retention after labeling, in case profile now exceeds cap
            await self.async_enforce_retention()

        await self.async_save()
        self._logger.info("Assigned profile '%s' to cycle %s", profile_name, cycle_id)
        # Trigger smart processing to potentially merge now-labeled cycle
        await self.async_smart_process_history()

    def _relabel_non_real_cycle(
        self, ref: CycleDict, profile_name: str | None
    ) -> set[str]:
        """Move a non-real cycle onto a profile, returning the envelopes to rebuild.

        The bookkeeping half of :meth:`_assign_reference_cycle_profile`, split out so a
        bulk caller (:meth:`auto_label_cycles`) can apply it to many cycles and then
        rebuild and save **once** instead of per cycle - the same batching the real-cycle
        path uses, and the difference between one store write and one per imported cycle.

        Synchronous and self-contained: it validates the target, moves the label, clears a
        stale ``sample_cycle_id`` on the profile the cycle is leaving, and drops that
        profile outright when nothing is left in it (mirrors `_delete_non_real_cycle`;
        without it a sampleless profile would later be re-populated by sample repair
        stealing an unrelated real cycle). Returns the names whose envelope the caller
        must rebuild - a profile this dropped is deliberately absent from that set.
        """
        if profile_name and profile_name not in self._data.get("profiles", {}):
            raise ValueError(f"Profile '{profile_name}' not found. Create it first.")
        old_profile = ref.get("profile_name")
        ref_id = ref.get("id")
        ref["profile_name"] = profile_name if profile_name else None
        touched: set[str] = set()
        if old_profile and old_profile != profile_name:
            op = self._data.get("profiles", {}).get(old_profile)
            if op is not None and op.get("sample_cycle_id") == ref_id:
                op["sample_cycle_id"] = None
            old_has_cycles = any(
                c.get("profile_name") == old_profile
                for c in self.iter_stored_cycles()
            )
            if old_has_cycles:
                touched.add(old_profile)
            else:
                self._data.get("profiles", {}).pop(old_profile, None)
                self._data.get("envelopes", {}).pop(old_profile, None)
        if profile_name:
            touched.add(profile_name)
        return touched

    async def _assign_reference_cycle_profile(
        self, ref: CycleDict, profile_name: str | None
    ) -> None:
        """Reassign a non-real cycle - an imported store recording or a backfilled
        history cycle - to a different profile.

        The cycle stays in its own list (out of usage stats); only which profile envelope
        it seeds changes. Rebuilds the old and new envelopes. ``profile_name=None`` clears
        the label (the cycle then seeds nothing).

        This is the *manual* (user-driven) relabel path, so it stamps provenance exactly
        like the real-cycle path in ``assign_profile_to_cycle``: preserve the original
        auto-guess once, then mark the label ``manual``. Without the manual stamp a later
        ``auto_label_cycles(overwrite=True)`` would still see an auto ``label_source`` on a
        backfilled cycle and silently overwrite the user's correction.
        """
        old_profile = ref.get("profile_name")
        # Preserve the original auto-assigned label before the first manual relabel.
        if profile_name and old_profile and not ref.get("original_auto_label"):
            if ref.get("label_source", "") in _AUTO_LABEL_SOURCES:
                ref["original_auto_label"] = old_profile
        touched = self._relabel_non_real_cycle(ref, profile_name)
        # _relabel_non_real_cycle only moves profile_name (it is shared with the auto
        # bulk path, which sets its own source); stamp the manual provenance here.
        ref["label_source"] = "manual" if profile_name else None
        for name in touched:
            await self.async_rebuild_envelope(name)
        await self.async_save()
        self._logger.info(
            "Reassigned imported reference cycle %s to profile '%s'",
            ref.get("id"), profile_name,
        )

    async def auto_label_cycles(
        self, confidence_threshold: float = 0.75, overwrite: bool = False
    ) -> dict[str, int]:
        """Auto-label cycles retroactively using profile matching.

        Args:
            confidence_threshold: Min confidence to apply a label.
            overwrite: If True, re-evaluates already labeled cycles.

        Returns stats: {labeled: int, relabeled: int, skipped: int, total: int}

        Covers **backfilled history cycles** (#344) as well as real ones. Nobody remembers
        which wash was Eco 40 six months later, so an import that left dozens of unnamed
        cycles to hand-label would be barely better than no import at all. The gate is the
        same for both (``confidence >= threshold`` and not ambiguous), but a backfilled
        cycle is written through :meth:`_relabel_non_real_cycle` rather than mutated like a
        real one, because moving one off a profile has to clear a stale sample pointer and
        may leave that profile empty.

        A label applied here is stamped ``auto_label_backfill``, distinct from the
        real-cycle ``auto_label_service``. That distinction matters: an auto-labelled
        backfill cycle immediately feeds its profile's envelope, so a wrong guess shifts
        what later cycles match against, and the marker is what makes that recoverable -
        by the user or by a later pass - rather than indistinguishable from a confirmed
        label.

        NB on a fresh install with no profiles there is nothing to match against, so the
        real sequence is "hand-label a couple, then auto-label the rest": each label
        strengthens the envelope the next batch matches against. There is no
        self-matching circularity - with ``overwrite=False`` only unlabelled cycles are
        touched, and an unlabelled cycle is in no envelope yet.
        """
        stats = {"labeled": 0, "relabeled": 0, "skipped": 0, "total": 0}

        # (cycle, is_backfill) so the write path can differ while the gate does not.
        candidates: list[tuple[CycleDict, bool]] = [
            *((c, False) for c in self._data.get("past_cycles", [])),
            *((c, True) for c in self.get_backfill_cycles()),
        ]
        if not overwrite:
            candidates = [(c, b) for c, b in candidates if not c.get("profile_name")]

        stats["total"] = len(candidates)
        # Envelopes are rebuilt once at the end, not per cycle: this is a bulk pass and
        # `_assign_reference_cycle_profile`'s per-call rebuild+save would mean one whole
        # store write per imported cycle.
        touched: set[str] = set()

        for cycle, is_backfill in candidates:
            # Never overwrite a user's manual label, even with overwrite=True: a manual
            # correction is exactly the ground truth this pass should defer to (real and
            # non-real alike - the non-real manual relabel now stamps this too).
            if cycle.get("label_source") == "manual":
                stats["skipped"] += 1
                continue

            # Reconstruct power data for matching
            power_data = decompress_power_data(cycle)
            if not power_data or len(power_data) < 10:
                stats["skipped"] += 1
                continue

            # Try to match
            result = await self.async_match_profile(power_data, cycle["duration"])

            # Honor the ambiguity safeguard: never auto-label a close/ambiguous match.
            if result.best_profile and result.confidence >= confidence_threshold and not result.is_ambiguous:
                current_label = cycle.get("profile_name")
                # Sanitize: strip heavy current/sample arrays before persisting.
                ranking_top5 = [
                    {
                        "name": c.get("name"),
                        "score": round(float(c.get("score", 0.0)), 3),
                        "profile_duration": c.get("profile_duration"),
                    }
                    for c in (getattr(result, "ranking", []) or [])[:5]
                ]

                label_source = "auto_label_backfill" if is_backfill else "auto_label_service"

                def _apply(
                    target: CycleDict = cycle,
                    backfill: bool = is_backfill,
                    match: MatchResult = result,
                    source: str = label_source,
                    ranking: list[dict[str, Any]] = ranking_top5,
                ) -> None:
                    """Move the label, then stamp the provenance the mover does not.

                    `_relabel_non_real_cycle` only moves `profile_name`, so without this
                    an auto-labelled imported cycle would carry no confidence for the
                    Cycles list to show and no marker saying the matcher guessed it.

                    EVERY loop value it reads is bound as a default, not closed over: the
                    call happens in the same iteration today, but a later change that
                    defers it (collect the callables, run them after the loop) would
                    otherwise silently apply the LAST iteration's match to every cycle.
                    """
                    if backfill:
                        touched.update(
                            self._relabel_non_real_cycle(target, match.best_profile)
                        )
                    else:
                        target["profile_name"] = match.best_profile
                    target["match_confidence"] = float(match.confidence)
                    target["label_source"] = source
                    if ranking:
                        target["match_ranking_top5"] = ranking

                # If overwriting, check if new match is different and better/valid
                if current_label:
                    if current_label != result.best_profile:
                        # Preserve original label before first auto-service relabeling
                        if not cycle.get("original_auto_label"):
                            orig_src = cycle.get("label_source", "")
                            if orig_src in _AUTO_LABEL_SOURCES:
                                cycle["original_auto_label"] = current_label
                        _apply()
                        stats["relabeled"] += 1
                        self._logger.info(
                            "Relabeled cycle %s: '%s' -> '%s' (confidence: %.2f)",
                            cycle["id"],
                            current_label,
                            result.best_profile,
                            result.confidence,
                        )
                else:
                    _apply()
                    stats["labeled"] += 1
                    self._logger.info(
                        "Auto-labeled cycle %s as '%s' (confidence: %.2f)",
                        cycle["id"],
                        result.best_profile,
                        result.confidence,
                    )
            else:
                stats["skipped"] += 1

        if stats["labeled"] > 0 or stats["relabeled"] > 0:
            # A labelled backfill cycle exists only to shape its profile's envelope, so
            # rebuild what changed before saving - otherwise the import accomplishes
            # nothing until some unrelated later trigger.
            for name in touched:
                await self.async_rebuild_envelope(name)
            await self.async_save()
            # Trigger smart processing after bulk labeling
            await self.async_smart_process_history()

        self._logger.info(
            "Auto-labeling complete: %s labeled, %s relabeled, %s skipped",
            stats["labeled"],
            stats["relabeled"],
            stats["skipped"],
        )
        return stats

    async def async_backfill_match_confidence(self) -> int:
        """Populate match_confidence for labeled cycles that predate the field.

        Runs the matcher once per cycle with profile_name set but no
        match_confidence, and persists the resulting confidence if the same
        profile is returned. Returns the number of cycles updated. Safe to
        call repeatedly - already-backfilled cycles are skipped.
        """
        cycles = self._data.get("past_cycles", []) or []
        updated = 0
        for cycle in cycles:
            if cycle.get("match_confidence") is not None:
                continue
            profile_name = cycle.get("profile_name")
            if not profile_name:
                continue
            power_data = decompress_power_data(cycle)
            if not power_data or len(power_data) < 10:
                continue
            try:
                result = await self.async_match_profile(
                    power_data, cycle.get("duration", 0)
                )
            except Exception:  # pylint: disable=broad-exception-caught
                continue
            if result.best_profile == profile_name and result.confidence > 0:
                cycle["match_confidence"] = float(result.confidence)
                updated += 1
        if updated:
            await self.async_save()
            self._logger.info("Backfilled match_confidence on %d cycles", updated)
        return updated

    async def async_migrate_cycles_to_compressed(self) -> int:
        """
        Migrate all cycles to the compressed format.
        Ensures all cycles use [offset_seconds, power] format.
        Returns number of cycles migrated.
        """
        raw_cycles = self._data.get("past_cycles", [])
        cycles: list[CycleDict] = (
            cast(list[CycleDict], raw_cycles) if isinstance(raw_cycles, list) else []
        )
        migrated = 0

        for cycle in cycles:
            raw_data: list[Any] = cycle.get("power_data", []) or []
            if not raw_data:
                continue

            # Check if already compressed (first element is number or mixed format)
            first_elem = raw_data[0][0]
            if isinstance(first_elem, (int, float)):
                # Already in offset format
                continue

            # Old format: ISO timestamp strings. Convert to compressed offsets.
            try:
                compressed = compress_power_data(cycle)
                if compressed:
                    cycle["power_data"] = compressed
                    migrated += 1
            except Exception as e:  # pylint: disable=broad-exception-caught
                self._logger.warning("Failed to migrate cycle %s: %s", cycle.get("id"), e)
                continue

        if migrated > 0:
            self._logger.info("Migrated %s cycles to compressed format", migrated)
            await self.async_save()

        return migrated



    async def async_smart_process_history(
        self
    ) -> dict[str, int]:
        # Orchestrate smart history processing: Cleanup, Retention.
        # Split/Merge is now manual via Interactive Editor.
        stats = {"cleaned_profiles": 0}

        # 1. Cleanup
        self._logger.debug("Running maintenance: cleanup_orphaned_profiles")
        stats["cleaned_profiles"] = self.cleanup_orphaned_profiles()

        # 2. Retention
        self._logger.debug("Running maintenance: async_enforce_retention")
        await self.async_enforce_retention()

        # 3. Save
        self._logger.debug("Maintenance complete, saving")
        await self.async_save()

        return stats



    def export_data(
        self,
        entry_data: JSONDict | None = None,
        entry_options: JSONDict | None = None,
        selection: dict[str, Any] | None = None,
    ) -> JSONDict:
        # Return a serializable snapshot of the store for backup/export.
        # Includes config entry data/options so users can transfer fine-tuned settings.
        #
        # ``selection`` (wizard) narrows the payload to chosen categories/items; when
        # ``None`` this is the whole-store dump the diagnostics + quick-export callers
        # rely on. The envelope shape ({version, data, entry_data, entry_options, ...})
        # is identical either way so old importers accept a selective export too.
        opts = entry_options or {}
        data = entry_data or {}
        _FINGERPRINT_KEYS = (
            "off_delay", "min_power", "sampling_interval",
            "start_power_threshold", "idle_power_threshold",
            "min_cycle_duration", "max_cycle_duration",
            "delay_start_detect_enabled",
        )
        device_fingerprint: JSONDict = {
            "device_type": data.get("device_type") or opts.get("device_type", "unknown"),
        }
        for key in _FINGERPRINT_KEYS:
            if key in opts:
                device_fingerprint[key] = opts[key]
        if selection is None:
            # Never let a backup/diagnostics export carry the GitHub refresh token: the
            # (legacy, now-global) per-device store account may still hold it. Shallow-copy
            # so we can drop the credential without mutating live state, and so a caller
            # can't alias-mutate self._data through the returned snapshot.
            export_store = dict(self._data)
            export_store.pop("store_account", None)
            out_entry_data = data
            out_entry_options = opts
        else:
            # Selective export: start from an empty blob and copy only chosen categories
            # (store_account can never leak because it is not a category). Raw entry_data
            # is never shipped (it carries the source power_sensor binding); settings
            # travel only as the shareable numeric subset, and only when selected.
            export_store = self._filter_export_data(dict(self._data), selection, opts)
            cats = _selected_categories(selection)
            out_entry_data = {}
            out_entry_options = _shareable_settings(opts) if "settings" in cats else {}
        return {
            "version": STORAGE_VERSION,
            "entry_id": self.entry_id,
            "exported_at": dt_util.now().isoformat(),
            "device_fingerprint": device_fingerprint,
            "data": export_store,
            "entry_data": out_entry_data,
            "entry_options": out_entry_options,
        }

    def get_export_inventory(self, entry_options: JSONDict | None = None) -> dict[str, Any]:
        """Per-category inventory of this device's store, for the export wizard tree."""
        return _scan_data(self._data, entry_options=entry_options or {})

    def _filter_export_data(
        self, data: dict[str, Any], selection: dict[str, Any], entry_options: JSONDict
    ) -> dict[str, Any]:
        """Build a filtered ``data`` blob containing only the selected categories/items.

        Enumerable categories (profiles, real/reference cycles) honour an optional
        per-category id/name subset from ``selection``; when the subset is absent the
        whole category is included. The "profiles empty" rule: when profiles are
        selected but neither cycle category is, the profiles' cached envelopes are
        carried so the target can still match without the raw traces.
        """
        cats = _selected_categories(selection)
        out: dict[str, Any] = {}
        profiles_src = data.get("profiles") if isinstance(data.get("profiles"), dict) else {}

        selected_profiles: set[str] | None = None
        if "profiles" in cats:
            names = selection.get("profiles")
            if isinstance(names, list):
                selected_profiles = {str(n) for n in names}
                out["profiles"] = {
                    n: profiles_src[n] for n in profiles_src if n in selected_profiles
                }
            else:
                selected_profiles = set(profiles_src.keys())
                out["profiles"] = dict(profiles_src)

        def _filter_cycles(key: str, id_sel_key: str) -> list[Any]:
            src = data.get(key) if isinstance(data.get(key), list) else []
            ids = selection.get(id_sel_key)
            if isinstance(ids, list):
                idset = {str(i) for i in ids}
                return [c for c in src if isinstance(c, dict) and str(c.get("id")) in idset]
            return [c for c in src if isinstance(c, dict)]

        include_real = "real_cycles" in cats
        include_ref = "reference_cycles" in cats
        if include_real:
            out["past_cycles"] = _filter_cycles("past_cycles", "real_cycle_ids")
        if include_ref:
            out["reference_cycles"] = _filter_cycles("reference_cycles", "reference_cycle_ids")

        # "profiles empty" rule: carry cached envelopes when profiles ship without cycles.
        if "profiles" in cats and not include_real and not include_ref:
            envs = data.get("envelopes") if isinstance(data.get("envelopes"), dict) else {}
            carried = {n: envs[n] for n in (selected_profiles or set()) if n in envs}
            if carried:
                out["envelopes"] = carried

        # Leaf categories: copy their raw store keys verbatim.
        for cat_id in cats:
            if cat_id in _STRUCTURAL_CATEGORIES:
                continue
            for key in _EXPORT_CATEGORIES.get(cat_id, {}).get("keys", []):
                if key in data:
                    out[key] = data[key]
        return out

    async def async_import_data(self, payload: dict[str, Any]) -> dict[str, Any]:
        """Wholesale import: replace the entire store from a JSON payload.

        Migration/wrapper aware via ``unwrap_import_payload`` (HA diagnostics,
        integration ``store_export``, v1 flat, v2 nested). This is the destructive
        "replace everything" path used by the legacy service / raw-JSON fallback;
        selective merge lives in ``async_import_data_selective``.
        """
        data_dict, meta = unwrap_import_payload(payload)
        self._logger.info(
            "Importing %s format: %s cycles",
            meta.get("format"),
            len(data_dict.get("past_cycles", [])),
        )

        if not data_dict.get("profiles") and not data_dict.get("past_cycles"):
            raise ValueError(
                "Import payload contains no profiles or cycles — aborting to prevent data loss"
            )
        self._data = data_dict
        self._cached_sample_segments = {}
        await self.async_save()

        return {
            "entry_data": meta.get("entry_data", {}),
            "entry_options": meta.get("entry_options", {}),
        }

    async def async_import_data_selective(
        self,
        payload: dict[str, Any],
        *,
        selection: dict[str, Any],
        mode: str = "merge",
        conflict_resolutions: dict[str, str] | None = None,
        cycle_destination: str = "reference",
        apply_settings: bool = True,
        local_device_type: str = "",
    ) -> dict[str, Any]:
        """Selectively import chosen categories/items, merging into existing data.

        ``mode`` = ``"merge"`` (additive, nothing local is lost) or ``"replace"``
        (each ticked category is wiped and replaced from the file). ``cycle_destination``
        routes imported real cycles to ``"reference"`` (shape-only, never touches usage/
        energy/count/lifetime stats — the hard isolation invariant) or ``"real_history"``
        (``past_cycles``, feeds stats; only when the source device type matches).
        ``conflict_resolutions`` maps a source profile name to
        ``keep_mine`` / ``overwrite`` / ``import_as_copy``.

        Reuses the community-adopt primitives (``_add_reference_cycle_nosave``,
        ``_add_cycle_data``) and rebuilds each cycle-receiving profile's envelope
        exactly once. Never routes through the wholesale replace path.
        """
        data_dict, meta = unwrap_import_payload(payload)
        conflict_resolutions = conflict_resolutions or {}
        mode = "replace" if str(mode) == "replace" else "merge"
        cycle_destination = (
            "real_history" if str(cycle_destination) == "real_history" else "reference"
        )
        cats = _selected_categories(selection)
        if not cats:
            raise ValueError("Import selection is empty — nothing to import")

        # Device-type gating: block device-specific categories and force reference-only
        # cycle destination when the source device type does not match this device.
        source_dt = str((meta.get("device_fingerprint") or {}).get("device_type") or "") or None
        # Keep this identical to build_import_manifest so the analysis shown to the user
        # (which disables device-specific categories / real-history on a mismatch) cannot
        # diverge from what the apply actually does. An unknown local device type is treated
        # as a mismatch (conservative), matching the manifest.
        device_type_match = (not source_dt) or (source_dt == local_device_type)
        if not device_type_match:
            cats = {c for c in cats if not _EXPORT_CATEGORIES.get(c, {}).get("device_specific")}
            cycle_destination = "reference"

        src_profiles = data_dict.get("profiles") if isinstance(data_dict.get("profiles"), dict) else {}
        src_past = [c for c in (data_dict.get("past_cycles") or []) if isinstance(c, dict)]
        src_refs = [c for c in (data_dict.get("reference_cycles") or []) if isinstance(c, dict)]

        prof_filter = selection.get("profiles") if isinstance(selection.get("profiles"), list) else None
        real_id_filter = (
            selection.get("real_cycle_ids") if isinstance(selection.get("real_cycle_ids"), list) else None
        )
        ref_id_filter = (
            selection.get("reference_cycle_ids")
            if isinstance(selection.get("reference_cycle_ids"), list)
            else None
        )

        def _apply_prof_filter(names: list[str]) -> list[str]:
            if prof_filter is None:
                return names
            keep = {str(n) for n in prof_filter}
            return [n for n in names if n in keep]

        def _apply_id_filter(cycles: list[dict[str, Any]], idf: list[Any] | None) -> list[dict[str, Any]]:
            if idf is None:
                return cycles
            keep = {str(i) for i in idf}
            return [c for c in cycles if str(c.get("id")) in keep]

        # The cycles that will actually be imported once the per-item id selection is applied.
        selected_refs = _apply_id_filter(src_refs, ref_id_filter)
        selected_past = _apply_id_filter(src_past, real_id_filter)

        def _any_usable(cs: list[dict[str, Any]]) -> bool:
            # A cycle bound for reference storage only refills the destination if its trace is
            # usable (_add_reference_cycle_nosave silently no-ops on a degenerate trace), so
            # the wipe guard checks usability, not mere presence, for reference-bound cycles.
            return any(_usable_reference_pairs(c.get("power_data")) is not None for c in cs)

        def _any_real_importable(cs: list[dict[str, Any]]) -> bool:
            # A real-history record is skipped in Step 3 if it lacks start_time/duration (the
            # fields _add_cycle_data requires), so the wipe guard requires at least one record
            # that carries both -- otherwise an all-malformed selection wipes past_cycles and
            # refills nothing.
            return any(
                c.get("start_time") is not None and c.get("duration") is not None for c in cs
            )

        # Replace-mode anti-data-loss guard: never wipe a destination unless the file has a
        # SELECTED set that will actually refill it. Guarding on the post-filter sets (not raw
        # src_*) means a stale/malformed id selection that matches nothing aborts here instead
        # of emptying the local list; for reference-bound cycles we further require at least
        # one usable trace so an all-degenerate selection can't wipe-then-add-nothing. Profiles
        # are never wiped (per-name overwrite/copy), so a profiles-only replace needs no guard.
        if mode == "replace":
            if "reference_cycles" in cats and not _any_usable(selected_refs):
                raise ValueError(
                    "Import payload has no usable reference cycles — aborting to prevent data loss"
                )
            if "real_cycles" in cats:
                if cycle_destination == "reference" and not _any_usable(selected_past):
                    raise ValueError(
                        "Import payload has no usable cycles — aborting to prevent data loss"
                    )
                if cycle_destination == "real_history" and not _any_real_importable(selected_past):
                    raise ValueError(
                        "Import payload contains no real cycles — aborting to prevent data loss"
                    )

        local_profiles = self._data.setdefault("profiles", {})
        name_remap: dict[str, str] = {}
        touched: set[str] = set()
        overwritten: set[str] = set()  # profiles whose definition the file replaced in place
        created_profiles = 0
        conflicts_resolved = 0
        real_imported = 0
        ref_imported = 0
        malformed_skipped = 0  # records dropped mid-import (surfaced in the summary)

        # ── Step 1: profiles + conflict resolution ──────────────────────────────
        if "profiles" in cats:
            for name in _apply_prof_filter(list(src_profiles.keys())):
                src_def = src_profiles.get(name)
                if not isinstance(src_def, dict):
                    continue
                if name in local_profiles:
                    # In replace mode the file wins for every ticked category, so a name
                    # clash always overwrites the local profile in place. This is enforced
                    # here (not just as a default) because the panel still transmits its
                    # analyze-time per-conflict resolutions even though it hides the resolver
                    # in replace mode — honouring an "import_as_copy" there would leave a
                    # stale original with an un-rebuilt envelope and, when reference_cycles
                    # is also replaced, a dangling sample_cycle_id. Merge mode keeps the safe
                    # copy default and shows the resolver.
                    res = "overwrite" if mode == "replace" else str(
                        conflict_resolutions.get(name, "import_as_copy")
                    )
                    conflicts_resolved += 1
                    if res == "keep_mine":
                        name_remap[name] = name  # cycles route to the existing local profile
                    elif res == "overwrite":
                        local_profiles[name] = dict(src_def)
                        name_remap[name] = name
                        overwritten.add(name)  # let Step 5 replace any stale local envelope
                    else:  # import_as_copy
                        new_name = _unique_profile_name(local_profiles, f"{name} (imported)")
                        local_profiles[new_name] = dict(src_def)
                        name_remap[name] = new_name
                        created_profiles += 1
                else:
                    local_profiles[name] = dict(src_def)
                    name_remap[name] = name
                    created_profiles += 1

        def _ensure_profile(name: str, duration: float) -> None:
            nonlocal created_profiles
            if name and name not in local_profiles:
                local_profiles[name] = {"avg_duration": float(duration or 0)}
                created_profiles += 1

        # Replace-mode wipes (hoisted so the id pools + dedup set below reflect the
        # post-wipe lists). Each ticked cycle category clears the list it actually writes to
        # (per the docstring's "each ticked category is wiped and replaced"), so real_cycles
        # routed to the default reference destination wipes reference_cycles too -- not just
        # when the reference_cycles category itself is ticked. The empty-payload guard above
        # already refused to run if the file had nothing to replace it with.
        if mode == "replace":
            wipe_reference = "reference_cycles" in cats or (
                "real_cycles" in cats and cycle_destination == "reference"
            )
            wipe_past = "real_cycles" in cats and cycle_destination == "real_history"
            if wipe_reference:
                self._data["reference_cycles"] = []
            if wipe_past:
                self._data["past_cycles"] = []

        # O(N) id pools threaded through the bulk cycle adds so SHA-id uniqueness stays
        # O(1) amortized instead of rebuilding the id set from the growing destination on
        # every insert (previously O(N^2) for a large import, all on the event loop).
        ref_id_pool: set[Any] = {c.get("id") for c in self._data.get("reference_cycles", []) if isinstance(c, dict)}
        past_id_pool: set[Any] = {c.get("id") for c in self._data.get("past_cycles", []) if isinstance(c, dict)}
        def _bare_store_id(raw_id: Any) -> str:
            # A reference cycle exported after a prior import already carries
            # meta.source="store:<id>". Strip EVERY leading "store:" so re-prefixing does not
            # accumulate "store:store:..." across round-trips and, crucially, so historical
            # double-prefixed values already persisted collapse to the same canonical id --
            # otherwise a later import of "store:<id>" would miss a stored "store:store:<id>"
            # and add a duplicate.
            sid = str(raw_id or "")
            while sid.startswith("store:"):
                sid = sid[len("store:"):]
            return sid

        def _ref_dedup_key(raw_id: Any) -> str:
            bare = _bare_store_id(raw_id)
            return f"store:{bare}" if bare else ""

        # Reference-cycle dedup: skip re-importing a reference cycle already held so a
        # repeated import stays idempotent (otherwise envelopes get double-weighted and
        # cycle_count inflates). Keyed on the CANONICAL source id, so a legacy double-prefixed
        # persisted value and a fresh single-prefixed import map to the same key.
        existing_ref_sources: set[str] = set()
        for c in self._data.get("reference_cycles", []):
            if not isinstance(c, dict):
                continue
            cyc_meta = c.get("meta")
            src = cyc_meta.get("source") if isinstance(cyc_meta, dict) else None
            if src:
                existing_ref_sources.add(_ref_dedup_key(src))

        # ── Step 2: reference cycles (shape-only) ───────────────────────────────
        # Each record is processed defensively: a single malformed record is skipped (not
        # fatal) so a replace-mode import can never abort part-way and leave a wiped
        # destination in an inconsistent state (the wipe above already happened).
        if "reference_cycles" in cats:
            for c in selected_refs:
                try:
                    orig = str(c.get("profile_name") or "")
                    target = name_remap.get(orig, orig)
                    if not target:
                        continue
                    src_meta = c.get("meta") if isinstance(c.get("meta"), dict) else {}
                    raw_sid = src_meta.get("source") or c.get("id")
                    dkey = _ref_dedup_key(raw_sid)
                    if dkey and dkey in existing_ref_sources:
                        continue  # already imported -> keep re-import idempotent
                    _ensure_profile(target, c.get("duration") or 0)
                    cid = self._add_reference_cycle_nosave(
                        target,
                        c.get("power_data") or [],
                        {
                            "store_cycle_id": _bare_store_id(raw_sid),
                            "store_uploaded_at": src_meta.get("store_uploaded_at"),
                            "sampling_interval": c.get("sampling_interval"),
                        },
                        id_pool=ref_id_pool,
                    )
                    if cid:
                        if dkey:
                            existing_ref_sources.add(dkey)
                        touched.add(target)
                        ref_imported += 1
                except Exception:  # noqa: BLE001 - skip a malformed record, never abort the import
                    malformed_skipped += 1
                    self._logger.debug("selective import: skipped a malformed reference cycle", exc_info=True)

        # ── Step 3: real cycles (reference-shape or real-history) ───────────────
        if "real_cycles" in cats:
            # Identity set (native ids + prior import provenance) for real-history dedup.
            known_ids: set[str] = set()
            for c in self._data.get("past_cycles", []):
                if isinstance(c, dict):
                    if c.get("id"):
                        known_ids.add(str(c.get("id")))
                    imp = (c.get("meta") or {}).get("imported_from")
                    if imp:
                        known_ids.add(str(imp))
            for c in selected_past:
              try:
                orig = str(c.get("profile_name") or "")
                target = name_remap.get(orig, orig)
                if cycle_destination == "reference":
                    raw_sid = c.get("id")
                    dkey = _ref_dedup_key(raw_sid)
                    if dkey and dkey in existing_ref_sources:
                        continue  # already imported as a reference cycle
                    _ensure_profile(target or "(imported)", c.get("duration") or 0)
                    cid = self._add_reference_cycle_nosave(
                        target or "(imported)", c.get("power_data") or [],
                        {"store_cycle_id": _bare_store_id(raw_sid)}, id_pool=ref_id_pool,
                    )
                    if cid:
                        if dkey:
                            existing_ref_sources.add(dkey)
                        touched.add(target or "(imported)")
                        ref_imported += 1
                else:  # real_history
                    orig_id = str(c.get("id") or "")
                    if orig_id and orig_id in known_ids:
                        continue  # already imported / round-trip -> skip duplicate
                    if c.get("start_time") is None or c.get("duration") is None:
                        continue  # malformed real record (add_cycle needs both) -> skip, don't raise
                    _ensure_profile(target, c.get("duration") or 0)
                    cyc = dict(c)
                    cyc["profile_name"] = target or None
                    meta_c = dict(cyc.get("meta") or {})
                    if orig_id:
                        meta_c["imported_from"] = orig_id
                    cyc["meta"] = meta_c
                    cyc.pop("id", None)  # let _add_cycle_data assign a fresh, collision-safe id
                    self._add_cycle_data(
                        cyc, target=self._data.setdefault("past_cycles", []), id_pool=past_id_pool
                    )
                    if orig_id:
                        known_ids.add(orig_id)
                    if target:
                        touched.add(target)
                    real_imported += 1
              except Exception:  # noqa: BLE001 - skip a malformed record, never abort the import
                  malformed_skipped += 1
                  self._logger.debug("selective import: skipped a malformed cycle", exc_info=True)

        # ── Step 4: leaf categories (additive merge / replace) ──────────────────
        for cat_id in cats:
            if cat_id in _STRUCTURAL_CATEGORIES:
                continue
            if cat_id == "lifetime_stats" and mode == "merge":
                continue  # never sum lifetime totals -- would double-count
            for key in _EXPORT_CATEGORIES.get(cat_id, {}).get("keys", []):
                if key not in data_dict:
                    continue
                src_val = data_dict.get(key)
                if mode == "replace":
                    self._data[key] = src_val
                    continue
                cur = self._data.get(key)
                if isinstance(src_val, dict):
                    base = cur if isinstance(cur, dict) else {}
                    for k, v in src_val.items():
                        base.setdefault(k, v)  # keep-mine on key collision
                    self._data[key] = base
                elif isinstance(src_val, list):
                    base = cur if isinstance(cur, list) else []
                    _merge_list_dedup(base, src_val)
                    self._data[key] = base
                elif cur is None:
                    self._data[key] = src_val  # scalar: fill only when absent locally

        # ── Step 5: envelopes -- carry for definition-only profiles, rebuild the rest ─
        src_envs = data_dict.get("envelopes") if isinstance(data_dict.get("envelopes"), dict) else {}
        local_envs = self._data.setdefault("envelopes", {})
        for orig, new_name in name_remap.items():
            if new_name in touched:
                continue  # will be rebuilt from imported cycles
            # Carry the file's envelope when the target has none, OR when the file
            # overwrote the definition in place (replace/overwrite: the file wins, so its
            # envelope must supersede the now-mismatched stale local one instead of being
            # discarded — otherwise matching keeps using the old shape).
            if orig in src_envs and (new_name not in local_envs or new_name in overwritten):
                local_envs[new_name] = src_envs[orig]  # keep it matchable without raw traces
        for p in sorted(touched):
            await self.async_rebuild_envelope(p)

        self._cached_sample_segments = {}
        await self.async_save()

        settings_out: dict[str, Any] = {}
        if apply_settings and "settings" in cats and device_type_match:
            settings_out = _shareable_settings(meta.get("entry_options") or {})

        return {
            "profiles_imported": created_profiles,
            "real_cycles_imported": real_imported,
            "reference_cycles_imported": ref_imported,
            "conflicts_resolved": conflicts_resolved,
            "skipped_cycles": malformed_skipped,
            "categories_applied": sorted(cats),
            "device_type_match": device_type_match,
            "settings": settings_out,
        }

    async def delete_cycle(self, cycle_id: str) -> bool:
        """Delete a cycle by ID."""
        cycles = cast(list[CycleDict], self._data.get("past_cycles", []))
        initial_len = len(cycles)
        cycle_to_delete = next((c for c in cycles if c.get("id") == cycle_id), None)
        if not cycle_to_delete:
            # Not a real cycle -- it may be an imported store recording or a backfilled
            # history cycle, which live in their own lists (never in usage stats).
            return await self._delete_non_real_cycle(cycle_id)

        profile_name = cycle_to_delete.get("profile_name")
        self._data["past_cycles"] = [c for c in cycles if c.get("id") != cycle_id]

        if len(self._data["past_cycles"]) < initial_len:
            # Check profile references
            for _p_name, p_data in self.get_profiles().items():
                if p_data.get("sample_cycle_id") == cycle_id:
                    p_data["sample_cycle_id"] = None

            # Drop any pending feedback for this cycle so it can't orphan the
            # "needs review" count (the count would say 1 while the cycle is gone).
            self._data.get("pending_feedback", {}).pop(cycle_id, None)

            # Rebuild envelope if cycle belonged to a profile
            if profile_name:
                await self.async_rebuild_envelope(profile_name)

            await self.async_save()
            return True
        return False

    async def _delete_non_real_cycle(self, cycle_id: str) -> bool:
        """Delete a single imported store recording or backfilled history cycle.

        Rebuilds the affected profile's envelope so removing a bad import immediately
        stops influencing the matcher template. Returns False when neither list carries
        that id.
        """
        cycle: CycleDict | None = None
        for key in ("reference_cycles", "backfill_cycles"):
            items = cast(list[CycleDict], self._data.get(key, []))
            cycle = next((c for c in items if c.get("id") == cycle_id), None)
            if cycle is not None:
                self._data[key] = [c for c in items if c.get("id") != cycle_id]
                break
        if cycle is None:
            return False
        profile_name = cycle.get("profile_name")
        # Clear any profile that sampled this now-deleted reference cycle, mirroring
        # the real-cycle path in delete_cycle, so no sample id is left dangling.
        for _p_name, p_data in self.get_profiles().items():
            if p_data.get("sample_cycle_id") == cycle_id:
                p_data["sample_cycle_id"] = None
        if profile_name:
            # If removing this recording leaves the profile with no cycles at all
            # (no real, no imported), drop the now-empty profile. Otherwise a
            # sampleless import-only profile would be re-populated by
            # async_repair_profile_samples stealing an unlabeled real cycle into it.
            remaining = any(
                c.get("profile_name") == profile_name
                for c in self.iter_stored_cycles()
            )
            if remaining:
                await self.async_rebuild_envelope(profile_name)
            else:
                self._data.get("profiles", {}).pop(profile_name, None)
                self._data.get("envelopes", {}).pop(profile_name, None)
        await self.async_save()
        return True

    def get_cycle_power_data(self, cycle_id: str) -> list[tuple[float, float]]:
        """Return decompressed power data for a cycle as [(offset_s, watts), ...].

        Returns an empty list if the cycle is not found or has no power data.
        """
        # Imported and backfilled cycles live in their own lists; the panel opens them
        # from the same Cycles table, so all three are searched.
        cycle, _origin = self.find_stored_cycle(cycle_id)
        if cycle is None:
            return []
        return decompress_power_data(cycle)

    async def trim_cycle_power_data(
        self,
        cycle_id: str,
        new_start_s: float,
        new_end_s: float,
    ) -> bool:
        """Trim a cycle's power_data to the window [new_start_s, new_end_s].

        Offsets are renormalized so the kept segment starts at 0.0.
        The cycle's ``duration``, ``signature``, and ``sampling_interval`` are
        recomputed from the trimmed data.

        Returns True if successful, False if the cycle was not found or the
        resulting data is empty.
        """
        cycles = cast(list[CycleDict], self._data.get("past_cycles", []))
        cycle = next((c for c in cycles if c.get("id") == cycle_id), None)
        if cycle is None:
            return False

        p_data = decompress_power_data(cycle)
        if not p_data:
            return False

        new_start_s = max(0.0, float(new_start_s))
        new_end_s = float(new_end_s)

        # Guard against a destructive trim (#366): an inverted/empty window would
        # produce a zero- or single-sample segment that collapses the cycle to
        # duration 0 / energy 0. Reject before touching the cycle.
        if new_end_s <= new_start_s:
            return False

        # Snap both boundaries to a real sample offset (#373). Trim inputs round
        # to whole seconds, but sample offsets are frequently fractional -- a
        # nominal 10 s cadence drifts, so ~3132.3 is the norm -- and the
        # [start, end] filter below is inclusive, so a whole-second entry lands
        # just below the sample the user aimed at and silently drops it. Snapping
        # against the full-resolution stored trace (independent of any client-side
        # curve decimation) makes the kept window land on the samples the
        # boundaries name, on both ends.
        #
        # Snap *inward*, tolerating only the whole-second input quantization
        # (_TRIM_SNAP_TOLERANCE_S). Nearest-in-either-direction snapping could
        # widen the window by up to half a sample interval per side -- on a
        # coarse trace (samples at 0 s and 100 s, request 40-60 s) that silently
        # kept the entire cycle while meta["trim"] claimed a narrow window.
        offsets = [float(offset) for offset, _ in p_data]
        starts = [o for o in offsets if o >= new_start_s - _TRIM_SNAP_TOLERANCE_S]
        ends = [o for o in offsets if o <= new_end_s + _TRIM_SNAP_TOLERANCE_S]
        if not starts or not ends:
            return False
        new_start_s = min(starts)
        new_end_s = max(ends)
        if new_end_s <= new_start_s:
            return False

        kept = sorted(
            (
                (offset, power)
                for offset, power in p_data
                if new_start_s <= offset <= new_end_s
            ),
            key=lambda x: x[0],
        )
        if not kept:
            return False

        # Re-normalize offsets so the trimmed segment starts at 0.0
        base = kept[0][0]
        renorm: list[list[float]] = [
            [round(offset - base, 2), power] for offset, power in kept
        ]

        # A window that keeps fewer than two samples (or whose kept span is zero)
        # would collapse the cycle to duration 0 with no recovery. Reject here,
        # BEFORE the start_time mutation below, so the stored cycle is untouched
        # and the caller reports a failure instead of destroying data (#366).
        if len(renorm) < 2 or round(renorm[-1][0], 1) <= 0.0:
            return False

        # Advance start_time when trimming from the front
        if base > 0:
            start_ts = _value_to_timestamp(cycle.get("start_time"))
            if start_ts is not None:
                cycle["start_time"] = dt_util.utc_from_timestamp(
                    start_ts + base
                ).isoformat()

        # Recompute sampling interval
        if len(renorm) > 1:
            offsets_arr = np.array([r[0] for r in renorm], dtype=float)
            intervals = np.diff(offsets_arr)
            pos = intervals[intervals > 0]
            sampling_interval = float(np.median(pos)) if len(pos) > 0 else 1.0
        else:
            sampling_interval = 1.0

        # Recompute signature
        ts_arr = np.array([r[0] for r in renorm], dtype=float)
        p_arr = np.array([r[1] for r in renorm], dtype=float)
        if len(ts_arr) > 1:
            sig = compute_signature(ts_arr, p_arr)
            cycle["signature"] = dataclasses.asdict(sig)
        else:
            cycle["signature"] = None

        new_duration = round(renorm[-1][0], 1) if renorm else 0.0
        cycle["power_data"] = renorm
        cycle["sampling_interval"] = round(sampling_interval, 1)
        cycle["duration"] = new_duration

        # Recompute energy and max_power from the trimmed trace so stored stats
        # reflect the kept window (not the pre-trim full cycle).
        ts_s = np.array([r[0] for r in renorm], dtype=float)
        p_s = np.array([r[1] for r in renorm], dtype=float)
        if len(ts_s) > 1:
            cycle["energy_wh"] = round(
                integrate_wh(ts_s, p_s, max_gap_s=energy_gap_threshold_s(ts_s)), 3
            )
            cycle["max_power"] = round(float(np.max(p_s)), 1)
        elif len(ts_s) == 1:
            cycle["energy_wh"] = 0.0
            cycle["max_power"] = round(float(p_s[0]), 1)

        # Keep end_time consistent with the updated start_time and duration
        new_start_ts = _value_to_timestamp(cycle.get("start_time"))
        if new_start_ts is not None:
            cycle["end_time"] = dt_util.utc_from_timestamp(
                new_start_ts + new_duration
            ).isoformat()

        # Clear manual_duration override - trimmed duration is now authoritative
        cycle.pop("manual_duration", None)

        # Mark the cycle as edited so store-upload provenance (qc) can distinguish a
        # trimmed cycle from a plain detected one.
        meta = cycle.setdefault("meta", {})
        if isinstance(meta, dict):
            meta["edited"] = True
            meta["trim"] = [round(new_start_s, 1), round(new_end_s, 1)]

        # Invalidate cached sample segments for this cycle so future lookups
        # are recomputed from the trimmed data
        stale_keys = [k for k in self._cached_sample_segments if k[0] == cycle_id]
        for k in stale_keys:
            del self._cached_sample_segments[k]

        # Rebuild envelope for the associated profile
        profile_name = cycle.get("profile_name")
        if profile_name:
            await self.async_rebuild_envelope(profile_name)

        await self.async_save()
        return True

    def analyze_split_sync(
        self, cycle: CycleDict, min_gap_s: int = 900, idle_power: float = 2.0
    ) -> list[tuple[float, float]]:
        """Analyze cycle for potential splits (sync for executor)."""
        p_data = decompress_power_data(cycle)
        if not p_data:
            return []

        # Parse all points to (rel_t, power)
        points: list[tuple[float, float]] = []
        for offset_seconds, val in p_data:
            points.append((float(offset_seconds), float(val)))

        if not points:
            return []

        valid_segments: list[tuple[float, float]] = []
        seg_start = 0.0

        for i in range(1, len(points)):
            t, _ = points[i]
            prev_t, prev_p = points[i-1]

            # Detect idle gap
            gap = t - prev_t
            if prev_p < idle_power and gap > min_gap_s:
                # Segment ending at prev_t
                if (prev_t - seg_start) > 60:
                    valid_segments.append((seg_start, prev_t))
                seg_start = t

        # Last segment
        last_t = points[-1][0]
        if (last_t - seg_start) > 60:
            valid_segments.append((seg_start, last_t))

        if len(valid_segments) < 2:
            return []

        self._logger.debug(
            "Analyzed split for %s: found %d segments",
            cycle.get("id"),
            len(valid_segments)
        )
        return valid_segments

    def build_split_segments_from_offsets(
        self,
        cycle: CycleDict,
        split_offsets_s: list[float],
        min_segment_s: float = 60.0,
    ) -> list[tuple[float, float]]:
        """Build segments for a manual split from explicit offsets (seconds from cycle start).

        Returns adjacent [(start, end)] segments covering the cycle window, split at the
        given offsets. Offsets are sorted and deduplicated; offsets outside the cycle window
        or producing a sub-`min_segment_s` slice are dropped. Returns [] if fewer than two
        segments would result.
        """
        p_data = decompress_power_data(cycle)
        if not p_data:
            return []

        last_t = float(p_data[-1][0])
        if last_t <= 0:
            return []

        unique_offsets = sorted(
            {round(float(o), 3) for o in split_offsets_s if 0.0 < float(o) < last_t}
        )
        if not unique_offsets:
            return []

        filtered_offsets: list[float] = []
        for offset in unique_offsets:
            if offset <= min_segment_s:
                continue
            if offset >= (last_t - min_segment_s):
                continue
            if filtered_offsets and (offset - filtered_offsets[-1]) < min_segment_s:
                continue
            filtered_offsets.append(offset)

        if not filtered_offsets:
            return []

        boundaries = [0.0, *filtered_offsets, last_t]
        segments: list[tuple[float, float]] = []
        for i in range(len(boundaries) - 1):
            seg_start = boundaries[i]
            seg_end = boundaries[i + 1]
            if (seg_end - seg_start) >= min_segment_s:
                segments.append((seg_start, seg_end))

        if len(segments) < 2:
            return []

        self._logger.debug(
            "Built manual split for %s: %d segments at offsets %s",
            cycle.get("id"),
            len(segments),
            filtered_offsets,
        )
        return segments

    async def apply_split_interactive(
        self, cycle_id: str, segments: list[dict[str, Any]]
    ) -> list[str]:
        """Apply a manual split config.

        segments format: [{"start": float, "end": float, "profile": str|None}]
        Returns list of new cycle IDs.
        """
        cycles = cast(list[CycleDict], self._data.get("past_cycles", []))
        idx = next((i for i, c in enumerate(cycles) if c.get("id") == cycle_id), -1)

        if idx == -1:
            return []

        cycle = cycles[idx]

        # Validate before mutating — early returns below this point would otherwise
        # permanently delete the source cycle from the live list.
        start_dt_base_parsed = _parse_start_dt(cycle["start_time"])
        if not start_dt_base_parsed:
            return []

        p_data_tuples = decompress_power_data(cycle)
        if not p_data_tuples:
            return []

        # Pre-materialise all segment bounds before touching history; a malformed
        # segment that raises after the pop would leave the source cycle permanently
        # deleted with only partial children created.
        try:
            materialized_segs: list[tuple[float, float, str | None]] = []
            for seg in segments:
                if isinstance(seg, (list, tuple)):
                    seg_s = float(seg[0])
                    seg_e = float(seg[1])
                    seg_p: str | None = None
                else:
                    seg_s = float(seg["start"])
                    seg_e = float(seg["end"])
                    seg_p = seg.get("profile")
                if seg_e <= seg_s:
                    return []
                materialized_segs.append((seg_s, seg_e, seg_p))
        except (TypeError, ValueError, KeyError, IndexError):
            return []
        # A split into fewer than two pieces is not a real split; guard here so
        # an empty or single-element segments list can't pop the source cycle and
        # leave zero or one orphaned children.
        if len(materialized_segs) < 2:
            return []

        cycles.pop(idx)  # Remove original — all segments validated, safe to mutate

        new_ids: list[str] = []
        original_profile = cycle.get("profile_name")
        start_ts = start_dt_base_parsed.timestamp()

        # Prepare points (relative seconds)
        points: list[tuple[float, float]] = []
        for offset_seconds, val in p_data_tuples:
            points.append((float(offset_seconds), float(val)))

        # Create new cycles
        for seg_start, seg_end, seg_profile in materialized_segs:
            seg_dur = seg_end - seg_start
            new_cycle_start = start_dt_base_parsed + timedelta(seconds=seg_start)
            new_cycle_start_ts = new_cycle_start.timestamp()

            # Extract points for this segment
            p_data_abs: list[list[float]] = []

            # Find closest state before/at start to ensure continuity?
            # Or just take points strictly inside?
            # Generally better to capture the state at start 0.
            state_val = 0.0
            for t, p in points:
                if t <= seg_start:
                    state_val = p
                else:
                    break

            # Start point (t=0 relative to new cycle)
            p_data_abs.append([round(new_cycle_start_ts, 1), state_val])

            for t, p in points:
                if seg_start < t <= seg_end:
                    p_data_abs.append([round(start_ts + t, 1), p])

            # Create Cycle Record
            new_cycle: dict[str, Any] = {
                "start_time": dt_util.as_utc(new_cycle_start).isoformat(),
                "end_time": dt_util.as_utc(new_cycle_start + timedelta(seconds=seg_dur)).isoformat(),
                "duration": round(seg_dur, 1),
                "status": "completed",
                "power_data": p_data_abs,
                "profile_name": seg_profile
            }
            await self.async_add_cycle(new_cycle)
            new_ids.append(new_cycle["id"])

        # Fix profile refs (handle original sample cycle logic)
        original_sample_id = cycle.get("id")
        best_replacement_id = None
        longest_dur = 0
        new_cycles_objs = [c for c in cycles if c["id"] in new_ids]

        for c in new_cycles_objs:
            d = c.get("duration", 0)
            if d > longest_dur:
                longest_dur = d
                best_replacement_id = c["id"]

        if best_replacement_id and original_profile:
            p_data = self._data["profiles"].get(original_profile)
            if p_data and p_data.get("sample_cycle_id") == original_sample_id:
                p_data["sample_cycle_id"] = best_replacement_id

        # Rebuild envelopes ONLY for the profiles whose dataset actually changed:
        # the original profile (it lost the parent cycle) plus any profile a labeled
        # segment was assigned to. This replaces a blanket rebuild-all-envelopes in
        # the caller, which re-scanned every profile serially and stalled low-power
        # hosts on a split (issue #311 follow-up).
        touched: set[str] = set()
        if original_profile:
            touched.add(original_profile)
        for seg in segments:
            if isinstance(seg, dict):
                seg_prof = seg.get("profile")
                if seg_prof:
                    touched.add(seg_prof)
        for name in touched:
            await self.async_rebuild_envelope(name)

        # The original cycle is gone; drop its now-orphaned pending feedback so the
        # "needs review" badge stays consistent with the review list (#362).
        self.prune_orphaned_feedback()

        await self.async_save()
        self._logger.info("Interactive Split Applied to %s -> %s", cycle_id, new_ids)
        return new_ids

    async def apply_merge_interactive(
        self, cycle_ids: list[str], target_profile: str | None
    ) -> str | None:
        """
        Merge multiple past cycles into a single cycle record, filling gaps between traces with short zero-power segments.
        
        Parameters:
            cycle_ids (list[str]): Unordered set of past-cycle IDs to merge; at least two IDs are required.
                The function internally sorts cycles by start_time and mutates the chronologically earliest cycle.
            target_profile (str | None): Profile name to assign to the merged cycle, or `None` to leave unlabeled.

        Description:
            When successful, this sorts the provided cycles by start_time and replaces the earliest cycle with the merged cycle,
            removing the other consumed cycles and updating related metadata.
        
            Side effects:
            - Updates the store's past_cycles (removes consumed cycles and replaces the first cycle with the merged record).
            - Clears any `manual_duration` override on the resulting cycle.
            - Updates `sample_cycle_id` references in profiles that pointed to removed cycles.
            - Attempts to recompute and store the merged cycle's signature.
            - Persists changes to storage and triggers envelope rebuilds for affected profiles.
        
        Returns:
            merged_id (str | None): The new merged cycle's ID if the merge was applied, `None` if the merge could not be performed.
        """
        if len(cycle_ids) < 2:
            return None

        cycles = self.get_past_cycles()
        target_cycles = [c for c in cycles if c.get("id") in cycle_ids]

        if len(target_cycles) != len(cycle_ids):
            return None

        # Sort by time - use timestamp comparison to handle mixed timezone offsets correctly
        def _cycle_start_ts(c: CycleDict) -> float:
            ts = _value_to_timestamp(c.get("start_time"))
            return ts if ts is not None else float("inf")

        target_cycles.sort(key=_cycle_start_ts)

        # Collect affected profiles for envelope rebuild
        affected_profiles: set[str] = set()
        for c in target_cycles:
            if c.get("profile_name"):
                affected_profiles.add(c["profile_name"])
        if target_profile:
            affected_profiles.add(target_profile)

        # We modify the first cycle (c1) to become the merged one
        c1 = target_cycles[0]
        ids_to_remove: list[str] = []

        # Base setup
        c1_start_dt = _parse_start_dt(c1["start_time"])
        if not c1_start_dt:
            return None

        # Helper to get parsed points from a cycle
        def get_points(cy: CycleDict) -> list[tuple[float, float, float]]:
            # content: [(timestamp, offset, power)]
            raw = decompress_power_data(cy)
            res: list[tuple[float, float, float]] = []
            if not raw:
                return []
            base_dt = _parse_start_dt(cy["start_time"])
            if base_dt is None:
                return []
            base_t = base_dt.timestamp()
            for offset_seconds, val in raw:
                t_abs = base_t + float(offset_seconds)
                res.append((t_abs, float(offset_seconds), float(val)))
            return res

        # Start with C1 points
        merged_points_abs: list[list[float]] = [] # [timestamp, power]

        # Add C1 points
        c1_pts = get_points(c1)
        for t_abs, _, p in c1_pts:
            merged_points_abs.append([t_abs, p])

        # Use the maximum t_abs seen so far (guards against out-of-order or corrupted points)
        last_t_abs = max((pt[0] for pt in c1_pts), default=c1_start_dt.timestamp())

        # Iterate others
        max_power = c1.get("max_power", 0)

        for next_c in target_cycles[1:]:
            c_start_dt = _parse_start_dt(next_c.get("start_time"))
            if not c_start_dt:
                continue

            c_pts = get_points(next_c)
            if not c_pts:
                continue

            current_start_ts = c_pts[0][0]

            # --- GAP FILLING ---
            gap = current_start_ts - last_t_abs
            # If gap > 1s, inject 0W points to ensure graph drops to 0
            if gap > 1.0:
                merged_points_abs.append([last_t_abs + 0.1, 0.0])
                merged_points_abs.append([current_start_ts - 0.1, 0.0])

            # Append points; track the running maximum to guard against reversed/corrupt data
            for t_abs, _, p in c_pts:
                merged_points_abs.append([t_abs, p])
                if t_abs > last_t_abs:
                    last_t_abs = t_abs

            max_power = max(max_power, next_c.get("max_power", 0))
            ids_to_remove.append(next_c["id"])

        # Derive merged end time from power data when available; otherwise fall back to
        # the end_time field of the last cycle (handles cycles without recorded power data).
        if merged_points_abs:
            # Use the maximum absolute timestamp from all collected data points
            last_t_abs = max(pt[0] for pt in merged_points_abs)
            final_end_dt = dt_util.utc_from_timestamp(last_t_abs)
        else:
            last_cycle = target_cycles[-1]
            fallback_end_dt = _parse_start_dt(last_cycle.get("end_time"))
            if fallback_end_dt is not None:
                final_end_dt = fallback_end_dt
            else:
                final_end_dt = c1_start_dt

        new_dur = (final_end_dt - c1_start_dt).total_seconds()

        c1["start_time"] = dt_util.as_utc(c1_start_dt).isoformat()
        c1["end_time"] = dt_util.as_utc(final_end_dt).isoformat()
        c1["duration"] = round(new_dur, 1)
        c1["max_power"] = max_power
        c1["profile_name"] = target_profile
        # Remove manual_duration override so the freshly computed duration is shown
        c1.pop("manual_duration", None)

        # Generate new compressed power_data [offset, power]
        new_power_data: list[list[float]] = []
        c1_start_ts = c1_start_dt.timestamp()

        for t_abs, p in merged_points_abs:
            offset = round(t_abs - c1_start_ts, 1)
            new_power_data.append([offset, float(p)])

        c1["power_data"] = new_power_data

        # Recompute energy from the merged trace (merge keeps only c1's original
        # energy_wh which under-represents the combined cycle).
        try:
            ts_s = np.array([pt[0] for pt in new_power_data], dtype=float)
            p_s = np.array([pt[1] for pt in new_power_data], dtype=float)
            if len(ts_s) > 1:
                c1["energy_wh"] = round(
                    integrate_wh(ts_s, p_s, max_gap_s=energy_gap_threshold_s(ts_s)), 3
                )
        except Exception:  # pylint: disable=broad-exception-caught
            self._logger.warning("Energy recompute failed after cycle merge", exc_info=True)

        # New Hash ID
        new_id = hashlib.sha256(f"{c1['start_time']}_{c1['duration']}".encode()).hexdigest()[:12]
        old_c1_id = c1["id"]
        c1["id"] = new_id

        # Update references in profiles
        all_removed_ids = ids_to_remove + [old_c1_id]
        for p_data in self.get_profiles().values():
            if p_data.get("sample_cycle_id") in all_removed_ids:
                p_data["sample_cycle_id"] = new_id

        # Remove consumed cycles
        self._data["past_cycles"] = [
            c for c in cycles if c.get("id") not in ids_to_remove
        ]

        # Update signature
        try:
            ts_arr = np.array([pt[0] for pt in new_power_data], dtype=float)
            p_arr = np.array([pt[1] for pt in new_power_data], dtype=float)
            if len(ts_arr) > 1:
                sig = compute_signature(ts_arr, p_arr)
                c1["signature"] = dataclasses.asdict(sig)
        except Exception as e:  # pylint: disable=broad-exception-caught
            self._logger.warning("Failed to update signature for merged cycle %s: %s", new_id, e)

        # Consumed cycles are gone; drop any pending feedback that pointed at them so
        # the "needs review" badge does not keep counting non-existent cycles (#362).
        self.prune_orphaned_feedback()

        await self.async_save()
        self._logger.info("Interactive Merge Applied: %s -> %s", cycle_ids, new_id)

        # Rebuild envelopes for all affected profiles
        for p_name in affected_profiles:
            await self.async_rebuild_envelope(p_name)

        return new_id

