#!/usr/bin/env python3
"""Clip-idea planner — OPAL 2026 setlist x Take89 recordings x Slopmotion playsets.

The question this answers: *which shareable clips can we actually make right now,
from audio we already have, for tracks that are actually in the OPAL set?*

Three sources are joined, none of them invented:

1. **The setlist** — `backlog.md` § "OPAL 2026" (canonical informal source), with the
   measured tempi from `docs/2026-07-25-opal-bpm-arc.md` (measured `setcps`, NOT the
   backlog's annotations, which were wrong on three tracks).
2. **The recordings** — Take89 = Montreuil Algorave V3, 2026-05-22. Per-track
   boundaries are PLN-ear-validated (`armada/tide-table/boundaries_take89_validated.json`);
   per-track peak-anchored promo cuts + measured features come from the Slopmotion
   studio bundle's `highlights/` (rule-based drops detection on stem-activity + kick GT
   + Foote SSM novelty).
3. **The visuals** — the Slopmotion (hydra-live-hexa) playset catalog, i.e. the local
   generated snapshot of the online Supabase `packs` table.

Pairing is grounded on MEASURED audio features, not vibes:
  * `tilt_mean`        spectral tilt -> bright vs dark playset families
  * `kick_density_p70` -> how trigger-heavy the FX chain should be
  * `ssm_novelty_max`  -> how fast Automix should cut clips

Usage:
    python3 visuals/slop/plan_clips.py               # write clip_ideas.json + .md
    python3 visuals/slop/plan_clips.py --print       # just print the table
"""

from __future__ import annotations

import argparse
import json
from pathlib import Path

TIDAL = Path(__file__).resolve().parents[2]
HEXA = Path("/home/pln/Work/Sound/hydra-live-hexa")
STUDIO = HEXA / "public/audio/Recordings/Montreuil26_master/studio"
HIGHLIGHTS = STUDIO / "tracks_bandcamp/highlights"
OUT_DIR = Path(__file__).resolve().parent

# --- Source 1: the OPAL 2026 setlist ---------------------------------------
# genre tags verbatim from backlog.md; bpm = MEASURED setcps (docs/2026-07-25-opal-bpm-arc.md)
OPAL_SETLIST = [
    # (position, title, genre_tag, measured_bpm, tidal_stem)
    (1, "Ceci n'est pas Une Bombe", "lightT", 124, "bombe_dj"),
    (2, "WAP", "lightT", 133, "wap"),
    (3, "Am i Doing it Right?", "lightT", 89, "do_it_right"),
    (4, "Take five Drops", "jazzT", 124, "take_5_drops"),
    (5, "Piment Bresilien", "hardT", 124, "piment_bresilien"),
    (6, "Perfect <3", "T", 128, "perfect"),
    (7, "Gimme Acid", "dnb", 80, "gimme_acid"),
    (8, "Vague de CRIME", "lightT", 120, "vague_de_crime"),
    (9, "Mafia", "Hdnb", 160, "mafia_sans_serif"),
    (10, "Sunshine", "Hdnb", 144, "you_my_sunshine"),
    (11, "Desire", "FINALE", 127, "desire"),
    (12, "La Revolution Sera Samplee", "encore", 114, "the_revolution_will_be_sampled"),
    (13, "Electric Hammer", "encore", 130, "electric_hammer"),
]

# --- The join: OPAL title -> Take89 recorded-track title --------------------
# Take89 track titles as they appear in the studio bundle's highlights/.
# Several are aka-renames; each mapping below is a same-track identity, not a guess:
#   "W.I.P. W.A.P."   is the work-in-progress title of WAP
#   "Aria Sans Serif" is the highlights-side name for Mafia Sans Serif
#                     (boundaries_take89_validated.json calls the same cut "Mafia Sans Serif")
#   "You My Sunshine" is Sunshine
OPAL_TO_TAKE89 = {
    "WAP": "W.I.P. W.A.P.",
    "Take five Drops": "Take 5 Drops",
    "Piment Bresilien": "Piment brésilien",
    "Mafia": "Aria Sans Serif",
    "Sunshine": "You My Sunshine",
    "Desire": "Desire",
    "La Revolution Sera Samplee": "La Révolution Sera Samplée",
}

# --- Source 3: playset families, keyed by what the measurement says ---------
# Families are a reading of the pack names + their source imagery, and are the one
# genuinely aesthetic (i.e. arguable) layer here. Everything routing INTO them is measured.
DARK_PACKS = ["obsidian-tide", "obsidian-iris", "behind-screen", "concrete-jungle", "point-cloud"]
BRIGHT_PACKS = ["iridescent", "white-hole", "fire-sparkles", "cloud-composition", "dune-calor"]
MID_PACKS = ["liquid-metal", "stripe-resonance", "liminal-spaces", "rainy-city-night", "hex-skull"]
OWN_PACK = "parvagues"  # PLN's own imagery — always offered as the alternate take


