"""Loaders + derived metrics — single-module home for everything notebooks consume.

Originally `analysis.py`. Moved into the `openrun` package as part of Phase 0.
Functions that need user-specific configuration (HR zones, race calendar) pick it
up from `openrun.config.default_config()` unless an explicit override is passed.
"""

from __future__ import annotations

import json
import sqlite3
from pathlib import Path

import pandas as pd

from .config import HRZones, UserProfile, default_config, default_profile
from .db import connect


# ---------------------------------------------------------------------------
# Convenience re-export — most notebooks just want `open_conn()`.
# ---------------------------------------------------------------------------

def open_conn(db_path: Path | str | None = None) -> sqlite3.Connection:
    """Open (and create-if-missing) the SQLite DB. Defaults to config path."""
    return connect(db_path)


# ---------------------------------------------------------------------------
# activities
# ---------------------------------------------------------------------------

def load_activities(conn: sqlite3.Connection, *, type: str | None = None) -> pd.DataFrame:
    """Activities with derived: distance_km, duration_min, pace_min_per_km, week, month, year."""
    sql = """
        SELECT activity_id, start_time_local, activity_type, activity_name,
               distance_m, duration_s, moving_duration_s,
               avg_speed_mps, max_speed_mps, avg_hr, max_hr, calories,
               elevation_gain_m, elevation_loss_m,
               training_load, aerobic_te, anaerobic_te, vo2_max,
               COALESCE(
                   json_extract(raw, '$.summaryDTO.averageRunCadence'),
                   json_extract(raw, '$.avgDoubleCadence')
               ) AS avg_cadence_spm
        FROM activities
    """
    if type:
        sql += " WHERE activity_type = ?"
        df = pd.read_sql(sql, conn, params=[type], parse_dates=["start_time_local"])
    else:
        df = pd.read_sql(sql, conn, parse_dates=["start_time_local"])

    df["distance_km"] = df["distance_m"] / 1000
    df["duration_min"] = df["duration_s"] / 60
    df["moving_min"] = df["moving_duration_s"] / 60
    df["pace_min_per_km"] = df["moving_min"] / df["distance_km"]
    impossible = (df["distance_km"] < 0.2) | (df["pace_min_per_km"] < 3) | (df["pace_min_per_km"] > 30)
    df.loc[impossible, "pace_min_per_km"] = pd.NA
    df["date"] = df["start_time_local"].dt.normalize()
    df["week"] = df["start_time_local"].dt.to_period("W").dt.start_time
    df["month"] = df["start_time_local"].dt.to_period("M").dt.to_timestamp()
    df["year"] = df["start_time_local"].dt.year
    return df.sort_values("start_time_local").reset_index(drop=True)


# ---------------------------------------------------------------------------
# wellness
# ---------------------------------------------------------------------------

def load_wellness(conn: sqlite3.Connection) -> pd.DataFrame:
    """Joined daily wellness frame indexed by calendar_date (datetime)."""
    df = pd.read_sql(
        """
        SELECT s.calendar_date,
               s.total_steps,
               sl.sleep_score,
               sl.deep_s, sl.light_s, sl.rem_s, sl.awake_s,
               st.avg_stress,
               h.last_night_avg AS hrv_last_night,
               h.weekly_avg     AS hrv_weekly,
               h.status         AS hrv_status,
               im.moderate_minutes,
               im.vigorous_minutes,
               rh.resting_hr,
               bb.charged       AS bb_charged,
               bb.drained       AS bb_drained,
               bb.highest       AS bb_highest,
               bb.lowest        AS bb_lowest
        FROM daily_steps s
        LEFT JOIN daily_sleep              sl ON sl.calendar_date = s.calendar_date
        LEFT JOIN daily_stress             st ON st.calendar_date = s.calendar_date
        LEFT JOIN daily_hrv                h  ON h.calendar_date  = s.calendar_date
        LEFT JOIN daily_intensity_minutes  im ON im.calendar_date = s.calendar_date
        LEFT JOIN daily_resting_hr         rh ON rh.calendar_date = s.calendar_date
        LEFT JOIN daily_body_battery       bb ON bb.calendar_date = s.calendar_date
        ORDER BY s.calendar_date
        """,
        conn,
        parse_dates=["calendar_date"],
    ).set_index("calendar_date")
    df["sleep_total_s"] = df[["deep_s", "light_s", "rem_s"]].sum(axis=1, min_count=1)
    df["sleep_hours"] = df["sleep_total_s"] / 3600
    df["deep_pct"] = df["deep_s"] / df["sleep_total_s"]
    df["rem_pct"] = df["rem_s"] / df["sleep_total_s"]
    return df


