# name=NolvraMILES
# url=https://nolvra.com/miles
# supportedDevices=NolvraMILES
# supportedHardwareIds=7D 4E 4D 01 00
"""
Nolvra MILES bridge for FL Studio (MILES-FL1, research branch).

READ-ONLY, WITH ONE DOOR (MILES-EXPERT1). The snapshot only calls getters
(get*/is*/count/selected*). The one writer is `_expert_write`, reached only by
a 'W' message, which the app sends only while its Expert switch is on and the
user asked in words. It writes NEW patterns (the next empty ones, four since
MILES-EXPERT2, one per take) and steps on the channels the app named, with a
velocity and a late shift per step; no existing pattern, channel, mixer track
or plugin is written. 'X' removes those patterns again (their steps cleared,
their names reset, so FL counts them as empty default patterns). Its only output is SysEx
to the linked output port, which is the Miles app's own virtual port.

Why SysEx: FL's controller sandbox refuses open() and socket() (MILES-FL0),
so the JSON that the Ableton bridge writes to a file travels here as SysEx.

Wire format (all bytes 7-bit, see FLSysEx.swift for the app side):
  F0 7D 4E 4D 02 'D' mh ml ih il th tl ck <ascii json> F7   data chunk
  F0 7D 4E 4D 02 'H' mh ml pk F7                            heartbeat (last msg id, master meter)
  F0 7D 4E 4D 02 'B' F7                                     goodbye
App -> script:
  F0 7D 4E 4D 02 'R' mh ml [ih il ...] F7   resend chunks (none listed = all)
  F0 7D 4E 4D 02 'S' F7                     send a full snapshot now
  F0 7D 4E 4D 02 'W' <ascii json> F7        Expert: write drum patterns (one per take)
  F0 7D 4E 4D 02 'X' <ascii json> F7        Expert: remove those patterns
  F0 7D 4E 4D 02 'I' <ascii json> F7        Expert: inventory (read only)
Script -> app, answering W/X/I:
  F0 7D 4E 4D 02 'A' <ascii json> F7        {"id":..,"ok":..,...}
m = message id (14 bit), i = chunk index, t = chunk total, ck = sum(payload) & 7F.
pk = the master's peak right now as dB below full scale, 0..127 (127 = silent).
It rides on the heartbeat, not the snapshot: a meter changes every tick, and
the snapshot is only re-sent when something in it changed.
"""

import math
import time

import channels
import device
import general
import mixer
import patterns
import plugins
import transport
import ui

try:
    import json
except Exception:  # FL ships the stdlib; never seen, but do not die on import
    json = None

SCHEMA = 1
VERSION = "fl-1.2"

HDR = bytes([0xF0, 0x7D, 0x4E, 0x4D, 0x02])
CHUNK = 1000                 # payload bytes per SysEx (FL0: 17 x 1000 arrived whole)
SNAPSHOT_MIN_INTERVAL = 0.5  # 2 Hz ceiling, same as the Ableton bridge
HEARTBEAT_INTERVAL = 1.0
FULL_REFRESH_INTERVAL = 10.0  # resend even when unchanged (a late-joining app)
KEEP_MESSAGES = 4            # how many past messages can be re-sent on request
PARAM_CAP = 64               # named params sent per plugin
NAME_SCAN_PER_TICK = 256     # param-name scan budget per OnIdle tick
PARAM_SCAN_LIMIT = 4096      # VSTs report 4240 (4096 + CC + AT); names live below 4096
MIXER_SLOTS = 10
MAX_MIXER_SCAN = 127

_state = {
    "msg_id": 0,
    "sent": {},              # msg_id -> list of frames (bytes)
    "last_snapshot": 0.0,
    "last_heartbeat": 0.0,
    "last_body": None,
    "last_full": 0.0,
    "dirty": True,
    "force": False,
    "stats": {"messages": 0, "frames": 0, "resend_requests": 0, "resent_frames": 0,
              "build_ms_max": 0.0, "build_ms_last": 0.0, "errors": 0},
    "errors": [],
    "names": {},             # plugin key -> {"next": i, "done": bool, "named": [i...]}
}


def _err(msg):
    _state["stats"]["errors"] += 1
    if msg not in _state["errors"]:
        _state["errors"].append(msg)
        _state["errors"] = _state["errors"][-10:]


