# 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/>.
"""Progress / remaining-time / phase / projected-energy estimation.

Single source of truth for the cycle-progress math. Both the live integration
(``manager.WashDataManager`` - thin wrappers over these functions) and the
Playground's headless simulation (``playground.SimRunner``) call the SAME
functions here, so the panel's what-if replay is byte-for-byte what the running
integration computes. Nothing here touches Home Assistant; every function is
pure given a ``ProfileStore`` (read-only), the entry options mapping, and a
replayed ``(timestamp, power)`` trace, so it is executor-safe.

Extracted verbatim from ``manager.py`` (``self.profile_store`` -> ``store``,
``self._logger`` -> ``logger``); the arithmetic is unchanged and guarded by the
existing progress/phase/ML/energy test suite plus a golden before/after snapshot.
"""
from __future__ import annotations

import logging
import math
from dataclasses import dataclass
from datetime import datetime
from typing import Any, cast

import numpy as np

from .const import (
    CYCLE_OVERRUN_ANOMALY_RATIO,
    DEVICE_SMOOTHING_THRESHOLDS,
    ML_PROGRESS_BLEND_WEIGHT,
    STATE_ENDING,
    STATE_PAUSED,
    STATE_RUNNING,
)
from .profile_store import decompress_power_data
from .time_utils import power_data_to_offsets

_LOGGER = logging.getLogger(__name__)

# Minimum progress before an energy projection is trusted (mirrors the manager
# class constant of the same purpose).
PROJECTION_MIN_PROGRESS = 3.0

# Cache type for profile_end_expectation: (profile_name, base_expectation_dict).
EndExpCache = tuple[str, dict[str, float]] | None


@dataclass
class ProgressResult:
    """Output of :func:`compute_progress`."""

    progress: float
    smoothed: float
    remaining: float
    total: float
    phase_progress: float | None  # raw pre-smoothing estimate (diagnostic)
    source: str  # "phase" | "linear"


def profile_end_expectation(
    store: Any,
    profile_name: str,
    expected_duration: float,
    cache: EndExpCache = None,
) -> tuple[dict[str, float] | None, EndExpCache]:
    """Median duration/energy/peak for a matched profile, for end features.

    Cached per profile (caller threads ``cache``) so the guard does not
    re-decompress history on every low-power reading during ENDING. The
    authoritative expected duration overrides the median when available.
    Returns ``(expectation, cache)``.
    """
    if cache is not None and cache[0] == profile_name:
        expectation = dict(cache[1])
    else:
        from .ml.feature_extraction import profile_expectation

        points_list: list[list[tuple[float, float]]] = []
        for cycle in store.get_past_cycles():
            if cycle.get("profile_name") != profile_name:
                continue
            pts = decompress_power_data(cycle)
            if pts:
                points_list.append(pts)
        base = profile_expectation(points_list[-20:])
        if base is None:
            return None, cache
        cache = (profile_name, dict(base))
        expectation = dict(base)
    if expected_duration and expected_duration > 0:
        expectation["duration"] = float(expected_duration)
    return expectation, cache


EndExpFn = Any  # Callable[[str, float], dict[str, float] | None]


def ml_progress_percent(
    store: Any,
    options: Any,
    matched_duration: float,
    trace: list[tuple[datetime, float]],
    profile_name: str,
    end_expectation_fn: EndExpFn,
    logger: logging.Logger | None = None,
) -> float | None:
    """ML completion-fraction estimate (0-100) for the running cycle, or None.

    Uses the on-device ``remaining_time`` regressor; gated on the ML opt-in and
    inert until training promotes a regressor. ``end_expectation_fn(name, dur)``
    supplies the profile expectation (the manager passes its cached
    ``_profile_end_expectation``; the Playground wraps :func:`profile_end_expectation`)
    so history is only decompressed after the cheap gates pass. Never raises.
    """
    logger = logger or _LOGGER
    try:
        from .ml.engine import ml_models_enabled, resolve_regressor

        if not ml_models_enabled(options):
            return None
        if (
            not profile_name
            or profile_name in ("off", "detecting...", "restored...")
            or profile_name not in store.get_profiles()
        ):
            return None
        predict_fn, _src = resolve_regressor("remaining_time", store)
        if predict_fn is None:
            return None
        if not trace or len(trace) < 4:
            return None
        expectation = end_expectation_fn(
            profile_name, float(matched_duration or 0.0)
        )
        if expectation is None:
            return None
        t0 = trace[0][0]
        pts = [(float((t - t0).total_seconds()), float(p)) for t, p in trace]
        from .ml.feature_extraction import progress_features

        feat = progress_features(pts, expectation)
        if feat is None:
            return None
        frac = float(predict_fn(feat))
        if not math.isfinite(frac):
            return None
        return float(min(max(frac, 0.0), 0.99)) * 100.0
    except Exception as err:  # noqa: BLE001 - ML must never break estimates
        logger.debug("ML progress estimate skipped: %s", err)
        return None