def load_sleep_stages(conn: sqlite3.Connection) -> pd.DataFrame:
    """Per-night sleep-stage breakdown from `daily_sleep`.

    Returns a DataFrame indexed by `calendar_date` (datetime) with columns:
        deep_s, light_s, rem_s, awake_s, sleep_score,
        sleep_total_s, sleep_hours,
        deep_pct, light_pct, rem_pct  (the non-NaN ones sum to 1.0),
        awake_pct (awake / total-time-in-bed; counts awake_s separately).

    Nights with no asleep time (deep+light+rem == 0) yield NaN percentages.
    Nights where one stage is NULL upstream (e.g. older Garmin firmware) leave
    that stage's pct as NaN; the remaining present-stage pcts still sum to 1.
    """
    df = pd.read_sql(
        """
        SELECT calendar_date, deep_s, light_s, rem_s, awake_s, sleep_score
        FROM daily_sleep
        ORDER BY calendar_date
        """,
        conn,
        parse_dates=["calendar_date"],
    ).set_index("calendar_date")

    df["sleep_total_s"] = df[["deep_s", "light_s", "rem_s"]].sum(axis=1, min_count=1)
    df["sleep_hours"] = df["sleep_total_s"] / 3600

    # Percent of *asleep* time spent in each sleep stage — these three sum to 1
    # for any night with positive sleep_total_s.
    df["deep_pct"] = df["deep_s"] / df["sleep_total_s"]
    df["light_pct"] = df["light_s"] / df["sleep_total_s"]
    df["rem_pct"] = df["rem_s"] / df["sleep_total_s"]

    # Awake is a fourth bucket — fraction of *time in bed* spent awake.
    time_in_bed = df["sleep_total_s"] + df["awake_s"]
    df["awake_pct"] = df["awake_s"] / time_in_bed

    return df


def daily_training_load(conn: sqlite3.Connection) -> pd.DataFrame:
    """Sum training load + distance per calendar date (any activity type)."""
    acts = load_activities(conn)
    daily = (
        acts.groupby("date")
        .agg(
            training_load=("training_load", "sum"),
            distance_km=("distance_km", "sum"),
            duration_min=("duration_min", "sum"),
            n_activities=("activity_id", "count"),
            avg_hr_weighted=("avg_hr", "mean"),
        )
    )
    daily.index = pd.to_datetime(daily.index)
    return daily


def joined(conn: sqlite3.Connection) -> pd.DataFrame:
    """Wellness joined with same-day and previous-day training load."""
    wellness = load_wellness(conn)
    tl = daily_training_load(conn)
    df = wellness.join(tl, how="left")
    df[["training_load", "distance_km", "duration_min", "n_activities"]] = (
        df[["training_load", "distance_km", "duration_min", "n_activities"]].fillna(0)
    )
    df["training_load_prev"] = df["training_load"].shift(1)
    df["distance_km_prev"] = df["distance_km"].shift(1)
    return df


def expand_raw(df: pd.DataFrame, raw_col: str = "raw") -> pd.DataFrame:
    """For a frame with a `raw` JSON column, return a normalized companion frame."""
    if raw_col not in df.columns:
        raise KeyError(f"no '{raw_col}' column in frame")
    return pd.json_normalize([json.loads(r) for r in df[raw_col]])


# ---------------------------------------------------------------------------
# splits — per-lap data with cadence, stride, GPS, etc. extracted from raw JSON
# ---------------------------------------------------------------------------

_SPLIT_RAW_FIELDS = (
    "averageRunCadence",
    "maxRunCadence",
    "strideLength",
    "verticalOscillation",
    "verticalRatio",
    "groundContactTime",
    "averagePower",
    "normalizedPower",
    "startLatitude",
    "startLongitude",
    "endLatitude",
    "endLongitude",
    "avgGradeAdjustedSpeed",
    "maxHR",
    "elevationGain",
    "elevationLoss",
)