def tilt_family(tilt: float) -> tuple[str, list[str]]:
    """Spectral tilt -> playset family. Thresholds are the terciles of the observed
    Take89 range (0.13 .. 0.54), so they split THIS set rather than an abstract scale."""
    if tilt < 0.25:
        return "dark", DARK_PACKS
    if tilt > 0.45:
        return "bright", BRIGHT_PACKS
    return "mid", MID_PACKS


def fx_chain(kick_density: float, novelty: float, tilt: float) -> dict:
    """FX chain proposal. Every knob traces to a measurement.

    kick_density_p70 is beats-above-threshold per second at the 70th percentile, i.e.
    how relentless the kick is; observed Take89 range 1.75 .. 3.75.
    """
    trigger_heavy = kick_density >= 2.5
    chain: dict[str, dict] = {}

    # Backbone: always a little grain + edge so the loop doesn't read as stock footage.
    chain["glitch"] = {"base": 0.35 if not trigger_heavy else 0.55}
    chain["rgbDelay"] = {"base": 0.30}

    if trigger_heavy:
        # Dense kick -> cut ON the kick; these are the percussive-feeling FX.
        chain["jumpCut"] = {"base": 1.0, "isTrigger": True, "syncBand": "kick"}
        chain["vibration"] = {"base": 0.5, "isTrigger": True, "syncBand": "kick"}
        chain["flash"] = {"base": 0.45, "isTrigger": True, "syncBand": "kick"}
    else:
        # Sparser kick -> let the image breathe; motion comes from warps, not cuts.
        chain["liquix"] = {"base": 0.55}
        chain["wetLens"] = {"base": 0.60}
        chain["slowmo"] = {"base": 0.45}

    # Bright material can take a hard monochrome/print treatment without going muddy.
    if tilt > 0.45:
        chain["lumaPrint"] = {"base": 0.70}
    elif tilt < 0.25:
        chain["glow"] = {"base": 0.45}

    # High structural novelty -> the track already changes a lot; let Automix follow it.
    automix_s = 4.0 if novelty >= 12.0 else 8.0
    return {"fx": chain, "automixSeconds": automix_s, "triggerHeavy": trigger_heavy}


def load_track_starts() -> dict[str, float]:
    """Cumulative master-timeline start second for each Take89 track title.

    Uses the ear-validated boundaries; track N starts at boundary N-1.
    """
    gt = json.loads((STUDIO / "boundaries_ground_truth.json").read_text())
    cuts = [b["boundary_final_time"] for b in gt]
    starts = [0.0] + cuts
    titles = []
    for f in sorted(HIGHLIGHTS.glob("*.highlights.json")):
        titles.append((json.loads(f.read_text())["track"], json.loads(f.read_text())["title"]))
    titles.sort()
    return {title: starts[i] for i, (_, title) in enumerate(titles) if i < len(starts)}


def load_highlights() -> dict[str, dict]:
    out = {}
    for f in sorted(HIGHLIGHTS.glob("*.highlights.json")):
        d = json.loads(f.read_text())
        out[d["title"]] = d
    return out


