"""Sync Garmin Connect data to local SQLite (Path B — live API via garth).

Usage:
    python -m openrun.ingest.garmin_api                # incremental: last 14 days wellness, new activities only
    python -m openrun.ingest.garmin_api --full         # full backfill (activities + 365 days wellness)
    python -m openrun.ingest.garmin_api --days 90      # custom wellness backfill window
    python -m openrun.ingest.garmin_api --no-details   # skip per-activity detail/splits (faster)
    python -m openrun.ingest.garmin_api --no-fit       # skip per-second FIT download for new activities
    python -m openrun.ingest.garmin_api --fit-backfill # download+link FITs for past activities missing them
    python -m openrun.ingest.garmin_api --skip-activities  # wellness only

Run from the project root. Run `openrun.ingest.auth` first to log in.
"""

from __future__ import annotations

import argparse
import dataclasses
import io
import json
import sqlite3
import sys
import time
import zipfile
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Any, Callable

import garth
from garth.exc import GarthHTTPError

from ..config import default_config
from ..db import connect, get_state, set_state
from .fit_linker import record_link


def _token_dir() -> Path:
    return Path.cwd() / ".secrets"


def _fit_dir() -> Path:
    """Managed directory for FITs pulled via the API, alongside the DB file."""
    d = default_config().db_path.parent / "fit"
    d.mkdir(parents=True, exist_ok=True)
    return d


# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------

def _to_jsonable(obj: Any) -> Any:
    """Recursively convert dataclasses/datetimes/dates to JSON-safe values."""
    if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
        return {f.name: _to_jsonable(getattr(obj, f.name)) for f in dataclasses.fields(obj)}
    if isinstance(obj, (datetime, date)):
        return obj.isoformat()
    if isinstance(obj, dict):
        return {k: _to_jsonable(v) for k, v in obj.items()}
    if isinstance(obj, (list, tuple)):
        return [_to_jsonable(v) for v in obj]
    return obj


def _dump(obj: Any) -> str:
    return json.dumps(_to_jsonable(obj), separators=(",", ":"), default=str)


def _safe_call(fn: Callable[[], Any], description: str) -> Any:
    """Run an API call with one retry on transient errors; return None on failure."""
    for attempt in (1, 2):
        try:
            return fn()
        except GarthHTTPError as exc:
            status = getattr(getattr(exc, "error", None), "response", None)
            code = getattr(status, "status_code", None)
            if code == 404:
                return None
            if attempt == 1 and code in (429, 500, 502, 503, 504):
                print(f"  retry {description} ({code})", file=sys.stderr)
                time.sleep(2)
                continue
            print(f"  ! {description} failed: {exc}", file=sys.stderr)
            return None
        except Exception as exc:  # noqa: BLE001
            print(f"  ! {description} failed: {exc}", file=sys.stderr)
            return None
    return None


# ---------------------------------------------------------------------------
# activity sync
# ---------------------------------------------------------------------------

