"""Tests for midiviz's non-drawing parts (no Qt, no ALSA needed).

The drawing itself is verified by `python3 midiviz.py --selftest`, which renders
synthetic events through the real paint path; green tests on pure functions prove
nothing about a window. What is worth pinning here is the stuff that would fail
*silently*: the port-preference copy drifting from midimon's authored one, and a
word sneaking into the glyph set.
"""
import subprocess
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import midimon                    # noqa: E402
import midiviz as V               # noqa: E402


# ── the copy must never disagree with the original ─────────────────────────
def test_watch_preference_does_not_drift_from_midimon():
    """midiviz keeps a fallback copy of the preference order for trees whose
    midimon predates `resolve_port`. Two lists that are supposed to be the same
    list are exactly the kind of thing that quietly stops being the same."""
    authored = getattr(midimon, "WATCH_PREFERENCE", None)
    if authored is None:
        return                     # this tree's midimon has none; nothing to drift from
    assert tuple(V.WATCH_PREFERENCE) == tuple(authored)


def test_resolve_delegates_to_midimon_when_it_has_the_helper(monkeypatch):
    monkeypatch.setattr(midimon, "resolve_port", lambda: ("99:9", "sentinel"),
                        raising=False)
    assert V.resolve_watch_port() == ("99:9", "sentinel")


# ── the fallback resolver ──────────────────────────────────────────────────
# Real `aseqdump -l` output from this rig, with the LCXL3 and the driver both up.
_PORTS = """ Port    Client name                      Port name
  0:0    System                           Timer
 14:0    Midi Through                     Midi Through Port-0
 20:0    LCXL3 1                          LCXL3 1 MIDI In
 20:1    LCXL3 1                          LCXL3 1 DAW In
133:0    RtMidiOut Client                 ParVagues LCXL3
"""


def _fallback(monkeypatch, text):
    monkeypatch.delattr(midimon, "resolve_port", raising=False)
    monkeypatch.setattr(V.subprocess, "run",
                        lambda *a, **k: subprocess.CompletedProcess(a, 0, text, ""))
    return V.resolve_watch_port()


def test_the_driver_is_found_by_its_PORT_name_not_its_client(monkeypatch):
    """The whole point. python-rtmidi registers the lcxl3 driver's client as
    'RtMidiOut Client' and puts 'ParVagues LCXL3' on the PORT, so matching only
    the client column silently falls through to the raw hardware — the monitor
    would work, and show the wrong stream."""
    pid, label = _fallback(monkeypatch, _PORTS)
    assert pid == "133:0"
    assert "ParVagues LCXL3" in label


def test_it_falls_back_down_the_preference_order(monkeypatch):
    without_driver = "\n".join(l for l in _PORTS.splitlines() if "ParVagues" not in l)
    assert _fallback(monkeypatch, without_driver)[0] == "20:1"     # raw v3 DAW port
    only_thru = "\n".join(l for l in _PORTS.splitlines() if "LCXL3" not in l)
    assert _fallback(monkeypatch, only_thru)[0] == "14:0"          # the catch-all
    assert _fallback(monkeypatch, " Port  Client name  Port name\n")[0] is None


def test_a_header_only_listing_is_not_mistaken_for_a_port(monkeypatch):
    pid, label = _fallback(monkeypatch, "")
    assert pid is None and "no MIDI source" in label


# ── the viz is wordless, and every value lands on one scale ────────────────
def test_no_glyph_is_a_word():
    """PLN: 'no word like control change pure viz'. Every rendered glyph is one
    character, and none of them is a letter — hex digits are the only alnum."""
    glyphs = [g for _kw, g in V.CLASS_GLYPH] + [V.OTHER_GLYPH] + list(V.HEX)
    for g in glyphs:
        assert len(g) == 1, g
        assert not g.isalpha() or g in V.HEX, g


def test_note_on_and_off_are_different_glyph_classes():
    on, on_fam = V._glyph_for("Note on")
    off, off_fam = V._glyph_for("Note off")
    assert on != off and on_fam == off_fam == V.FAM_NOTE
    assert V._glyph_for("Some future event")[0] == V.OTHER_GLYPH


def test_every_event_kind_normalises_onto_0_127():
    assert V._norm_value({"value": 0}) == 0
    assert V._norm_value({"value": 127}) == 127
    assert V._norm_value({"velocity": 100}) == 100
    assert V._norm_value({"program": 7}) == 7
    assert V._norm_value({}) == 64                      # no value = mid, not 0
    # pitch bend is ±8192, and centre must land mid-scale rather than clamp to 0
    assert V._norm_value({"value": 0, "event": "Pitch bend"}) == 0
    assert 61 <= V._norm_value({"value": -1}) <= 65
    assert V._norm_value({"value": -8192}) == 0
    assert V._norm_value({"value": 8191}) == 127


def test_dataless_transport_messages_do_not_parse_at_all():
    """A real gap, pinned here rather than papered over.

    `midimon.parse_line`'s row regex is `addr  EVENT  <2+ spaces>  DATA`, so an
    aseqdump line with no data column — which is exactly how Start, Stop,
    Continue and Clock print — matches nothing and is dropped. midiviz therefore
    has glyphs (▶ ■ ▷ ·) that can never light up, and midimon's own console
    silently omits transport too. Fixing it belongs in midimon (one parser per
    concept); when that lands, this test is what tells you to delete the xfail.
    """
    assert midimon.parse_line(" 28:0   Start") is None
    assert midimon.parse_line(" 28:0   Stop") is None
    # ...while the same event WITH a data column parses fine:
    assert midimon.parse_line(" 28:0   Note on   1, note 60, velocity 99") is not None


def test_the_real_parser_feeds_the_real_ingest_path():
    """The selftest's synthetic lines must actually parse — if the fixture drifted
    out of aseqdump's shape the selftest would render an empty window and pass."""
    lines = V._synthetic_lines()
    parsed = [midimon.parse_line(l) for l in lines]
    unparsed = [l.split()[1] for l, p in zip(lines, parsed) if p is None]
    # the ONLY tolerated failures are the data-less transport lines above
    assert set(unparsed) <= {"Start", "Stop"}, unparsed
    enriched = [midimon.enrich(p) for p in parsed if p is not None]
    ccs = [e["controller"] for e in enriched if "controller" in e]
    # every one of the 48 authored grid cells is covered
    assert set(V.grid.CC_TO_CELL) <= set(ccs)
    assert any(cc not in V.grid.CC_TO_CELL for cc in ccs), "no un-gridded CC to rain"