def ml_energy_total(
    store: Any,
    options: Any,
    matched_duration: float,
    trace: list[tuple[datetime, float]],
    profile_name: str,
    end_expectation_fn: EndExpFn,
    logger: logging.Logger | None = None,
) -> float | None:
    """Predicted total cycle energy (Wh) from the on-device ``total_energy``
    regressor, or None. ``end_expectation_fn`` as in :func:`ml_progress_percent`.
    Never raises.
    """
    logger = logger or _LOGGER
    try:
        from .ml.engine import ml_models_enabled, resolve_regressor

        if not ml_models_enabled(options):
            return None
        if (
            not profile_name
            or profile_name in ("off", "detecting...", "restored...")
            or profile_name not in store.get_profiles()
        ):
            return None
        predict_fn, _src = resolve_regressor("total_energy", store)
        if predict_fn is None:
            return None
        if not trace or len(trace) < 4:
            return None
        expectation = end_expectation_fn(
            profile_name, float(matched_duration or 0.0)
        )
        if expectation is None:
            return None
        t0 = trace[0][0]
        pts = [(float((t - t0).total_seconds()), float(p)) for t, p in trace]
        from .ml.feature_extraction import cumulative_energy_wh, progress_features

        feat = progress_features(pts, expectation)
        if feat is None:
            return None
        frac = float(predict_fn(feat))
        # Floor the fraction so an under-confident prediction can't blow the
        # projection up; below the floor, defer to the time-based fallback.
        if not math.isfinite(frac) or frac < 0.05:
            return None
        energy_so_far = float(cumulative_energy_wh(pts)[-1])
        if energy_so_far <= 0.0:
            return None
        total = energy_so_far / min(max(frac, 0.05), 1.0)
        return max(total, energy_so_far)  # never below what's already consumed
    except Exception as err:  # noqa: BLE001 - ML must never break estimates
        logger.debug("ML energy projection skipped: %s", err)
        return None