def sync_activities(
    conn: sqlite3.Connection,
    *,
    full: bool,
    fetch_details: bool,
    fetch_fit: bool = False,
    fit_dir: Path | None = None,
) -> int:
    existing_ids: set[int] = {
        row["activity_id"] for row in conn.execute("SELECT activity_id FROM activities")
    }
    if fetch_fit and fit_dir is None:
        fit_dir = _fit_dir()
    page_size = 100
    offset = 0
    inserted = 0
    detail_count = 0
    fit_count = 0

    while True:
        page = _safe_call(
            lambda: garth.connectapi(
                "/activitylist-service/activities/search/activities",
                params={"limit": page_size, "start": offset},
            ),
            f"activity list start={offset}",
        )
        if not page:
            break

        new_in_page = 0
        for raw in page:
            aid = raw.get("activityId")
            if aid is None:
                continue
            if aid in existing_ids and not full:
                continue

            new_in_page += 1
            existing_ids.add(aid)

            atype = raw.get("activityType") or {}
            conn.execute(
                """
                INSERT INTO activities (
                    activity_id, start_time_local, start_time_gmt, 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, raw, fetched_at
                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
                ON CONFLICT(activity_id) DO UPDATE SET
                    activity_name=excluded.activity_name,
                    distance_m=excluded.distance_m,
                    duration_s=excluded.duration_s,
                    raw=excluded.raw,
                    fetched_at=excluded.fetched_at
                """,
                (
                    aid,
                    raw.get("startTimeLocal"),
                    raw.get("startTimeGMT"),
                    atype.get("typeKey"),
                    raw.get("activityName"),
                    raw.get("distance"),
                    raw.get("duration"),
                    raw.get("movingDuration"),
                    raw.get("averageSpeed"),
                    raw.get("maxSpeed"),
                    raw.get("averageHR"),
                    raw.get("maxHR"),
                    raw.get("calories"),
                    raw.get("elevationGain"),
                    raw.get("elevationLoss"),
                    raw.get("activityTrainingLoad"),
                    raw.get("aerobicTrainingEffect"),
                    raw.get("anaerobicTrainingEffect"),
                    raw.get("vO2MaxValue"),
                    _dump(raw),
                ),
            )
            inserted += 1

            if fetch_details:
                if _fetch_activity_details(conn, aid):
                    detail_count += 1

            if fetch_fit:
                if download_fit(conn, aid, fit_dir=fit_dir):
                    fit_count += 1

        conn.commit()
        print(f"  activities: page offset={offset} → {new_in_page} new (total inserted: {inserted})")

        if not full and new_in_page == 0:
            break
        if len(page) < page_size:
            break
        offset += page_size

    if fetch_details:
        print(f"  fetched details/splits for {detail_count} activities")
    if fetch_fit:
        print(f"  downloaded per-second FIT for {fit_count} activities")
    return inserted


def _fetch_activity_details(conn: sqlite3.Connection, aid: int) -> bool:
    detail = _safe_call(
        lambda: garth.connectapi(f"/activity-service/activity/{aid}"),
        f"activity detail {aid}",
    )
    if detail:
        summary = detail.get("summaryDTO", {}) or {}
        conn.execute(
            """
            UPDATE activities SET
                training_load = COALESCE(?, training_load),
                aerobic_te = COALESCE(?, aerobic_te),
                anaerobic_te = COALESCE(?, anaerobic_te),
                vo2_max = COALESCE(?, vo2_max),
                raw = ?
            WHERE activity_id = ?
            """,
            (
                summary.get("activityTrainingLoad"),
                summary.get("trainingEffect"),
                summary.get("anaerobicTrainingEffect"),
                detail.get("vO2MaxValue") or summary.get("vO2MaxValue"),
                _dump(detail),
                aid,
            ),
        )

    splits = _safe_call(
        lambda: garth.connectapi(f"/activity-service/activity/{aid}/splits"),
        f"activity splits {aid}",
    )
    if splits and isinstance(splits, dict):
        lap_dtos = splits.get("lapDTOs", []) or []
        for idx, lap in enumerate(lap_dtos):
            conn.execute(
                """
                INSERT OR REPLACE INTO activity_splits
                  (activity_id, split_index, distance_m, duration_s, avg_hr, avg_speed_mps, elevation_gain_m, raw)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                """,
                (
                    aid,
                    idx,
                    lap.get("distance"),
                    lap.get("duration"),
                    lap.get("averageHR"),
                    lap.get("averageSpeed"),
                    lap.get("elevationGain"),
                    _dump(lap),
                ),
            )
    return detail is not None


# ---------------------------------------------------------------------------
# per-second FIT download (Path B per-second, no website export needed)
# ---------------------------------------------------------------------------

def _extract_fit_bytes(blob: bytes | None) -> bytes | None:
    """Pull the FIT payload out of a download-service response.

    Garmin's `/download-service/files/activity/{id}` returns a ZIP wrapping the
    original FIT; occasionally it hands back a bare FIT. Return the FIT bytes, or
    None if nothing FIT-like is present.
    """
    if not blob:
        return None
    buf = io.BytesIO(blob)
    if zipfile.is_zipfile(buf):
        with zipfile.ZipFile(buf) as zf:
            names = [n for n in zf.namelist() if n.lower().endswith(".fit")]
            if not names:  # member sometimes lacks an extension — take the largest
                names = sorted(
                    zf.namelist(), key=lambda n: zf.getinfo(n).file_size, reverse=True
                )[:1]
            return zf.read(names[0]) if names else None
    # Bare FIT: the FIT header carries the literal b".FIT" at bytes 8..12.
    if len(blob) > 12 and blob[8:12] == b".FIT":
        return blob
    return None


