"""
vita.calibration — turning all-out sets into a calibrated lift

A ``Lift`` is a movement with a fitted (or fixed) load-rep curve. Two ways in:

    # quick: two all-out sets
    bench = vita.calibrate(weight1=315, reps1=5, weight2=275, reps2=9, name="Bench")

    # builder: any number of sets, with a confidence read and outlier flags
    c = vita.Calibration(name="Bench")
    c.add_set(315, 5); c.add_set(275, 9); c.add_set(225, 14)
    bench = c.fit()
    bench.quality        # 0..1: how well-identified the curve is
    bench.confidence     # "high" / "medium" / "low"
    bench.report         # the full assessment

The curve-fitting math lives in vita.core (LoadRepCurve). This module adds the
intelligence around it: a confidence score, outlier detection, and graceful
handling of imperfect data.
"""

from __future__ import annotations

import math
import warnings
from dataclasses import dataclass
from typing import List, Optional, Tuple

from .core import LoadRepCurve

__all__ = ["Lift", "Calibration", "FitReport", "calibrate", "calibrate_bodyweight"]


# --------------------------------------------------------------------------
# assessment of a fitted curve
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class FitReport:
    """How trustworthy a calibration is, and why."""

    quality: float  # 0..1
    confidence: str  # "high" / "medium" / "low"
    n_sets: int  # sets the curve was fitted on (after any exclusions)
    load_spread: float  # heaviest / lightest load
    r_squared: Optional[float]  # fit tightness (None for 2 sets — fit is exact)
    excluded: Tuple[Tuple[float, float, float], ...]  # (load, reps, residual) dropped
    messages: Tuple[str, ...]

    def summary(self) -> str:
        lines = [
            f"  Calibration — {self.confidence} confidence (quality {self.quality:.2f})",
            f"  {self.n_sets} sets · load spread {self.load_spread:.2f}×",
        ]
        if self.r_squared is not None:
            lines.append(f"  fit R² = {self.r_squared:.3f}")
        for m in self.messages:
            lines.append(f"  • {m}")
        return "\n".join(lines)

    def __str__(self) -> str:
        return self.summary()

    def __repr__(self) -> str:
        return (
            f"<FitReport {self.confidence} confidence · quality {self.quality:.2f}"
            f" · {self.n_sets} sets>"
        )


# confidence-scoring constants (heuristic, tunable)
_N_BASE = {2: 0.85, 3: 0.93}  # more sets -> more trust; 4+ -> 1.0
_FULL_SPREAD = 1.25  # a 25% load spread earns full marks for spread
_OUTLIER_REPS = 3.0  # a point >3 reps off the trend is flagged
_HIGH, _MED = 0.70, 0.40  # confidence thresholds


def _find_outliers(
    points: List[Tuple[float, float]],
) -> Tuple[List[Tuple[float, float, float]], List[Tuple[float, float]]]:
    """Flag sets that don't fit the trend, via leave-one-out residuals.

    A single gross outlier corrupts an ordinary least-squares fit enough that
    the *good* points look like the outliers. To avoid that, we refit on every
    n-1 subset, check the held-out point, and peel off the single worst-fitting
    set at a time until the remainder is internally consistent.

    Needs at least three sets to isolate an outlier. Returns
    (flagged, kept) where flagged is [(load, reps, residual), ...].
    """
    remaining = list(points)
    flagged: List[Tuple[float, float, float]] = []
    while len(remaining) >= 3:
        worst_i, worst_resid = None, 0.0
        for i in range(len(remaining)):
            others = remaining[:i] + remaining[i + 1 :]
            try:
                curve = LoadRepCurve.from_sets(others)
            except ValueError:
                continue  # the others alone don't fit; can't judge this point
            w, r = remaining[i]
            resid = r - curve.fresh_reps(w)
            if abs(resid) > abs(worst_resid):
                worst_i, worst_resid = i, resid
        if worst_i is None or abs(worst_resid) <= _OUTLIER_REPS:
            break
        w, r = remaining.pop(worst_i)
        flagged.append((w, r, worst_resid))
    return flagged, remaining


def _assess(
    points: List[Tuple[float, float]],
    curve: "LoadRepCurve",
    excluded: Tuple[Tuple[float, float, float], ...] = (),
) -> FitReport:
    """Score a fitted curve for confidence (outliers are handled before this)."""
    n = len(points)
    loads = [w for w, _ in points]
    spread = max(loads) / min(loads)

    resid = [(w, r, r - curve.fresh_reps(w)) for w, r in points]
    ys = [r for _, r in points]
    ybar = sum(ys) / n
    ss_tot = sum((y - ybar) ** 2 for y in ys)
    ss_res = sum(e**2 for *_, e in resid)
    r2 = None if (n < 3 or ss_tot == 0) else max(0.0, 1.0 - ss_res / ss_tot)

    n_base = _N_BASE.get(n, 1.0)
    spread_factor = (
        min(1.0, math.log(spread) / math.log(_FULL_SPREAD)) if spread > 1 else 0.0
    )
    fit_factor = r2 if r2 is not None else 1.0
    quality = round(n_base * spread_factor * fit_factor, 3)
    confidence = "high" if quality >= _HIGH else "medium" if quality >= _MED else "low"

    messages: List[str] = []
    for w, r, e in excluded:
        messages.append(
            f"Excluded the set at {w:g} lb × {r:g} ({e:+.1f} reps off trend)."
        )
    if spread < 1.12:
        messages.append(
            f"Loads are close together ({spread:.2f}×) — a notably heavier or "
            f"lighter all-out set would sharpen the estimate."
        )
    if confidence == "low" and not messages:
        messages.append(
            "Low-confidence fit — treat predictions as rough until you add data."
        )
    if not messages:
        messages.append(f"Looks solid ({confidence} confidence).")

    return FitReport(
        quality=quality,
        confidence=confidence,
        n_sets=n,
        load_spread=round(spread, 3),
        r_squared=(round(r2, 3) if r2 is not None else None),
        excluded=tuple(excluded),
        messages=tuple(messages),
    )


