# 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/>.
"""On-device, NumPy-only model training for WashData (Stage 4).

This is the runtime counterpart of the offline lab's promotion pipeline. All
three embedded models are ``standardized_logistic`` heads - a mean/std scaler
plus a weight vector, bias, and decision threshold - which the lab fits with a
short pure-NumPy gradient descent (``wash_ml/end_detection.py::_fit_logistic``).
This module reproduces that fit and the exact scoring math the embedded
``*_model.py`` modules use, so a model trained here on the user's own cycles is
byte-compatible with the shipped baseline and can be scored identically.

No new dependencies: NumPy only. Nothing here runs unless the caller (behind the
``ENABLE_ML_TRAINING`` flag) invokes it.
"""
from __future__ import annotations

from typing import Any, Mapping, Sequence

import numpy as np

# Matches wash_ml/promotion.py so a trained spec is interchangeable with the
# shipped bundles and could be rendered into a *_model.py if ever needed.
PROMOTION_SCHEMA = "washdata.promoted_model/1"


def _sigmoid(values: np.ndarray) -> np.ndarray:
    clipped = np.clip(values, -60.0, 60.0)
    return 1.0 / (1.0 + np.exp(-clipped))


def fit_logistic(
    matrix: np.ndarray,
    labels: np.ndarray,
    *,
    l2: float = 0.01,
    learning_rate: float = 0.2,
    iterations: int = 4000,
) -> dict[str, np.ndarray | float]:
    """Fit a class-balanced, L2-regularised logistic head with NumPy GD.

    Identical in shape to the lab's ``_fit_logistic``: mean/std standardisation,
    inverse-frequency class weights, and a fixed-step gradient descent. Returns
    ``{center, scale, coef, bias}``.
    """
    matrix = np.asarray(matrix, dtype=float)
    labels = np.asarray(labels, dtype=float)
    if matrix.ndim != 2 or matrix.shape[0] == 0:
        raise ValueError("matrix must be a non-empty 2D array")
    if labels.shape[0] != matrix.shape[0]:
        raise ValueError("labels/matrix row mismatch")
    if len(np.unique(labels)) < 2:
        raise ValueError(
            f"fit_logistic requires both positive and negative examples; "
            f"got labels: {np.unique(labels)}"
        )

    center = np.mean(matrix, axis=0)
    scale = np.std(matrix, axis=0)
    scale = np.where(scale <= 1e-8, 1.0, scale)
    scaled = (matrix - center) / scale

    weight = np.ones(labels.size, dtype=float)
    for label_value in (0.0, 1.0):
        mask = labels == label_value
        count = float(np.sum(mask))
        if count > 0:
            weight[mask] = labels.size / (2.0 * count)
    normalized = weight / (float(np.sum(weight)) or 1.0)

    coef = np.zeros(matrix.shape[1], dtype=float)
    bias = 0.0
    for _ in range(iterations):
        predictions = _sigmoid(scaled @ coef + bias)
        residual = (predictions - labels) * normalized
        coef -= learning_rate * (scaled.T @ residual + l2 * coef)
        bias -= learning_rate * float(np.sum(residual))
    return {"center": center, "scale": scale, "coef": coef, "bias": float(bias)}


def _safe_ratio(numerator: float, denominator: float) -> float:
    return float(numerator) / float(denominator) if denominator else 0.0


def binary_metrics(labels: np.ndarray, scores: np.ndarray, threshold: float) -> dict[str, Any]:
    """Confusion-matrix metrics at a threshold (pure NumPy)."""
    labels = np.asarray(labels, dtype=float)
    scores = np.asarray(scores, dtype=float)
    if labels.size == 0:
        return {}
    predictions = (scores >= threshold).astype(int)
    tp = int(np.sum((labels == 1) & (predictions == 1)))
    fp = int(np.sum((labels == 0) & (predictions == 1)))
    tn = int(np.sum((labels == 0) & (predictions == 0)))
    fn = int(np.sum((labels == 1) & (predictions == 0)))
    precision = _safe_ratio(tp, tp + fp)
    recall = _safe_ratio(tp, tp + fn)
    specificity = _safe_ratio(tn, tn + fp)
    f1 = _safe_ratio(2.0 * precision * recall, precision + recall)
    accuracy = _safe_ratio(tp + tn, labels.size)
    positive_rate = _safe_ratio(int(np.sum(labels == 1)), labels.size)
    # Key names mirror the shipped MODEL_METRICS schema (see *_model.py):
    # ``problem_recall`` (recall of the positive/"problem" class) and
    # ``positive_rate`` (base rate of positives), so on-device-trained metrics
    # are schema-identical to the embedded baselines they are compared against.
    return {
        "rows": int(labels.size),
        "tp": tp, "fp": fp, "tn": tn, "fn": fn,
        "precision": round(precision, 6),
        "problem_recall": round(recall, 6),
        "positive_rate": round(positive_rate, 6),
        "specificity": round(specificity, 6),
        "balanced_accuracy": round((recall + specificity) / 2.0, 6),
        "f1": round(f1, 6),
        "accuracy": round(accuracy, 6),
    }