def download_fit(
    conn: sqlite3.Connection,
    activity_id: int,
    *,
    fit_dir: Path,
    force: bool = False,
) -> bool:
    """Download one activity's original FIT from the API, store it under
    `fit_dir/<activity_id>.fit`, and link it in `activity_fit_files`.

    Returns True if a linked FIT is present afterward. Idempotent: skips the
    network call when the FIT already exists on disk and is linked, unless
    `force`. Everything downstream (decoupling, time-in-zone, route map) reads
    these the same way it reads export-sourced FITs.
    """
    dest = fit_dir / f"{activity_id}.fit"
    linked = conn.execute(
        "SELECT 1 FROM activity_fit_files WHERE activity_id = ? LIMIT 1", (activity_id,)
    ).fetchone() is not None
    if dest.exists() and linked and not force:
        return True

    blob = _safe_call(
        lambda: garth.download(f"/download-service/files/activity/{activity_id}"),
        f"FIT download {activity_id}",
    )
    fit = _extract_fit_bytes(blob)
    if fit is None:
        return False
    dest.write_bytes(fit)
    record_link(conn, activity_id, dest)
    return True


def backfill_fits(
    conn: sqlite3.Connection,
    *,
    fit_dir: Path,
    activity_type: str | None = None,
    limit: int | None = None,
    force: bool = False,
) -> int:
    """Download+link FITs for already-synced activities that lack one.

    Lets you pull per-second history straight from the API instead of requesting
    a full website export. Restrict with `activity_type` (e.g. "running") and
    `limit` to keep the request volume sane.
    """
    sql = "SELECT activity_id FROM activities a"
    params: list = []
    where: list[str] = []
    if activity_type:
        where.append("a.activity_type = ?")
        params.append(activity_type)
    if not force:
        where.append("a.activity_id NOT IN (SELECT activity_id FROM activity_fit_files)")
    if where:
        sql += " WHERE " + " AND ".join(where)
    sql += " ORDER BY a.start_time_local DESC"
    if limit:
        sql += f" LIMIT {int(limit)}"

    targets = [r[0] for r in conn.execute(sql, params)]
    print(f"  {len(targets):,} activities need a FIT"
          + (f" (type={activity_type})" if activity_type else ""))
    got = 0
    t0 = time.time()
    for i, aid in enumerate(targets, 1):
        if download_fit(conn, aid, fit_dir=fit_dir, force=force):
            got += 1
        if i % 25 == 0:
            conn.commit()
            rate = i / max(time.time() - t0, 1e-6)
            print(f"  {i}/{len(targets)}  linked={got}  rate={rate:.1f}/s")
    conn.commit()
    return got


# ---------------------------------------------------------------------------
# wellness sync
# ---------------------------------------------------------------------------

def sync_steps(conn: sqlite3.Connection, end: date, period: int) -> int:
    rows = _safe_call(lambda: garth.DailySteps.list(end=end, period=period), "DailySteps") or []
    for r in rows:
        conn.execute(
            """INSERT OR REPLACE INTO daily_steps
               (calendar_date, total_steps, step_goal, distance_m, raw, fetched_at)
               VALUES (?, ?, ?, ?, ?, datetime('now'))""",
            (r.calendar_date.isoformat(), r.total_steps, r.step_goal, r.total_distance, _dump(r)),
        )
    conn.commit()
    return len(rows)


def sync_sleep(conn: sqlite3.Connection, end: date, period: int) -> int:
    rows = _safe_call(lambda: garth.DailySleep.list(end=end, period=period), "DailySleep") or []
    for r in rows:
        conn.execute(
            """INSERT OR REPLACE INTO daily_sleep
               (calendar_date, sleep_score, raw, fetched_at)
               VALUES (?, ?, ?, datetime('now'))""",
            (r.calendar_date.isoformat(), r.value, _dump(r)),
        )
    conn.commit()
    return len(rows)