def load_splits(conn: sqlite3.Connection, *, activity_type: str | None = "running") -> pd.DataFrame:
    """Per-split frame with rich fields expanded from raw JSON, joined to activity start time."""
    sql = """
        SELECT s.activity_id, s.split_index, s.distance_m, s.duration_s,
               s.avg_hr, s.avg_speed_mps, s.elevation_gain_m AS split_elev_gain_m,
               s.raw, a.start_time_local, a.activity_type
        FROM activity_splits s
        JOIN activities a ON a.activity_id = s.activity_id
    """
    params: list = []
    if activity_type:
        sql += " WHERE a.activity_type = ?"
        params.append(activity_type)
    sql += " ORDER BY s.activity_id, s.split_index"
    df = pd.read_sql(sql, conn, params=params, parse_dates=["start_time_local"])

    raws = [json.loads(r) if r else {} for r in df["raw"]]
    for k in _SPLIT_RAW_FIELDS:
        df[k] = [r.get(k) for r in raws]
    df = df.drop(columns=["raw"])

    df["pace_min_per_km"] = (df["duration_s"] / 60) / (df["distance_m"] / 1000)
    df["pace_min_per_mile"] = (df["duration_s"] / 60) / (df["distance_m"] / 1609.344)
    df["speed_kmh"] = df["avg_speed_mps"] * 3.6

    bad = (
        df["distance_m"].lt(200)
        | df["avg_hr"].isna()
        | df["avg_hr"].lt(60)
        | df["pace_min_per_km"].gt(30)
        | df["pace_min_per_km"].lt(2.5)
    )
    df = df.loc[~bad].copy()

    df["split_seq"] = df.groupby("activity_id").cumcount()
    df["n_splits"] = df.groupby("activity_id")["activity_id"].transform("count")
    denom = (df["n_splits"] - 1).replace(0, pd.NA)
    df["frac_through"] = df["split_seq"] / denom
    df["year"] = df["start_time_local"].dt.year
    df["month"] = df["start_time_local"].dt.to_period("M").dt.to_timestamp()
    return df.reset_index(drop=True)


def decoupling(splits: pd.DataFrame, min_splits: int = 6) -> pd.DataFrame:
    """Per-activity Pa:Hr decoupling using duration-weighted halves (split-level).

    See `fit_decoupling` for the per-second version (cleaner — excludes stopped time).
    """
    valid = splits[splits["n_splits"] >= min_splits].copy()
    valid["half"] = (valid["frac_through"] >= 0.5).map({False: "first", True: "second"})

    def _half_eff(d: pd.DataFrame) -> float:
        w = d["duration_s"].to_numpy()
        speed = (d["avg_speed_mps"].to_numpy() * w).sum() / w.sum()
        hr = (d["avg_hr"].to_numpy() * w).sum() / w.sum()
        return speed / hr if hr else float("nan")

    eff = (
        valid.groupby(["activity_id", "half"])[["avg_speed_mps", "avg_hr", "duration_s"]]
        .apply(_half_eff)
        .unstack("half")
    )
    eff["decoupling_pct"] = (eff["first"] / eff["second"] - 1) * 100
    eff = eff.dropna(subset=["decoupling_pct"])

    meta = valid.groupby("activity_id").agg(
        start_time_local=("start_time_local", "first"),
        distance_km=("distance_m", lambda s: s.sum() / 1000),
        duration_min=("duration_s", lambda s: s.sum() / 60),
        avg_hr=("avg_hr", "mean"),
        avg_pace_min_per_km=("pace_min_per_km", "mean"),
        n_splits=("n_splits", "first"),
    )
    out = eff.join(meta).reset_index()
    out["year"] = out["start_time_local"].dt.year
    return out


# ---------------------------------------------------------------------------
# HR zones — bound to the loaded user profile by default
# ---------------------------------------------------------------------------

ZoneTuples = tuple[tuple[str, int, int], ...]


def _resolve_zones(zones: ZoneTuples | HRZones | UserProfile | None) -> ZoneTuples:
    """Accept any of: explicit tuples, HRZones, UserProfile, or None (= config default)."""
    if zones is None:
        return default_profile().hr_zones().as_tuples()
    if isinstance(zones, UserProfile):
        return zones.hr_zones().as_tuples()
    if isinstance(zones, HRZones):
        return zones.as_tuples()
    return zones


# Legacy %HRmax helper — preserved for callers who want the generic Karvonen-ish bands.
_HR_ZONE_BOUNDS_PCT = (0.50, 0.60, 0.70, 0.80, 0.90, 1.01)
_HR_ZONE_LABELS = ("Z1", "Z2", "Z3", "Z4", "Z5")


def assign_hr_zone(hr: float, hr_max: float) -> str | None:
    """Generic %HRmax bands (Z1=50–60%, Z2=60–70%, …, Z5=90–100%). Prefer hr_to_user_zone."""
    if hr is None or pd.isna(hr) or not hr_max:
        return None
    frac = hr / hr_max
    for lo, hi, lab in zip(_HR_ZONE_BOUNDS_PCT[:-1], _HR_ZONE_BOUNDS_PCT[1:], _HR_ZONE_LABELS):
        if lo <= frac < hi:
            return lab
    return "Z5" if frac >= _HR_ZONE_BOUNDS_PCT[-2] else "Z1"