def estimate_phase_progress(
    store: Any,
    current_power_data: list[tuple[datetime, float]] | list[tuple[str, float]],
    current_duration: float,
    profile_name: str,
    logger: logging.Logger | None = None,
    quiet_threshold_w: float = 0.0,
) -> tuple[float, float] | None:
    """Estimate cycle progress by analyzing which phase we're in.

    Uses cached statistical envelope built from ALL cycles labeled with this
    profile, normalized by TIME to account for different sampling rates. Returns
    ``(progress_pct, variance_watts)`` or ``None`` if estimation fails.

    ``quiet_threshold_w`` is the detector's own off-noise floor
    (``CycleDetectorConfig.stop_threshold_w``, itself derived from the configured
    minimum power). A window that never rises above it is *not* the appliance
    doing something, so it carries no phase information and the scan declines
    rather than guessing (#386); a dead-flat window declines for the same reason
    at any power level. The default 0.0 leaves only the flatness rule for callers
    that do not know the floor.
    """
    logger = logger or _LOGGER
    # Get cached envelope (fast - already computed and stored)
    envelope = store.get_envelope(profile_name)

    if envelope is None:
        logger.debug("No envelope cached for profile %s", profile_name)
        return None

    # Convert cached lists back to numpy arrays
    try:
        env_min = envelope.get("min", [])
        env_max = envelope.get("max", [])
        env_avg = envelope.get("avg", [])
        env_std = envelope.get("std", [])

        def extract_y_values(data: list[Any]) -> np.ndarray[Any, np.dtype[np.float64]]:
            if not data:
                return np.array([], dtype=float)
            first = data[0]
            if isinstance(first, (list, tuple)):
                first_seq = cast(list[Any] | tuple[Any, ...], first)
                if len(first_seq) < 2:
                    return np.array([], dtype=float)
                # New format: [[t, y], ...]
                points = cast(list[list[Any] | tuple[Any, ...]], data)
                return np.array([float(pt[1]) for pt in points], dtype=float)
            # Legacy format: [y, ...]
            scalars = cast(list[float | int], data)
            return np.array(scalars, dtype=float)

        envelope_arrays: dict[str, np.ndarray[Any, np.dtype[np.float64]]] = {
            "min": extract_y_values(env_min),
            "max": extract_y_values(env_max),
            "avg": extract_y_values(env_avg),
            "std": extract_y_values(env_std),
        }
        time_grid: np.ndarray[Any, np.dtype[np.float64]] = np.array(
            envelope.get("time_grid", []), dtype=float
        )
        target_duration = float(envelope.get("target_duration", 0.0) or 0.0)
    except (KeyError, ValueError, TypeError, IndexError) as e:
        logger.warning("Invalid envelope format for %s: %s", profile_name, e)
        return None

    if len(time_grid) == 0 or target_duration <= 0:
        if target_duration > 0 and len(envelope_arrays["avg"]) > 0:
            # Reconstruct time_grid if missing (Legacy envelope support)
            count = len(envelope_arrays["avg"])
            time_grid = np.linspace(0, target_duration, count)
            logger.debug(
                "Reconstructed missing time_grid for %s (n=%d)",
                profile_name,
                count,
            )
        else:
            logger.debug("Envelope missing time grid/duration, cannot estimate phase")
            return None

    # Extract power offsets from current cycle (any format -> [offset, power])
    current_offsets_list = power_data_to_offsets(
        cast(list[list[Any] | tuple[Any, ...]], current_power_data)
    )
    current_offsets = np.array([o for o, _ in current_offsets_list])
    current_values = np.array([p for _, p in current_offsets_list])
    if current_offsets.size == 0:
        logger.debug("No valid current power offsets, cannot estimate phase")
        return None

    # Use sliding window on TIME, not sample count
    window_duration = min(60.0, target_duration * 0.25)
    current_time = current_offsets[-1]
    window_start_time = max(0, current_time - window_duration)

    window_mask = current_offsets >= window_start_time
    current_window_values = current_values[window_mask]

    if len(current_window_values) < 3:
        logger.debug("Insufficient data in current window for phase estimation")
        return None

    # Two window shapes carry no information the scan can align on, and both
    # mislocate badly when it tries anyway (#386): the correlation term is dead or
    # is noise on the plug's last reported digit, the MAE/bounds terms then score
    # every similar stretch of the envelope alike, and the only term left that
    # knows the clock is the time penalty - which is capped at 40%.
    #   * BELOW THE OFF FLOOR. The appliance is not drawing anything the detector
    #     would call active, so there is nothing to locate. Catches a quiet tail
    #     whatever jitter the plug puts on its last digit.
    #   * DEAD FLAT. No shape at any power level, e.g. a steady plateau reported
    #     by a plug that re-reports unchanged values. Replay says these mislocate
    #     too (a late offset wins on level alone), and the cost of declining is
    #     within noise, so a plateau defers to the clock as well.
    # Declining hands the caller its linear (clock) estimate, which is what ran
    # before phase-aware progress existed.
    quiet_w = float(quiet_threshold_w or 0.0)
    window_max = float(np.max(current_window_values))
    window_flat = float(np.std(current_window_values)) == 0.0
    if window_max <= quiet_w or window_flat:
        logger.debug(
            "Uninformative current window (max=%.2fW, off-floor=%.2fW, flat=%s), "
            "skipping phase estimation",
            window_max,
            quiet_w,
            window_flat,
        )
        return None

    # The envelope's trailing all-zero stretch is an artefact of averaging cycles
    # that ended at different times (real dishwasher envelopes carry 30+ min of
    # it). It is a perfect fit for any quiet window of any length, while the true
    # region scores 0 on bounds because the drain pump smears across cycles and
    # keeps the envelope's own min above zero - so a near-zero reading is drawn to
    # the pad and progress collapses to the 99% clamp (#386). Offsets inside the
    # pad are not candidate alignments: the scan stops at the last offset where
    # the envelope is still active.
    active_offsets = np.flatnonzero(envelope_arrays["max"] > 0.0)
    active_len = int(active_offsets[-1]) + 1 if active_offsets.size else 0
    # A malformed envelope can carry bands of differing length; never index past
    # the shortest of the three the scan slices in lockstep.
    active_len = min(
        active_len,
        len(envelope_arrays["avg"]),
        len(envelope_arrays["min"]),
        len(envelope_arrays["max"]),
    )
    scan_n = min(len(time_grid) - 1, active_len)
    if scan_n <= 0:
        logger.debug("Envelope has no active offsets, cannot estimate phase")
        return None

    # Slide the current window across the whole envelope grid and keep the
    # best-scoring alignment. The scalar form below is the reference; the
    # vectorized form computes the identical per-offset score in bulk (the grid is
    # O(cycle length), so for a multi-hour cycle this scalar loop is ~thousands of
    # corrcoef calls per update - the #311 live/Playground hot spot). The vectorized
    # path falls back to the scalar loop on any error, so behavior can never regress.
    def _scan_scalar() -> tuple[float | None, float, bool, float | None]:
        b_progress: float | None = None
        b_score = -1.0
        b_in_bounds = False
        b_tws: float | None = None
        for i in range(scan_n):
            time_window_start = float(time_grid[i])
            envelope_window_start = i
            envelope_window_end = min(i + len(current_window_values), active_len)
            if envelope_window_end <= envelope_window_start:
                continue
            avg_window = envelope_arrays["avg"][envelope_window_start:envelope_window_end]
            min_window = envelope_arrays["min"][envelope_window_start:envelope_window_end]
            max_window = envelope_arrays["max"][envelope_window_start:envelope_window_end]
            if len(avg_window) != len(current_window_values):
                x_old = np.linspace(0, 1, len(avg_window))
                x_new = np.linspace(0, 1, len(current_window_values))
                avg_window = np.interp(x_new, x_old, avg_window)
                min_window = np.interp(x_new, x_old, min_window)
                max_window = np.interp(x_new, x_old, max_window)
            within_bounds = np.all(
                (current_window_values >= min_window * 0.8)
                & (current_window_values <= max_window * 1.2)
            )
            bounds_score = np.mean(
                (current_window_values >= min_window)
                & (current_window_values <= max_window)
            )
            try:
                if np.std(current_window_values) > 0 and np.std(avg_window) > 0:
                    correlation = np.corrcoef(current_window_values, avg_window)[0, 1]
                else:
                    correlation = 0.0
                mae = np.mean(np.abs(current_window_values - avg_window))
                max_power = max(np.max(avg_window), np.max(current_window_values), 1.0)
                mae_normalized = 1.0 - min(mae / max_power, 1.0)
                score = (
                    0.4 * max(correlation, 0.0)
                    + 0.3 * mae_normalized
                    + 0.3 * bounds_score
                )
                time_diff = abs(time_window_start - current_duration)
                time_penalty = min(1.0, time_diff / (target_duration * 0.3))
                score = score * (1.0 - 0.4 * time_penalty)
                if score > b_score:
                    b_score = score
                    b_progress = (time_window_start / target_duration) * 100.0
                    b_in_bounds = bool(within_bounds)
                    b_tws = float(time_window_start)
            except Exception:  # pylint: disable=broad-exception-caught
                continue
        return b_progress, b_score, b_in_bounds, b_tws

    def _scan_vectorized() -> tuple[float | None, float, bool, float | None]:
        from numpy.lib.stride_tricks import sliding_window_view

        cur = np.asarray(current_window_values, dtype=float)
        w = len(cur)
        avg_arr = envelope_arrays["avg"]
        min_arr = envelope_arrays["min"]
        max_arr = envelope_arrays["max"]
        length = active_len
        n = scan_n
        if n <= 0 or w == 0:
            return _scan_scalar()

        scores = np.full(n, -np.inf, dtype=float)
        within = np.zeros(n, dtype=bool)

        cur_mean = float(cur.mean())
        cur_c = cur - cur_mean
        cur_ss = float(cur_c @ cur_c)          # Σ(x-x̄)²  (== np.corrcoef numerator basis)
        cur_std_pos = cur_ss > 0.0             # equivalent to np.std(cur) > 0
        cur_max = float(cur.max())
        tg = np.asarray(time_grid[:n], dtype=float)
        time_penalty = np.minimum(1.0, np.abs(tg - current_duration) / (target_duration * 0.3))

        # Interior: full-width windows (no interpolation). i in [0, hi].
        hi = min(length - w, n - 1)
        if hi >= 0 and length >= w:
            rows = hi + 1
            A = sliding_window_view(avg_arr, w)[:rows]
            Mn = sliding_window_view(min_arr, w)[:rows]
            Mx = sliding_window_view(max_arr, w)[:rows]
            row_mean = A.mean(axis=1)
            A_c = A - row_mean[:, None]
            row_ss = np.einsum("ij,ij->i", A_c, A_c)
            dot = A_c @ cur_c
            corr = np.zeros(rows, dtype=float)
            good = (row_ss > 0.0) & cur_std_pos
            corr[good] = dot[good] / np.sqrt(row_ss[good] * cur_ss)
            mae = np.mean(np.abs(A - cur[None, :]), axis=1)
            row_max = A.max(axis=1)
            max_power = np.maximum(np.maximum(row_max, cur_max), 1.0)
            mae_norm = 1.0 - np.minimum(mae / max_power, 1.0)
            bounds_score = np.mean((cur[None, :] >= Mn) & (cur[None, :] <= Mx), axis=1)
            within[:rows] = np.all(
                (cur[None, :] >= Mn * 0.8) & (cur[None, :] <= Mx * 1.2), axis=1
            )
            sc = 0.4 * np.maximum(corr, 0.0) + 0.3 * mae_norm + 0.3 * bounds_score
            scores[:rows] = sc * (1.0 - 0.4 * time_penalty[:rows])

        # Tail: partial windows (i + w > length) need the same interp as the scalar
        # path; there are at most w-1 of these, so a small loop is fine.
        for i in range(max(hi + 1, 0), min(length, n)):
            avg_window = np.interp(
                np.linspace(0, 1, w), np.linspace(0, 1, length - i), avg_arr[i:length]
            )
            min_window = np.interp(
                np.linspace(0, 1, w), np.linspace(0, 1, length - i), min_arr[i:length]
            )
            max_window = np.interp(
                np.linspace(0, 1, w), np.linspace(0, 1, length - i), max_arr[i:length]
            )
            within[i] = bool(np.all((cur >= min_window * 0.8) & (cur <= max_window * 1.2)))
            bounds_score = float(np.mean((cur >= min_window) & (cur <= max_window)))
            if cur_std_pos and np.std(avg_window) > 0:
                correlation = float(np.corrcoef(cur, avg_window)[0, 1])
            else:
                correlation = 0.0
            mae = float(np.mean(np.abs(cur - avg_window)))
            max_power = max(float(np.max(avg_window)), cur_max, 1.0)
            mae_norm = 1.0 - min(mae / max_power, 1.0)
            score = 0.4 * max(correlation, 0.0) + 0.3 * mae_norm + 0.3 * bounds_score
            scores[i] = score * (1.0 - 0.4 * float(time_penalty[i]))

        best_i = int(np.argmax(scores))          # first max -> matches scalar `>` tie-break
        b_score = float(scores[best_i])
        if not np.isfinite(b_score):
            return None, -1.0, False, None
        b_tws = float(time_grid[best_i])
        return (b_tws / target_duration) * 100.0, b_score, bool(within[best_i]), b_tws

    try:
        best_progress, best_score, in_bounds, best_time_window_start = _scan_vectorized()
    except Exception as e:  # pylint: disable=broad-exception-caught
        logger.debug("Vectorized phase scan failed (%s); using scalar path", e)
        best_progress, best_score, in_bounds, best_time_window_start = _scan_scalar()

    if best_progress is None or best_score < 0.4:
        logger.debug("Phase detection failed: best_score=%.3f", best_score)
        return None

    best_variance = 0.0
    if best_time_window_start is not None:
        idx_start = int((best_time_window_start / target_duration) * len(time_grid))
        idx_end = min(
            idx_start + len(current_window_values), len(envelope_arrays["std"])
        )
        if idx_end > idx_start:
            window_std = envelope_arrays["std"][idx_start:idx_end]
            if len(window_std) > 0:
                best_variance = float(np.mean(window_std))

    best_progress = max(0.0, min(best_progress, 99.0))

    cycle_count = envelope.get("cycle_count", 0)
    avg_sample_rates_raw = envelope.get("sampling_rates", [1.0])
    avg_sample_rates = (
        cast(list[float | int], avg_sample_rates_raw)
        if isinstance(avg_sample_rates_raw, list)
        else [1.0]
    )
    avg_sample_rate = (
        float(np.median(np.array(avg_sample_rates, dtype=float)))
        if avg_sample_rates
        else 1.0
    )

    tws = (
        best_time_window_start
        if best_time_window_start is not None
        else float(current_duration)
    )
    if not in_bounds:
        logger.debug(
            "Phase detection: progress=%.1f%%, score=%.3f, var=%.1fW, "
            "time=%.0f/%.0fs [OUT OF BOUNDS, %s cycles, avg_sample_rate=%.1fs]",
            best_progress,
            best_score,
            best_variance,
            tws,
            target_duration,
            cycle_count,
            avg_sample_rate,
        )
    else:
        logger.debug(
            "Phase detection: progress=%.1f%%, score=%.3f, var=%.1fW, "
            "time=%.0f/%.0fs [IN BOUNDS, %s cycles, avg_sample_rate=%.1fs]",
            best_progress,
            best_score,
            best_variance,
            tws,
            target_duration,
            cycle_count,
            avg_sample_rate,
        )

    return (best_progress, best_variance)