def auc(labels: np.ndarray, scores: np.ndarray) -> float:
    """Rank-based ROC AUC (Mann-Whitney U). 0.5 when one class is absent."""
    labels = np.asarray(labels, dtype=float)
    scores = np.asarray(scores, dtype=float)
    finite_mask = np.isfinite(scores)
    scores = scores[finite_mask]
    labels = labels[finite_mask]
    if len(scores) == 0:
        return 0.5
    pos = scores[labels == 1]
    neg = scores[labels == 0]
    if pos.size == 0 or neg.size == 0:
        return 0.5
    order = np.argsort(scores, kind="mergesort")
    ranks = np.empty(scores.size, dtype=float)
    ranks[order] = np.arange(1, scores.size + 1, dtype=float)
    # Average ranks over ties so AUC is exact for discrete scores.
    _assign_tie_ranks(scores, ranks, order)
    rank_sum_pos = float(np.sum(ranks[labels == 1]))
    n_pos = float(pos.size)
    n_neg = float(neg.size)
    u = rank_sum_pos - n_pos * (n_pos + 1.0) / 2.0
    return float(u / (n_pos * n_neg))


def _assign_tie_ranks(scores: np.ndarray, ranks: np.ndarray, order: np.ndarray) -> None:
    sorted_scores = scores[order]
    i = 0
    n = scores.size
    while i < n:
        j = i
        while j + 1 < n and sorted_scores[j + 1] == sorted_scores[i]:
            j += 1
        if j > i:
            avg = (ranks[order[i]] + ranks[order[j]]) / 2.0
            for k in range(i, j + 1):
                ranks[order[k]] = avg
        i = j + 1


def select_threshold(
    labels: np.ndarray,
    scores: np.ndarray,
    *,
    default: float = 0.5,
) -> float:
    """Pick the threshold maximising balanced accuracy (model-agnostic).

    Ties break toward the ``default`` so the operating point stays stable when
    the data does not clearly prefer one cut.
    """
    labels = np.asarray(labels, dtype=float)
    scores = np.asarray(scores, dtype=float)
    if scores.size == 0:
        return default
    candidates = np.unique(
        np.concatenate([
            np.quantile(scores, np.linspace(0.05, 0.95, 37)),
            np.linspace(0.1, 0.95, 86),
        ])
    )
    candidates = candidates[(candidates >= 0.05) & (candidates <= 0.999)]
    if candidates.size == 0:
        pos_scores = scores[labels == 1]
        return float(np.min(pos_scores)) if len(pos_scores) > 0 else default
    best_key: tuple[float, float] | None = None
    best_threshold = default
    best_ba = 0.5
    for threshold in candidates:
        m = binary_metrics(labels, scores, float(threshold))
        bal = float(m.get("balanced_accuracy") or 0.0)
        key = (bal, -abs(float(threshold) - default))
        if best_key is None or key > best_key:
            best_key = key
            best_threshold = float(threshold)
            best_ba = bal
    if best_ba == 0.5:
        pos_scores = scores[labels == 1]
        return float(np.min(pos_scores)) if len(pos_scores) > 0 else default
    return round(best_threshold, 6)