def hr_to_user_zone(
    hr: float,
    zones: ZoneTuples | HRZones | UserProfile | None = None,
) -> str | None:
    """Map a single HR reading to its configured zone label (Z1..Z5).

    Below Z1 floor → None (warmup / walking).
    Above Z5 ceiling → still Z5 (rare, edge of effort).
    """
    if hr is None or pd.isna(hr):
        return None
    z = _resolve_zones(zones)
    if hr < z[0][1]:
        return None
    for label, lo, hi in z:
        if lo <= hr <= hi:
            return label
    return z[-1][0]


def time_in_zone_from_fit(
    records: pd.DataFrame,
    zones: ZoneTuples | HRZones | UserProfile | None = None,
) -> dict[str, float]:
    """Per-second time-in-zone (seconds) from a FIT records frame.

    Each record contributes `elapsed_s - prev_elapsed_s` to whichever zone its HR
    falls in. Large gaps (>30 s, e.g. a paused recording) are clipped to 30 s.
    """
    if records is None or records.empty or "heart_rate" not in records:
        return {}
    r = records.dropna(subset=["heart_rate", "elapsed_s"]).copy()
    if r.empty:
        return {}
    z = _resolve_zones(zones)
    r["dt"] = r["elapsed_s"].diff().fillna(1.0).clip(lower=0, upper=30.0)
    r["zone"] = r["heart_rate"].apply(lambda h: hr_to_user_zone(h, z))
    return r.dropna(subset=["zone"]).groupby("zone")["dt"].sum().to_dict()


def time_in_zone_from_splits(
    splits_df: pd.DataFrame,
    zones: ZoneTuples | HRZones | UserProfile | None = None,
) -> dict[str, float]:
    """Fallback when there's no FIT — assign each split's avg HR to one zone.

    Biased toward whichever zone the lap-average falls in; FIT version is the
    ground truth where available.
    """
    if splits_df is None or splits_df.empty:
        return {}
    z = _resolve_zones(zones)
    s = splits_df.dropna(subset=["avg_hr", "duration_s"]).copy()
    s["zone"] = s["avg_hr"].apply(lambda h: hr_to_user_zone(h, z))
    return s.dropna(subset=["zone"]).groupby("zone")["duration_s"].sum().to_dict()


def weekly_time_in_zone(
    conn: sqlite3.Connection,
    *,
    start: str | pd.Timestamp | None = None,
    end: str | pd.Timestamp | None = None,
    activity_types: tuple[str, ...] = ("running", "trail_running"),
) -> pd.DataFrame:
    """ISO-week pivot of the cached `activity_time_in_zone` table.

    Returns a DataFrame indexed by the week's Monday with columns
    z1_s..z5_s, total_s, n_activities. Only activities whose
    `activity_type` is in `activity_types` are included; pass `()` to skip
    the type filter entirely.

    `start` / `end` filter on `activities.start_time_local` (inclusive).
    Empty input returns an empty frame with the same columns.
    """
    sql = """
        SELECT a.start_time_local AS d,
               tiz.z1_s, tiz.z2_s, tiz.z3_s, tiz.z4_s, tiz.z5_s, tiz.total_s
        FROM activity_time_in_zone AS tiz
        JOIN activities AS a USING(activity_id)
    """
    where: list[str] = []
    params: list = []
    if activity_types:
        where.append(f"a.activity_type IN ({','.join(['?'] * len(activity_types))})")
        params.extend(activity_types)
    if start is not None:
        where.append("a.start_time_local >= ?")
        params.append(str(pd.Timestamp(start)))
    if end is not None:
        where.append("a.start_time_local <= ?")
        params.append(str(pd.Timestamp(end)))
    if where:
        sql += " WHERE " + " AND ".join(where)

    df = pd.read_sql(sql, conn, params=params, parse_dates=["d"])
    cols = ["z1_s", "z2_s", "z3_s", "z4_s", "z5_s", "total_s", "n_activities"]
    if df.empty:
        return pd.DataFrame(columns=cols)

    # Week label = Monday of the ISO week (calendar week starting Monday).
    df["week"] = df["d"].dt.to_period("W-SUN").dt.start_time
    df["n_activities"] = 1
    agg = df.groupby("week")[["z1_s", "z2_s", "z3_s", "z4_s", "z5_s", "total_s", "n_activities"]].sum()
    return agg.sort_index()


