"""Tests for tools/gig-log.py — the session recorder.

Every positive case here is a real line captured from this machine's `pw-top -b`
and `aseqdump`, not an invented format. The three properties that actually matter:

  1. the recorder must NOT be a firehose (CC coalescing), but must never lose a note;
  2. counters must be reported as deltas from an HONEST baseline;
  3. it must degrade to nothing when the gear it watches is absent.
"""
from __future__ import annotations

import importlib.util
import json
import os
import time
from datetime import datetime
from pathlib import Path

import pytest

SPEC = importlib.util.spec_from_file_location(
    "gig_log", Path(__file__).resolve().parents[1] / "gig-log.py")
gl = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(gl)


# --------------------------------------------------------------------------- #
# pw-top parsing
# --------------------------------------------------------------------------- #

# Real lines. Note the varying FORMAT column: empty for MIDI/idle nodes, three
# tokens for a running audio node. That is why ERR is indexed from the left (always
# token 8) and NAME from the right (always last) and never from the middle.
PWTOP_HEADER = ("S   ID  QUANT   RATE    WAIT    BUSY   W/Q   B/Q  ERR "
                "FORMAT           NAME ")
PWTOP_ROWS = [
    ("R  160      0      0  13.6us 462.9us  0.00  0.09  67072                   "
     "+ ardour", ("ardour", 67072)),
    ("C   49      0      0    ---     ---   ---   ---     0                  "
     "alsa_output.usb-BEHRINGER_UMC202HD_192k_12345678-00.HiFi__Line__sink",
     ("alsa_output.usb-BEHRINGER_UMC202HD_192k_12345678-00.HiFi__Line__sink", 0)),
    ("I   29      0      0   0.0us   0.0us  ???   ???     0                  "
     "Dummy-Driver", ("Dummy-Driver", 0)),
    ("R  128     512  48000  20.1us  55.0us  0.02  0.05    3 S32LE 2 48000    "
     "SuperCollider", ("SuperCollider", 3)),
]


@pytest.mark.parametrize("line,want", PWTOP_ROWS)
def test_pwtop_rows_parse(line, want):
    assert gl.XrunReader().parse_line(line) == want


def test_the_pwtop_header_is_not_mistaken_for_a_node():
    """'S' is also a valid state char, so the header must be rejected on the ID
    column not the state column."""
    assert gl.XrunReader().parse_line(PWTOP_HEADER) is None


@pytest.mark.parametrize("junk", [
    "", "\n", "Waiting for data.",
    "R  160      0",                                   # truncated row
    "R  abc      0      0    ---   ---  ---  ---   0        thing",  # bad id
])
def test_pwtop_junk_is_ignored(junk):
    assert gl.XrunReader().parse_line(junk) is None


def test_a_node_name_with_a_plus_prefix_is_stripped():
    """A linked node is printed as '+ ardour'; the '+' is presentation, not name."""
    name, _ = gl.XrunReader().parse_line(PWTOP_ROWS[0][0])
    assert name == "ardour"


# --------------------------------------------------------------------------- #
# the baseline rule — this is the bug the first report caught
# --------------------------------------------------------------------------- #

def _warm(xr: gl.XrunReader, name: str, value: int, zeros: int = 1) -> None:
    """Feed `zeros` of pw-top's zero-filled tables, then fill the warm-up window
    with the node's real lifetime count."""
    for _ in range(zeros):
        xr._baseline(name, 0)
    for _ in range(xr.WARMUP):
        xr._baseline(name, value)


def test_pwtops_zero_filled_first_table_does_not_become_the_baseline():
    """Regression #1: baselining on FIRST sight captured 0, so Ardour's lifetime
    67072 was reported as 67072 xruns in a 25-second idle session."""
    xr = gl.XrunReader()
    _warm(xr, "ardour", 67072, zeros=1)
    assert xr.deltas()["ardour"] == 0
    assert xr.total_delta() == 0


def test_TWO_zero_filled_tables_still_do_not_become_the_baseline():
    """Regression #2, and the reason the rule is max-over-a-warm-up rather than
    'the second value': pw-top emits a non-deterministic NUMBER of zero-filled
    tables. A rule that is usually right is the worst kind here, because the one
    bad reading looks exactly like a disaster."""
    xr = gl.XrunReader()
    _warm(xr, "ardour", 67123, zeros=2)
    assert xr.deltas()["ardour"] == 0


@pytest.mark.parametrize("zeros", [0, 1, 2, 3, 4])
def test_the_baseline_survives_any_number_of_leading_zero_tables(zeros):
    xr = gl.XrunReader()
    _warm(xr, "ardour", 67134, zeros=zeros)
    assert xr.deltas()["ardour"] == 0


def test_a_real_xrun_after_the_warmup_is_counted():
    xr = gl.XrunReader()
    _warm(xr, "SuperCollider", 3)
    xr._baseline("SuperCollider", 7)
    assert xr.deltas()["SuperCollider"] == 4
    assert xr.total_delta() == 4


def test_the_baseline_is_frozen_after_the_warmup_so_a_rise_is_never_absorbed():
    """If the baseline kept tracking upward, xruns would silently become the new
    normal and the session would always read as clean."""
    xr = gl.XrunReader()
    _warm(xr, "ardour", 100)
    for v in (105, 110, 120, 130):
        xr._baseline("ardour", v)
    assert xr.deltas()["ardour"] == 30


def test_a_node_seen_only_once_reports_zero_not_its_history():
    xr = gl.XrunReader()
    xr._baseline("ardour", 67072)
    assert xr.deltas()["ardour"] == 0