def _compute_progress_base(
    device_type: str,
    matched_duration: float,
    duration_so_far: float,
    prev_smoothed: float,
    phase_result: tuple[float, float] | None,
    ml_pct: float | None,
    logger: logging.Logger | None = None,
) -> ProgressResult | None:
    """The blend + EMA + monotonicity + back-calculation body of the estimate loop.

    Pure arithmetic: the caller supplies ``phase_result`` (from
    :func:`estimate_phase_progress`, or ``None`` to force the linear fallback) and
    ``ml_pct`` (from :func:`ml_progress_percent`, or ``None``); both the live
    manager and the Playground compute those via the same functions, so this is
    the single implementation of the smoothing/back-calc. Returns ``None`` when no
    profile duration is known (caller clears the estimate). Behavior-identical to
    the matched-duration branch of ``manager._update_remaining_only``.
    """
    logger = logger or _LOGGER
    if not (matched_duration and matched_duration > 0):
        return None

    # --- PHASE-AWARE ESTIMATION ---
    if phase_result is not None:
        phase_progress, phase_variance = phase_result

        if ml_pct is not None:
            w = ML_PROGRESS_BLEND_WEIGHT
            phase_progress = (1.0 - w) * phase_progress + w * ml_pct

        if prev_smoothed == 0.0:
            smoothed = phase_progress
        else:
            current_smoothed = prev_smoothed
            alpha = 0.2  # Default
            if phase_variance > 100.0:
                alpha = 0.05
                logger.debug(
                    "High variance phase (std=%.1fW), "
                    "locking time estimate (alpha=0.05)",
                    phase_variance,
                )
            elif phase_variance > 50.0:
                alpha = 0.1

            smoothing_threshold = DEVICE_SMOOTHING_THRESHOLDS.get(device_type, 5.0)
            if phase_progress < current_smoothed - smoothing_threshold:
                smoothed = (current_smoothed * 0.95) + (phase_progress * 0.05)
                logger.debug(
                    "Progress drop detected (%.1f%% < %.1f%% - %.1f%%), "
                    "applying heavy damping for %s",
                    phase_progress,
                    current_smoothed,
                    smoothing_threshold,
                    device_type,
                )
            else:
                smoothed = (prev_smoothed * (1.0 - alpha)) + (phase_progress * alpha)

        smoothed = min(99.0, smoothed)
        progress = smoothed

        remaining = matched_duration * (1.0 - (progress / 100.0))
        remaining = max(0.0, remaining)
        total = duration_so_far + remaining

        logger.debug(
            "Phase-aware estimate: raw=%.1f%%, smoothed=%.1f%%, remaining=%smin",
            phase_progress,
            progress,
            int(remaining / 60),
        )
        return ProgressResult(progress, smoothed, remaining, total, phase_progress, "phase")

    # --- LINEAR FALLBACK (if phase analysis unavailable) ---
    matched_dur = float(matched_duration)
    remaining = max(matched_dur - duration_so_far, 0.0)
    progress = (duration_so_far / matched_dur) * 100.0

    if ml_pct is not None:
        w = ML_PROGRESS_BLEND_WEIGHT
        progress = (1.0 - w) * progress + w * ml_pct
        remaining = max(matched_dur * (1.0 - progress / 100.0), 0.0)

    if prev_smoothed > 0:
        smoothed = (prev_smoothed * 0.9) + (progress * 0.1)
    else:
        smoothed = progress

    progress = max(0.0, min(smoothed, 100.0))
    remaining = max(matched_dur * (1.0 - progress / 100.0), 0.0)
    total = duration_so_far + remaining
    logger.debug(
        "Linear estimate: remaining=%smin, progress=%.1f%%",
        int(remaining / 60),
        progress,
    )
    return ProgressResult(progress, smoothed, remaining, total, None, "linear")


