"""Match FIT files to activity rows by content (session.start_time).

The Garmin Connect data export embeds activity IDs in FIT filenames
(`<id>_<name>.fit`), so `ingest_export.py` can index them straight from the
filename. The newer Garmin **Takeout** dump uses upload IDs instead
(`<email>_<upload_id>.fit`), which are a different ID space, so filename-based
linking fails. This script does the linking via FIT content: it opens each FIT,
reads the `session.start_time` (UTC), and matches it against
`activities.start_time_gmt` within a small tolerance.

Stored paths are **absolute** (resolved at link time). If the export directory
is moved, run with `--relink <new_root>` to rewrite the table by basename.

Usage:
    uv run link_fit_files.py <export_root>
    uv run link_fit_files.py <export_root> --dry-run --min-size-kb 50
    uv run link_fit_files.py <new_root>   --relink

Defaults:
    Only parses FITs >= 50 KB (workout FITs; smaller files are HRV/sleep/body-battery
    monitoring snapshots, ~99% of the dump by count).
    Tolerance: 60 seconds. Garmin sometimes rounds session start to the second
    while activities.start_time_gmt is also second-precision.

Re-running is safe — the table is upsert by activity_id.
"""

from __future__ import annotations

import argparse
import sqlite3
import sys
from bisect import bisect_left
from datetime import datetime, timezone
from pathlib import Path
from typing import Iterable, Iterator

import fitparse

from ..db import connect


def _parse_session_start(fit_path: Path) -> datetime | None:
    """Return the session start_time as a UTC datetime, or None if the file has none."""
    try:
        fit = fitparse.FitFile(str(fit_path))
        for msg in fit.get_messages("session"):
            vals = msg.get_values()
            ts = vals.get("start_time")
            if ts is None:
                continue
            if isinstance(ts, datetime):
                # fitparse returns naive datetimes in UTC for FIT timestamps.
                return ts.replace(tzinfo=timezone.utc) if ts.tzinfo is None else ts.astimezone(timezone.utc)
        return None
    except Exception as exc:  # noqa: BLE001
        print(f"  ! parse failed for {fit_path.name}: {exc}", file=sys.stderr)
        return None


def _load_activity_index(conn: sqlite3.Connection) -> dict[int, int]:
    """Map (epoch_seconds_utc) -> activity_id for every activity with a start_time_gmt."""
    idx: dict[int, int] = {}
    for aid, gmt in conn.execute("SELECT activity_id, start_time_gmt FROM activities"):
        if gmt is None:
            continue
        # start_time_gmt may be an ISO string, ms epoch number, or sec epoch.
        try:
            if isinstance(gmt, (int, float)):
                v = float(gmt)
                # heuristic: >1e12 means ms epoch, otherwise seconds
                secs = int(v / 1000) if v > 1e12 else int(v)
            else:
                s = str(gmt).strip().rstrip("Z")
                secs = int(datetime.fromisoformat(s).replace(tzinfo=timezone.utc).timestamp())
        except (ValueError, TypeError):
            continue
        idx[secs] = aid
    return idx


def _match_activity(
    target_secs: int,
    sorted_keys: list[int],
    index: dict[int, int],
    tolerance_s: int,
) -> int | None:
    """Return the activity_id whose start_time_gmt is closest to `target_secs`,
    if within ±tolerance_s; otherwise None.

    `sorted_keys` is `sorted(index.keys())`; passing it in lets callers reuse it
    across many matches without re-sorting.
    """
    pos = bisect_left(sorted_keys, target_secs)
    near: list[int] = []
    if pos < len(sorted_keys):
        near.append(sorted_keys[pos])
    if pos > 0:
        near.append(sorted_keys[pos - 1])
    in_tolerance = [c for c in near if abs(c - target_secs) <= tolerance_s]
    if not in_tolerance:
        return None
    best = min(in_tolerance, key=lambda c: abs(c - target_secs))
    return index[best]


def _iter_fits_with_session_start(
    export_root: Path, *, min_size_kb: int
) -> Iterator[tuple[Path, datetime | None]]:
    """Yield (fit_path, session_start_utc) for every FIT under `export_root`
    that's at least `min_size_kb` in size. Yields `None` when parsing fails so
    callers can count parse failures separately from unmatched.
    """
    for p in export_root.rglob("*.fit"):
        if p.stat().st_size < min_size_kb * 1024:
            continue
        yield p, _parse_session_start(p)


def record_link(conn: sqlite3.Connection, activity_id: int, fit_path: Path) -> None:
    """Upsert `activity_fit_files` with an absolute path to the FIT.

    Pure helper — no commit, no I/O beyond `.resolve()`. Callers commit when
    they're done batching.
    """
    abs_path = str(Path(fit_path).resolve())
    conn.execute(
        """INSERT OR REPLACE INTO activity_fit_files (activity_id, fit_path, indexed_at)
           VALUES (?, ?, datetime('now'))""",
        (activity_id, abs_path),
    )