def test_a_recreated_node_rebaselines_instead_of_going_negative():
    """Destroy/recreate resets the node's counter. A negative delta is never a
    real reading, so re-baseline."""
    xr = gl.XrunReader()
    _warm(xr, "ardour", 900)
    xr._baseline("ardour", 950)
    assert xr.deltas()["ardour"] == 50
    xr._baseline("ardour", 2)            # node came back fresh
    assert xr.deltas()["ardour"] == 0
    xr._baseline("ardour", 9)
    assert xr.deltas()["ardour"] == 7


def test_total_delta_sums_across_nodes_and_never_goes_negative():
    xr = gl.XrunReader()
    for name in ("ardour", "SuperCollider"):
        _warm(xr, name, 10)
        xr._baseline(name, 15)
    assert xr.total_delta() == 10
    xr._baseline("ardour", 1)            # mid-flight re-baseline
    assert xr.total_delta() >= 0


def test_ardours_idle_xrun_drift_is_baselined_away():
    """Measured on this machine: Ardour's ERR climbs ~6/min even with nothing
    playing (67072 -> 67123 -> 67134 across three captures minutes apart). Only
    the session delta is ours, so an idle drift baseline must read as zero."""
    xr = gl.XrunReader()
    _warm(xr, "ardour", 67072, zeros=2)
    assert xr.deltas()["ardour"] == 0


# --------------------------------------------------------------------------- #
# MIDI: coalesce state, never drop events
# --------------------------------------------------------------------------- #

def _reader():
    return gl.MidiReader(port="0:0")


def test_a_fader_sweep_collapses_to_one_line_carrying_the_whole_move():
    """The #71/#72 lesson applied to the logger itself: 128 events on one control
    is ONE line, and that line still tells you where the fader started, ended, and
    how far it travelled."""
    md = _reader()
    for v in range(128):
        assert md.feed(f" 24:0   Control change          0, controller 13, value {v}",
                       1000.0 + v / 128)
    cc, disc = md.drain()
    assert len(cc) == 1
    e = cc[0]
    assert (e["cc"], e["n"], e["v0"], e["v1"], e["lo"], e["hi"]) == (13, 128, 0, 127, 0, 127)
    assert disc == []


def test_a_sweep_up_and_back_down_records_the_extremes_not_just_the_endpoints():
    md = _reader()
    for v in list(range(0, 128)) + list(range(127, -1, -1)):
        md.feed(f" 24:0   Control change          0, controller 20, value {v}", 1000.0)
    cc, _ = md.drain()
    e = cc[0]
    assert (e["v0"], e["v1"]) == (0, 0)      # started and ended at the bottom...
    assert (e["lo"], e["hi"]) == (0, 127)    # ...but the log still shows the travel


def test_distinct_controls_never_collapse_into_each_other():
    md = _reader()
    for cc_num in (13, 14, 15, 77):
        md.feed(f" 24:0   Control change          0, controller {cc_num}, value 64", 1.0)
    cc, _ = md.drain()
    assert sorted(e["cc"] for e in cc) == [13, 14, 15, 77]


def test_the_same_controller_on_a_different_channel_is_a_different_control():
    md = _reader()
    md.feed(" 24:0   Control change          0, controller 13, value 1", 1.0)
    md.feed(" 24:0   Control change          5, controller 13, value 2", 1.0)
    cc, _ = md.drain()
    assert len(cc) == 2
    assert {e["ch"] for e in cc} == {0, 5}


def test_the_same_controller_on_a_different_port_is_a_different_control():
    """Two surfaces are plugged in (LCXL 24:0, KeyStep 16:0) — a CC1 from each is
    two controls, not one."""
    md = _reader()
    md.feed(" 24:0   Control change          0, controller 1, value 1", 1.0)
    md.feed(" 16:0   Control change          0, controller 1, value 2", 1.0)
    cc, _ = md.drain()
    assert len(cc) == 2
    assert {e["p"] for e in cc} == {"24:0", "16:0"}


def test_every_note_survives_a_flood_of_ccs():
    """A CC is a state and may be superseded; a note is an EVENT and dropping one
    loses a thing that happened. 200 CCs must not shadow the 3 notes."""
    md = _reader()
    for v in range(200):
        md.feed(f" 24:0   Control change          0, controller 13, value {v % 128}", 1.0)
    for n in (36, 38, 42):
        md.feed(f" 16:0   Note on                 0, note {n}, velocity 100", 1.0)
    cc, disc = md.drain()
    assert len(cc) == 1
    assert [d["note"] for d in disc] == [36, 38, 42]
    assert all(d["on"] for d in disc)


def test_note_off_is_kept_and_distinguished_from_note_on():
    md = _reader()
    md.feed(" 16:0   Note on                 0, note 60, velocity 100", 1.0)
    md.feed(" 16:0   Note off                0, note 60, velocity 0", 2.0)
    _, disc = md.drain()
    assert [d["on"] for d in disc] == [True, False]


def test_pitch_bend_is_coalesced_like_a_continuous_control():
    md = _reader()
    for v in (-8192, 0, 8191):
        assert md.feed(f" 16:0   Pitch bend              0, value {v}", 1.0)
    cc, _ = md.drain()
    assert len(cc) == 1
    assert cc[0]["k"] == "pb" and "cc" not in cc[0]
    assert (cc[0]["lo"], cc[0]["hi"]) == (-8192, 8191)


def test_draining_twice_does_not_replay_the_first_window():
    md = _reader()
    md.feed(" 24:0   Control change          0, controller 13, value 5", 1.0)
    assert len(md.drain()[0]) == 1
    assert md.drain() == ([], [])


def test_event_count_counts_raw_events_not_written_lines():
    """The report divides the two to show the coalescing ratio, so `events` has to
    stay the RAW count."""
    md = _reader()
    for v in range(50):
        md.feed(f" 24:0   Control change          0, controller 13, value {v}", 1.0)
    md.drain()
    assert md.events == 50


