#!/usr/bin/env python3
"""apply_beebo_catalog.py <UI_DIR>

Reversible, deterministic patch adding the four Beebo performance plugins to
the stock digit_ui curated effect catalog in module_info.py:

  - effect_type_maps["beebo"]     : name -> LV2 URI
  - effect_prototypes_models_all  : category/controls/inputs/outputs + description

Behaviour mirrors tools/apply_beebo_extension.sh:
  - original preserved byte-exact as module_info.py.orig (never overwritten);
  - refuses to run twice (already patched -> roll back first);
  - the effect_type_maps anchor and the brace-matched prototypes closing
    brace must be unique/unambiguous or it aborts without writing;
  - py_compile syntax check of the patched file;
  - state file .beebo_catalog_state.json with pre/post hashes + keys added.

Catalog-entry conventions taken from the stock file: controls are
{port_symbol: [Label, default, minimum, maximum(, 'int')]}; inputs/outputs
are {port_symbol: [Label, 'AudioPort'|'CVPort']}. Plugin control symbols and
ranges come from the plugin .ttl files (cached in
/srv/beebo/407_audit/plugin_control_maps.json).

Keys are collision-free with the existing catalog (it already has a
'granular', so ours are performance_*).
"""
import hashlib
import json
import py_compile
import shutil
import sys
from pathlib import Path

PLUGINS = {
    "performance_buffer": {
        "uri": "http://polyeffects.com/lv2/performance_buffer",
        "category": 0,
        "description": "Four-bank retrospective audio buffer: capture, "
                       "reverse, slices/stutter, rate.",
        "tags": "time effects",
        "in": {"in_l": "In L", "in_r": "In R"},
        "out": {"out_l": "Out L", "out_r": "Out R"},
        "controls": [
            ("history_seconds", "History", 8, 0.05, 30, False),
            ("capture_previous", "Capture Previous", 0, 0, 1, False),
            ("play", "Play", 0, 0, 1, False),
            ("reverse", "Reverse", 0, 0, 1, False),
            ("clear", "Clear", 0, 0, 1, False),
            ("wet", "Wet", 1, 0, 1, False),
            ("bank", "Bank", 0, 0, 3, True),
            ("rate", "Rate", 1, 0.25, 2, False),
            ("stutter", "Stutter", 0, 0, 1, False),
            ("slice_start", "Slice Start", 0, 0, 1, False),
            ("slice_length", "Slice Length", 0.125, 0.01, 1, False),
            ("random_slice", "Random Slice", 0, 0, 1, False),
        ],
    },
    "performance_spectral": {
        "uri": "http://polyeffects.com/lv2/performance_spectral",
        "category": 0,
        "description": "STFT spectral freeze/blur/bin-shift/gate.",
        "tags": "time effects",
        "in": {"in_l": "In L", "in_r": "In R"},
        "out": {"out_l": "Out L", "out_r": "Out R"},
        "controls": [
            ("freeze", "Freeze", 0, 0, 1, False),
            ("wet", "Wet", 1, 0, 1, False),
            ("blur", "Blur", 0, 0, 1, False),
            ("bin_shift", "Bin Shift", 0, -1, 1, False),
            ("gate", "Spectral Gate", 0, 0, 1, False),
        ],
    },
    "performance_space": {
        "uri": "http://polyeffects.com/lv2/performance_space",
        "category": 0,
        "description": "SPACE reverb: diffusion + 8-channel feedback delay "
                       "network, width/motion, freeze cloud.",
        "tags": "time effects",
        "in": {"in_l": "In L", "in_r": "In R"},
        "out": {"out_l": "Out L", "out_r": "Out R"},
        "controls": [
            ("predelay", "Predelay", 0, 0, 1, False),
            ("size", "Size", 0.5, 0, 1, False),
            ("decay", "Decay", 0.5, 0, 1, False),
            ("damp", "Damp", 0.3, 0, 1, False),
            ("diffusion", "Diffusion", 0.6, 0, 1, False),
            ("width", "Width", 1.0, 0, 1, False),
            ("motion", "Motion", 0.15, 0, 1, False),
            ("mix", "Mix", 0.5, 0, 1, False),
            ("cloud", "Cloud", 0, 0, 1, False),
            ("cloud_size", "Cloud Size", 0.3, 0, 1, False),
            ("cloud_density", "Cloud Density", 0.4, 0, 1, False),
            ("cloud_spread", "Cloud Spread", 0.3, 0, 1, False),
        ],
    },
    "performance_granular": {
        "uri": "http://polyeffects.com/lv2/performance_granular",
        "category": 0,
        "description": "8-voice granulator over a freeze-able 4 s capture.",
        "tags": "time effects",
        "in": {"in_l": "In L", "in_r": "In R"},
        "out": {"out_l": "Out L", "out_r": "Out R"},
        "controls": [
            ("position", "Position", 0.5, 0, 1, False),
            ("pitch", "Pitch", 1.0, 0.25, 4.0, False),
            ("size", "Grain Size", 0.25, 0, 1, False),
            ("density", "Density", 0.1, 0, 1, False),
            ("spread", "Spread", 0.5, 0, 1, False),
            ("width", "Stereo Width", 1.0, 0, 1, False),
            ("freeze", "Freeze", 0, 0, 1, False),
            ("wet", "Wet", 1.0, 0, 1, False),
        ],
    },
}