# --------------------------------------------------------------------------
# Lift
# --------------------------------------------------------------------------
@dataclass
class Lift:
    curve: "LoadRepCurve"
    name: str = "Lift"
    report: Optional["FitReport"] = None

    @property
    def one_rep_max(self) -> float:
        return self.curve.one_rep_max

    @property
    def is_bodyweight(self) -> bool:
        return self.curve.is_fixed

    def fresh_reps(self, weight: Optional[float] = None) -> float:
        return self.curve.fresh_reps(weight)

    @property
    def quality(self) -> Optional[float]:
        """Confidence score (0..1) of the calibration, or None if not assessed."""
        return self.report.quality if self.report else None

    @property
    def confidence(self) -> Optional[str]:
        """'high' / 'medium' / 'low', or None if not assessed."""
        return self.report.confidence if self.report else None

    def __repr__(self) -> str:
        if self.is_bodyweight:
            head = f"<Lift {self.name} · bodyweight"
        else:
            head = f"<Lift {self.name} · 1RM {round(self.one_rep_max)} lb"
        if self.confidence:
            head += f" · {self.confidence} confidence"
        return head + ">"


# --------------------------------------------------------------------------
# Calibration builder (any number of sets)
# --------------------------------------------------------------------------
class Calibration:
    """Collect all-out sets, then fit a lift with a confidence read.

    add_set returns self, so calls can be chained:
        lift = vita.Calibration("Bench").add_set(315, 5).add_set(275, 9).fit()
    """

    def __init__(self, name: str = "Lift"):
        self.name = name
        self._sets: List[Tuple[float, float]] = []

    def add_set(self, weight: float, reps: float) -> "Calibration":
        if weight <= 0 or reps <= 0:
            raise ValueError("weight and reps must be positive")
        self._sets.append((float(weight), float(reps)))
        return self

    @property
    def n_sets(self) -> int:
        return len(self._sets)

    def fit(self, strict: bool = True) -> Lift:
        """Fit the curve from all added sets.

        strict=True (default) raises if a set is flagged as an outlier;
        strict=False excludes the offending set(s) and fits on the rest.
        Impossible data (heavier set with more reps, or a single load) always
        raises.
        """
        if len(self._sets) < 2:
            raise ValueError(
                "add at least two sets before fitting "
                "(for a single bodyweight max, use calibrate_bodyweight)"
            )
        curve_all = LoadRepCurve.from_sets(
            self._sets
        )  # raises on contradictory / single-load
        flagged, kept = _find_outliers(self._sets)

        if flagged:
            detail = "; ".join(
                f"{w:g} lb × {r:g} ({e:+.1f} reps off)" for w, r, e in flagged
            )
            if strict:
                raise ValueError(
                    f"calibration has outlier set(s): {detail} — "
                    f"pass strict=False to fit without them, or remove the set"
                )
            if len(kept) >= 2:
                curve = LoadRepCurve.from_sets(kept)
                report = _assess(kept, curve, excluded=tuple(flagged))
                warnings.warn(
                    f"excluded outlier set(s): {detail}; fitted on the remaining "
                    f"{len(kept)} sets",
                    stacklevel=2,
                )
            else:
                curve = curve_all
                report = _assess(self._sets, curve, excluded=tuple(flagged))
                warnings.warn(
                    f"outlier set(s): {detail}; too few sets to exclude them, "
                    f"so the fit is unreliable",
                    stacklevel=2,
                )
        else:
            curve = curve_all
            report = _assess(self._sets, curve)

        if report.confidence == "low":
            warnings.warn(
                "low-confidence calibration: " + report.messages[0], stacklevel=2
            )

        return Lift(curve, self.name, report=report)

    def __repr__(self) -> str:
        return f"<Calibration {self.name} · {self.n_sets} sets added>"


# --------------------------------------------------------------------------
# convenience constructors
# --------------------------------------------------------------------------
def calibrate(
    set_a=None,
    set_b=None,
    *,
    weight1=None,
    reps1=None,
    weight2=None,
    reps2=None,
    name: str = "Lift",
) -> Lift:
    """Calibrate a weighted movement from two all-out sets.

    Two clear ways to call it:
        vita.calibrate(weight1=315, reps1=5, weight2=275, reps2=9, name="Bench")
        vita.calibrate((315, 5), (275, 9), name="Bench")

    Inconsistent data (the heavier set allowing more reps) raises. The returned
    Lift carries a .report / .quality / .confidence read on the calibration.
    """
    if set_a is not None and set_b is not None:
        (w1, r1), (w2, r2) = set_a, set_b
    elif None not in (weight1, reps1, weight2, reps2):
        w1, r1, w2, r2 = weight1, reps1, weight2, reps2
    else:
        raise ValueError("give two sets, e.g. weight1=, reps1=, weight2=, reps2=")

    pts = [(w1, r1), (w2, r2)]
    curve = LoadRepCurve.from_two_sets((w1, r1), (w2, r2))
    report = _assess(pts, curve)
    if report.confidence == "low":
        warnings.warn("low-confidence calibration: " + report.messages[0], stacklevel=2)
    return Lift(curve, name, report=report)


def calibrate_bodyweight(max_reps: float, name: str = "Bodyweight") -> Lift:
    """Calibrate a bodyweight movement from one measured fresh max.

    A single fixed-load max has no curve to score, so .quality is None.
    """
    return Lift(LoadRepCurve.fixed(max_reps), name)