def _safe(fn, *a):
    try:
        return fn(*a)
    except Exception as e:
        _err("%s: %r" % (getattr(fn, "__name__", "?"), e))
        return None


# ---------------------------------------------------------------- params

def _plugin_key(index, slot):
    name = _safe(plugins.getPluginName, index, slot, False, slot == -1)
    return "%d:%d:%s" % (index, slot, name)


def _scan_names(index, slot, use_global):
    """Find the named params of one plugin, a budget per tick. FL answers
    getParamName for all 4240 VST indices; scanning them in one go is the
    kind of stall the brief's crash test is about, so it is spread out."""
    key = _plugin_key(index, slot)
    entry = _state["names"].get(key)
    if entry is None:
        count = _safe(plugins.getParamCount, index, slot, use_global) or 0
        entry = {"next": 0, "done": False, "named": [], "count": count}
        if len(_state["names"]) >= 8:   # a small cache; selection changes rarely
            _state["names"].clear()
        _state["names"][key] = entry
    if entry["done"]:
        return entry
    end = min(entry["count"], PARAM_SCAN_LIMIT, entry["next"] + NAME_SCAN_PER_TICK)
    for p in range(entry["next"], end):
        nm = _safe(plugins.getParamName, p, index, slot, use_global)
        if nm:
            entry["named"].append(p)
            if len(entry["named"]) >= PARAM_CAP:
                end = p + 1
                break
    entry["next"] = end
    if end >= min(entry["count"], PARAM_SCAN_LIMIT) or len(entry["named"]) >= PARAM_CAP:
        entry["done"] = True
    return entry


def _plugin_params(index, slot):
    use_global = slot == -1
    entry = _scan_names(index, slot, use_global)
    out = []
    for p in entry["named"]:
        nm = _safe(plugins.getParamName, p, index, slot, use_global)
        if not nm:
            continue
        val = _safe(plugins.getParamValue, p, index, slot, use_global)
        txt = _safe(plugins.getParamValueString, p, index, slot, use_global)
        item = {"name": nm}
        if val is not None:
            item["v"] = round(float(val), 4)
        if txt:
            item["s"] = txt
        out.append(item)
    return {"params": out, "param_count": entry["count"],
            "scan_done": entry["done"], "truncated": len(entry["named"]) >= PARAM_CAP}


# ---------------------------------------------------------------- snapshot

def _channels():
    n = _safe(channels.channelCount, 1) or 0
    rows = []
    for i in range(n):
        rows.append({"name": _safe(channels.getChannelName, i, True),
                     "type": _safe(channels.getChannelType, i, True),
                     "mixer": _safe(channels.getTargetFxTrack, i, True)})
    sel = _safe(channels.selectedChannel, 1, 0, 1)
    detail = None
    if sel is not None and 0 <= sel < n:
        detail = {"index": sel}
        if _safe(plugins.isValid, sel, -1, True):
            detail["plugin"] = _safe(plugins.getPluginName, sel, -1, False, True)
            detail["user_name"] = _safe(plugins.getPluginName, sel, -1, True, True)
            detail.update(_plugin_params(sel, -1))
        detail["steps"] = [1 if _safe(channels.getGridBit, sel, s, True) else 0 for s in range(16)]
    return {"count": n, "list": rows, "selected": detail}


def _mixer():
    n = min(_safe(mixer.trackCount) or 0, MAX_MIXER_SCAN)
    sel = _safe(mixer.trackNumber)
    tracks = []
    for i in range(n):
        name = _safe(mixer.getTrackName, i)
        slots = []
        for s in range(MIXER_SLOTS):
            if _safe(mixer.isTrackPluginValid, i, s):
                slots.append({"slot": s, "plugin": _safe(plugins.getPluginName, i, s),
                              "user_name": _safe(plugins.getPluginName, i, s, True)})
        if slots or i == 0 or i == sel or (name and not name.startswith("Insert ")):
            tracks.append({"i": i, "name": name, "slots": slots,
                           "slots_on": bool(_safe(mixer.isTrackSlotsEnabled, i))})
    focused = _safe(mixer.getActiveEffectIndex)
    fx = None
    if focused:
        t, s = focused
        fx = {"track": t, "slot": s, "plugin": _safe(plugins.getPluginName, t, s),
              "user_name": _safe(plugins.getPluginName, t, s, True)}
        fx.update(_plugin_params(t, s))
    return {"track_count": n, "selected": sel, "tracks": tracks, "focused": fx}