# Backwards-compat alias — old notebooks reference HR_ZONES_USER directly.
# Resolves lazily via the config default.
def __getattr__(name: str):
    if name == "HR_ZONES_USER":
        return default_profile().hr_zones().as_tuples()
    raise AttributeError(f"module {__name__!r} has no attribute {name!r}")


# ---------------------------------------------------------------------------
# Banister fitness/fatigue/form
# ---------------------------------------------------------------------------

def banister(
    daily_load: pd.Series,
    *,
    ctl_tau: float | None = None,
    atl_tau: float | None = None,
    start_date: str | pd.Timestamp | None = None,
    end_date: str | pd.Timestamp | None = None,
) -> pd.DataFrame:
    """Banister fitness/fatigue/form (CTL/ATL/TSB) from a daily training-load series.

    Time constants default to config (`banister.ctl_tau_days` / `atl_tau_days`).

    Returns a frame indexed by date with columns CTL, ATL, TSB.
    """
    import numpy as np

    if daily_load.empty:
        return pd.DataFrame(columns=["CTL", "ATL", "TSB"])

    ban = default_config().banister
    if ctl_tau is None:
        ctl_tau = ban.ctl_tau_days
    if atl_tau is None:
        atl_tau = ban.atl_tau_days

    idx = pd.to_datetime(daily_load.index).normalize()
    s = pd.Series(daily_load.values, index=idx).groupby(level=0).sum()

    lo = pd.Timestamp(start_date) if start_date else s.index.min()
    hi = pd.Timestamp(end_date) if end_date else s.index.max()
    full = s.reindex(pd.date_range(lo, hi, freq="D"), fill_value=0.0)

    decay_ctl, decay_atl = np.exp(-1 / ctl_tau), np.exp(-1 / atl_tau)
    w_ctl, w_atl = 1 - decay_ctl, 1 - decay_atl

    n = len(full)
    ctl = np.zeros(n)
    atl = np.zeros(n)
    loads = full.to_numpy()
    for i in range(n):
        prev_ctl = ctl[i - 1] if i else 0.0
        prev_atl = atl[i - 1] if i else 0.0
        ctl[i] = prev_ctl * decay_ctl + loads[i] * w_ctl
        atl[i] = prev_atl * decay_atl + loads[i] * w_atl

    out = pd.DataFrame({"CTL": ctl, "ATL": atl}, index=full.index)
    out["TSB"] = out["CTL"].shift(1) - out["ATL"].shift(1)
    return out


def banister_forecast(
    history: pd.Series,
    future: pd.Series,
    *,
    today: pd.Timestamp | str | None = None,
    ctl_tau: float | None = None,
    atl_tau: float | None = None,
) -> pd.DataFrame:
    """Splice historical daily training load with a planned future load schedule,
    then run `banister` over the combined series.

    `today` is the boundary: history is kept through and including `today`,
    future is kept strictly after. When `today` is None it defaults to the last
    day in `history`. Index of either series may be `DatetimeIndex` or any
    date-like values that `pd.to_datetime` accepts.

    The combined series feeds the same recursion as `banister`, so the PMC at
    any historical date matches what `banister(history)` would have produced.
    """
    today_ts = (
        pd.Timestamp(today).normalize()
        if today is not None
        else pd.to_datetime(history.index).normalize().max()
    )

    hist_idx = pd.to_datetime(history.index).normalize()
    fut_idx = pd.to_datetime(future.index).normalize()
    hist = pd.Series(history.values, index=hist_idx)
    fut = pd.Series(future.values, index=fut_idx)

    hist_part = hist[hist.index <= today_ts]
    fut_part = fut[fut.index > today_ts]

    combined = pd.concat([hist_part, fut_part]).sort_index()
    # Defensive de-dup if history/future overlap with same dates somehow slipped
    # past the boundary check (e.g. duplicate index entries in input).
    combined = combined[~combined.index.duplicated(keep="first")]

    return banister(combined, ctl_tau=ctl_tau, atl_tau=atl_tau)


