#!/usr/bin/env python3
"""midimon — a clean live MIDI monitor (aseqdump, parsed & rendered right).

`aseqdump | tr -d …` is a hack. This wraps aseqdump, parses each event, and
renders an aligned, colourised stream: note numbers → names (C4…), velocity as a
little bar, events tinted by type. Ctrl-C to stop.

    python3 midimon.py                 # monitor aseqdump's default port
    python3 midimon.py -p 28:0         # subscribe to a specific source
    python3 midimon.py -l              # list ports and exit
    python3 midimon.py --raw           # passthrough (debug)

Parsing is a pure function (`parse_line`) so it's unit-tested without ALSA.
"""
from __future__ import annotations

import argparse
import re
import subprocess
import sys

NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]

# event keyword → ANSI colour
C = {"note on": "92", "note off": "90", "control": "93", "program": "96",
     "pitch": "95", "channel": "94", "aftertouch": "94",
     "clock": "90", "start": "92", "stop": "91", "continue": "92",
     "song": "90", "sysex": "95", "active": "90", "reset": "91"}
RESET, DIM, BOLD = "\033[0m", "\033[2m", "\033[1m"

# " 28:0   Note on                 0, note 60, velocity 100"
_ROW = re.compile(r"^\s*(\d+:\d+)\s+(\S.*?\S|\S)\s{2,}(.*?)\s*$")


def note_name(n: int) -> str:
    return f"{NOTE_NAMES[n % 12]}{n // 12 - 1}"


def parse_line(line: str) -> dict | None:
    """aseqdump event row → {source, event, data, ch?}. None if not an event."""
    m = _ROW.match(line.rstrip("\n"))
    if not m:
        return None
    source, event, data = m.group(1), m.group(2), m.group(3)
    ch = None
    mc = re.match(r"^(\d+)\b", data)          # first int of data is the channel
    if mc:
        ch = int(mc.group(1))
    return {"source": source, "event": event, "data": data, "ch": ch}


def enrich(ev: dict) -> dict:
    """Add structured fields parsed from `data` (note/velocity/controller/…).

    One parsing source of truth — used by the CLI renderer and the web stream.
    """
    out = dict(ev)
    for key, pat in (("note", r"note (\d+)"), ("velocity", r"velocity (\d+)"),
                     ("controller", r"controller (\d+)"), ("value", r"value (-?\d+)"),
                     ("program", r"program (\d+)")):
        m = re.search(pat, ev["data"])
        if m:
            out[key] = int(m.group(1))
    if "note" in out:
        out["note_name"] = note_name(out["note"])
    return out


def _color(event: str) -> str:
    e = event.lower()
    for kw, code in C.items():
        if e.startswith(kw):
            return code
    return "97"


def _render_data(event: str, data: str) -> str:
    """Friendlier data: note names + a velocity bar where it applies."""
    note = re.search(r"note (\d+)", data)
    vel = re.search(r"velocity (\d+)", data)
    out = data
    if note:
        n = int(note.group(1))
        out = out.replace(f"note {n}", f"note {n} {DIM}({note_name(n)}){RESET}")
    if vel:
        v = int(vel.group(1))
        bars = "▁▂▃▄▅▆▇█"
        out += f"  {bars[min(v, 127) * (len(bars) - 1) // 127]}"
    return out


def render(ev: dict) -> str:
    code = _color(ev["event"])
    ch = f"ch{ev['ch']:<2}" if ev["ch"] is not None else "   "
    return (f"{DIM}{ev['source']:>6}{RESET}  "
            f"\033[{code}m{BOLD}{ev['event']:<18}{RESET} "
            f"{DIM}{ch}{RESET} {_render_data(ev['event'], ev['data'])}")


def list_ports():
    subprocess.run(["aseqdump", "-l"])


def monitor(port: str | None, raw: bool):
    cmd = ["aseqdump"] + (["-p", port] if port else [])
    print(f"{DIM}⚓ midimon — {' '.join(cmd)} · Ctrl-C to stop{RESET}", file=sys.stderr)
    proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, text=True, bufsize=1)
    try:
        for line in proc.stdout or []:
            if raw:
                sys.stdout.write(line); continue
            ev = parse_line(line)
            if ev:
                print(render(ev), flush=True)
            elif line.strip() and not line.startswith(("Source", "Waiting")):
                print(f"{DIM}{line.rstrip()}{RESET}")
    except KeyboardInterrupt:
        pass
    finally:
        proc.terminate()


def main(argv=None):
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("-p", "--port", help="source port (CLIENT:PORT), e.g. 28:0")
    ap.add_argument("-l", "--list", action="store_true", help="list ports and exit")
    ap.add_argument("--raw", action="store_true", help="passthrough, no parsing")
    a = ap.parse_args(argv)
    if a.list:
        return list_ports()
    monitor(a.port, a.raw)


if __name__ == "__main__":
    main()