TYPE_ANCHOR = ("'boost': 'http://polyeffects.com/lv2/basic_modular#boost',\n"
               "     }}")


def replace_once(text, old, new, label):
    n = text.count(old)
    if n != 1:
        raise RuntimeError(f"{label}: expected one anchor, found {n}")
    return text.replace(old, new, 1)


def build_proto(key, d, ind=" " * 18):
    ctl = (",\n" + ind).join(
        f"{sym!r}: [{lab!r}, {defv}, {lo}, {hi}"
        + (", 'int'" if fl else "")
        + "]"
        for sym, lab, defv, lo, hi, fl in d["controls"])
    ins = (",\n" + ind).join(
        f"{sym!r}: [{lab!r}, 'AudioPort']" for sym, lab in d["in"].items())
    outs = (",\n" + ind).join(
        f"{sym!r}: [{lab!r}, 'AudioPort']" for sym, lab in d["out"].items())
    return (f"{key!r}: {{'category': {d['category']},\n" + ind
            + f"'controls': {{{ctl}}},\n" + ind
            + f"'description': {d['description']!r},\n" + ind
            + f"'inputs': {{{ins}}},\n" + ind
            + f"'long_description': '',\n" + ind
            + f"'outputs': {{{outs}}},\n" + ind
            + f"'tags': {{{d['tags']!r}}},\n" + ind
            + f"'video_url': ''}}")


def _sha(p):
    return hashlib.sha256(Path(p).read_bytes()).hexdigest()


def patch(s0):
    # 1) effect_type_maps["beebo"]: append our keys before its closing '}}'
    type_add = ("'boost': 'http://polyeffects.com/lv2/basic_modular#boost',\n"
                + ",\n".join(f"    '{k}': '{d['uri']}'" for k, d in PLUGINS.items())
                + ",\n     }}")
    s = replace_once(s0, TYPE_ANCHOR, type_add, "effect_type_maps")

    # 2) effect_prototypes_models_all: insert before the literal's closing
    #    brace, found by brace matching from its unique start
    marker = "effect_prototypes_models_all = {"
    start = s.find(marker)
    if start == -1 or s.count(marker) != 1:
        raise RuntimeError("prototypes dict start not unique")
    i = start + len(marker) - 1  # at '{'
    depth = 0
    k = i
    while True:
        ch = s[k]
        if ch == "{":
            depth += 1
        elif ch == "}":
            depth -= 1
            if depth == 0:
                break
        k += 1
    protos = ",\n".join(build_proto(k_, d) for k_, d in PLUGINS.items())
    return s[:k] + ",\n" + protos + s[k:]


def main():
    if len(sys.argv) != 2:
        print("usage: apply_beebo_catalog.py /path/to/digit_ui")
        return 2
    target = Path(sys.argv[1]).resolve()
    mi = target / "module_info.py"
    orig = target / "module_info.py.orig"
    state = target / ".beebo_catalog_state.json"
    if not mi.exists():
        print("error: missing", mi)
        return 1
    if state.exists():
        print("error: already extended (state file exists); roll back first")
        return 1

    s0 = mi.read_text()
    s = patch(s0)

    # validate syntax BEFORE touching anything (dry compile on a temp file)
    tmp = target / ".module_info_patched_compile_test.py"
    tmp.write_text(s)
    try:
        py_compile.compile(str(tmp), doraise=True)
    except Exception:
        tmp.unlink(missing_ok=True)
        raise
    tmp.unlink()

    if not orig.exists():
        shutil.copy2(mi, orig)
    mi.write_text(s)
    py_compile.compile(str(mi), doraise=True)

    state.write_text(json.dumps({
        "file": "module_info.py",
        "original_sha256": _sha(orig),
        "patched_sha256": _sha(mi),
        "keys_added": sorted(PLUGINS.keys()),
    }, indent=2, sort_keys=True) + "\n")
    print(f"Beebo catalog entries added to {mi} (original preserved at {orig})")
    print(f"keys: {', '.join(sorted(PLUGINS))}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())