@pytest.mark.parametrize("junk", [
    "Waiting for data. Press Ctrl+C to end.",
    " Source  Event                  Ch  Data",
    "",
    " 24:0   Program change          0, program 5",     # a kind we don't record
])
def test_non_event_lines_are_reported_as_not_events(junk):
    assert gl.MidiReader(port="0:0").feed(junk, 1.0) is False


# --------------------------------------------------------------------------- #
# procfs sampling
# --------------------------------------------------------------------------- #

def _stat_line(comm: str, utime: int, stime: int, rss: int) -> str:
    """A synthetic /proc/<pid>/stat: field 1 pid, 2 comm, 3 state, then 3..52."""
    fields = [str(100 + i) for i in range(3, 53)]     # fields 3..52, distinct values
    fields[3 - 3] = "S"
    fields[14 - 3] = str(utime)
    fields[15 - 3] = str(stime)
    fields[24 - 3] = str(rss)
    return f"42 ({comm}) " + " ".join(fields)


def test_proc_stat_parsing_survives_a_comm_containing_spaces_and_parens():
    """pulsar's renderer comms look like `(pulsar) --type=renderer`. Splitting on
    whitespace from the left shifts every field, so utime/rss come back as garbage
    from some other column — and a logger that silently misreports CPU is worse
    than none. Split after the LAST ')'.
    """
    assert gl.parse_proc_stat(_stat_line("my (weird) proc", 1234, 567, 98765)) \
        == (1234 + 567, 98765)


def test_proc_stat_parsing_handles_an_ordinary_comm():
    assert gl.parse_proc_stat(_stat_line("scsynth", 10, 5, 400)) == (15, 400)


@pytest.mark.parametrize("raw", ["", "garbage", "42 (x) S", "42 (x)"])
def test_proc_stat_parsing_returns_none_on_a_short_or_broken_line(raw):
    assert gl.parse_proc_stat(raw) is None


def test_a_dead_pid_reads_as_none_not_as_zero():
    assert _proc_cpu_rss_of_nothing() is None


def _proc_cpu_rss_of_nothing():
    return gl._proc_cpu_rss("999999999")


def test_gear_watch_samples_the_real_machine_without_forking():
    gw = gl.GearWatch()
    first = gw.sample()
    assert set(first) == set(gl.GEAR)
    time.sleep(0.15)
    second = gw.sample()
    for comm, entry in second.items():
        assert "up" in entry
        if entry["up"]:
            assert entry["rss"] > 0
            assert entry.get("cpu", 0) >= 0


def test_gear_watch_reports_a_missing_process_as_down_not_as_zero_percent():
    """A dead scsynth reported as '0% cpu' reads like a healthy idle one. It must
    read as absent — that distinction is the whole reason to log gear at all."""
    gw = gl.GearWatch(gear={"definitely-not-a-real-process-xyz": False})
    out = gw.sample()
    assert out["definitely-not-a-real-process-xyz"] == {"up": False}


def test_package_throttle_sum_is_readable_rootless():
    assert gl._package_throttle_sum() >= 0


# --------------------------------------------------------------------------- #
# the record → mark → report round trip
# --------------------------------------------------------------------------- #

def test_a_recorded_session_round_trips_through_the_report_loader(tmp_path):
    r = gl.Recorder(out_dir=tmp_path, hz=50.0, xruns=False, midi=False)
    r._write(r.header())
    for _ in range(3):
        r._write(r.sample())
    r._write({"t": time.time(), "k": "end", "samples": 3, "midi_events": 0,
              "xrun": None})
    r.f.close()
    hdr, samples, events, marks, end = gl.load(r.path)
    assert hdr["k"] == "hdr" and hdr["v"] == 1
    assert len(samples) == 3
    assert end["samples"] == 3
    # the header must carry the baselines, or the deltas in the samples are unreadable
    assert "base_pkg_throttle" in hdr and "base_core_throttle" in hdr
    # and the wall clock, which is how the log lines up with the Ardour take
    assert hdr["iso"].startswith("20")


def test_mark_appends_to_the_newest_log_and_survives_the_loader(tmp_path):
    r = gl.Recorder(out_dir=tmp_path, hz=50.0, xruns=False, midi=False)
    r._write(r.header())
    r._write(r.sample())
    r.f.close()
    assert gl.cmd_mark("gimme acid drop", tmp_path) == 0
    _, _, _, marks, _ = gl.load(r.path)
    assert [m["label"] for m in marks] == ["gimme acid drop"]


def test_mark_with_no_session_fails_loudly_instead_of_creating_an_orphan(tmp_path):
    assert gl.cmd_mark("nobody is recording", tmp_path) == 1
    assert list(tmp_path.glob("*.jsonl")) == []


def test_the_loader_skips_a_torn_final_line(tmp_path):
    """A `kill -9` mid-write leaves half a line. That must cost one sample, not
    the whole log."""
    r = gl.Recorder(out_dir=tmp_path, hz=50.0, xruns=False, midi=False)
    r._write(r.header())
    r._write(r.sample())
    r.f.write('{"t":1,"k":"s","pkg":')          # torn
    r.f.close()
    hdr, samples, _, _, _ = gl.load(r.path)
    assert hdr["k"] == "hdr" and len(samples) == 1


def test_report_renders_a_real_session_without_raising(tmp_path, capsys):
    r = gl.Recorder(out_dir=tmp_path, hz=50.0, xruns=False, midi=False)
    r._write(r.header())
    for _ in range(4):
        r._write(r.sample())
    gl.cmd_mark("marker", tmp_path)
    r.f.close()
    assert gl.cmd_report(r.path) == 0
    out = capsys.readouterr().out
    assert "GIG LOG" in out
    assert "align the Ardour take" in out
    assert "marker" in out
    # absolute counters must never be presented as session damage
    assert "absolute counts are meaningless" in out