def personal_records(
    activities: pd.DataFrame,
    *,
    distance_bins_km: tuple[float, ...] = (5.0, 10.0, 21.0975, 42.195, 50.0),
    tolerance: float = 0.05,
) -> pd.DataFrame:
    """Pick the fastest activity within ±`tolerance` of each bin distance.

    Operates on the DataFrame `load_activities()` returns: requires
    `distance_km` and `pace_min_per_km`. Activities with NaN pace
    (masked by `load_activities` for implausible values) are ignored.

    Returns one row per bin (in the original bin order) with columns:
        bin_km, activity_id, start_time_local, distance_km, pace_min_per_km,
        moving_duration_s.
    Bins with no qualifying activity are omitted from the result.
    """
    if activities.empty or "pace_min_per_km" not in activities.columns:
        return pd.DataFrame(
            columns=[
                "bin_km", "activity_id", "start_time_local",
                "distance_km", "pace_min_per_km", "moving_duration_s",
            ]
        )

    df = activities.dropna(subset=["pace_min_per_km", "distance_km"])
    rows: list[dict] = []
    for bin_km in distance_bins_km:
        lo, hi = bin_km * (1 - tolerance), bin_km * (1 + tolerance)
        in_bin = df[(df["distance_km"] >= lo) & (df["distance_km"] <= hi)]
        if in_bin.empty:
            continue
        best = in_bin.loc[in_bin["pace_min_per_km"].idxmin()]
        rows.append({
            "bin_km": bin_km,
            "activity_id": int(best["activity_id"]),
            "start_time_local": best.get("start_time_local"),
            "distance_km": float(best["distance_km"]),
            "pace_min_per_km": float(best["pace_min_per_km"]),
            "moving_duration_s": float(best.get("moving_duration_s", float("nan"))),
        })
    return pd.DataFrame(rows)


def calibrate_tl_per_km(
    conn: sqlite3.Connection,
    *,
    activity_types: tuple[str, ...] = ("running", "trail_running"),
    min_distance_km: float = 2.0,
    lookback_days: int | None = 365,
) -> dict:
    """Empirical training-load-per-km ratio for converting plan-km → forecast load.

    Returns a dict with `median`, `q1`, `q3`, `n`. Designed to replace the
    hard-coded `RACE_DAY_TL_PER_KM` / training-run constant in race-plan flows:
    feed `median` into `banister_forecast`'s upstream load builder.

    Activities with NULL `training_load`, NULL `distance_m`, or distance below
    `min_distance_km` are excluded. `lookback_days=None` removes the recency
    filter and uses all history.
    """
    placeholders = ",".join(["?"] * len(activity_types))
    where = [
        f"activity_type IN ({placeholders})",
        "training_load IS NOT NULL",
        "distance_m IS NOT NULL",
        f"distance_m >= {min_distance_km * 1000}",
    ]
    params: list = list(activity_types)
    if lookback_days is not None:
        where.append(f"date(start_time_local) >= date('now', '-{lookback_days} days')")

    sql = f"""
        SELECT distance_m, training_load
        FROM activities
        WHERE {' AND '.join(where)}
    """
    df = pd.read_sql(sql, conn, params=params)
    if df.empty:
        return {"median": float("nan"), "q1": float("nan"), "q3": float("nan"), "n": 0}

    ratio = df["training_load"] / (df["distance_m"] / 1000.0)
    return {
        "median": float(ratio.median()),
        "q1": float(ratio.quantile(0.25)),
        "q3": float(ratio.quantile(0.75)),
        "n": int(len(ratio)),
    }


def plan_to_daily_load(
    plan: pd.DataFrame,
    *,
    tl_per_km: float,
    race_day_tl_per_km: float = 7.0,
    race_dates: tuple[str, ...] = (),
    weekday_weights: dict[int, float] | None = None,
) -> pd.Series:
    """Distribute weekly km from a race plan into a daily training-load Series.

    `plan` columns: `week_start` (date), `weekly_km`, `long_run_km`. Weekday
    weights default to long-run-Saturday-heavy (Sat 40 %, Tue 20 %, Thu 20 %,
    Mon 10 %, Wed 10 %); override `weekday_weights` if you train differently.

    For race weeks (a `race_dates` entry falls inside the week): the race day
    gets `long_run_km × race_day_tl_per_km`, remaining km distribute across
    Mon/Wed/Fri shakeouts at the training `tl_per_km`. Empirically, race-day
    TL/km is lower than training because long ultras spread load over more
    time — see `calibrate_tl_per_km` for the data-driven number.
    """
    if plan.empty:
        return pd.Series(dtype=float)

    weights = weekday_weights or {0: 0.10, 1: 0.20, 2: 0.10, 3: 0.20, 4: 0.0, 5: 0.40, 6: 0.0}
    race_day_set = {pd.Timestamp(d).normalize() for d in race_dates}

    rows: list[tuple[pd.Timestamp, float]] = []
    for _, week in plan.iterrows():
        week_start = pd.Timestamp(week["week_start"]).normalize()
        week_days = [week_start + pd.Timedelta(days=i) for i in range(7)]
        race_day = next((d for d in week_days if d in race_day_set), None)
        weekly_km = float(week.get("weekly_km") or 0)
        long_run_km = float(week.get("long_run_km") or 0)

        if race_day is not None:
            race_load = long_run_km * race_day_tl_per_km
            rem_km = max(weekly_km - long_run_km, 0.0)
            rem_load = rem_km * tl_per_km
            for d in week_days:
                if d == race_day:
                    rows.append((d, race_load))
                elif d.weekday() in (0, 2, 4):
                    rows.append((d, rem_load / 3))
                else:
                    rows.append((d, 0.0))
        else:
            total_load = weekly_km * tl_per_km
            for d in week_days:
                rows.append((d, total_load * weights.get(d.weekday(), 0.0)))

    s = pd.Series(dict(rows)).sort_index()
    s.index.name = "d"
    return s