def build_spec(
    *,
    name: str,
    target: str,
    feature_columns: Sequence[str],
    fit: Mapping[str, Any],
    threshold: float,
    metrics: Mapping[str, Any] | None = None,
    trained_at: str = "",
    cycle_count: int = 0,
) -> dict[str, Any]:
    """Assemble a runtime model spec (same schema as the shipped bundles).

    The returned dict is JSON-serialisable and can be scored by
    :func:`score_spec` with math identical to the embedded ``*_model.py``.
    """
    return {
        "schema": PROMOTION_SCHEMA,
        "name": name,
        "kind": "standardized_logistic",
        "target": target,
        "target_units": "",
        "feature_columns": list(feature_columns),
        "center": [round(float(v), 8) for v in np.asarray(fit["center"], dtype=float)],
        "scale": [round(float(v), 8) for v in np.asarray(fit["scale"], dtype=float)],
        "coef": [round(float(v), 8) for v in np.asarray(fit["coef"], dtype=float)],
        "bias": round(float(fit["bias"]), 8),
        "threshold": round(float(threshold), 8),
        "output_center": 0.0,
        "output_scale": 1.0,
        "metrics": dict(metrics or {}),
        "notes": ["Trained on-device from the user's own labelled cycles."],
        "created_at": trained_at,
        "cycle_count": int(cycle_count),
        "source": "on_device",
    }


def score_matrix_spec(spec: Mapping[str, Any], matrix: np.ndarray) -> np.ndarray:
    """Pure-NumPy probabilities for a (rows, features) matrix from a spec."""
    matrix = np.asarray(matrix, dtype=float)
    if matrix.size == 0:
        return np.empty(0, dtype=float)
    center = np.asarray(spec["center"], dtype=float)
    scale = np.asarray(spec["scale"], dtype=float)
    coef = np.asarray(spec["coef"], dtype=float)
    raw = ((matrix - center) / scale) @ coef + float(spec["bias"])
    return _sigmoid(raw)


def score_spec(spec: Mapping[str, Any], features: Mapping[str, float]) -> float:
    """Pure-NumPy probability for one feature mapping.

    Byte-identical to the embedded ``score()`` in ``*_model.py`` for *complete*
    feature mappings (the normal case: the extractors in ``feature_extraction``
    always populate every ``FEATURE_COLUMNS`` key). The two intentionally differ
    only on the defensive missing-key fallback: this fills a missing feature with
    the training center (standardises to 0.0 = neutral, avoiding 8+ SD corruption
    of inference), whereas the embedded ``score()`` fills raw 0.0. That path is
    not exercised by the parity fixtures and is not reachable in practice.
    """
    columns = spec["feature_columns"]
    center = np.asarray(spec["center"], dtype=float)
    row = []
    for i, col in enumerate(columns):
        val = features.get(col)
        row.append(float(center[i]) if val is None else float(val))
    vector = np.array(row, dtype=float)
    return float(score_matrix_spec(spec, vector.reshape(1, -1))[0])



# ---------------------------------------------------------------------------
# Regression head (standardized_linear) - remaining-time / progress regressor.
#
# The three classifier heads above are logistic. The remaining-time model is a
# ridge-regularised linear regressor over standardised features with a
# standardised target; prediction un-standardises back to target units using the
# spec's ``output_center``/``output_scale``. Same NumPy-only, JSON-serialisable
# spec schema as :func:`build_spec` so it is stored/loaded identically, but it is
# scored with :func:`predict_matrix_spec` (no sigmoid) rather than ``score_spec``.
# ---------------------------------------------------------------------------


def fit_ridge(
    matrix: np.ndarray,
    labels: np.ndarray,
    *,
    alpha: float = 1.0,
) -> dict[str, np.ndarray | float]:
    """Fit a standardised ridge-regression head via NumPy normal equations.

    Standardises features (mean/std) and the target, solves
    ``(ZᵀZ + αI) w = Zᵀ y_std`` in closed form, and returns
    ``{center, scale, coef, bias, y_center, y_scale}``. Because both the
    standardised features and the centred target are zero-mean, the intercept in
    standardised space is 0. Prediction is
    ``((x - center)/scale) @ coef * y_scale + y_center``.
    """
    matrix = np.asarray(matrix, dtype=float)
    labels = np.asarray(labels, dtype=float)
    if matrix.ndim != 2 or matrix.shape[0] == 0:
        raise ValueError("matrix must be a non-empty 2D array")
    if labels.shape[0] != matrix.shape[0]:
        raise ValueError("labels/matrix row mismatch")
    if np.std(labels) < 1e-8:
        raise ValueError(
            f"fit_ridge requires non-constant targets; "
            f"all labels are approximately {labels[0]:.4f}"
        )

    center = np.mean(matrix, axis=0)
    scale = np.std(matrix, axis=0)
    scale = np.where(scale <= 1e-8, 1.0, scale)
    scaled = (matrix - center) / scale

    y_center = float(np.mean(labels))
    y_scale = float(np.std(labels))
    if y_scale <= 1e-9:
        y_scale = 1.0
    y_std = (labels - y_center) / y_scale

    n_features = scaled.shape[1]
    gram = scaled.T @ scaled + float(alpha) * np.eye(n_features)
    rhs = scaled.T @ y_std
    try:
        coef = np.linalg.solve(gram, rhs)
    except np.linalg.LinAlgError:
        coef = np.linalg.lstsq(gram, rhs, rcond=None)[0]
    return {
        "center": center,
        "scale": scale,
        "coef": coef,
        "bias": 0.0,
        "y_center": y_center,
        "y_scale": y_scale,
    }


