#!/usr/bin/env python3
"""fader-baseline — remember the fader levels PLN plays at, and put them back.

THE FAILURE THIS EXISTS FOR, three recurrences and counting (#124):

The Ardour session IS the boot state. PLN ends a set with the faders pulled down —
which is what you do at the end of a set — Ardour saves that, and the next launch
comes up silent. On 2026-08-02 the saved session had NINE of twelve Tidal tracks
at -inf, and every one of the 15 setlist tracks would have lost an orbit. The
riser convention (#33) lives on d10, so all ten risers would have been silent and
he would not have heard them go.

`check-mix.py` catches it. But catching it costs PLN a fader ride and a Ctrl+S
every time, and his answer to that was the right one:

    "ok raised and saved 10 and 12, saved, check again. but ideally we should
     not depend in that?"

So: CAPTURE the levels while they are good, and RESTORE them when they drift.

WHY THIS IS ALLOWED TO WRITE FADERS, WHEN THE STANDING RULE SAYS NEVER TO

The rule (memory: reference_ardour_saved_vs_live_recurs) is that you must never
write fader values from outside, for three good reasons: it desyncs the file from
the physical desk, CC77 is a global gain where down means total silence, and
levels are MUSICAL INTENT that an agent has no business inventing.

All three objections are about INVENTING a level on a LIVE desk. This tool does
neither:

  * it never invents anything — it replays a value PLN himself set and approved,
    recorded with the date and session it came from;
  * it refuses to run while Ardour is running, so it cannot desync a live desk.
    The desk wins whenever the desk exists;
  * it only ever touches routes named "Tidal NN". Master, monitor, busses and
    anything else in the session are not addressable by this tool at all.

It also always writes a .bak first, because this is PLN's performance session and
a corrupted one six days before a gig is not a recoverable mistake.

Usage:
    tools/fader-baseline.py --capture       # snapshot the current (good) levels
    tools/fader-baseline.py --check         # does the session still match?  (exit 1 = no)
    tools/fader-baseline.py --restore       # put the baseline back (Ardour must be closed)
"""
from __future__ import annotations

import argparse
import datetime as dt
import json
import math
import pathlib
import re
import shutil
import sys
import xml.etree.ElementTree as ET

ROOT = pathlib.Path(__file__).resolve().parent.parent
BASELINE = ROOT / "armada" / "ardour_faders.json"
SESSION = pathlib.Path.home() / "Work/Sound/Ardour/Tidal Live/Tidal Live.ardour"

TIDAL_ROUTE = re.compile(r"^Tidal\s+(\d+)$")
# Below this a track is inaudible in practice; same constant check-mix.py uses.
SILENT = 1e-3


def to_db(linear: float) -> float:
    return -math.inf if linear <= 0 else 20.0 * math.log10(linear)


def ardour_running(session: pathlib.Path | None = None) -> str | None:
    """The .ardour path a live Ardour holds, or None.

    Matches check-mix.py: read argv, because the binary is `ardour-N.N.N` and a
    name match on 'ardour' also hits this script and any grep for it.

    Scoped to `session` when given. The first version asked only "is ANY Ardour
    running", which is the safe-looking answer and the wrong one: it refuses to
    repair a session nobody has open, and it made this tool's own restore path
    untestable while PLN had his live session up. The hazard is writing UNDER a
    live session, so that is exactly what we check for.
    """
    want = session.resolve() if session else None
    for proc in pathlib.Path("/proc").iterdir():
        if not proc.name.isdigit():
            continue
        try:
            argv = (proc / "cmdline").read_bytes().split(b"\0")
        except OSError:
            continue
        if not argv or b"ardour" not in pathlib.Path(argv[0].decode(
                "utf-8", "replace") or "x").name.encode():
            continue
        for arg in argv:
            if not arg.endswith(b".ardour"):
                continue
            got = arg.decode("utf-8", "replace")
            if want is None:
                return got
            try:
                if pathlib.Path(got).resolve() == want:
                    return got
            except OSError:
                continue
    return None


def gain_controllable(route: ET.Element) -> ET.Element | None:
    """The route's main fader element. Matched on the Controllable NAME, because
    the enclosing Processor has been called both 'Amp' and 'Fader' across Ardour
    versions and matching on that is how you silently check nothing."""
    for proc in route.iter("Processor"):
        for ctl in proc.iter("Controllable"):
            if ctl.get("name") == "gaincontrol":
                return ctl
    return None


def read_faders(session: pathlib.Path) -> dict[str, float]:
    tree = ET.parse(session)
    out: dict[str, float] = {}
    for route in tree.getroot().iter("Route"):
        name = route.get("name") or ""
        if not TIDAL_ROUTE.match(name):
            continue
        ctl = gain_controllable(route)
        if ctl is None:
            continue
        try:
            out[name] = float(ctl.get("value", "nan"))
        except ValueError:
            continue
    return dict(sorted(out.items(), key=lambda kv: int(TIDAL_ROUTE.match(kv[0]).group(1))))


