#!/usr/bin/env python3
"""tidal-remote — drive the Pulsar Tidal plugin from the command line.

Why
---
Every diagnostic on this rig used to dead-end at "now press ctrl-enter". The
measurement tools are all scriptable (probe-chain, check-mix, lcxl-init), but
the act of MAKING SOUND was reachable only through PLN's keyboard — so a
4-second measurement cost a human round-trip, and an unattended self-test (#44)
could not even be written.

This talks to the `remote` OSC channel in pulsar-tidalcycles (lib/remote.js,
default 127.0.0.1:3334), so a script can reboot the interpreter, load a track
and play it, and hush — in order, unattended.

The reboot ordering that matters
--------------------------------
Tidal's control map (`sStateMV`, where `^NN` values live) is state INSIDE the
ghci process. A reboot empties it. So the correct order is always:

    reboot  ->  wait for BootTidal  ->  seed the surface  ->  paint the LEDs  ->  eval

Seeding before a reboot is wasted work, and evaluating before seeding is the
mute-bomb (an untouched `^NN` is `silence`, not 0 — see #61). `boot` does the
whole dance for you, in that order, with the waits.

Usage
-----
    tools/tidal-remote.py reboot
    tools/tidal-remote.py eval-file live/midi/nova/jazz/the_revolution_will_be_sampled.tidal
    tools/tidal-remote.py eval          # whatever editor is active
    tools/tidal-remote.py hush
    tools/tidal-remote.py status        # logged to the Pulsar console, not returned

    tools/tidal-remote.py boot <track.tidal>   # reboot + wait + seed + eval

No reply is expected: this is fire-and-forget UDP. Readiness and success are
confirmed by MEASUREMENT (probe-chain.py) and by the SC journal, never by this
tool's own say-so — a remote that reports its own success is how you get a green
check on a silent rig.
"""

from __future__ import annotations

import argparse
import socket
import struct
import subprocess
import sys
import time
from pathlib import Path

HOST = "127.0.0.1"
PORT = 3334
ADDRESS = "/editor/remote"

REPO = Path(__file__).resolve().parent.parent

# How long BootTidal.hs takes to load into a fresh ghci. Evaluating before this
# completes yields a wall of "Variable not in scope" and no sound.
BOOT_WAIT = 6.0


def _pad(b: bytes) -> bytes:
    """OSC pads every string/blob to a 4-byte boundary, with at least one NUL."""
    return b + b"\0" * (4 - len(b) % 4)


def osc_message(address: str, args: list[str]) -> bytes:
    """Hand-rolled OSC encoding — all-string args, which is all we need.

    Deliberately dependency-free: this tool has to run from a bare launch script
    on a machine where a pip install is not part of the plan.
    """
    out = _pad(address.encode())
    out += _pad(("," + "s" * len(args)).encode())
    for a in args:
        out += _pad(a.encode())
    return out


def send(pairs: dict[str, str], *, quiet: bool = False) -> None:
    args: list[str] = []
    for k, v in pairs.items():
        args += [k, str(v)]
    payload = osc_message(ADDRESS, args)
    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
        s.sendto(payload, (HOST, PORT))
    if not quiet:
        desc = " ".join(f"{k}={v}" for k, v in pairs.items())
        print(f"tidal-remote: sent {desc}")


def channel_open() -> bool:
    """Is anything actually bound to the remote port?

    UDP sends succeed into the void, so without this check every command would
    report success against a Pulsar that never loaded the channel. Binding the
    port ourselves is the test: if the bind SUCCEEDS, nobody was listening.
    """
    try:
        probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        probe.bind((HOST, PORT))
        probe.close()
        return False
    except OSError:
        return True


