#!/usr/bin/env python3
"""PunkAChien stem-master builder (config-driven Rhadamanthe chain).

Katana first: track-level, role-driven master from per-orbit stems. Once proven
here, promote to `tidal-ears master track`. Reuses ffmpeg (no bespoke DSP).

Role map measured via `tidal-ears master eda` on Take89 [2387:2674.74]:
  01 kick(A#1, low transient)  02 snare/clap  03 hats(air)  04 acid(D2, front half)
  05 cpluck(A#2 — the riff/bass SPINE)  07 silent(disabled d7)  08 jungle break
  09 moog SUB(drop only ~208-240s)  11 outro atmos   06/10/12 silent.

Chain (per MASTERING.md / layering.tidal):
  per-stem gain-stage→HPF/LPF  ·  bass bus (acid+cpluck+moog) sidechain-ducked by
  kick  ·  drums widened to sides, kick centred  ·  sum  ·  mono-bass ≤120  ·
  glue comp 1.5:1  ·  two-pass loudnorm.
"""
import argparse, json, subprocess, sys, tempfile, re
from pathlib import Path

STEMS = Path(__file__).parent / "stems"

# role spec: orbit -> (label, highpass Hz, lowpass Hz|None, group)
# group ∈ {kick, drums, bass, top, atmos}; silent orbits omitted.
ROLES = {
    "01": ("kick",   28,   18000, "kick"),
    "02": ("snare",  160,  None,  "drums"),
    "03": ("hats",   450,  None,  "drums"),
    "04": ("acid",   70,   None,  "bass"),
    "05": ("cpluck", 42,   None,  "bass"),
    "08": ("break",  130,  None,  "top"),
    "09": ("moog",   25,   1800,  "bass"),
    "11": ("atmos",  120,  None,  "atmos"),
}


def ff(args, **kw):
    return subprocess.run(args, capture_output=True, text=True, **kw)


def loudnorm_measure(infile, I, TP, LRA):
    """Pass 1: measure."""
    r = ff(["ffmpeg", "-hide_banner", "-nostats", "-i", str(infile),
            "-af", f"loudnorm=I={I}:TP={TP}:LRA={LRA}:print_format=json",
            "-f", "null", "-"])
    m = re.findall(r"(\{[^{}]*?\"input_i\"[^{}]*?\})", r.stderr + r.stdout, re.S)
    return json.loads(m[-1]) if m else None


