#!/usr/bin/env python3
"""gig-metadata — build a gig's tracks.json from the parsers, never by hand.

Sources, all of them canonical (nothing here is typed twice):
  backlog.md          via tools/setlist.py    -> membership, ORDER, name, bpm, section
  the .tidal files    via tools/setlist_samples.py -> sample packs actually referenced
  a segments file     (JSON: [{name,start_s,end_s}]) -> measured timecodes from the take

The timecodes matter beyond bookkeeping: they are what lets a visualist
(Slopmotion) cut to the set without re-deriving boundaries by ear.

Usage:
  tools/gig-metadata.py --gig opal-festival-2026 --title "Sunset Forest" \
      --date 2026-08-08 --segments segments.json --out tracks.json
"""
from __future__ import annotations

import argparse
import json
import re
import subprocess
import sys
import unicodedata
from pathlib import Path

REPO = Path(__file__).resolve().parent.parent

STYLE_BY_PATH = [
    ("live/midi/nova/ambient/", "ambient"),
    ("live/midi/nova/techno/", "techno"),
    ("live/midi/nova/dnb/", "dnb"),
    ("live/midi/nova/lounge/", "lounge"),
    ("live/midi/nova/acid/", "acid"),
    ("live/midi/nova/jazz/", "nujazz"),
    ("live/midi/nova/remix/", "remix"),
    ("live/midi/nova/breaks/", "breaks"),
    ("live/techno/", "techno"),
    ("live/collab/", "collab"),
    ("live/boeuf/", "jam"),
]


def style_of(path: str) -> str:
    for prefix, style in STYLE_BY_PATH:
        if path.startswith(prefix):
            return style
    return "other"


def read_setlist() -> list[dict]:
    """tools/setlist.py --long is THE parser for what a set is and in what order."""
    out = subprocess.run([sys.executable, str(REPO / "tools/setlist.py"), "--long"],
                         capture_output=True, text=True, timeout=120)
    if out.returncode != 0:
        raise SystemExit(f"setlist.py failed:\n{out.stderr}")
    rows = []
    for line in out.stdout.splitlines():
        m = re.match(r"\s*(\S+)\s+(.{1,20}?)\s\s+(\S+\.tidal)\s+(.*)$", line)
        if not m:
            continue
        bpm, section, path, name = m.groups()
        rows.append({
            "name": name.strip().rstrip("]"),
            "file": path.strip(),
            "bpm": int(bpm) if bpm.isdigit() else None,
            "section": section.strip(),
            "style": style_of(path.strip()),
        })
    return rows


def read_samples(files: list[str]) -> dict[str, list[str]]:
    """Which sample folders each track references (setlist_samples is the parser)."""
    per_track: dict[str, list[str]] = {}
    for f in files:
        p = REPO / f
        if not p.exists():
            per_track[f] = []
            continue
        out = subprocess.run([sys.executable, str(REPO / "tools/setlist_samples.py"), str(p)],
                             capture_output=True, text=True, timeout=120)
        names = []
        for line in out.stdout.splitlines():
            m = re.match(r"\s{2}(\S+)\s+/", line)
            if m:
                names.append(m.group(1))
        per_track[f] = sorted(set(names))
    return per_track


def hms(x: float) -> str:
    x = max(0.0, float(x))
    return f"{int(x // 3600)}:{int(x % 3600) // 60:02d}:{int(x % 60):02d}"


def _fold(s: str) -> str:
    """Normalise a title hard enough that the two sources can meet.

    backlog.md is prose meant for a human — names carry markdown emphasis,
    hearts, and the odd stray bracket (`There's **Something About Drums** <3`,
    `GHOSTS IN THE TOILETS]`). The mastering side names the same track
    `There's Something About Drums`. Folding both to letters-and-digits is what
    lets a measured boundary find its .tidal file.
    """
    s = unicodedata.normalize("NFKD", s or "")
    s = "".join(c for c in s if not unicodedata.combining(c))
    # `<3` must go BEFORE the alphanumeric fold, or its digit survives and
    # `Perfect <3` folds to "perfect3" — which matches nothing, silently.
    s = re.sub(r"<3+", " ", s)
    return re.sub(r"[^a-z0-9]", "", s.lower())