def load_baseline() -> dict:
    if not BASELINE.exists():
        raise SystemExit(f"fader-baseline: no baseline at {BASELINE}\n"
                         f"       capture one while the mix is good: "
                         f"tools/fader-baseline.py --capture")
    return json.loads(BASELINE.read_text())


def cmd_capture(session: pathlib.Path) -> int:
    faders = read_faders(session)
    if not faders:
        raise SystemExit(f"fader-baseline: no 'Tidal NN' routes in {session}")
    silent = [n for n, v in faders.items() if v <= SILENT]
    if silent:
        # Capturing end-of-set silence as the baseline would enshrine the very
        # bug this tool exists to undo.
        print(f"fader-baseline: REFUSING to capture — {len(silent)} fader(s) are "
              f"silent: {', '.join(silent)}", file=sys.stderr)
        print("       Raise them in Ardour and Ctrl+S first. A baseline is only "
              "worth having if it is the mix you PLAY at.", file=sys.stderr)
        return 1
    BASELINE.write_text(json.dumps({
        "captured": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
        "session": str(session),
        "note": "Levels PLN set and approved. Replayed by --restore, never invented.",
        "faders": faders,
        "faders_db": {n: round(to_db(v), 2) for n, v in faders.items()},
    }, indent=2) + "\n")
    print(f"fader-baseline: captured {len(faders)} fader(s) -> "
          f"{BASELINE.relative_to(ROOT)}")
    for n, v in faders.items():
        print(f"    {n:<10} {to_db(v):>7.1f} dB")
    return 0


def cmd_check(session: pathlib.Path) -> int:
    base = load_baseline()["faders"]
    now = read_faders(session)
    drift = []
    for name, want in base.items():
        got = now.get(name)
        if got is None:
            drift.append((name, None, want)); continue
        # 0.5 dB of slack: PLN nudges levels between runs and that is not drift.
        if got <= SILENT or abs(to_db(got) - to_db(want)) > 0.5:
            drift.append((name, got, want))
    if not drift:
        print(f"fader-baseline: ok — {len(base)} fader(s) match the baseline "
              f"captured {load_baseline()['captured'][:10]}")
        return 0
    print(f"fader-baseline: DRIFT — {len(drift)} fader(s) differ from the baseline:")
    for name, got, want in drift:
        g = "missing" if got is None else f"{to_db(got):.1f} dB"
        print(f"    {name:<10} {g:>10}   baseline {to_db(want):.1f} dB")
    print("\n  Restore with:  tools/fader-baseline.py --restore   (Ardour must be closed)")
    print("  Or, if these levels are the NEW intent:  --capture")
    return 1


def cmd_restore(session: pathlib.Path) -> int:
    live = ardour_running(session)
    if live:
        print(f"fader-baseline: REFUSING — Ardour is running ({live}).", file=sys.stderr)
        print("       Writing the file underneath a live session desyncs it from the "
              "physical desk, and the desk wins on next touch. Close Ardour first.",
              file=sys.stderr)
        return 1
    base = load_baseline()
    want = base["faders"]
    tree = ET.parse(session)
    changed = []
    for route in tree.getroot().iter("Route"):
        name = route.get("name") or ""
        if name not in want:              # Master/monitor/busses are unreachable here
            continue
        ctl = gain_controllable(route)
        if ctl is None:
            continue
        try:
            cur = float(ctl.get("value", "nan"))
        except ValueError:
            cur = float("nan")
        if not (abs(to_db(cur) - to_db(want[name])) <= 0.5):
            ctl.set("value", repr(want[name]))
            changed.append((name, cur, want[name]))
    if not changed:
        print("fader-baseline: nothing to restore — session already matches.")
        return 0
    bak = session.with_suffix(session.suffix + ".prefader.bak")
    shutil.copy2(session, bak)
    tree.write(session, encoding="utf-8", xml_declaration=True)
    print(f"fader-baseline: restored {len(changed)} fader(s) "
          f"(backup: {bak.name}, captured {base['captured'][:10]})")
    for name, cur, w in changed:
        print(f"    {name:<10} {to_db(cur):>7.1f} -> {to_db(w):>6.1f} dB")
    return 0


def main() -> int:
    ap = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--session", type=pathlib.Path, default=SESSION)
    g = ap.add_mutually_exclusive_group(required=True)
    g.add_argument("--capture", action="store_true")
    g.add_argument("--check", action="store_true")
    g.add_argument("--restore", action="store_true")
    args = ap.parse_args()
    if not args.session.exists():
        raise SystemExit(f"fader-baseline: no session at {args.session}")
    if args.capture:
        return cmd_capture(args.session)
    if args.check:
        return cmd_check(args.session)
    return cmd_restore(args.session)


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