def test_report_on_a_header_only_log_refuses_rather_than_dividing_by_zero(tmp_path):
    r = gl.Recorder(out_dir=tmp_path, hz=50.0, xruns=False, midi=False)
    r._write(r.header())
    r.f.close()
    assert gl.cmd_report(r.path) == 1


def test_prune_deletes_old_logs_and_keeps_todays(tmp_path):
    r = gl.Recorder(out_dir=tmp_path, hz=50.0, xruns=False, midi=False)
    old = tmp_path / "gig-20200101-000000.jsonl"
    old.write_text("{}\n")
    os.utime(old, (0, time.time() - 40 * 86400))
    assert r.prune(days=30) == 1
    assert not old.exists()
    assert r.path.exists()
    r.f.close()


def test_newest_log_picks_the_latest_by_mtime(tmp_path):
    a = tmp_path / "gig-20260101-000000.jsonl"
    b = tmp_path / "gig-20260202-000000.jsonl"
    for p in (a, b):
        p.write_text("{}\n")
    os.utime(a, (0, 1000))
    os.utime(b, (0, 2000))
    assert gl.newest_log(tmp_path) == b


def test_newest_log_on_an_empty_dir_is_none(tmp_path):
    assert gl.newest_log(tmp_path) is None


# --------------------------------------------------------------------------- #
# degradation: absent gear must cost nothing
# --------------------------------------------------------------------------- #

def test_the_recorder_runs_with_both_children_disabled(tmp_path):
    """If pw-top and aseqdump are missing, the thermal timeline still records.
    Partial data beats no data, and beats a crash on stage."""
    r = gl.Recorder(out_dir=tmp_path, hz=50.0, xruns=False, midi=False)
    assert r.xr is None and r.mid is None
    s = r.sample()
    assert "pkg" in s and "gear" in s
    assert "xrun" not in s
    hdr = r.header()
    assert hdr["xruns"] is False and hdr["midi"] is False
    r.f.close()


def test_a_sample_is_json_serialisable_even_when_every_reading_is_none(tmp_path):
    """On a machine with no coretemp/dell_smm hwmon every field is None. The line
    must still be valid JSONL."""
    r = gl.Recorder(out_dir=tmp_path, hz=50.0, xruns=False, midi=False)
    r.th.coretemp = r.th.dell = r.th._pkg_input = None
    json.loads(json.dumps(r.sample()))
    r.f.close()


def test_find_seq_port_never_returns_the_hui_port():
    """The LCXL exposes two ports; 'HUI' is the Mackie-emulation one and carries
    none of our control traffic."""
    got = gl.find_seq_port("Launch Control XL")
    if got is not None:
        assert ":" in got


def test_the_midi_reader_re_resolves_its_port_by_name():
    """The device has already moved 20:0 -> 24:0 across a replug, so the port must
    be looked up on every (re)connect, not cached at startup."""
    md = gl.MidiReader()
    assert md.fixed is None
    src = (Path(__file__).resolve().parents[1] / "gig-log.py").read_text()
    body = src[src.index("class MidiReader"):src.index("# the recorder")]
    assert "find_seq_port(self.want)" in body


# --------------------------------------------------------------------------- #
# presentation
# --------------------------------------------------------------------------- #

def test_spark_keeps_the_time_axis_and_survives_holes():
    assert gl.spark([]) == "(no data)"
    assert "flat" in gl.spark([5, 5, 5])
    s = gl.spark([1, 2, 3, None, 4])
    assert len(s) == 4 and s[0] == gl.SPARK[0] and s[-1] == gl.SPARK[-1]


def test_spark_buckets_by_max_so_a_single_spike_cannot_be_averaged_away():
    """A one-second xrun burst inside a 40-minute set is the whole point of the
    log. Bucketing by mean would erase it."""
    vals = [0] * 500 + [99] + [0] * 500
    assert gl.SPARK[-1] in gl.spark(vals, width=60)


def test_spark_respects_the_width():
    assert len(gl.spark(list(range(1000)), width=60)) == 60


@pytest.mark.parametrize("secs,want", [
    (0, "0:00"), (61, "1:01"), (599, "9:59"), (3600, "1:00:00"), (3725, "1:02:05"),
])
def test_hms(secs, want):
    assert gl.hms(secs) == want


def test_the_report_flags_ardour_owned_ccs_in_the_surface_table(tmp_path, capsys):
    """CC 77-84 are MIDI-learned to Ardour's track gains. If they show up in a log
    the reader needs to know that traffic moved FADERS, not Tidal params."""
    r = gl.Recorder(out_dir=tmp_path, hz=50.0, xruns=False, midi=False)
    r._write(r.header())
    r._write(r.sample())
    t = time.time()
    r._write({"t": t, "k": "cc", "p": "24:0", "ch": 0, "cc": 80,
              "n": 40, "v0": 0, "v1": 100, "lo": 0, "hi": 100})
    r._write({"t": t, "k": "cc", "p": "24:0", "ch": 0, "cc": 13,
              "n": 5, "v0": 64, "v1": 70, "lo": 64, "hi": 70})
    r.f.close()
    gl.cmd_report(r.path)
    out = capsys.readouterr().out
    assert "ARDOUR-OWNED" in out
    lines = [ln for ln in out.splitlines() if ln.strip().startswith("13 ")]
    assert lines and "ARDOUR-OWNED" not in lines[0]