def load_segments(path: Path, rows: list[dict]) -> dict[str, dict]:
    """Accept either segments shape, keyed out to .tidal stems.

      {name, start_s, end_s}   the stem-keyed shape this tool was built for
      {title, start, end}      what the mastering side produces (segments_v3)

    The second is matched to the setlist BY TITLE, never by position. Position
    would silently pair the wrong boundary with the wrong track the first time
    a soundcheck row or an unplayed track shifts the list — and a silent
    mis-pair here ships wrong timecodes to the website and to the visualist.
    So an unmatched title is a hard failure, listing both sides.
    """
    raw = json.loads(path.read_text())
    if not isinstance(raw, list) or not raw:
        raise SystemExit(f"{path}: expected a non-empty JSON list of segments")

    if "name" in raw[0] and "start_s" in raw[0]:
        return {s["name"]: {"start_s": s["start_s"], "end_s": s["end_s"]}
                for s in raw}

    by_title = {_fold(r["name"]): Path(r["file"]).stem for r in rows}
    out, unmatched = {}, []
    for s in raw:
        title = s.get("title") or s.get("name") or ""
        stem = by_title.get(_fold(title))
        if not stem:
            unmatched.append(title)
            continue
        out[stem] = {"start_s": float(s.get("start", s.get("start_s"))),
                     "end_s": float(s.get("end", s.get("end_s")))}
    if unmatched:
        raise SystemExit(
            f"{path}: {len(unmatched)} segment title(s) match no setlist entry:\n"
            + "".join(f"    {t!r}\n" for t in unmatched)
            + "  setlist offers:\n"
            + "".join(f"    {r['name']!r}\n" for r in rows))
    return out


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--gig", required=True)
    ap.add_argument("--title", required=True)
    ap.add_argument("--date", required=True)
    ap.add_argument("--venue")
    ap.add_argument("--stage")
    ap.add_argument("--segments", help="JSON [{name,start_s,end_s}] measured from the take")
    ap.add_argument("--only-played", action="store_true",
                    help="keep only tracks present in --segments (drops soundcheck/unplayed)")
    ap.add_argument("--out", required=True)
    a = ap.parse_args()

    rows = read_setlist()
    segs = load_segments(Path(a.segments), rows) if a.segments else {}

    tracks = []
    for r in rows:
        stem = Path(r["file"]).stem
        seg = segs.get(stem)
        if a.only_played and not seg:
            continue
        entry = dict(r)
        if seg:
            entry["start_s"] = round(seg["start_s"], 2)
            entry["end_s"] = round(seg["end_s"], 2)
            entry["duration_s"] = round(seg["end_s"] - seg["start_s"], 2)
            entry["start"] = hms(seg["start_s"])
            entry["end"] = hms(seg["end_s"])
        tracks.append(entry)

    samples = read_samples([t["file"] for t in tracks])
    for t in tracks:
        t["samples"] = samples.get(t["file"], [])

    bpms = [t["bpm"] for t in tracks if t.get("bpm")]
    styles: dict[str, int] = {}
    for t in tracks:
        styles[t["style"]] = styles.get(t["style"], 0) + 1

    doc = {
        "gig": a.gig,
        "title": a.title,
        "date": a.date,
        "venue": a.venue,
        "stage": a.stage,
        "bpmRange": [min(bpms), max(bpms)] if bpms else None,
        "styleDistribution": dict(sorted(styles.items(), key=lambda kv: -kv[1])),
        "trackCount": len(tracks),
        "totalDuration_s": round(sum(t.get("duration_s", 0) for t in tracks), 2),
        "samplePacks": sorted({s for t in tracks for s in t["samples"]}),
        "tracks": tracks,
    }
    doc = {k: v for k, v in doc.items() if v is not None}
    Path(a.out).parent.mkdir(parents=True, exist_ok=True)
    Path(a.out).write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n")
    print(f"wrote {a.out}: {len(tracks)} tracks, "
          f"{doc.get('bpmRange')} bpm, {len(doc['samplePacks'])} sample packs, "
          f"{hms(doc['totalDuration_s'])} total")


if __name__ == "__main__":
    main()
