#!/usr/bin/env python3
"""build_release_plan — the upload plan, generated from canonical sources only.

`tidal-ears master sc album --plan …` wants a JSON list of tracks with titles,
files and tags. Typing that by hand is exactly how a release goes out with the
wrong album name: `split_bandcamp.py` once carried an ALBUM string that was
simply wrong, and nothing downstream could tell.

So this generates it, and every field is copied from the one place that owns it:

    gig metadata (title, date, venue, description, tags)
        <- Web/www content/lives/{year}/{slug}.md   -- CANONICAL, never invented
    tracklist (per-track bpm, section, sample banks)
        <- .../{slug}/tracks.json                    -- CANONICAL
    what actually ships (release position, duration, edits)
        <- the rendered segments + the split FLACs   -- the artefact itself
    the ear's approval
        <- judge_specs/<gig>_boundaries_ear.json     -- release_signoff

Nothing is composed from a script variable, and `_provenance` records where each
value came from so a wrong string can be traced instead of argued about.

**It refuses to emit an unsigned plan.** The ear file carries a `release_signoff`
pinned to the segments it approved; if that block is missing, or the rendered
tracks are newer than it, the record on disk is not the record PLN cleared and
the plan is not written. An upload is hard to take back — the check belongs
before it, not in a checklist.

    python3 build_release_plan.py judge_specs/opal26.json \\
        --variant streaming --out /tmp/opal26_plan.json
"""
from __future__ import annotations

import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path

HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))
from build_judge_set import clean_title, ffprobe_dur  # noqa: E402

WWW = Path("/home/pln/Work/Web/www")


def match_key(s: str) -> str:
    """A join key that survives PLN's prose, for MATCHING only.

    The canonical names are written in a blog voice — `There's **Something
    About Drums** <3`, `Am i _Doing it Right_`, `GHOSTS IN THE TOILETS` — while
    the segments carry the plain form. Joining on the raw strings silently
    missed 4 of 14 tracks and produced a plan with empty genre, empty section
    and NO sample banks, which is the rights input. It looked fine.

    So the key throws away everything that is presentation (emphasis, hearts,
    case, punctuation, spacing) and keeps only letters and digits. It is used
    ONLY to find the row; every value that ships still comes from the canonical
    record verbatim.
    """
    s = clean_title(s).lower().replace("<3", "")
    return "".join(c for c in s if c.isalnum())


def frontmatter(md: Path) -> dict:
    """Minimal YAML front-matter reader — flat scalars and inline lists only.

    Deliberately not a YAML dependency: this reads four fields out of a file
    whose shape we control, and a parser that silently accepts more than the
    format allows is how a typo becomes a released album title.
    """
    text = md.read_text()
    if not text.startswith("---"):
        raise ValueError(f"{md}: no front matter")
    body = text.split("---", 2)[1]
    out: dict = {}
    for line in body.splitlines():
        if not line.strip() or line.lstrip().startswith("#") or ":" not in line:
            continue
        k, v = line.split(":", 1)
        v = v.strip()
        if v.startswith("[") and v.endswith("]"):
            out[k.strip()] = [x.strip().strip('"\'') for x in v[1:-1].split(",") if x.strip()]
        else:
            out[k.strip()] = v.strip('"\'')
    return out