def _snapshot():
    ppq = _safe(general.getRecPPQ)
    ppb = _safe(general.getRecPPB)
    return {
        "schema": SCHEMA,
        "bridge": VERSION,
        "api": _safe(general.getVersion),
        "fl": _safe(ui.getVersion),
        "tempo_raw": _safe(mixer.getCurrentTempo),
        "beats_per_bar": (ppb // ppq) if (ppq and ppb) else None,
        "playing": bool(_safe(transport.isPlaying)),
        # MILES-EXPERT2: unsaved changes (0 = none). The app reads chords from
        # the saved .flp only when this says the file is what FL is showing.
        "changed": _safe(general.getChangedFlag),
        "recording": bool(_safe(transport.isRecording)),
        "channels": _channels(),
        "mixer": _mixer(),
    }


# ---------------------------------------------------------------- wire

def _u14(n):
    return bytes([(n >> 7) & 0x7F, n & 0x7F])


def _frames(msg_id, text):
    data = text.encode("ascii")
    parts = [data[i:i + CHUNK] for i in range(0, len(data), CHUNK)] or [b""]
    total = len(parts)
    out = []
    for i, part in enumerate(parts):
        ck = sum(part) & 0x7F
        out.append(HDR + b"D" + _u14(msg_id) + _u14(i) + _u14(total) + bytes([ck]) + part + b"\xF7")
    return out


def _send(frames):
    if not device.isAssigned():
        return False
    for f in frames:
        device.midiOutSysex(f)
    return True


def _publish(doc):
    _state["msg_id"] = (_state["msg_id"] + 1) & 0x3FFF
    mid = _state["msg_id"]
    doc["msg"] = mid
    doc["stats"] = dict(_state["stats"])
    doc["errors"] = list(_state["errors"])
    text = json.dumps(doc, ensure_ascii=True, separators=(",", ":"))
    frames = _frames(mid, text)
    _state["sent"][mid] = frames
    for old in sorted(_state["sent"])[:-KEEP_MESSAGES]:
        del _state["sent"][old]
    if _send(frames):
        _state["stats"]["messages"] += 1
        _state["stats"]["frames"] += len(frames)


def _master_peak_byte():
    peak = _safe(mixer.getTrackPeaks, 0, 2)   # 2 = the louder of L and R
    try:
        peak = float(peak)
    except Exception:
        return 127
    if peak <= 0.0:
        return 127
    return int(min(127, max(0, round(-20.0 * math.log10(peak)))))


def _heartbeat():
    if device.isAssigned():
        device.midiOutSysex(HDR + b"H" + _u14(_state["msg_id"]) + bytes([_master_peak_byte()]) + b"\xF7")


def _tick(now):
    if now - _state["last_heartbeat"] >= HEARTBEAT_INTERVAL:
        _state["last_heartbeat"] = now
        _heartbeat()
    if now - _state["last_snapshot"] < SNAPSHOT_MIN_INTERVAL:
        return
    _state["last_snapshot"] = now
    t0 = time.time()
    doc = _snapshot()
    ms = (time.time() - t0) * 1000
    _state["stats"]["build_ms_last"] = round(ms, 2)
    if ms > _state["stats"]["build_ms_max"]:
        _state["stats"]["build_ms_max"] = round(ms, 2)
    body = json.dumps(doc, sort_keys=True)
    full_due = now - _state["last_full"] >= FULL_REFRESH_INTERVAL
    if body == _state["last_body"] and not full_due and not _state["force"]:
        return
    _state["last_body"] = body
    _state["last_full"] = now
    _state["force"] = False
    _publish(doc)


# ---------------------------------------------------------------- Expert (MILES-EXPERT1)

EXPERT_MAX_STEPS = 128         # 8 bars of 16ths
EXPERT_MAX_TAKES = 4
# patterns.findFirstNextEmptyPat: no name dialog. FL's own midi.py says
# FFNEP_DontPromptName = 1 << 1. EXPERT1-DOGRULAMA: 1 opened FL's rename box,
# a modal that held the script, and the app said "Your DAW didn't answer".
FFNEP_DONT_PROMPT_NAME = 1 << 1
# FL's step parameters (midi.py): pVelocity = 1, pShift = 7.
P_VELOCITY = 1
P_SHIFT = 7


def _grid(ch, step, value=None):
    """getGridBit/setGridBit on the CURRENT pattern, by global channel index.
    The useGlobalIndex argument is newer than the functions; fall back."""
    try:
        if value is None:
            return channels.getGridBit(ch, step, True)
        return channels.setGridBit(ch, step, value, True)
    except TypeError:
        if value is None:
            return channels.getGridBit(ch, step)
        return channels.setGridBit(ch, step, value)


def _step_param(ch, pat, step, param, value):
    try:
        channels.setStepParameterByIndex(ch, pat, step, param, value, True)
        return True
    except TypeError:
        try:
            channels.setStepParameterByIndex(ch, pat, step, param, value)
            return True
        except Exception:
            return False
    except Exception:
        return False


def _answer(doc):
    if device.isAssigned():
        text = json.dumps(doc, ensure_ascii=True, separators=(",", ":"))
        device.midiOutSysex(HDR + b"A" + text.encode("ascii") + b"\xF7")


def _expert_rows(rows_in):
    """[(channel, [(step, velocity, shift)])], refusing anything that does not
    match this project as it is now: the channel index must exist and still
    carry the name the app saw, so a stale index can never write into someone
    else."""
    n = channels.channelCount(1)
    rows = []
    for row in rows_in or []:
        ch = int(row.get("ch"))
        if not (0 <= ch < n):
            return None, "channel_gone"
        if channels.getChannelName(ch, True) != row.get("name"):
            return None, "channel_renamed"
        steps = [int(x) for x in row.get("steps") or []]
        vel = list(row.get("vel") or [])
        shift = list(row.get("shift") or [])
        if any(not (0 <= x < EXPERT_MAX_STEPS) for x in steps):
            return None, "bad_steps"
        cells = []
        for k, x in enumerate(steps):
            v = int(vel[k]) if k < len(vel) else 100
            sh = float(shift[k]) if k < len(shift) else 0.0
            cells.append((x, max(1, min(127, v)), max(0.0, min(0.9, sh))))
        rows.append((ch, cells))
    if not rows:
        return None, "no_rows"
    return rows, None


def _takes(cmd):
    """MILES-EXPERT2 carries `takes` (one rows list each); EXPERT1's single
    `rows` is one take."""
    takes = cmd.get("takes")
    if takes is None:
        takes = [{"name": cmd.get("name"), "rows": cmd.get("rows")}]
    if not isinstance(takes, list) or not (1 <= len(takes) <= EXPERT_MAX_TAKES):
        return None, "bad_takes"
    out = []
    for t in takes:
        rows, error = _expert_rows(t.get("rows"))
        if error:
            return None, error
        out.append(((t.get("name") or cmd.get("name") or "Miles Drums")[:40], rows))
    return out, None


def _expert_write(cmd):
    takes, error = _takes(cmd)
    if error:
        return {"error": error}
    before = patterns.patternNumber()
    ppq = _safe(general.getRecPPQ) or 96
    made = []
    written = 0
    for name, rows in takes:
        last = patterns.patternNumber()
        patterns.findFirstNextEmptyPat(FFNEP_DONT_PROMPT_NAME)
        pat = patterns.patternNumber()
        try:
            fresh = patterns.isPatternDefault(pat)
        except Exception:
            fresh = None
        if pat == last or pat == before or fresh is False or pat in made:
            return {"error": "no_empty_pattern", "pattern": pat, "patterns": made}
        patterns.setPatternName(pat, name)
        made.append(pat)
        for ch, cells in rows:
            for x, v, sh in cells:
                _grid(ch, x, 1)
                if v != 100:
                    _step_param(ch, pat, x, P_VELOCITY, v)
                if sh > 0:
                    _step_param(ch, pat, x, P_SHIFT, int(round(sh * ppq / 4)))
                written += 1
    if before:
        patterns.jumpToPattern(made[0])
    return {"ok": True, "pattern": made[0], "patterns": made,
            "names": [patterns.getPatternName(p) for p in made],
            "length_beats": [patterns.getPatternLength(p) for p in made], "steps_written": written,
            "previous_pattern": before, "tempo_raw": _safe(mixer.getCurrentTempo)}


def _expert_remove(cmd):
    """Undo of `_expert_write`: only OUR patterns, only if they still carry
    our names, and they end as FL's default empty patterns."""
    takes, error = _takes(cmd)
    if error:
        return {"error": error}
    pats = cmd.get("patterns") or [cmd.get("pattern")]
    if len(pats) != len(takes):
        return {"error": "bad_patterns"}
    for pat, (name, rows) in zip(pats, takes):
        if patterns.getPatternName(int(pat)) != name:
            return {"error": "pattern_renamed", "pattern": pat}
    cleared = 0
    defaults = []
    for pat, (name, rows) in zip(pats, takes):
        pat = int(pat)
        patterns.jumpToPattern(pat)
        for ch, cells in rows:
            for x in range(EXPERT_MAX_STEPS):
                if _grid(ch, x):
                    _grid(ch, x, 0)
                    cleared += 1
        patterns.setPatternName(pat, "")
        try:
            defaults.append(patterns.isPatternDefault(pat))
        except Exception:
            defaults.append(None)
    back = cmd.get("previous_pattern")
    if back:
        patterns.jumpToPattern(int(back))
    return {"ok": True, "patterns": pats, "steps_cleared": cleared, "now_default": defaults}


def _expert_inventory():
    """Read only: every pattern's name and length, and each channel's steps in
    the ACTIVE pattern. Before/after this is the acceptance run's proof."""
    out = {"active": patterns.patternNumber(), "patterns": [], "channels": []}
    for i in range(1, patterns.patternMax() + 1):
        try:
            if patterns.isPatternDefault(i):
                continue
        except Exception:
            if i > patterns.patternCount() + 8:
                break
        out["patterns"].append([i, patterns.getPatternName(i), patterns.getPatternLength(i)])
    for ch in range(channels.channelCount(1)):
        out["channels"].append([channels.getChannelName(ch, True),
                                "".join("1" if _grid(ch, x) else "0" for x in range(32))])
    return out


def _expert(kind, data):
    doc = {"ok": False}
    try:
        cmd = json.loads(data.decode("ascii"))
        doc["id"] = cmd.get("id")
        if kind == ord("W"):
            doc.update(_expert_write(cmd))
        elif kind == ord("X"):
            doc.update(_expert_remove(cmd))
        else:
            doc.update(ok=True, inventory=_expert_inventory())
    except Exception as e:
        doc["error"] = "exception: %r" % (e,)
    _state["force"] = True
    _answer(doc)


# ---------------------------------------------------------------- FL events

def OnInit():
    _state["force"] = True


def OnDeInit():
    try:
        if device.isAssigned():
            device.midiOutSysex(HDR + b"B" + b"\xF7")
    except Exception:
        pass


def OnIdle():
    if json is None:
        return
    try:
        _tick(time.time())
    except Exception as e:
        _err("tick: %r" % (e,))


def OnSysEx(event):
    data = bytes(event.sysex or b"")
    if not data.startswith(HDR) or len(data) < 7:
        return
    kind = data[5]
    event.handled = True
    if kind == ord("S"):
        _state["force"] = True
        _state["last_snapshot"] = 0.0
        return
    if kind in (ord("W"), ord("X"), ord("I")):
        _expert(kind, data[6:-1])
        return
    if kind != ord("R") or len(data) < 9:
        return
    mid = (data[6] << 7) | data[7]
    frames = _state["sent"].get(mid)
    _state["stats"]["resend_requests"] += 1
    if frames is None:
        # Gone from the window: a fresh snapshot replaces it.
        _state["force"] = True
        _state["last_snapshot"] = 0.0
        return
    wanted = []
    body = data[8:-1]
    for k in range(0, len(body) - 1, 2):
        wanted.append((body[k] << 7) | body[k + 1])
    pick = [frames[i] for i in wanted if 0 <= i < len(frames)] if wanted else frames
    if _send(pick):
        _state["stats"]["resent_frames"] += len(pick)


def OnMidiMsg(event):
    # The port carries nothing musical; never let it reach FL as notes/CC.
    event.handled = True