def build(variant, target_lufs, sc_ratio, sc_thresh, widen, target_tp=-1.0,
          fade_in=0.25, fade_out=4.0, dur=287.74, clip=None, moog_gain=0.0,
          outdir="masters", prefix="montreuil"):
    out = Path(__file__).parent / outdir / f"{prefix}_{variant}.flac"
    seek = []
    if clip:
        cs, ce = (float(x) for x in clip.split(":"))
        seek = ["-ss", str(cs), "-t", str(ce - cs)]
        dur, fade_in, fade_out = ce - cs, 0.02, 0.15
    inputs, idx = [], {}
    for n, orbit in enumerate(ROLES):
        f = STEMS / f"orbit-{orbit}.flac"
        idx[orbit] = n
        inputs += [*seek, "-i", str(f)]

    parts, kick_lbl = [], None
    bass_lbls, drum_lbls, top_lbls, atmos_lbls = [], [], [], []
    for orbit, (label, hp, lp, group) in ROLES.items():
        i = idx[orbit]
        chain = [f"highpass=f={hp}"]
        if lp:
            chain.append(f"lowpass=f={lp}")
        if group == "moog" or label == "moog":
            chain.append("pan=stereo|c0=0.5*c0+0.5*c1|c1=0.5*c0+0.5*c1")  # mono sub
            if moog_gain:
                chain.append(f"volume={moog_gain}dB")
        lbl = f"s{orbit}"
        parts.append(f"[{i}:a]" + ",".join(chain) + f"[{lbl}]")
        if group == "kick":
            kick_lbl = lbl
        elif group == "bass":
            bass_lbls.append(lbl)
        elif group == "drums":
            drum_lbls.append(lbl)
        elif group == "top":
            top_lbls.append(lbl)
        elif group == "atmos":
            atmos_lbls.append(lbl)

    # kick split: one to mix, one as sidechain key
    parts.append(f"[{kick_lbl}]asplit=2[kmix][kkey]")

    # bass bus → sidechain-ducked by kick
    parts.append("".join(f"[{l}]" for l in bass_lbls)
                 + f"amix=inputs={len(bass_lbls)}:normalize=0[bassraw]")
    if sc_ratio > 1.01:
        sc_lin = 10 ** (sc_thresh / 20.0)  # filter wants linear threshold
        parts.append(f"[bassraw][kkey]sidechaincompress="
                     f"threshold={sc_lin:.6f}:ratio={sc_ratio}:attack=5:release=120:makeup=1[bass]")
    else:
        parts.append("[bassraw]anull[bass]")

    # drums widened to the sides (Rhad: drums spatialised), kick stays centred
    drum_mix = "".join(f"[{l}]" for l in drum_lbls) + \
        f"amix=inputs={len(drum_lbls)}:normalize=0,extrastereo=m={widen}[drums]"
    parts.append(drum_mix)

    top_all = top_lbls + atmos_lbls
    parts.append("".join(f"[{l}]" for l in top_all)
                 + f"amix=inputs={len(top_all)}:normalize=0[top]")

    # sum everything (normalize=0 keeps levels; we gain-staged via HPF only,
    # so pre-trim the sum to leave headroom, then glue)
    parts.append("[kmix][bass][drums][top]amix=inputs=4:normalize=0,"
                 "volume=-7dB[summed]")
    # mono-bass ≤120: split, low→mono, recombine
    parts.append("[summed]asplit=2[sumlo][sumhi]")
    parts.append("[sumlo]lowpass=f=120,pan=stereo|c0=0.5*c0+0.5*c1|c1=0.5*c0+0.5*c1[lo]")
    parts.append("[sumhi]highpass=f=120[hi]")
    parts.append("[lo][hi]amix=inputs=2:normalize=0[mb]")
    # glue comp
    parts.append("[mb]acompressor=threshold=-22dB:ratio=1.5:attack=30:release=100:makeup=1dB[glue]")
    # fades for clean intro/outro
    parts.append(f"[glue]afade=t=in:st=0:d={fade_in},"
                 f"afade=t=out:st={dur-fade_out}:d={fade_out}[pre]")

    graph = ";".join(parts)
    tmp = Path(tempfile.gettempdir()) / f"pac_{variant}_pre.wav"
    r = ff(["ffmpeg", "-hide_banner", "-nostats", *inputs,
            "-filter_complex", graph, "-map", "[pre]",
            "-c:a", "pcm_f32le", str(tmp), "-y"])
    if r.returncode != 0:
        print(r.stderr[-3000:], file=sys.stderr)
        sys.exit(1)

    # two-pass loudnorm
    meas = loudnorm_measure(tmp, target_lufs, target_tp, 11)
    ln = (f"loudnorm=I={target_lufs}:TP={target_tp}:LRA=11:"
          f"measured_I={meas['input_i']}:measured_TP={meas['input_tp']}:"
          f"measured_LRA={meas['input_lra']}:measured_thresh={meas['input_thresh']}:"
          f"offset={meas['target_offset']}:linear=true:print_format=summary")
    out.parent.mkdir(exist_ok=True)
    r2 = ff(["ffmpeg", "-hide_banner", "-nostats", "-i", str(tmp),
             "-af", ln, "-ar", "44100", "-c:a", "flac", "-compression_level", "8",
             str(out), "-y"])
    if r2.returncode != 0:
        print(r2.stderr[-3000:], file=sys.stderr)
        sys.exit(1)
    tmp.unlink(missing_ok=True)
    print(f"✓ {out.name}  (LUFS≈{target_lufs}, sc_ratio={sc_ratio}, widen={widen})")
    return out