def check_signoff(ear: dict, tracks: list[Path], force: bool) -> list[str]:
    """The gate. Returns problems; empty means the record is the approved one."""
    problems = []
    so = ear.get("release_signoff")
    if not so:
        problems.append(
            "no `release_signoff` in the ear file — nobody has confirmed this "
            "record by ear. Run render_release + build_release_joins and listen.")
        return problems
    signed = datetime.fromisoformat(so["date"]).date()
    newest = max((datetime.fromtimestamp(p.stat().st_mtime).date() for p in tracks),
                 default=None)
    if newest and newest > signed:
        problems.append(
            f"the rendered tracks ({newest}) are NEWER than the signoff "
            f"({signed}). Audio has changed since PLN approved it; re-audition "
            f"before uploading.")
    if force and problems:
        for p in problems:
            print(f"  ! OVERRIDDEN: {p}", file=sys.stderr)
        return []
    return problems


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("spec")
    ap.add_argument("--variant", default="streaming")
    ap.add_argument("--artwork", help="cover image applied to every track")
    ap.add_argument("--include-mix", action="store_true",
                    help="also publish the continuous *_nodrop mix, listed first")
    ap.add_argument("--out", required=True)
    ap.add_argument("--titles", default="canonical", choices=["canonical", "file"],
                    help="track names from the canonical tracklist (default) or "
                         "from the rendered filenames")
    ap.add_argument("--force", action="store_true",
                    help="emit even without a valid ear signoff (say why in the log)")
    a = ap.parse_args()

    spec = json.loads(Path(a.spec).read_text())
    gig = spec["gig"]
    year = spec["date"][:4]
    md_path = WWW / "content" / "lives" / year / f"{gig}.md"
    tj_path = WWW / "content" / "lives" / year / gig / "tracks.json"
    if not md_path.exists():
        sys.exit(f"canonical gig metadata not found: {md_path}\n"
                 f"  Gig metadata is never invented here — create it there first.")
    fm = frontmatter(md_path)
    tj = json.loads(tj_path.read_text())
    tj_tracks = tj.get("tracks", tj) if isinstance(tj, dict) else tj
    by_name = {match_key(t["name"]): t for t in tj_tracks}

    ear = json.loads(Path(spec["earBoundaries"]).read_text())
    segs = json.loads(Path(spec["segmentsRelease"]).read_text())
    tdir = Path(spec["releaseRoot"]) / f"tracks_{spec['releaseTag']}_{a.variant}"
    files = sorted(tdir.glob("*.flac"))
    if len(files) != len(segs):
        sys.exit(f"{len(files)} rendered files but {len(segs)} segments in {tdir}")

    problems = check_signoff(ear, files, a.force)
    if problems:
        print("REFUSING TO WRITE A PLAN — the record is not ear-approved:")
        for p in problems:
            print("  ✗ " + p)
        return 1

    album = fm.get("title") or spec.get("album")
    base_tags = list(fm.get("tags", []))

    # A parser miss must never become a quiet gap in the catalog. Refuse the
    # whole plan rather than upload four tracks with no genre, no section and
    # no sample-bank list — a missing field is invisible once it is on the shelf.
    unmatched = [s["title"] for s in segs if match_key(s["title"]) not in by_name]
    if unmatched:
        print(f"REFUSING TO WRITE A PLAN — {len(unmatched)} track(s) have no row "
              f"in the canonical tracklist:")
        for t in unmatched:
            print(f"  ✗ {t!r}  (key {match_key(t)!r})")
        print(f"  canonical names in {tj_path.name}:")
        for t in tj_tracks:
            print(f"      {t['name']!r}")
        return 1

    tracks, renamed = [], []
    for seg, f in zip(segs, files):
        src = by_name[match_key(seg["title"])]
        # The canonical record owns the NAME, including the decorations that are
        # PLN's voice ("Perfect <3"). The segment title is the working name the
        # rendered file carries. They differ on four tracks, so say so out loud
        # rather than pick one silently.
        title = clean_title(src["name"]) if a.titles == "canonical" else seg["title"]
        if title != seg["title"]:
            renamed.append((seg["title"], title))
        # Sample banks are per-track FACT from the score, and they are the input
        # to the rights question (A4). Carried into the plan so that question is
        # answerable per track rather than re-derived later.
        banks = src.get("samples", [])
        tags = base_tags + [t for t in (src.get("style"), src.get("section")) if t]
        tracks.append({
            "title": title,
            "audio": str(f),
            "artwork": a.artwork,
            "genre": src.get("style", ""),
            "tag_list": " ".join(f'"{t}"' if " " in t else t for t in dict.fromkeys(tags)),
            "description": (
                f"{album} — {fm.get('address','')}, {fm.get('date','')}. "
                f"Track {seg['track']}/{len(segs)}"
                + (f" · {src['section']}" if src.get("section") else "")
                + (f" · {seg['bpm']} BPM" if seg.get("bpm") else "")
                + ". Live coded in TidalCycles."),
            "_bpm": seg.get("bpm"),
            "_perf_track": seg.get("perf_track"),
            "_file_title": seg["title"],
            "_duration_s": round(ffprobe_dur(f), 3),
            "_sample_banks": banks,
            "_rights_checked": False,
        })

    # The continuous mix goes FIRST in the list, deliberately. It is the
    # lower-risk artefact (an isolated track gets flagged where the long mix
    # passes) and the one PLN would most want on the shelf, so if an upload run
    # dies halfway it dies having landed the important thing.
    if a.include_mix:
        mix = Path(spec["variants"][a.variant])
        mix = mix.with_name(mix.stem + "_nodrop" + mix.suffix)
        if not mix.exists():
            sys.exit(f"continuous mix not rendered: {mix}\n"
                     f"  run: python3 render_release.py {a.spec} --only continuous")
        # Timecodes are CUMULATIVE RENDERED durations, not segment arithmetic:
        # the mix is literally these files concatenated, so summing what came
        # out is exact — and it stays exact through effects that lengthen a
        # track (the reverb tail, the 2s rest) which segment maths would miss.
        def tc(s: float) -> str:
            # h:mm:ss past the hour. A 73-minute set written as "69:15" reads
            # as a timestamp nobody can place, and the last five tracks of this
            # record are all past 60 minutes.
            s = int(s)
            return (f"{s//3600}:{s//60%60:02d}:{s%60:02d}" if s >= 3600
                    else f"{s//60}:{s%60:02d}")

        lines, t = [], 0.0
        for tr in tracks:
            lines.append(f"{tc(t)}  {tr['title']}")
            t += tr["_duration_s"]
        tracks.insert(0, {
            # Composed from canonical FIELDS (title · venue · date), never from
            # a remembered string. The format is here; the facts are theirs.
            "title": f"{album} — {fm.get('address','')} {fm.get('date','')[:4]} (full set)",
            "audio": str(mix),
            "artwork": a.artwork,
            "genre": "livecoding",
            "tag_list": " ".join(dict.fromkeys(base_tags)),
            "description": (
                f"{fm.get('description','')}\n\n"
                f"{fm.get('stage','')}, {fm.get('location','')}. "
                f"{len(tracks)} tracks, {t/60:.0f} minutes, live coded in "
                f"TidalCycles.\n\n" + "\n".join(lines)),
            "_duration_s": round(ffprobe_dur(mix), 3),
            "_is_continuous_mix": True,
            "_rights_checked": False,
        })

    plan = {
        "_comment": [
            "GENERATED by build_release_plan.py — do not hand-edit.",
            "Every value traces to `_provenance`; fix the source, re-generate.",
            "`_rights_checked` is false on every track: the sample banks are",
            "listed but nobody has cleared them. The continuous mix is the",
            "lower-risk upload; per-track needs that pass first.",
        ],
        "album": album,
        "artist": spec.get("artist", "ParVagues"),
        "date": fm.get("date"),
        "_provenance": {
            "album/date/description/tags": str(md_path.relative_to(WWW)),
            "per-track section/style/samples": str(tj_path.relative_to(WWW)),
            "release order/duration/edits": spec["segmentsRelease"],
            "audio": str(tdir),
            "ear signoff": f"{Path(spec['earBoundaries']).name} :: release_signoff "
                           f"({ear.get('release_signoff', {}).get('date', 'NONE')})",
            "generated": datetime.now(timezone.utc).isoformat(timespec="seconds"),
        },
        "tracks": tracks,
    }
    Path(a.out).write_text(json.dumps(plan, indent=1, ensure_ascii=False) + "\n")

    print(f"{album} · {plan['artist']} · {fm.get('date')} · {len(tracks)} tracks "
          f"({a.variant})")
    for t in tracks:
        print(f"  {t['_duration_s']:>8.1f}s  {t['title'][:34]:<34} {t['genre']}")
    if renamed:
        print(f"\n  {len(renamed)} title(s) taken from the canonical tracklist "
              f"rather than the filename (--titles file to keep the filename):")
        for old, new in renamed:
            print(f"    {old!r} -> {new!r}")
    if not a.artwork:
        print("\n  ! no --artwork: tracks would go up without a cover")
    print(f"\n✓ {a.out}")
    print("  Next: tidal-ears master sc album --plan <this> --sharing private "
          "--transport browser")
    return 0


if __name__ == "__main__":
    sys.exit(main())
