"""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 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