def resolve_track(raw: str) -> str:
    """Accept a repo-relative path, an absolute one, or a bare track name."""
    p = Path(raw)
    if p.is_absolute() and p.exists():
        return str(p)
    cand = REPO / raw
    if cand.exists():
        return str(cand.resolve())
    if not raw.endswith(".tidal"):
        raw += ".tidal"
    hits = sorted((REPO / "live").rglob(raw)) + sorted((REPO / "copycat").rglob(raw))
    if len(hits) == 1:
        return str(hits[0].resolve())
    if len(hits) > 1:
        print(f"tidal-remote: ambiguous track '{raw}' — {len(hits)} matches; "
              "pass a path", file=sys.stderr)
        raise SystemExit(2)
    print(f"tidal-remote: no such track '{raw}'", file=sys.stderr)
    raise SystemExit(2)


def cmd_boot(track: str) -> int:
    """reboot -> wait -> seed -> eval, in the one order that works."""
    path = resolve_track(track)

    print("tidal-remote: [1/5] rebooting the interpreter (empties the control map)")
    send({"cmd": "reboot"}, quiet=True)

    print(f"tidal-remote: [2/5] waiting {BOOT_WAIT:g}s for BootTidal.hs to load")
    time.sleep(BOOT_WAIT)

    print("tidal-remote: [3/5] seeding the LCXL surface (lcxl-init)")
    seed = subprocess.run([str(REPO / "tools" / "lcxl-init.py"), "-q"],
                          capture_output=True, text=True)
    if seed.returncode != 0:
        print(f"tidal-remote: WARN — seeding failed:\n{seed.stderr.strip()}",
              file=sys.stderr)
        print("  Continuing anyway, but expect silent orbits: an unmoved `^NN` is "
              "`silence`, not 0.", file=sys.stderr)
    else:
        print("  seeded")

    # Paint the surface right after seeding, and paint it FOR THIS TRACK. The seed
    # gives every `^NN` a value; this makes that state visible. Doing it here rather
    # than leaving it to the Pulsar HUD is deliberate: the HUD only paints when the
    # editor is open and its frame pipeline is healthy (#57), so a boot could
    # complete onto a completely dark board with no error anywhere.
    print("tidal-remote: [4/5] painting the LCXL for this track (lcxl-leds --map)")
    paint = subprocess.run([str(REPO / "tools" / "lcxl-leds.py"), "--map", path, "-q"],
                           capture_output=True, text=True)
    if paint.returncode != 0:
        # Non-fatal by design: LEDs are feedback, not sound. A dark board is
        # annoying; a boot that aborts because of a dark board is a lost gig.
        print(f"tidal-remote: WARN — LED paint failed:\n{paint.stderr.strip()}",
              file=sys.stderr)
    else:
        print("  painted (verify by EYE — a clean exit is not a lit LED)")

    print(f"tidal-remote: [5/5] eval {Path(path).name}")
    send({"cmd": "eval-file", "path": path}, quiet=True)

    print("\n  Now MEASURE it — do not trust that this printed without errors:\n"
          "    tools/probe-chain.py -s 6")
    return 0


def main() -> int:
    ap = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("cmd", choices=["reboot", "hush", "eval", "eval-file",
                                    "status", "boot"])
    ap.add_argument("track", nargs="?", help="track path or name (eval-file / boot)")
    ap.add_argument("--force", action="store_true",
                    help="send even if nothing is listening on the port")
    args = ap.parse_args()

    if not channel_open() and not args.force:
        print(f"tidal-remote: FAIL — nothing is listening on {HOST}:{PORT}.\n"
              "  The remote channel lives in pulsar-tidalcycles and loads at package\n"
              "  activation, so NEW package code needs one `Window: Reload` in Pulsar\n"
              "  before it exists. After that, this works unattended.",
              file=sys.stderr)
        return 2

    if args.cmd == "boot":
        if not args.track:
            print("tidal-remote: boot needs a track", file=sys.stderr)
            return 2
        return cmd_boot(args.track)

    if args.cmd == "eval-file":
        if not args.track:
            print("tidal-remote: eval-file needs a track", file=sys.stderr)
            return 2
        send({"cmd": "eval-file", "path": resolve_track(args.track)})
        return 0

    send({"cmd": args.cmd})
    return 0


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