def build_twotrack(src, variant, target_lufs, widen, target_tp=-1.0,
                   fade_in=0.0, fade_out=0.0):
    """Master a stereo 2-track (e.g. Hamburg live capture — no stems).
    Bus-only chain: HPF/LPF → mono-bass ≤120 → drum-less widen → glue → loudnorm.
    """
    out = Path(__file__).parent / "masters" / f"hamburg_{variant}.flac"
    out.parent.mkdir(exist_ok=True)
    dur = float(ff(["ffprobe", "-v", "error", "-show_entries", "format=duration",
                    "-of", "default=nw=1:nk=1", str(src)]).stdout.strip())
    parts = [
        "[0:a]highpass=f=22,lowpass=f=19000[clean]",
        "[clean]asplit=2[lo0][hi0]",
        "[lo0]lowpass=f=120,pan=stereo|c0=0.5*c0+0.5*c1|c1=0.5*c0+0.5*c1[lo]",
        f"[hi0]highpass=f=120,extrastereo=m={widen}[hi]",
        "[lo][hi]amix=inputs=2:normalize=0[mb]",
        "[mb]acompressor=threshold=-22dB:ratio=1.5:attack=30:release=100:makeup=1dB[glue]",
    ]
    last = "glue"
    if fade_in or fade_out:
        parts.append(f"[glue]afade=t=in:st=0:d={fade_in or 0.01},"
                     f"afade=t=out:st={dur-(fade_out or 0.01)}:d={fade_out or 0.01}[pre]")
        last = "pre"
    graph = ";".join(parts)
    tmp = Path(tempfile.gettempdir()) / f"pac_ham_{variant}_pre.wav"
    r = ff(["ffmpeg", "-hide_banner", "-nostats", "-i", str(src),
            "-filter_complex", graph, "-map", f"[{last}]",
            "-c:a", "pcm_f32le", str(tmp), "-y"])
    if r.returncode != 0:
        print(r.stderr[-3000:], file=sys.stderr); sys.exit(1)
    meas = loudnorm_measure(tmp, target_lufs, target_tp, 11)
    ln = (f"loudnorm=I={target_lufs}:TP={target_tp}:LRA=11:"
          f"measured_I={meas['input_i']}:measured_TP={meas['input_tp']}:"
          f"measured_LRA={meas['input_lra']}:measured_thresh={meas['input_thresh']}:"
          f"offset={meas['target_offset']}:linear=true:print_format=summary")
    r2 = ff(["ffmpeg", "-hide_banner", "-nostats", "-i", str(tmp), "-af", ln,
             "-ar", "44100", "-c:a", "flac", "-compression_level", "8", str(out), "-y"])
    if r2.returncode != 0:
        print(r2.stderr[-3000:], file=sys.stderr); sys.exit(1)
    tmp.unlink(missing_ok=True)
    print(f"✓ {out.name}  (LUFS≈{target_lufs}, widen={widen})")
    return out


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--twotrack", help="master a stereo file instead of stems")
    ap.add_argument("--variant", required=True)
    ap.add_argument("--lufs", type=float, default=-14.0)
    ap.add_argument("--sc-ratio", type=float, default=3.0)
    ap.add_argument("--sc-thresh", type=float, default=-24.0)
    ap.add_argument("--widen", type=float, default=1.15)
    ap.add_argument("--clip", help="render only START:END (seconds) → frags/ dir")
    ap.add_argument("--moog-gain", type=float, default=0.0, help="dB trim on moog sub (orbit-09)")
    ap.add_argument("--stems-dir", help="override stems dir (e.g. hamburg_stems)")
    ap.add_argument("--dur", type=float, help="override track duration (s)")
    ap.add_argument("--prefix", default="montreuil", help="output filename prefix")
    a = ap.parse_args()
    if a.stems_dir:
        STEMS = Path(__file__).parent / a.stems_dir
    if a.twotrack:
        build_twotrack(a.twotrack, a.variant, a.lufs, a.widen)
    else:
        outdir = "frags" if a.clip else "masters"
        kw = {"clip": a.clip, "moog_gain": a.moog_gain, "outdir": outdir, "prefix": a.prefix}
        if a.dur: kw["dur"] = a.dur
        build(a.variant, a.lufs, a.sc_ratio, a.sc_thresh, a.widen, **kw)