def regression_metrics(labels: np.ndarray, predictions: np.ndarray) -> dict[str, Any]:
    """MAE / RMSE / R² for a regression fit (pure NumPy)."""
    labels = np.asarray(labels, dtype=float)
    predictions = np.asarray(predictions, dtype=float)
    if labels.size == 0:
        return {}
    err = predictions - labels
    mae = float(np.mean(np.abs(err)))
    rmse = float(np.sqrt(np.mean(err ** 2)))
    ss_res = float(np.sum(err ** 2))
    ss_tot = float(np.sum((labels - float(np.mean(labels))) ** 2))
    r2 = float(1.0 - ss_res / ss_tot) if ss_tot > 1e-12 else 0.0
    return {
        "rows": int(labels.size),
        "mae": round(mae, 6),
        "rmse": round(rmse, 6),
        "r2": round(r2, 6),
    }


def predict_matrix_spec(spec: Mapping[str, Any], matrix: np.ndarray) -> np.ndarray:
    """Regression predictions (target units) for a (rows, features) matrix."""
    matrix = np.asarray(matrix, dtype=float)
    if matrix.size == 0:
        return np.empty(0, dtype=float)
    center = np.asarray(spec["center"], dtype=float)
    scale = np.asarray(spec["scale"], dtype=float)
    coef = np.asarray(spec["coef"], dtype=float)
    y_std = ((matrix - center) / scale) @ coef + float(spec.get("bias", 0.0))
    y_center = float(spec.get("output_center", 0.0))
    y_scale = float(spec.get("output_scale", 1.0))
    return y_std * y_scale + y_center


def predict_value_spec(spec: Mapping[str, Any], features: Mapping[str, float]) -> float:
    """Un-standardised regression output for one feature mapping.

    Missing feature keys are filled with the training center (which standardises
    to 0.0 = neutral), not raw 0.0, to avoid 8+ SD corruption of inference.
    """
    columns = spec["feature_columns"]
    center = np.asarray(spec["center"], dtype=float)
    row = []
    for i, col in enumerate(columns):
        val = features.get(col)
        row.append(float(center[i]) if val is None else float(val))
    vector = np.array(row, dtype=float)
    return float(predict_matrix_spec(spec, vector.reshape(1, -1))[0])


def build_regression_spec(
    *,
    name: str,
    target: str,
    feature_columns: Sequence[str],
    fit: Mapping[str, Any],
    target_units: str = "",
    metrics: Mapping[str, Any] | None = None,
    trained_at: str = "",
    cycle_count: int = 0,
) -> dict[str, Any]:
    """Assemble a ``standardized_linear`` regression spec (JSON-serialisable).

    Scored by :func:`predict_matrix_spec` / :func:`predict_value_spec` with math
    that mirrors :func:`fit_ridge`. ``threshold`` is retained (0.0) only so the
    spec shape stays uniform with the classifier bundles.
    """
    return {
        "schema": PROMOTION_SCHEMA,
        "name": name,
        "kind": "standardized_linear",
        "target": target,
        "target_units": target_units,
        "feature_columns": list(feature_columns),
        "center": [round(float(v), 8) for v in np.asarray(fit["center"], dtype=float)],
        "scale": [round(float(v), 8) for v in np.asarray(fit["scale"], dtype=float)],
        "coef": [round(float(v), 8) for v in np.asarray(fit["coef"], dtype=float)],
        "bias": round(float(fit.get("bias", 0.0)), 8),
        "threshold": 0.0,
        "output_center": round(float(fit["y_center"]), 8),
        "output_scale": round(float(fit["y_scale"]), 8),
        "metrics": dict(metrics or {}),
        "notes": ["Trained on-device from the user's own labelled cycles."],
        "created_at": trained_at,
        "cycle_count": int(cycle_count),
        "source": "on_device",
    }