def compute_progress(
    device_type: str,
    matched_duration: float,
    duration_so_far: float,
    prev_smoothed: float,
    phase_result: tuple[float, float] | None,
    ml_pct: float | None,
    logger: logging.Logger | None = None,
    phase_remaining_s: float | None = None,
) -> ProgressResult | None:
    """Progress/remaining estimate, optionally blended with a phase-resolved ETA.

    When ``phase_remaining_s`` is provided (opt-in phase matching for a supported
    device type), the phase-budget remaining is converted to a completion PERCENT
    and blended into the phase-progress signal **before** delegating to
    :func:`_compute_progress_base` - so the blend rides the proven, golden-locked
    EMA + monotonicity + back-calculation guards (design §8, "one smoothing
    implementation"), rather than re-deriving a raw, unsmoothed progress. The
    blend leans on the phase budget early (low base progress) and on the proven
    phase estimate late::

        phase_pct = duration_so_far / (duration_so_far + phase_remaining_s) * 100
        f = base_phase_progress / 100
        blended = (1 - f) * phase_pct + f * base_phase_progress

    Because this feeds the percent-domain smoothing, the displayed progress stays
    monotone/smoothed (no tick-to-tick jitter or collapse-to-99%), and remaining
    is re-derived by the base from ``matched_duration``.

    Behaviour is BYTE-IDENTICAL to before when ``phase_remaining_s is None`` (the
    default) - the golden progress snapshot and every existing caller are
    unaffected. This is the single implementation of the blend; the manager and
    the Playground SimRunner both go through it.
    """
    blended = False
    if phase_remaining_s is not None and matched_duration and matched_duration > 0:
        try:
            pr = float(phase_remaining_s)
        except (TypeError, ValueError):
            pr = float("nan")
        if math.isfinite(pr) and pr >= 0.0:
            denom = duration_so_far + pr
            phase_pct = (duration_so_far / denom * 100.0) if denom > 0 else 0.0
            phase_pct = max(0.0, min(100.0, phase_pct))
            if phase_result is not None:
                base_pp, variance = phase_result
                f = max(0.0, min(1.0, float(base_pp) / 100.0))
                phase_result = ((1.0 - f) * phase_pct + f * float(base_pp), variance)
            else:
                # No envelope phase-progress: blend the phase budget's implied
                # percent with the linear (elapsed/matched) percent, still leaning
                # on the phase budget early and the linear estimate late.
                lin_pct = max(0.0, min(100.0, duration_so_far / matched_duration * 100.0))
                f = lin_pct / 100.0
                phase_result = ((1.0 - f) * phase_pct + f * lin_pct, 0.0)
            blended = True

    base = _compute_progress_base(
        device_type, matched_duration, duration_so_far, prev_smoothed,
        phase_result, ml_pct, logger,
    )
    if base is None or not blended:
        return base
    # Relabel the source for diagnostics; values already reflect the blend.
    return ProgressResult(
        base.progress, base.smoothed, base.remaining, base.total,
        base.phase_progress, "phase_blend",
    )


