#!/usr/bin/env python3
r"""lcxl-init — give every templated LCXL control an initial value, from the hardware side.

The problem this exists to kill
------------------------------
In Tidal, `"^51"` is sugar for `cF_ "51"`, which is `_cX_ getF "51"`:

    _cX_ f s = Pattern $ \(State a m) -> queryArc (maybe SILENCE (...) $ Map.lookup s m) a
                                                        ^^^^^^^

An untouched control is **`silence`**, not 0. (Verified against tidal-1.9.5
source, not inferred.) And `#` / `|>` cannot emit an event without a right-hand
value — so ANY stream referencing a control that has never been moved is
COMPLETELY SILENT. Not quiet. Silent.

That is the rig's single worst failure mode, because:
  * it produces no error anywhere — not in ghci, not in the SC post window;
  * `dN` is `xfade N`, so a re-eval fades the OUTGOING pattern out over ~4
    cycles. You hear the previous pattern dying and read it as "a filter is
    closing" or "Ardour is broken" — which cost a full evening on 2026-07-27;
  * the workaround was ritual: sweep every knob and tap every button after each
    boot. Miss one and that orbit is dead on stage.

Why do it from HERE and not in BootTidal.hs
-------------------------------------------
BootTidal.hs already carries two mitigations: an `orDef` combinator on the g*
helpers, and a `setF`-based seed block. Both are real, but both are weaker than
this:

  * `orDef` can only wrap the ~13 g* helpers. It cannot reach the ~1860 DIRECT
    control references written inline across 343 track files
    (`# crushbus 41 (range 16 3.5 "^53")`), and it cannot fix the arithmetic
    call sites (`midiOn ("^34" - "^18")`) where `touched - untouched` is itself
    empty.
  * the seed block writes Tidal's `sStateMV` via `setF`. Correct in principle
    (same map the OSC `/ctrl` handler writes — checked in Stream.hs), but it
    depends on boot ORDER and on that ghci actually having run the block. When a
    stale ghci predates the edit, it silently does nothing.

This tool instead emits real MIDI CC messages into the same path a physical
knob move takes: CC -> SC's `MIDIFunc.cc` -> `osc.sendMsg("/ctrl", num, val/127)`
-> Tidal's control map. It is therefore, by construction, indistinguishable from
PLN sweeping the whole surface by hand — the one thing already known to work. It
cannot diverge from reality, because it IS the reality path. It fixes helpers and
inline refs and arithmetic sites in one shot, and it does not care what state
ghci is in.

Convention it encodes
---------------------
The corpus writes `range <neutral> <extreme> "^NN"`, so **0 is the neutral value**
for almost everything, and the exceptions are the three DJ filters, whose neutral
is the centre detent (0.5 = true bypass: lpf 20000 / hpf 20).

Usage
-----
    tools/lcxl-init.py            # seed everything, verify, report
    tools/lcxl-init.py --dry-run  # show what would be sent
    tools/lcxl-init.py --faders   # ALSO seed D-row faders (see the warning below)
"""

from __future__ import annotations

import argparse
import re
import subprocess
import sys
import time

# --- the control map (see reference_controller_map memory / lib/lcxl-map.js) ---
KNOBS_A = list(range(13, 21))          # row A
KNOBS_B = list(range(29, 37))           # row B
KNOBS_C = list(range(49, 57))           # row C  (C1-C3 = the DJ filters)
FADERS_D = list(range(77, 85))           # row D  — NOT seeded by default
BUTTONS_E = list(range(41, 45)) + list(range(57, 61))
BUTTONS_F = list(range(73, 77)) + list(range(89, 93))

DJ_FILTERS = [49, 50, 51]
PANIC = 93


def build_seed(include_faders: bool) -> list[tuple[int, int, str]]:
    """(cc, 7-bit value, why) for every control we initialise."""
    seed: list[tuple[int, int, str]] = []

    for cc in KNOBS_A + KNOBS_B:
        seed.append((cc, 0, "knob, neutral end of its range"))

    for cc in KNOBS_C:
        if cc in DJ_FILTERS:
            # 64/127 = 0.504 -> gDJF maps this to lpf 20000 / hpf 20, i.e. true
            # bypass. The centre detent is the only value that means "no filter".
            seed.append((cc, 64, "DJ filter — CENTRE detent = bypass"))
        else:
            seed.append((cc, 0, "effect knob, neutral"))

    for cc in BUTTONS_E + BUTTONS_F:
        seed.append((cc, 0, "button, released"))

    seed.append((PANIC, 0, "panic toggle, off"))

    if include_faders:
        # Off by default ON PURPOSE. The D-row faders are MIDI-learned to Ardour
        # TRACK GAINS. Blasting a value at them moves Ardour's faders while the
        # PHYSICAL faders stay where they are — instant desync between what the
        # surface shows and what the mix is doing, which is worse than the
        # problem we are solving. Tidal's own `^77` is already covered by
        # `orDef 0.769` in BootTidal.hs.
        for cc in FADERS_D:
            seed.append((cc, 127, "fader, open (Ardour gain — see warning)"))

    return seed