def test_the_report_states_zero_xruns_as_a_positive_verdict(tmp_path, capsys):
    r = gl.Recorder(out_dir=tmp_path, hz=50.0, xruns=False, midi=False)
    r._write(r.header())
    hdr = json.loads(r.path.read_text().splitlines()[0])
    hdr["xruns"] = True
    r.f.write(json.dumps({"t": time.time(), "k": "s", "pkg": 50, "core_max": 50,
                          "fan": [1700], "fmax": 2900, "favg": 2900,
                          "cpu_max": 10, "cpu_avg": 5, "thr_core": 0,
                          "thr_pkg": 0, "w": None, "gear": {}, "xrun": 0}) + "\n")
    r.f.close()
    # rewrite the header with xruns enabled
    lines = r.path.read_text().splitlines()
    lines[0] = json.dumps(hdr)
    r.path.write_text("\n".join(lines) + "\n")
    gl.cmd_report(r.path)
    out = capsys.readouterr().out
    assert "ZERO" in out and "the audio path held" in out


# ---------------------------------------------------------------------------
# #82 — the LAST value, and what it MEANS
#
# Range and recency cannot answer "why is that orbit silent"; only the parked
# value can. These pin the arithmetic (which is copied from BootTidal.hs and
# must not drift) and the ordering rule.
# ---------------------------------------------------------------------------

def test_djf_lpf_matches_bootTidal_arithmetic():
    # gDJF ch = (# lpf (range 180 20000 (fmap (\v -> 1 - 2*max 0 (0.5-v)) ...)))
    # Centre and above is fully open; the bottom is linear in HERTZ.
    assert round(gl.djf_lpf(0)) == 180            # hard left = 180 Hz = silence
    assert round(gl.djf_lpf(127)) == 20000        # hard right = open
    assert round(gl.djf_lpf(64)) == 20000         # centre = ALREADY open
    assert round(gl.djf_lpf(96)) == 20000         # upper half does nothing
    mid = gl.djf_lpf(32)                          # quarter-turn
    assert 9000 < mid < 11000, mid


def test_djf_lpf_is_linear_in_hz_which_is_the_footgun():
    # Equal knob steps = equal HERTZ steps, so the whole audible bottom lives in
    # the last few percent of travel. If this ever becomes log-spaced the
    # verdicts below must be re-derived, so assert the shape explicitly.
    a, b, c = gl.djf_lpf(10), gl.djf_lpf(20), gl.djf_lpf(30)
    assert abs((b - a) - (c - b)) < 1.0


def test_djf_verdict_flags_only_the_genuinely_inaudible():
    assert "NEAR-SILENT" in gl.djf_verdict(*gl.djf_bands(0))
    assert "NO BODY" in gl.djf_verdict(*gl.djf_bands(127))
    assert gl.djf_verdict(*gl.djf_bands(64)) == "open"


def test_surface_state_keeps_the_last_value_not_the_last_seen_record():
    # Out-of-order records must not decide where a knob is parked.
    ev = [
        {"t": 100.0, "k": "cc", "cc": 49, "n": 3, "v0": 0, "v1": 10, "lo": 0, "hi": 10},
        {"t": 130.0, "k": "cc", "cc": 49, "n": 2, "v0": 60, "v1": 64, "lo": 60, "hi": 64},
        {"t": 110.0, "k": "cc", "cc": 49, "n": 1, "v0": 5, "v1": 5, "lo": 5, "hi": 5},
    ]
    a = gl.surface_state(ev)[49]
    assert a["v"] == 64, "the latest TIMESTAMP wins, not file order"
    assert a["n"] == 6 and a["lo"] == 0 and a["hi"] == 64


def test_surface_state_survives_a_log_with_no_v1(tmp_path):
    # Logs written before v1 existed must degrade to "?" rather than crash.
    a = gl.surface_state([{"t": 1.0, "k": "cc", "cc": 7, "n": 1, "lo": 3, "hi": 3}])[7]
    assert a["v"] is None


def test_slice_session_rebases_cumulative_counters():
    # THE bug this guards: xrun/throttle are running totals, so a naive window
    # reports the WHOLE session's count inside it — wrong, and wrong high.
    s = [{"t": float(i), "k": "s", "xrun": i * 10,
          "xrun_by": {"ardour": i * 7}, "thr_pkg": i, "thr_core": 0}
         for i in range(11)]
    sl, _, _ = gl.slice_session(s, [], [], 5.0, 9.0)
    assert [r["t"] for r in sl] == [5.0, 6.0, 7.0, 8.0, 9.0]
    assert sl[0]["xrun"] == 0, "the window must start at zero"
    assert sl[-1]["xrun"] == 40, "5..9 is a delta of 40, not the total of 90"
    assert sl[-1]["xrun_by"]["ardour"] == 28
    assert sl[-1]["thr_pkg"] == 4


def test_slice_session_without_a_window_does_not_rebase():
    s = [{"t": float(i), "k": "s", "xrun": i * 10} for i in range(4)]
    sl, _, _ = gl.slice_session(s, [], [], None, None)
    assert sl[-1]["xrun"] == 30, "an unwindowed report must be unchanged"


def test_parse_when_accepts_offsets_and_wall_clock():
    t0 = datetime(2026, 7, 29, 0, 57, 8).timestamp()
    assert gl.parse_when("+1:30", t0) == t0 + 90
    assert gl.parse_when("+1:00:00", t0) == t0 + 3600
    got = datetime.fromtimestamp(gl.parse_when("09:15", t0))
    assert (got.hour, got.minute) == (9, 15)


def test_parse_when_wall_clock_before_start_crosses_midnight():
    t0 = datetime(2026, 7, 29, 23, 30, 0).timestamp()
    got = datetime.fromtimestamp(gl.parse_when("00:15", t0))
    assert got.day == 30 and (got.hour, got.minute) == (0, 15)


