"""User profile and project configuration.

A single TOML file (default: `./openrun.toml`) describes the athlete's biometric
profile, configured HR zones, race calendar, and project paths. Loaded once per
process via `load_config()`.

Discovery order:
  1. `OPENRUN_CONFIG` env var (absolute path)
  2. `./openrun.toml` in the current working directory
  3. `~/.config/openrun/config.toml`
  4. Built-in defaults (Z1..Z5 from %HRmax with HRmax=200) — fine for getting started
     but the per-second time-in-zone and projected PMC are only as good as this file.

The profile is intentionally simple — a dataclass, not an ORM. Functions in
`openrun.model` that need user-specific numbers take a `UserProfile` argument
(usually defaulting to `default_profile()` which reads the current config).
"""

from __future__ import annotations

import os
import sys
from dataclasses import dataclass, field
from pathlib import Path

if sys.version_info >= (3, 11):
    import tomllib
else:  # pragma: no cover
    import tomli as tomllib


@dataclass(frozen=True)
class HRZones:
    """Inclusive [lo, hi] bpm bounds per zone, Z1..Z5."""
    z1: tuple[int, int]
    z2: tuple[int, int]
    z3: tuple[int, int]
    z4: tuple[int, int]
    z5: tuple[int, int]

    def as_tuples(self) -> tuple[tuple[str, int, int], ...]:
        return (
            ("Z1", self.z1[0], self.z1[1]),
            ("Z2", self.z2[0], self.z2[1]),
            ("Z3", self.z3[0], self.z3[1]),
            ("Z4", self.z4[0], self.z4[1]),
            ("Z5", self.z5[0], self.z5[1]),
        )

    @classmethod
    def from_pct_hr_max(cls, hr_max: int) -> "HRZones":
        """Generic %HRmax fallback (Karvonen-ish, not personalised)."""
        def band(lo_pct: float, hi_pct: float) -> tuple[int, int]:
            return int(round(lo_pct * hr_max)), int(round(hi_pct * hr_max))
        return cls(
            z1=band(0.50, 0.60),
            z2=band(0.60, 0.70),
            z3=band(0.70, 0.80),
            z4=band(0.80, 0.90),
            z5=band(0.90, 1.00),
        )


@dataclass(frozen=True)
class UserProfile:
    """One athlete's identity + biometric + zone config."""
    name: str = "Athlete"
    height_cm: float | None = None
    weight_kg: float | None = None
    hr_max: int = 200
    lthr: int | None = None
    resting_hr: int | None = None
    zones: HRZones | None = None  # if None, falls back to from_pct_hr_max(hr_max)

    def hr_zones(self) -> HRZones:
        return self.zones or HRZones.from_pct_hr_max(self.hr_max)


@dataclass(frozen=True)
class BanisterParams:
    ctl_tau_days: float = 42.0
    atl_tau_days: float = 7.0
    race_day_tl_per_km: float = 7.0  # ultra-paced races burn ~7 TL/km; easy runs ~11


@dataclass(frozen=True)
class Config:
    """Top-level project config."""
    user: UserProfile = field(default_factory=UserProfile)
    db_path: Path = field(default=Path("data/garmin.db"))
    banister: BanisterParams = field(default_factory=BanisterParams)
    # Optional race calendar — list of (label, ISO date).
    races: tuple[tuple[str, str], ...] = ()

    @property
    def race_dates(self) -> dict[str, str]:
        return dict(self.races)


def _walk_up_for(filename: str, start: Path | None = None) -> Path | None:
    """Walk up the directory tree from `start` (default cwd) looking for `filename`."""
    cur = (start or Path.cwd()).resolve()
    while True:
        candidate = cur / filename
        if candidate.is_file():
            return candidate
        if cur.parent == cur:
            return None
        cur = cur.parent


def _find_config() -> Path | None:
    env = os.environ.get("OPENRUN_CONFIG")
    if env:
        p = Path(env).expanduser()
        if p.is_file():
            return p
    found = _walk_up_for("openrun.toml")
    if found is not None:
        return found
    user_cfg = Path.home() / ".config" / "openrun" / "config.toml"
    if user_cfg.is_file():
        return user_cfg
    return None


def _zones_from_toml(d: dict) -> HRZones | None:
    if not d:
        return None
    def _pair(key: str) -> tuple[int, int]:
        v = d[key]
        return int(v[0]), int(v[1])
    try:
        return HRZones(
            z1=_pair("Z1"), z2=_pair("Z2"), z3=_pair("Z3"),
            z4=_pair("Z4"), z5=_pair("Z5"),
        )
    except (KeyError, TypeError, ValueError):
        return None