def sync_stress(conn: sqlite3.Connection, end: date, period: int) -> int:
    rows = _safe_call(lambda: garth.DailyStress.list(end=end, period=period), "DailyStress") or []
    for r in rows:
        conn.execute(
            """INSERT OR REPLACE INTO daily_stress
               (calendar_date, avg_stress, raw, fetched_at)
               VALUES (?, ?, ?, datetime('now'))""",
            (r.calendar_date.isoformat(), r.overall_stress_level, _dump(r)),
        )
    conn.commit()
    return len(rows)


def sync_hrv(conn: sqlite3.Connection, end: date, period: int) -> int:
    rows = _safe_call(lambda: garth.DailyHRV.list(end=end, period=period), "DailyHRV") or []
    for r in rows:
        conn.execute(
            """INSERT OR REPLACE INTO daily_hrv
               (calendar_date, weekly_avg, last_night_avg, last_night_5min, status, raw, fetched_at)
               VALUES (?, ?, ?, ?, ?, ?, datetime('now'))""",
            (
                r.calendar_date.isoformat(),
                r.weekly_avg,
                r.last_night_avg,
                r.last_night_5_min_high,
                r.status,
                _dump(r),
            ),
        )
    conn.commit()
    return len(rows)


def sync_intensity_minutes(conn: sqlite3.Connection, end: date, period: int) -> int:
    rows = _safe_call(
        lambda: garth.DailyIntensityMinutes.list(end=end, period=period),
        "DailyIntensityMinutes",
    ) or []
    for r in rows:
        conn.execute(
            """INSERT OR REPLACE INTO daily_intensity_minutes
               (calendar_date, moderate_minutes, vigorous_minutes, raw, fetched_at)
               VALUES (?, ?, ?, ?, datetime('now'))""",
            (r.calendar_date.isoformat(), r.moderate_value, r.vigorous_value, _dump(r)),
        )
    conn.commit()
    return len(rows)


def sync_body_battery(conn: sqlite3.Connection, end: date, period: int) -> int:
    inserted = 0
    for i in range(period):
        d = end - timedelta(days=i)
        existing = conn.execute(
            "SELECT 1 FROM daily_body_battery WHERE calendar_date = ?", (d.isoformat(),)
        ).fetchone()
        if existing and i > 2:
            continue

        data = _safe_call(
            lambda d=d: garth.connectapi(
                "/wellness-service/wellness/bodyBattery/reports/daily",
                params={"startDate": d.isoformat(), "endDate": d.isoformat()},
            ),
            f"body battery {d}",
        )
        if not data:
            continue

        item = data[0] if isinstance(data, list) and data else data
        if not isinstance(item, dict):
            continue
        conn.execute(
            """INSERT OR REPLACE INTO daily_body_battery
               (calendar_date, charged, drained, highest, lowest, raw, fetched_at)
               VALUES (?, ?, ?, ?, ?, ?, datetime('now'))""",
            (
                d.isoformat(),
                item.get("charged"),
                item.get("drained"),
                item.get("highestBatteryLevel") or item.get("highest"),
                item.get("lowestBatteryLevel") or item.get("lowest"),
                _dump(item),
            ),
        )
        inserted += 1
    conn.commit()
    return inserted


def sync_resting_hr(conn: sqlite3.Connection, end: date, period: int) -> int:
    inserted = 0
    for i in range(period):
        d = end - timedelta(days=i)
        if i > 2:
            existing = conn.execute(
                "SELECT 1 FROM daily_resting_hr WHERE calendar_date = ?", (d.isoformat(),)
            ).fetchone()
            if existing:
                continue

        data = _safe_call(
            lambda d=d: garth.connectapi(
                f"/usersummary-service/usersummary/daily/{garth.client.username}",
                params={"calendarDate": d.isoformat()},
            ),
            f"daily summary {d}",
        )
        if not isinstance(data, dict):
            continue
        rhr = data.get("restingHeartRate")
        if rhr is None:
            continue
        conn.execute(
            """INSERT OR REPLACE INTO daily_resting_hr
               (calendar_date, resting_hr, raw, fetched_at)
               VALUES (?, ?, ?, datetime('now'))""",
            (d.isoformat(), rhr, _dump(data)),
        )
        inserted += 1
    conn.commit()
    return inserted


# ---------------------------------------------------------------------------
# orchestration (shared by the CLI and the web app)
# ---------------------------------------------------------------------------