def test_controls_report_names_the_dangerous_parking(tmp_path, capsys):
    r = gl.Recorder(out_dir=tmp_path, hz=50.0, xruns=False, midi=False)
    r._write(r.header())
    t = time.time()
    r._write({"t": t, "k": "cc", "p": "24:0", "ch": 0, "cc": 51,
              "n": 4, "v0": 90, "v1": 0, "lo": 0, "hi": 90})     # parked hard left
    r._write({"t": t, "k": "cc", "p": "24:0", "ch": 0, "cc": 74,
              "n": 2, "v0": 0, "v1": 127, "lo": 0, "hi": 127})   # mute held on
    r._write({"t": t, "k": "cc", "p": "24:0", "ch": 0, "cc": 49,
              "n": 2, "v0": 0, "v1": 64, "lo": 0, "hi": 100})    # safe = CENTRE
              # NB 100 is NOT safe: gDJF's upper half is a high-pass, and 100 is
              # hpf 4607 Hz. Only the centre is true bypass on both sides.
    r.f.close()
    assert gl.cmd_controls(r.path) == 0
    out = capsys.readouterr().out
    assert "PARKED SOMEWHERE THAT SILENCES" in out
    assert "cc 51" in out and "NEAR-SILENT" in out
    assert "cc 74" in out and "100%" in out
    risky = out.split("PARKED SOMEWHERE THAT SILENCES")[1]
    assert "cc 49" not in risky, "an open filter must not be flagged"


def test_controls_report_is_quiet_when_the_surface_is_safe(tmp_path, capsys):
    r = gl.Recorder(out_dir=tmp_path, hz=50.0, xruns=False, midi=False)
    r._write(r.header())
    r._write({"t": time.time(), "k": "cc", "p": "24:0", "ch": 0, "cc": 49,
              "n": 1, "v0": 64, "v1": 64, "lo": 64, "hi": 64})
    r.f.close()
    assert gl.cmd_controls(r.path) == 0
    assert "No control is parked in a known-dangerous zone" in capsys.readouterr().out


def test_report_window_narrows_the_xrun_total(tmp_path, capsys):
    r = gl.Recorder(out_dir=tmp_path, hz=50.0, xruns=False, midi=False)
    r._write(r.header())
    hdr = json.loads(r.path.read_text().splitlines()[0])
    hdr["xruns"] = True
    t0 = hdr["t"]
    for i in range(20):
        r.f.write(json.dumps({"t": t0 + i, "k": "s", "pkg": 50, "core_max": 50,
                              "fan": [1700], "fmax": 2900, "favg": 2900,
                              "cpu_max": 10, "cpu_avg": 5, "thr_core": 0,
                              "thr_pkg": 0, "w": None, "gear": {},
                              "xrun": i * 100}) + "\n")
    r.f.close()
    lines = r.path.read_text().splitlines()
    lines[0] = json.dumps(hdr)
    r.path.write_text("\n".join(lines) + "\n")

    gl.cmd_report(r.path)
    assert "1900" in capsys.readouterr().out

    gl.cmd_report(r.path, since="+0:10", until="+0:15")
    out = capsys.readouterr().out
    assert "WINDOW" in out and "rebased" in out
    assert "500" in out and "1900" not in out


def test_report_says_RECORDING_NOW_for_a_live_log(tmp_path, capsys):
    # The ambiguous "still running, or it was killed" made a healthy recorder
    # read as a possible failure; that ambiguity was once resolved the wrong way.
    r = gl.Recorder(out_dir=tmp_path, hz=50.0, xruns=False, midi=False)
    r._write(r.header())
    r.f.write(json.dumps({"t": time.time(), "k": "s", "pkg": 50, "core_max": 50,
                          "fan": [1700], "fmax": 2900, "favg": 2900, "cpu_max": 5,
                          "cpu_avg": 5, "thr_core": 0, "thr_pkg": 0, "w": None,
                          "gear": {}}) + "\n")
    r.f.close()
    gl.cmd_report(r.path)
    out = capsys.readouterr().out
    assert "RECORDING NOW" in out
    assert "KILLED" not in out


def test_report_says_KILLED_for_a_stale_log(tmp_path, capsys):
    r = gl.Recorder(out_dir=tmp_path, hz=50.0, xruns=False, midi=False)
    r._write(r.header())
    r.f.write(json.dumps({"t": time.time() - 3600, "k": "s", "pkg": 50,
                          "core_max": 50, "fan": [1700], "fmax": 2900,
                          "favg": 2900, "cpu_max": 5, "cpu_avg": 5,
                          "thr_core": 0, "thr_pkg": 0, "w": None,
                          "gear": {}}) + "\n")
    r.f.close()
    gl.cmd_report(r.path)
    out = capsys.readouterr().out
    assert "KILLED" in out and "RECORDING NOW" not in out


def test_is_live_uses_data_recency_not_a_process_match():
    hdr = {"period": 1.0}
    assert gl.is_live([{"t": time.time()}], hdr) is True
    assert gl.is_live([{"t": time.time() - 600}], hdr) is False
    assert gl.is_live([], hdr) is False


def test_djf_models_BOTH_halves_not_just_the_lowpass():
    # BootTidal.hs:376-377 applies lpf AND hpf. Reading only the lpf line made
    # the report call gF3-parked-at-80 "open" when it is a ~2 kHz high-pass.
    lo, hi = gl.djf_bands(80)
    assert round(lo) == 20000, "upper half leaves the lowpass open"
    assert 1900 < hi < 2200, hi
    assert "open" not in gl.djf_verdict(lo, hi)
    # "Centre = true bypass" is an APPROXIMATION, not an identity: 0..127 is an
    # odd range, so 0.5 falls BETWEEN cc 63 and cc 64 and no cc value hits bypass
    # exactly. cc 63 -> a ~19.7 kHz lowpass, cc 64 -> an 83 Hz highpass. Both are
    # inaudible, so the knob is fine in practice — but assert the truth, not the
    # comment in BootTidal.hs.
    lo63, hi63 = gl.djf_bands(63)
    lo64, hi64 = gl.djf_bands(64)
    assert hi63 == 20 and lo63 > 19000 and lo63 < 20000
    assert lo64 == 20000 and 20 < hi64 < 120
    assert gl.djf_verdict(lo63, hi63) == "open"
    assert gl.djf_verdict(lo64, hi64) == "open"
    # hard right is a brutal high-pass, not "open"
    assert gl.djf_hpf(127) > 7900