def current_phase(
    store: Any,
    state: str,
    current_program: str | None,
    cycle_progress: float,
) -> str | None:
    """Live phase from the profile's configured ranges + ML-blended progress.

    Indexed by the smoothed progress fraction rather than raw elapsed seconds, so
    overrun/underrun cycles still name the phase correctly. Returns ``None`` when
    not running, no profile is matched, or the profile has no configured phase
    ranges. Never raises.
    """
    try:
        if state not in (STATE_RUNNING, STATE_PAUSED, STATE_ENDING):
            return None
        profile = current_program
        if not profile or profile in ("off", "detecting...", "restored...", "none", "unknown"):
            return None
        ranges = store.get_profile_phase_ranges(profile)
        if not ranges:
            return None
        nominal = max((float(r.get("end") or 0.0) for r in ranges), default=0.0)
        if nominal <= 0.0:
            return None
        frac = max(0.0, min(1.0, float(cycle_progress) / 100.0))
        return store.check_phase_match(profile, frac * nominal)
    except Exception:  # noqa: BLE001 - phase readout must never break
        return None


def projected_energy(
    store: Any,
    options: Any,
    matched_duration: float,
    trace: list[tuple[datetime, float]],
    current_program: str | None,
    cycle_progress: float,
    energy_so_far: float,
    price: float | None,
    end_expectation_fn: EndExpFn,
    logger: logging.Logger | None = None,
) -> tuple[float | None, float | None]:
    """Project total energy (Wh) and cost for the running cycle.

    Prefers the on-device ``total_energy`` regressor; otherwise falls back to
    ``energy_so_far / progress_fraction``. Returns ``(wh, cost)``; both values are
    ``None`` when progress is too low or there is no energy yet. Never raises.
    """
    logger = logger or _LOGGER
    try:
        progress = float(cycle_progress or 0.0)
        energy_so_far = float(energy_so_far or 0.0)
        if progress < PROJECTION_MIN_PROGRESS or energy_so_far <= 0.0:
            return None, None
        projected_wh = ml_energy_total(
            store, options, matched_duration, trace, current_program,
            end_expectation_fn, logger,
        )
        if projected_wh is None:
            projected_wh = energy_so_far / (progress / 100.0)
        projected_wh = max(projected_wh, energy_so_far)
        # A valid price of 0 (free/zero tariff) must yield cost 0.0, not None; only an
        # absent or non-numeric price is "unknown".
        try:
            price_val = float(price)
        except (TypeError, ValueError):
            price_val = None
        cost = (projected_wh / 1000.0) * price_val if price_val is not None else None
        return projected_wh, cost
    except Exception:  # noqa: BLE001 - projection must never break estimates
        return None, None


def cycle_anomaly(matched_duration: float, duration_so_far: float) -> tuple[float, str]:
    """Return ``(overrun_ratio, anomaly)`` - the soft runtime overrun signal.

    ``anomaly`` is ``"overrun"`` once elapsed/expected crosses
    ``CYCLE_OVERRUN_ANOMALY_RATIO``, else ``"none"``. Never raises.
    """
    try:
        expected = float(matched_duration or 0.0)
        if expected <= 0.0 or duration_so_far <= 0.0:
            return 0.0, "none"
        ratio = duration_so_far / expected
        return ratio, ("overrun" if ratio >= CYCLE_OVERRUN_ANOMALY_RATIO else "none")
    except Exception:  # noqa: BLE001 - anomaly signal must never break estimates
        return 0.0, "none"