def daily_training_load_series(
    conn: sqlite3.Connection,
    *,
    activity_types: tuple[str, ...] = ("running", "trail_running"),
    include_manual: bool = False,
) -> pd.Series:
    """Daily-summed training_load across the given activity types.

    When `include_manual=True`, also sums `manual_activities` rows whose
    `activity_type` is in `activity_types`. Manual rows let users surface
    off-watch training volume (strength, hikes, unrecorded runs) so the PMC
    reflects total training load, not just what Garmin recorded.
    """
    placeholders = ",".join(["?"] * len(activity_types))
    parts = [
        f"""SELECT date(start_time_local) AS d, SUM(training_load) AS tl
            FROM activities
            WHERE activity_type IN ({placeholders})
              AND training_load IS NOT NULL
            GROUP BY d"""
    ]
    params: list = list(activity_types)

    if include_manual:
        parts.append(
            f"""SELECT activity_date AS d, SUM(training_load) AS tl
                FROM manual_activities
                WHERE activity_type IN ({placeholders})
                  AND training_load IS NOT NULL
                GROUP BY d"""
        )
        params.extend(activity_types)

    sql = " UNION ALL ".join(parts)
    df = pd.read_sql(sql, conn, params=params, parse_dates=["d"])
    if df.empty:
        return pd.Series(dtype=float)
    return df.groupby("d")["tl"].sum().sort_index()


# ---------------------------------------------------------------------------
# FIT records
# ---------------------------------------------------------------------------

def _resolve_fit_path(stored_path: str) -> Path:
    """Return the FIT path stored in `activity_fit_files.fit_path`.

    Paths are stored absolute at link time. If the export directory has since
    moved, the FIT won't be at the recorded location — raise with a hint to
    run `openrun-link-fit <new_root> --relink` to rewrite the table.
    """
    p = Path(stored_path)
    if p.exists():
        return p
    raise FileNotFoundError(
        f"FIT file not at recorded path: {stored_path}\n"
        "If the export directory has moved, run:\n"
        "    openrun-link-fit <new_export_root> --relink"
    )


def load_fit_records(conn: sqlite3.Connection, activity_id: int) -> pd.DataFrame:
    """Per-second FIT `record` messages for one activity as a DataFrame.

    Columns (subset of what's present):
      timestamp (UTC), elapsed_s, heart_rate, speed_mps, distance_m, cadence_spm,
      altitude_m, power_w, position_lat_deg, position_long_deg,
      vertical_oscillation_mm, step_length_mm.
    """
    import fitparse

    row = conn.execute(
        "SELECT fit_path FROM activity_fit_files WHERE activity_id = ?", (activity_id,)
    ).fetchone()
    if row is None:
        raise ValueError(f"no FIT linked for activity {activity_id}")
    fit_file = _resolve_fit_path(row[0])

    fit = fitparse.FitFile(str(fit_file))
    rows: list[dict] = []
    for msg in fit.get_messages("record"):
        rows.append(msg.get_values())
    if not rows:
        return pd.DataFrame()

    df = pd.DataFrame(rows)
    out = pd.DataFrame()
    if "timestamp" in df.columns:
        out["timestamp"] = pd.to_datetime(df["timestamp"], utc=True)
        out["elapsed_s"] = (out["timestamp"] - out["timestamp"].iloc[0]).dt.total_seconds()
    out["heart_rate"] = df.get("heart_rate")
    out["speed_mps"] = df.get("enhanced_speed", df.get("speed"))
    out["distance_m"] = df.get("distance")
    # FIT "cadence" for running is single-leg rev/min (~80s); true steps/min
    # is (cadence + fractional_cadence) * 2.
    cad = df.get("cadence")
    if cad is not None:
        cad = cad.astype("float64")
        frac = df.get("fractional_cadence")
        if frac is not None:
            cad = cad + frac.astype("float64").fillna(0.0)
        cad = cad * 2.0
    out["cadence_spm"] = cad
    out["altitude_m"] = df.get("enhanced_altitude", df.get("altitude"))
    out["power_w"] = df.get("power")
    out["vertical_oscillation_mm"] = df.get("vertical_oscillation")
    out["step_length_mm"] = df.get("step_length")
    SEMI = 180.0 / (2 ** 31)
    if "position_lat" in df.columns:
        out["position_lat_deg"] = df["position_lat"] * SEMI
    if "position_long" in df.columns:
        out["position_long_deg"] = df["position_long"] * SEMI
    return out