def run_sync(
    conn: sqlite3.Connection,
    *,
    full: bool = False,
    days: int | None = None,
    fetch_details: bool = True,
    fetch_fit: bool = True,
    skip_activities: bool = False,
    fit_backfill: bool = False,
    fit_type: str | None = None,
    fit_limit: int | None = None,
    progress: Callable[[str], None] | None = None,
) -> dict[str, int]:
    """Run a full incremental sync against an already-authenticated garth client.

    Auth is the caller's responsibility — call `garth.resume(...)` (or complete a
    login) first. Top-level step messages go to `progress` (defaults to `print`),
    so the web app can stream them into a status panel. Returns a counts summary.
    """
    say = progress or print
    days = days if days is not None else (365 if full else 14)
    end = date.today()
    summary: dict[str, int] = {}

    if not skip_activities:
        say(f"→ activities (full={full}, details={fetch_details}, fit={fetch_fit})")
        summary["activities"] = sync_activities(
            conn, full=full, fetch_details=fetch_details, fetch_fit=fetch_fit
        )
        say(f"  done: {summary['activities']} activities upserted")

    if fit_backfill:
        say(f"→ FIT backfill (type={fit_type or 'all'}, limit={fit_limit or 'none'})")
        summary["fit_backfill"] = backfill_fits(
            conn, fit_dir=_fit_dir(), activity_type=fit_type, limit=fit_limit
        )
        say(f"  done: {summary['fit_backfill']} FITs downloaded/linked")

    wellness = [
        ("steps", sync_steps),
        ("sleep score", sync_sleep),
        ("stress", sync_stress),
        ("HRV", sync_hrv),
        ("intensity minutes", sync_intensity_minutes),
        ("resting HR", sync_resting_hr),
        ("body battery", sync_body_battery),
    ]
    for name, fn in wellness:
        say(f"→ {name} (last {days} days)")
        n = fn(conn, end, days)
        summary[name] = n
        say(f"  done: {n} rows")

    set_state(conn, "last_sync_utc", datetime.utcnow().isoformat(timespec="seconds"))
    conn.commit()
    say("✓ sync complete")
    return summary


# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------

def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--full", action="store_true", help="Full backfill (365 days of wellness)")
    parser.add_argument("--days", type=int, default=None, help="Days of wellness to backfill")
    parser.add_argument("--no-details", action="store_true", help="Skip per-activity detail/splits")
    parser.add_argument("--no-fit", action="store_true",
                        help="Skip per-second FIT download for newly synced activities")
    parser.add_argument("--fit-backfill", action="store_true",
                        help="Download+link FITs for past activities that lack one, then exit the FIT pass")
    parser.add_argument("--fit-type", default=None,
                        help="Restrict --fit-backfill to one activity_type (e.g. running)")
    parser.add_argument("--fit-limit", type=int, default=None,
                        help="Cap how many activities --fit-backfill pulls (newest first)")
    parser.add_argument("--skip-activities", action="store_true",
                        help="Wellness only, no activity sync")
    parser.add_argument("--backend", choices=("auto", "garth", "garminconnect"),
                        default="auto",
                        help="Auth backend. 'auto' picks garminconnect when "
                             ".secrets/garmin_tokens.json exists, else garth.")
    args = parser.parse_args()

    tok = _token_dir()
    from . import garminconnect_backend

    backend = args.backend
    if backend == "auto":
        backend = "garminconnect" if garminconnect_backend.has_tokens(tok) else "garth"

    if backend == "garminconnect":
        user = garminconnect_backend.activate(tok)
        print(f"auth: garminconnect backend (user: {user})")
    else:
        if not tok.exists() or not any(tok.iterdir()):
            print(f"No tokens at {tok} — run `python -m openrun.ingest.auth` first.", file=sys.stderr)
            sys.exit(1)
        garth.resume(tok)

    conn = connect()
    try:
        run_sync(
            conn,
            full=args.full,
            days=args.days,
            fetch_details=not args.no_details,
            fetch_fit=not args.no_fit,
            skip_activities=args.skip_activities,
            fit_backfill=args.fit_backfill,
            fit_type=args.fit_type,
            fit_limit=args.fit_limit,
        )
    finally:
        conn.close()


if __name__ == "__main__":
    main()