# ---------------------------------------------------------------------------
# preflight (2026-07-29)
#
# Every other check in the toolbox is COLD -- pvlint parses files, silent-eval
# queries patterns against an empty control map, surface-columns reads .tidal
# text. All three were green for an entire afternoon while gMask sat ARMED AT 127
# on the live board, chopping an eighth out of every bar of every orbit. Nothing
# was broken; a global was switched on and no tool in the chain looks at the
# switches. PLN found it by ear.
#
# So preflight reads LIVE STATE, and these tests pin the two properties that
# decide whether it is worth having.
# ---------------------------------------------------------------------------

def test_gmask_retirement_is_READ_from_boottidal_not_hardcoded(tmp_path):
    """^41 was a global gate; it is now d1's gate. Same CC, opposite verdict.

    A preflight that hardcodes either answer is wrong half the time, and goes
    stale exactly the way every other cached binding in this rig has.
    """
    boot = tmp_path / "BootTidal.hs"
    boot.write_text('    gMask = (midiOn "^41" (mask "t!7 f"))\n')
    assert gl.gmask_is_retired(boot) is False
    boot.write_text("    gMask = id\n")
    assert gl.gmask_is_retired(boot) is True


def test_an_unreadable_boottidal_assumes_the_mask_is_STILL_ACTIVE(tmp_path):
    """The cautious read. Guessing "retired" would silence a real FAIL."""
    assert gl.gmask_is_retired(tmp_path / "nope.hs") is False


def test_a_commented_gmask_line_does_not_count_as_retired(tmp_path):
    boot = tmp_path / "BootTidal.hs"
    boot.write_text('    -- gMask = id\n    gMask = (midiOn "^41" (mask "t!7 f"))\n')
    assert gl.gmask_is_retired(boot) is False


def test_a_stale_log_must_FAIL_preflight_rather_than_pass(tmp_path, capsys):
    """THE load-bearing test.

    A preflight that reports "SAFE" because it read yesterday's session is worse
    than no preflight at all: it converts an unknown into a false reassurance,
    which is the exact failure shape this rig produces over and over. Exit 2,
    distinct from exit 1 (surface unsafe), so a caller can tell "I could not
    check" from "I checked and it is bad".
    """
    log = tmp_path / "old.jsonl"
    log.write_text(
        '{"t": 1000000, "period": 1.0}\n'
        '{"t": 1000001, "xruns": 0}\n'
    )
    assert gl.cmd_preflight(log) == 2
    assert "NOT running" in capsys.readouterr().err


# --------------------------------------------------------------------------- #
# eval events (#150) — the ctrl+enter signal, replacing focus-only boundaries
# --------------------------------------------------------------------------- #

@pytest.mark.parametrize("raw,want", [
    ({"t": 1785350744.4591, "path": "live/techno/do_it_right.tidal", "d": ["d1", "d3"]},
     {"t": 1785350744.459, "k": "eval", "path": "live/techno/do_it_right.tidal",
      "d": ["d1", "d3"]}),
    # dN list absent entirely — the publisher's regex found nothing. Must not
    # be invented; an empty list, not a guess.
    ({"t": 1785350744.0, "path": "live/techno/do_it_right.tidal"},
     {"t": 1785350744.0, "k": "eval", "path": "live/techno/do_it_right.tidal", "d": []}),
    # duplicates and out-of-order dN from the publisher's regex are normalized
    ({"t": 1.0, "path": "x.tidal", "d": ["d3", "d1", "d3"]},
     {"t": 1.0, "k": "eval", "path": "x.tidal", "d": ["d1", "d3"]}),
])
def test_eval_record_normalizes_a_well_formed_line(raw, want):
    assert gl._eval_record(raw) == want


@pytest.mark.parametrize("raw", [
    {},                                              # empty
    {"path": "x.tidal"},                             # no t
    {"t": 1.0},                                       # no path
    {"t": "now", "path": "x.tidal"},                  # t is not a number
    {"t": 1.0, "path": ""},                           # empty path
    {"t": 1.0, "path": 123},                          # path is not a string
    "not a dict",
    None,
])
def test_eval_record_drops_malformed_input_instead_of_crashing_the_recorder(raw):
    # This file is written by a DIFFERENT process (Pulsar/Node). A schema
    # mismatch must degrade this one record, exactly like _read_track
    # degrades a half-written current-track file — never take the recorder
    # down over a line it doesn't own the writing of.
    assert gl._eval_record(raw) is None


def test_eval_record_ignores_non_string_entries_in_d_without_crashing():
    raw = {"t": 1.0, "path": "x.tidal", "d": ["d1", 2, None, "d2"]}
    assert gl._eval_record(raw) == {"t": 1.0, "k": "eval", "path": "x.tidal",
                                     "d": ["d1", "d2"]}


def test_eval_record_degrades_a_malformed_d_to_empty_rather_than_dropping_the_event():
    # t and path are the load-bearing fields; `d` is best-effort ("if cheaply
    # available"). A publisher bug in the optional field must not cost the
    # whole eval — that would be worse than the #148 bug this replaces.
    raw = {"t": 1.0, "path": "x.tidal", "d": "d1"}      # d is a string, not a list
    assert gl._eval_record(raw) == {"t": 1.0, "k": "eval", "path": "x.tidal", "d": []}