def relink(conn: sqlite3.Connection, new_root: Path) -> tuple[int, int]:
    """Rewrite `activity_fit_files.fit_path` after the export directory moved.

    Walks `new_root` for `*.fit`, builds a basename → absolute-path map, and
    updates every row whose current path's basename appears in that map.
    Returns (updated, unmatched) counts.
    """
    new_root = Path(new_root).resolve()
    if not new_root.is_dir():
        raise FileNotFoundError(f"relink root is not a directory: {new_root}")

    by_name: dict[str, Path] = {}
    for p in new_root.rglob("*.fit"):
        # Last-write-wins on duplicate basenames; in practice Garmin FIT names
        # are unique per dump (upload ID or activity ID embedded).
        by_name[p.name] = p.resolve()

    updated = unmatched = 0
    rows = conn.execute(
        "SELECT activity_id, fit_path FROM activity_fit_files"
    ).fetchall()
    for aid, old_path in rows:
        name = Path(old_path).name
        if name in by_name:
            record_link(conn, aid, by_name[name])
            updated += 1
        else:
            unmatched += 1
    conn.commit()
    return updated, unmatched


def link(
    export_root: Path,
    *,
    conn: sqlite3.Connection | None = None,
    dry_run: bool,
    min_size_kb: int,
    tolerance_s: int,
    fit_iter: Iterable[tuple[Path, datetime | None]] | None = None,
) -> dict[str, int]:
    """Match each FIT under `export_root` to an activity by session start time.

    Returns a summary dict: ``{"linked", "unmatched", "parse_failed", "collisions"}``.

    `fit_iter` is an optional injection point — tests pass a pre-built sequence
    of (path, session_start_utc | None) tuples to avoid the FIT-parsing path.
    When None, FITs are discovered under `export_root` and parsed.

    Collision behavior: if two FITs both match the *same* activity, the first
    wins; subsequent matches are logged + skipped, never silently overwritten.
    """
    if fit_iter is None and not export_root.exists():
        sys.exit(f"path does not exist: {export_root}")

    own_conn = conn is None
    if conn is None:
        conn = connect()
    index = _load_activity_index(conn)
    if not index:
        sys.exit("no activities with start_time_gmt — ingest activities first")
    sorted_keys = sorted(index.keys())
    print(f"loaded {len(index):,} activity start times")

    if fit_iter is None:
        fit_iter = _iter_fits_with_session_start(export_root, min_size_kb=min_size_kb)

    linked_aids: dict[int, Path] = {}
    linked = unmatched = parse_failed = collisions = 0
    for i, (p, start) in enumerate(fit_iter, 1):
        if i % 50 == 0:
            print(f"  … {i} processed (linked={linked}, unmatched={unmatched})")
        if start is None:
            parse_failed += 1
            continue
        aid = _match_activity(int(start.timestamp()), sorted_keys, index, tolerance_s)
        if aid is None:
            unmatched += 1
            continue
        if aid in linked_aids:
            print(
                f"  ! collision: FIT {p.name} matches activity {aid} already linked "
                f"to {linked_aids[aid].name}; skipping",
                file=sys.stderr,
            )
            collisions += 1
            continue
        linked_aids[aid] = p
        if not dry_run:
            record_link(conn, aid, p)
        linked += 1

    if not dry_run:
        conn.commit()
    if own_conn:
        conn.close()

    print()
    print("=== linker summary ===")
    print(f"  linked            : {linked:,}" + ("  (dry-run)" if dry_run else ""))
    print(f"  unmatched start   : {unmatched:,}  (no activity within ±{tolerance_s}s)")
    print(f"  collisions        : {collisions:,}  (two FITs matched the same activity)")
    print(f"  parse failures    : {parse_failed:,}")

    return {
        "linked": linked,
        "unmatched": unmatched,
        "parse_failed": parse_failed,
        "collisions": collisions,
    }


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("export_root", help="Path to the unzipped Garmin export folder (or new location, with --relink)")
    parser.add_argument("--dry-run", action="store_true")
    parser.add_argument("--min-size-kb", type=int, default=50,
                        help="Skip FITs smaller than this; default 50 KB filters out HRV/sleep snapshots")
    parser.add_argument("--tolerance-s", type=int, default=60,
                        help="Max seconds between FIT session start and activity start to count as a match")
    parser.add_argument("--relink", action="store_true",
                        help="Rewrite stored FIT paths to a new export root by basename; skips FIT parsing")
    args = parser.parse_args()

    root = Path(args.export_root).expanduser().resolve()
    if args.relink:
        conn = connect()
        try:
            updated, unmatched = relink(conn, root)
        finally:
            conn.close()
        print(f"=== relink summary ===\n  updated   : {updated:,}\n  unmatched : {unmatched:,}")
        return

    link(
        root,
        dry_run=args.dry_run,
        min_size_kb=args.min_size_kb,
        tolerance_s=args.tolerance_s,
    )


if __name__ == "__main__":
    main()