def fit_decoupling(
    records: pd.DataFrame,
    *,
    segments: int = 2,
    warmup_min: float = 5.0,
    cooldown_min: float = 2.0,
    min_speed_mps: float = 0.5,
) -> pd.DataFrame:
    """Per-second Pa:Hr decoupling (Friel's method): chunks of moving-only time."""
    r = records.dropna(subset=["heart_rate", "speed_mps", "elapsed_s"]).copy()
    if r.empty:
        return pd.DataFrame()
    total = r["elapsed_s"].iloc[-1]
    r = r[(r["elapsed_s"] >= warmup_min * 60) & (r["elapsed_s"] <= total - cooldown_min * 60)]
    moving = r[r["speed_mps"] >= min_speed_mps].copy()
    if moving.empty:
        return pd.DataFrame()

    moving = moving.reset_index(drop=True)
    seg_size = len(moving) // segments
    out_rows: list[dict] = []
    for i in range(segments):
        s = i * seg_size
        e = (i + 1) * seg_size if i < segments - 1 else len(moving)
        chunk = moving.iloc[s:e]
        speed = chunk["speed_mps"].mean()
        hr = chunk["heart_rate"].mean()
        out_rows.append(
            {
                "segment": i + 1,
                "from_min": chunk["elapsed_s"].iloc[0] / 60,
                "to_min": chunk["elapsed_s"].iloc[-1] / 60,
                "mean_speed_mps": speed,
                "mean_pace_min_per_km": (1 / speed) * 1000 / 60 if speed else float("nan"),
                "mean_hr": hr,
                "efficiency": speed / hr if hr else float("nan"),
            }
        )
    out = pd.DataFrame(out_rows)
    base = out["efficiency"].iloc[0]
    out["decoupling_pct"] = (base / out["efficiency"] - 1) * 100
    return out


def fit_rolling_efficiency(records: pd.DataFrame, window_s: int = 300) -> pd.DataFrame:
    """Rolling (smoothed) speed/HR over elapsed time, useful for drift plots."""
    r = records.dropna(subset=["heart_rate", "speed_mps", "elapsed_s"]).copy()
    if r.empty:
        return r
    r = r.set_index("elapsed_s")
    win = f"{window_s}s"
    r["_ts"] = pd.to_datetime(r.index, unit="s")
    r = r.set_index("_ts")
    rolled = pd.DataFrame(index=r.index)
    rolled["rolling_speed_mps"] = r["speed_mps"].rolling(win).mean()
    rolled["rolling_hr"] = r["heart_rate"].rolling(win).mean()
    rolled["rolling_efficiency"] = rolled["rolling_speed_mps"] / rolled["rolling_hr"]
    rolled["elapsed_min"] = (rolled.index - rolled.index[0]).total_seconds() / 60
    return rolled.reset_index(drop=True)


# ---------------------------------------------------------------------------
# Geo helpers + route clustering
# ---------------------------------------------------------------------------

def haversine_km(lat1, lon1, lat2, lon2):
    """Vectorised great-circle distance, kilometres. Inputs in degrees."""
    import numpy as np

    r = 6371.0
    lat1, lon1, lat2, lon2 = map(np.radians, (lat1, lon1, lat2, lon2))
    dlat = lat2 - lat1
    dlon = lon2 - lon1
    a = np.sin(dlat / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2) ** 2
    return 2 * r * np.arcsin(np.sqrt(a))


def cluster_routes(lats, lons, radius_km: float = 0.25):
    """Greedy haversine-radius clustering of run start points.

    Returns an integer label array; -1 means unclustered (no neighbours within radius).
    """
    import numpy as np

    lats = np.asarray(lats, dtype=float)
    lons = np.asarray(lons, dtype=float)
    n = len(lats)
    labels = np.full(n, -1, dtype=int)
    next_label = 0
    for i in range(n):
        if labels[i] != -1:
            continue
        d = haversine_km(lats[i], lons[i], lats, lons)
        neigh = np.where((d <= radius_km) & (labels == -1))[0]
        if len(neigh) >= 2:
            labels[neigh] = next_label
            next_label += 1
    return labels