def sc_midi_input_port() -> str | None:
    """Resolve SuperCollider's MIDI input port BY NAME, every time.

    Never cache or hardcode this. Client numbers move (the LCXL has already
    gone 20:0 -> 24:0 across a replug), and a binding resolved once at boot and
    silently invalidated by a device event is this rig's most expensive recurring
    bug class. Resolve at use, verify by behaviour.

    We target SuperCollider DIRECTLY rather than Midi Through, so these seeding
    messages never reach Ardour — where the same CCs could nudge MIDI-learned
    track faders.
    """
    # `aconnect -o` lists WRITABLE (destination) ports. This matters: `-i` lists
    # readable ports, and SuperCollider exposes BOTH — its inputs (in0..) and its
    # outputs (out0..) live under the same client. Asking `-i` returns `out0`,
    # which is a source; sending to it silently goes nowhere. Got this wrong
    # once already, so the flag is the whole correctness argument here.
    try:
        out = subprocess.run(["aconnect", "-o"], capture_output=True, text=True,
                             timeout=5).stdout
    except Exception:
        return None
    client = None
    for line in out.splitlines():
        m = re.match(r"^client (\d+): '([^']*)'", line)
        if m:
            client = m.group(1) if "SuperCollider" in m.group(2) else None
            continue
        if client:
            pm = re.match(r"^\s+(\d+) '(.*?)\s*'", line)
            if pm:
                return f"{client}:{pm.group(1)}"
    return None


def send_cc(port: str, cc: int, value: int) -> bool:
    """One CC message on channel 1. B0 = CC, status byte for channel 1."""
    hexmsg = f"B0 {cc:02X} {value:02X}"
    try:
        r = subprocess.run(["aseqsend", "-p", port, hexmsg],
                           capture_output=True, text=True, timeout=5)
        return r.returncode == 0
    except Exception:
        return False


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--dry-run", action="store_true")
    ap.add_argument("--faders", action="store_true",
                    help="also seed D-row faders (they drive Ardour gains — read the source)")
    ap.add_argument("-q", "--quiet", action="store_true")
    args = ap.parse_args()

    seed = build_seed(args.faders)

    if args.dry_run:
        print(f"lcxl-init: would send {len(seed)} CC messages:")
        for cc, val, why in seed:
            print(f"  CC {cc:3d} = {val:3d} ({val/127:.3f})   {why}")
        return 0

    port = sc_midi_input_port()
    if not port:
        print("lcxl-init: FAIL — no SuperCollider MIDI input port found.\n"
              "  Is SuperDirt running?  systemctl --user status parvagues-sc",
              file=sys.stderr)
        return 2

    ok = 0
    for cc, val, _ in seed:
        if send_cc(port, cc, val):
            ok += 1
        # A tiny gap keeps us from overrunning the ALSA seq queue; the whole
        # sweep still finishes in well under a second.
        time.sleep(0.002)

    failed = len(seed) - ok
    if not args.quiet:
        print(f"lcxl-init: seeded {ok}/{len(seed)} controls via SuperCollider MIDI in ({port})")
        print(f"  C1/C2/C3 (CC 49/50/51) -> 64 = centre detent = filter BYPASS")
        print(f"  everything else -> 0 = the neutral end of its `range`")
        if not args.faders:
            print("  D-row faders (77-84) deliberately NOT seeded — they drive Ardour gains")
    if failed:
        print(f"lcxl-init: WARN — {failed} message(s) failed to send", file=sys.stderr)
        return 1

    if not args.quiet:
        print("\n  Every `^NN` now resolves to a value instead of `silence`.\n"
              "  Verify end-to-end (not just 'the maths is right'): play a track that\n"
              "  was dead and run  tools/probe-chain.py -d <orbit>  — SC rms must leave -inf.")
    return 0


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