def test_eval_tail_never_replays_lines_written_before_it_started(tmp_path):
    """Mirrors the `_pkg_base` rule: a fresh recording reports what happens
    DURING the session, not the publish file's whole history."""
    f = tmp_path / "eval-events.jsonl"
    f.write_text('{"t": 1.0, "path": "old.tidal", "d": []}\n')
    tail = gl.EvalTail(f)
    assert tail.poll() == []                          # nothing new yet
    with f.open("a") as fh:
        fh.write('{"t": 2.0, "path": "new.tidal", "d": ["d1"]}\n')
    got = tail.poll()
    assert len(got) == 1 and got[0]["path"] == "new.tidal"


def test_eval_tail_returns_every_line_appended_since_the_last_poll(tmp_path):
    """Two evals in the same 1 Hz tick must BOTH survive — an eval is an
    EVENT (like a MIDI note), not a STATE (like current-track)."""
    f = tmp_path / "eval-events.jsonl"
    f.write_text("")
    tail = gl.EvalTail(f)
    with f.open("a") as fh:
        fh.write('{"t": 1.0, "path": "a.tidal", "d": ["d1"]}\n')
        fh.write('{"t": 1.2, "path": "a.tidal", "d": ["d2"]}\n')
    got = tail.poll()
    assert [g["t"] for g in got] == [1.0, 1.2]
    assert tail.poll() == []                           # drained, not re-read


def test_eval_tail_holds_a_torn_trailing_line_for_the_next_poll(tmp_path):
    """The HUD writes with a single fs.writeFileSync per line, but a tick can
    still land mid-write on a slow disk. A torn line must cost one eval, not
    desync the tail forever."""
    f = tmp_path / "eval-events.jsonl"
    f.write_text('{"t": 1.0, "path": "a.tidal", "d": []}\n')
    tail = gl.EvalTail(f)
    with f.open("a") as fh:
        fh.write('{"t": 2.0, "path": "b.tidal",')       # torn — no newline yet
    assert tail.poll() == []                            # nothing COMPLETE yet
    with f.open("a") as fh:
        fh.write(' "d": ["d4"]}\n')                     # completed next tick
    got = tail.poll()
    assert len(got) == 1 and got[0]["d"] == ["d4"]


def test_eval_tail_rebaselines_if_the_publish_file_is_recreated_smaller(tmp_path):
    """Same rule as XrunReader._baseline for a node that respawns with a
    fresh counter: a size going BACKWARDS means a new file, not corruption."""
    f = tmp_path / "eval-events.jsonl"
    f.write_text('{"t": 1.0, "path": "a.tidal", "d": []}\n' * 5)
    tail = gl.EvalTail(f)
    f.write_text('{"t": 9.0, "path": "fresh.tidal", "d": ["d9"]}\n')   # shorter
    got = tail.poll()
    assert len(got) == 1 and got[0]["path"] == "fresh.tidal"


def test_eval_tail_is_silent_when_the_publish_file_does_not_exist_yet(tmp_path):
    """The HUD writes it lazily (same as current-track) — absence must
    degrade the recorder, never stop it."""
    tail = gl.EvalTail(tmp_path / "does-not-exist.jsonl")
    assert tail.poll() == []


def test_eval_events_reach_the_report_loader_as_ranked_marks(tmp_path):
    """The WRITE path (recorder -> jsonl) through the PARSE path (load()),
    exactly as the recorder's run loop drives it — not just the helper
    functions in isolation."""
    eval_file = tmp_path / "eval-events.jsonl"
    r = gl.Recorder(out_dir=tmp_path, hz=50.0, xruns=False, midi=False)
    r.ev = gl.EvalTail(eval_file)          # point the recorder at our fixture
    r._write(r.header())
    r._write(r.sample())
    # Written AFTER the tail exists, exactly like the live rig: the HUD
    # appends while gig-log's recorder is already running and polling.
    with eval_file.open("a") as fh:
        fh.write('{"t": 100.5, "path": "live/techno/do_it_right.tidal", "d": ["d1"]}\n')
        fh.write('{"t": 101.0, "path": "live/techno/do_it_right.tidal", "d": []}\n')
    for raw in r.ev.poll():                # exactly what Recorder.run() does per tick
        rec = gl._eval_record(raw)
        if rec:
            r._write(rec)
    r.f.close()

    _, _, _, marks, _ = gl.load(r.path)
    evals = [m for m in marks if m.get("k") == "eval"]
    assert len(evals) == 2
    assert evals[0]["d"] == ["d1"]
    assert evals[0]["label"] == "⏵ eval d1  live/techno/do_it_right.tidal"
    # no dN found — must say so honestly (?), never fabricate one
    assert evals[1]["label"] == "⏵ eval ?  live/techno/do_it_right.tidal"


def test_track_and_eval_marks_coexist_the_new_lens_does_not_replace_the_old(tmp_path):
    """#150 ADDS a lens; `track` (focus) must still come through untouched —
    a prior finding established boundary detection wants MULTIPLE lenses."""
    r = gl.Recorder(out_dir=tmp_path, hz=50.0, xruns=False, midi=False)
    r._write(r.header())
    r._write({"t": 10.0, "k": "track", "path": "a.tidal", "from": None})
    r._write(gl._eval_record({"t": 11.0, "path": "a.tidal", "d": ["d2"]}))
    r.f.close()
    _, _, _, marks, _ = gl.load(r.path)
    kinds = [m["k"] for m in marks]
    assert kinds == ["track", "eval"]