def load_config(path: Path | str | None = None) -> Config:
    """Load configuration. If `path` is None, walks the discovery list."""
    cfg_path = Path(path).expanduser() if path else _find_config()
    if cfg_path is None:
        return Config()

    with cfg_path.open("rb") as fh:
        data = tomllib.load(fh)

    base_dir = cfg_path.parent
    user_d = data.get("user", {}) or {}
    zones = _zones_from_toml(user_d.get("hr_zones") or data.get("hr_zones") or {})
    user = UserProfile(
        name=user_d.get("name", "Athlete"),
        height_cm=user_d.get("height_cm"),
        weight_kg=user_d.get("weight_kg"),
        hr_max=int(user_d.get("hr_max", 200)),
        lthr=user_d.get("lthr"),
        resting_hr=user_d.get("resting_hr"),
        zones=zones,
    )

    ban_d = data.get("banister", {}) or {}
    banister = BanisterParams(
        ctl_tau_days=float(ban_d.get("ctl_tau_days", 42.0)),
        atl_tau_days=float(ban_d.get("atl_tau_days", 7.0)),
        race_day_tl_per_km=float(ban_d.get("race_day_tl_per_km", 7.0)),
    )

    db_d = data.get("db", {}) or {}
    db_raw = db_d.get("path", "data/garmin.db")
    db_path = Path(db_raw)
    if not db_path.is_absolute():
        db_path = (base_dir / db_path).resolve()

    races_raw = data.get("races", []) or []
    races = tuple((str(r["label"]), str(r["date"])) for r in races_raw if "label" in r and "date" in r)

    return Config(user=user, db_path=db_path, banister=banister, races=races)


def _toml_quote(s: str) -> str:
    """Quote a TOML basic string. Escapes the few characters TOML cares about."""
    return '"' + s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + '"'


def write_config(config: Config, path: Path | str) -> None:
    """Serialise a Config to an `openrun.toml` file.

    Used by the first-run web wizard. The format is deliberately hand-rolled
    (no `tomli_w` dependency) — the schema is small and stable, and the file
    is human-edited often enough that we want full control over comments,
    section ordering, and quoting.
    """
    p = Path(path)
    user = config.user
    zones = user.hr_zones()
    ban = config.banister

    lines: list[str] = [
        "# openrun project config. Discovered by openrun.config.load_config().",
        "# Edit values here; nothing in src/openrun/ should be user-specific.",
        "",
        "[user]",
        f"name        = {_toml_quote(user.name)}",
    ]
    if user.height_cm is not None:
        lines.append(f"height_cm   = {user.height_cm}")
    if user.weight_kg is not None:
        lines.append(f"weight_kg   = {user.weight_kg}")
    lines.append(f"hr_max      = {user.hr_max}")
    if user.lthr is not None:
        lines.append(f"lthr        = {user.lthr}")
    if user.resting_hr is not None:
        lines.append(f"resting_hr  = {user.resting_hr}")

    lines += [
        "",
        "[user.hr_zones]",
        f"Z1 = [{zones.z1[0]}, {zones.z1[1]}]",
        f"Z2 = [{zones.z2[0]}, {zones.z2[1]}]",
        f"Z3 = [{zones.z3[0]}, {zones.z3[1]}]",
        f"Z4 = [{zones.z4[0]}, {zones.z4[1]}]",
        f"Z5 = [{zones.z5[0]}, {zones.z5[1]}]",
        "",
        "[db]",
        f"path = {_toml_quote(str(config.db_path))}",
        "",
        "[banister]",
        f"ctl_tau_days       = {ban.ctl_tau_days}",
        f"atl_tau_days       = {ban.atl_tau_days}",
        f"race_day_tl_per_km = {ban.race_day_tl_per_km}",
        "",
    ]

    if config.races:
        for label, when in config.races:
            lines += [
                "[[races]]",
                f"label = {_toml_quote(label)}",
                f"date  = {_toml_quote(when)}",
                "",
            ]

    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text("\n".join(lines))


# Cached default — load once per process; explicit overrides go through load_config().
_default: Config | None = None


def default_config() -> Config:
    global _default
    if _default is None:
        _default = load_config()
    return _default


def default_profile() -> UserProfile:
    return default_config().user


def reset_default_config() -> None:
    """Invalidate the process-wide cache so the next `default_config()` re-reads
    from disk. Called by the wizard after writing a fresh `openrun.toml`."""
    global _default
    _default = None
