#!/usr/bin/env python3
"""Watch the sample folders; register new packs into the RUNNING SuperCollider.

Drop a pack in, keep playing, `s "newbank"` works about a minute later. No
reboot — the SC half (`sample_watch.scd`) answers an OSC message and registers
the bank lazily, which reads WAV headers only.

  tools/sample-watcher.py --once      one pass, then exit
  tools/sample-watcher.py             loop (default every 60 s)
  tools/sample-watcher.py --dry-run   report what it WOULD load

Two refusals it makes on purpose:

  SHADOWING. `loadSoundFiles` defaults to appendToExisting = false, so a folder
  whose basename matches an existing bank frees that bank's buffers and takes
  the name. Auto-loading one mid-session would change what an old track plays,
  with no error anywhere. A pack that would shadow is reported and SKIPPED
  until a human decides.

  A SILENT RIG. It pings first and refuses to claim anything if the responder
  does not answer, then verifies each load against the file count SC reports
  back. A watcher that fires into the void and prints "loaded" is worse than
  no watcher at all.
"""
from __future__ import annotations

import argparse
import json
import socket
import sys
import time
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import pvbanks  # noqa: E402

STATE = Path("~/.cache/parvagues/sample-watcher.json").expanduser()
SC_ADDR = ("127.0.0.1", 57120)


# ------------------------------------------------------------------ tiny OSC

def _pad(b: bytes) -> bytes:
    return b + b"\0" * ((4 - len(b) % 4) % 4)


def osc_msg(addr: str, *args: str | int) -> bytes:
    tags, body = ",", b""
    for a in args:
        if isinstance(a, int):
            tags += "i"
            body += a.to_bytes(4, "big", signed=True)
        else:
            tags += "s"
            body += _pad(str(a).encode() + b"\0")
    return _pad(addr.encode() + b"\0") + _pad(tags.encode() + b"\0") + body


def osc_parse(data: bytes) -> tuple[str, list]:
    def take_str(b, i):
        e = b.index(b"\0", i)
        s = b[i:e].decode("utf-8", "replace")
        return s, i + (((e - i) + 4) // 4) * 4
    addr, i = take_str(data, 0)
    if i >= len(data):
        return addr, []
    tags, i = take_str(data, i)
    out = []
    for t in tags[1:]:
        if t == "i":
            out.append(int.from_bytes(data[i:i + 4], "big", signed=True)); i += 4
        elif t in "fd":
            i += 4 if t == "f" else 8
        elif t == "s":
            s, i = take_str(data, i); out.append(s)
    return addr, out


class Rig:
    def __init__(self, timeout=2.0):
        self.s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        self.s.bind(("127.0.0.1", 0))
        self.s.settimeout(timeout)

    def call(self, addr, *args, expect=None):
        self.s.sendto(osc_msg(addr, *args), SC_ADDR)
        deadline = time.time() + self.s.gettimeout()
        while time.time() < deadline:
            try:
                data, _ = self.s.recvfrom(65535)
            except socket.timeout:
                return None
            a, vals = osc_parse(data)
            if expect is None or a == expect:
                return vals
        return None

    def ping(self):
        return self.call("/pv/ping", expect="/pv/pong")

    def load(self, path: str, naming: str):
        return self.call("/pv/loadBank", path, naming, expect="/pv/loaded")


# --------------------------------------------------------------------- watch

def load_state() -> dict:
    try:
        return json.loads(STATE.read_text())
    except Exception:
        return {"known": [], "skipped": {}}


def save_state(st: dict):
    STATE.parent.mkdir(parents=True, exist_ok=True)
    STATE.write_text(json.dumps(st, indent=1))


def naming_for(label: str) -> str:
    return "nodash" if label == "drum-machines" else "basename"


def one_pass(rig: Rig | None, st: dict, dry: bool, verbose: bool) -> int:
    banks = pvbanks.scan()
    known = set(st.get("known", []))
    new = {k: v for k, v in banks.items() if k not in known}

    if not known:                       # first run: adopt, never bulk-load
        st["known"] = sorted(banks)
        if not dry:
            save_state(st)
        print(f"  baseline: {len(banks)} banks adopted, nothing loaded"
              f"{' (dry-run: not saved)' if dry else ''}")
        return 0

    if not new:
        if verbose:
            print(f"  no new banks ({len(banks)} known)")
        return 0

    loaded = 0
    for name, entries in sorted(new.items()):
        if len(entries) > 1:
            print(f"  SKIP {name!r}: {len(entries)} folders claim this name — "
                  f"would shadow. Resolve by hand.")
            st.setdefault("skipped", {})[name] = "ambiguous"
            continue
        label, folder, n_files, mt = entries[0]
        # A NEW name cannot shadow, but re-check against the live rig anyway:
        # the state file can be stale if a bank was added outside the watcher.
        print(f"  NEW  {name!r}  {n_files} files  ({label}, {pvbanks.fmt_time(mt)})")
        if dry:
            continue
        reply = rig.load(str(folder), naming_for(label))
        if reply is None:
            print("       ! no reply from SuperCollider — NOT marking as known")
            continue
        got = reply[1] if len(reply) > 1 else -1
        if got < 0:
            print(f"       ! refused by SC: {reply[2] if len(reply) > 2 else '?'}")
            continue
        if got != n_files:
            # Not fatal — SC counts what it can decode — but say so plainly.
            print(f"       ~ SC registered {got} files, I counted {n_files}")
        else:
            print(f"       loaded, {got} files — `s \"{name}\"` is live")
        known.add(name)
        loaded += 1

    if not dry:
        st["known"] = sorted(known)
        save_state(st)
    return loaded


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--interval", type=float, default=60.0)
    ap.add_argument("--once", action="store_true")
    ap.add_argument("--dry-run", action="store_true")
    ap.add_argument("--verbose", action="store_true")
    ap.add_argument("--reset", action="store_true",
                    help="forget the baseline and re-adopt everything")
    a = ap.parse_args()

    if a.reset and STATE.exists():
        STATE.unlink()
        print("  state reset")

    st = load_state()
    rig = None
    if not a.dry_run:
        rig = Rig()
        pong = rig.ping()
        if pong is None:
            sys.exit(
                "SuperCollider is not answering /pv/ping.\n"
                "\n"
                "The responder is armed AT BOOT, from start_and_midi.scd. There\n"
                "is no way to arm it in a rig that is already running: sclang\n"
                "runs headless under systemd with stdin on /dev/null, so there\n"
                "is no post window to evaluate a line in and no eval-over-OSC\n"
                "endpoint. A rig booted before the responder existed will never\n"
                "answer, however long you wait.\n"
                "\n"
                "So the fix is a restart, and a restart STOPS THE SOUND:\n"
                "  systemctl --user restart parvagues-sc\n"
                "\n"
                "Do it between tracks, not mid-set.")
        print(f"  SuperCollider answered: {pong[0]} banks registered")

    while True:
        try:
            one_pass(rig, st, a.dry_run, a.verbose or a.once)
        except Exception as e:                       # never die on one bad pass
            print(f"  pass failed: {type(e).__name__}: {e}", file=sys.stderr)
        if a.once:
            break
        time.sleep(a.interval)


if __name__ == "__main__":
    main()