def build() -> dict:
    highlights = load_highlights()
    starts = load_track_starts()
    master = STUDIO / "Montreuil26_streaming_final.opus"

    ideas, unmatched = [], []
    for pos, title, genre, bpm, stem in OPAL_SETLIST:
        take_title = OPAL_TO_TAKE89.get(title)
        if not take_title or take_title not in highlights:
            unmatched.append(
                {"setlist_pos": pos, "title": title, "tidal_stem": stem,
                 "reason": "no Take89 recording — never played at Montreuil (or added since)"}
            )
            continue

        h = highlights[take_title]
        ft = h["features"]
        tilt = ft["tilt_mean"]
        family, packs = tilt_family(tilt)
        chain = fx_chain(ft["kick_density_p70"], ft["ssm_novelty_max"], tilt)
        t0 = starts[take_title]

        cuts = {}
        for label, c in h["promo_cuts"].items():
            cuts[label] = {
                "track_relative_start": round(c["start"], 2),
                "master_start": round(t0 + c["start"], 2),
                "duration": round(c["end"] - c["start"], 2),
                "anchor": c["anchor"],
            }

        ideas.append({
            "setlist_pos": pos,
            "opal_title": title,
            "take89_title": take_title,
            "tidal_stem": stem,
            "genre_tag": genre,
            "measured_bpm": bpm,
            "take": "Take89",
            "master_audio": str(master),
            "track_master_start": round(t0, 2),
            "track_duration": h["duration"],
            "measured": {
                "tilt_mean": tilt,
                "kick_density_p70": ft["kick_density_p70"],
                "ssm_novelty_max": ft["ssm_novelty_max"],
                "n_active_mean": ft["n_active_mean"],
            },
            "promo_cuts": cuts,
            "visual": {
                "tilt_family": family,
                "playsets": packs[:3],
                "alternate_playset": OWN_PACK,
                **chain,
            },
        })

    return {
        "schema": "slop clip ideas — OPAL 2026 setlist x Take89 x Slopmotion playsets",
        "generated_by": "visuals/slop/plan_clips.py",
        "provenance": {
            "setlist": {"src": "file:backlog.md § OPAL 2026", "type": "file"},
            "tempi": {"src": "file:docs/2026-07-25-opal-bpm-arc.md (measured setcps)", "type": "derived"},
            "boundaries": {"src": "file:armada/tide-table/boundaries_take89_validated.json",
                           "type": "user", "note": "PLN ear-validated 2026-06-05"},
            "highlights": {"src": f"file:{HIGHLIGHTS}", "type": "derived",
                           "note": "rule-based drops detection, stem-activity + kick GT + Foote SSM"},
            "playsets": {"src": "file:hydra-live-hexa/src/data/playsetPacks.json",
                         "type": "derived", "note": "local generated snapshot of Supabase packs"},
        },
        "gig": {
            "name": "OPAL 2026",
            "note": "gig metadata is NOT canonical here — see Web/www next/content/lives/",
        },
        "n_ideas": len(ideas),
        "n_unmatched": len(unmatched),
        "ideas": ideas,
        "unmatched": unmatched,
    }


def to_markdown(doc: dict) -> str:
    L = ["# Slop clip ideas — OPAL 2026 x Take89", ""]
    L.append(f"**{doc['n_ideas']} OPAL tracks have a real recording we can cut from today** "
             f"({doc['n_unmatched']} do not). Source take: Take89 = Montreuil Algorave V3, 2026-05-22.")
    L.append("")
    L.append("Every clip below is a *peak-anchored* window found by the drops detector, "
             "offset onto the master timeline using PLN's ear-validated boundaries. "
             "Playset + FX pairing is derived from measured audio features (see each row).")
    L.append("")
    L.append("| # | Track | BPM | tilt | kick | nov | family | playsets | 30s cut (master) |")
    L.append("|---|-------|-----|------|------|-----|--------|----------|------------------|")
    for i in doc["ideas"]:
        m, v = i["measured"], i["visual"]
        c = i["promo_cuts"]["30s"]
        mm, ss = divmod(int(c["master_start"]), 60)
        L.append(
            f"| {i['setlist_pos']} | {i['opal_title']} | {i['measured_bpm']} | "
            f"{m['tilt_mean']:.2f} | {m['kick_density_p70']:.2f} | {m['ssm_novelty_max']:.1f} | "
            f"{v['tilt_family']} | {', '.join(v['playsets'])} | {mm}:{ss:02d} |"
        )
    L.append("")
    L.append("## Not cuttable yet")
    L.append("")
    for u in doc["unmatched"]:
        L.append(f"- **{u['title']}** (`{u['tidal_stem']}`) — {u['reason']}")
    L.append("")
    L.append("These are the newer / reworked tracks. To get promo clips for them we need a "
             "fresh recording — which the always-on `tools/gig_record.sh` will produce at the "
             "next rehearsal or at OPAL itself.")
    L.append("")
    L.append("## Rendering one")
    L.append("")
    L.append("```bash")
    L.append("cd /home/pln/Work/Sound/hydra-live-hexa && nvm use 22")
    L.append("npm run dev &                       # the renderer drives the real app")
    L.append("node scripts/render_slop_clip.mjs \\")
    L.append("  --ideas ../Tidal/visuals/slop/clip_ideas.json --pick 11 --cut 30s \\")
    L.append("  --out ../Tidal/visuals/slop/out/")
    L.append("```")
    return "\n".join(L) + "\n"


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--print", dest="show", action="store_true")
    args = ap.parse_args()

    doc = build()
    if args.show:
        print(to_markdown(doc))
        return

    (OUT_DIR / "clip_ideas.json").write_text(json.dumps(doc, indent=2, ensure_ascii=False))
    (OUT_DIR / "CLIP_IDEAS.md").write_text(to_markdown(doc))
    print(f"wrote clip_ideas.json + CLIP_IDEAS.md — {doc['n_ideas']} ideas, "
          f"{doc['n_unmatched']} unmatched")


if __name__ == "__main__":
    main()
