"""Tests for tools/orphan-orbits.py — the ghost-orbit analysis (#68).

The invariant that matters: this tool tells PLN which orbits will still be audible
after a transition. A MISS is a ghost on stage he was told would not be there, so
the parse has to be conservative in the right direction, and the integration test
below is run against the REAL set rather than fixtures — a parser that agrees with
a synthetic file and disagrees with the corpus has proven nothing.
"""
from __future__ import annotations

import importlib.util
from pathlib import Path

import pytest

TOOLS = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location("orphan_orbits", TOOLS / "orphan-orbits.py")
oo = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(oo)


def _track(tmp_path: Path, body: str, name: str = "t.tidal") -> Path:
    p = tmp_path / name
    p.write_text(body)
    return p


# --------------------------------------------------------------------------- #
# what counts as "declared"
# --------------------------------------------------------------------------- #

def test_declared_orbits_are_the_ones_that_will_be_overwritten(tmp_path):
    p = _track(tmp_path, 'd1 $ s "bd"\n\nd7 $ s "hh"\n\nd10 $ s "arpy"\n')
    assert oo.orbits_of(p) == {1, 7, 10}


def test_a_commented_out_orbit_is_not_declared(tmp_path):
    """Right in BOTH directions, which is why it is safe: a `-- d6` never plays so
    it leaves no ghost, and it never overwrites so an inherited d6 survives it."""
    p = _track(tmp_path, 'd1 $ s "bd"\n\n-- d6 $ s "ghost"\n')
    assert oo.orbits_of(p) == {1}


def test_an_indented_continuation_line_is_not_a_new_orbit(tmp_path):
    p = _track(tmp_path, 'd1 $ s "bd"\n  # gain 1\n  # room 0.3\n')
    assert oo.orbits_of(p) == {1}


def test_two_digit_orbits_parse(tmp_path):
    p = _track(tmp_path, 'd1 $ s "bd"\n\nd12 $ s "x"\n\nd14 $ s "y"\n')
    assert oo.orbits_of(p) == {1, 12, 14}


# --------------------------------------------------------------------------- #
# the orphan set
# --------------------------------------------------------------------------- #

def test_orphans_are_what_the_outgoing_track_has_and_the_incoming_lacks(tmp_path):
    a = _track(tmp_path, 'd1 $ s "a"\n\nd6 $ s "b"\n\nd10 $ s "c"\n', "a.tidal")
    b = _track(tmp_path, 'd1 $ s "a"\n\nd9 $ s "d"\n', "b.tidal")
    assert oo.orphans(a, b) == {6, 10}


def test_orphans_are_asymmetric(tmp_path):
    """A->B and B->A are different questions. An orbit the INCOMING track adds is
    not a ghost — it is just a new sound."""
    a = _track(tmp_path, 'd1 $ s "a"\n', "a.tidal")
    b = _track(tmp_path, 'd1 $ s "a"\n\nd9 $ s "d"\n', "b.tidal")
    assert oo.orphans(a, b) == set()
    assert oo.orphans(b, a) == {9}


def test_a_superset_track_is_always_a_clean_destination(tmp_path):
    a = _track(tmp_path, 'd1 $ s "a"\n\nd6 $ s "b"\n', "a.tidal")
    b = _track(tmp_path, 'd1 $ s "a"\n\nd6 $ s "b"\n\nd9 $ s "c"\n', "b.tidal")
    assert oo.orphans(a, b) == set()


def test_a_track_never_orphans_itself(tmp_path):
    a = _track(tmp_path, 'd1 $ s "a"\n\nd6 $ s "b"\n', "a.tidal")
    assert oo.orphans(a, a) == set()


# --------------------------------------------------------------------------- #
# the silence block
# --------------------------------------------------------------------------- #

def test_the_silence_block_covers_every_orbit_the_track_does_not_declare(tmp_path):
    p = _track(tmp_path, 'd1 $ s "a"\n\nd3 $ s "b"\n')
    block = oo.silence_block(p, used_by_set={1, 2, 3, 6, 10})
    assert block == ["d2 $ silence", "d6 $ silence", "d10 $ silence"]


def test_the_silence_block_never_silences_an_orbit_the_track_uses(tmp_path):
    """Silencing the track's own orbit would mute the track it is meant to protect —
    the mute-bomb shape, and it would be blamed on the rig."""
    p = _track(tmp_path, "".join(f'd{n} $ s "x"\n\n' for n in (1, 4, 9)))
    block = oo.silence_block(p, used_by_set=set(range(1, 15)))
    for n in (1, 4, 9):
        assert f"d{n} $ silence" not in block


def test_the_silence_block_uses_silence_and_never_gain_zero(tmp_path):
    """`# gain 0` leaves the pattern scheduled and still burning a voice, and a
    later global gain change can bring the ghost back from the dead."""
    p = _track(tmp_path, 'd1 $ s "a"\n')
    block = oo.silence_block(p, used_by_set={1, 2})
    assert all(ln.endswith("$ silence") for ln in block)
    assert not any("gain" in ln for ln in block)


def test_a_track_declaring_everything_needs_no_silence_block(tmp_path):
    p = _track(tmp_path, "".join(f'd{n} $ s "x"\n\n' for n in range(1, 5)))
    assert oo.silence_block(p, used_by_set={1, 2, 3, 4}) == []


def test_the_silence_block_defaults_to_the_full_orbit_span(tmp_path):
    p = _track(tmp_path, 'd1 $ s "a"\n')
    block = oo.silence_block(p)
    assert len(block) == oo.MAX_ORBIT - 1
    assert "d14 $ silence" in block


def test_the_silence_block_is_scoped_to_the_set_when_known(tmp_path):
    """Shorter blocks get pasted; 13 lines of silence for orbits nobody uses do not.
    Same reasoning as measuring the set rather than the corpus."""
    p = _track(tmp_path, 'd1 $ s "a"\n')
    assert len(oo.silence_block(p, used_by_set={1, 6})) == 1


# --------------------------------------------------------------------------- #
# sound hints — the report has to be readable at a glance ON STAGE
# --------------------------------------------------------------------------- #

def test_sound_hints_name_the_sample(tmp_path):
    p = _track(tmp_path, 'd6 $ slice 4 "<0 1>" $ "crimewave:15" # cut 9\n')
    assert "crimewave" in oo.sounds_of(p)[6]


def test_sound_hints_drop_short_mininotation_tokens(tmp_path):
    """The regression: hints came out as `d7(FMRhodes1, q)` and `d8(t,
    jungle_breaks)` because one-character quoted note/mini-notation tokens matched.
    Noise in the one field that has to be readable under pressure."""
    p = _track(tmp_path, 'd7 $ n "0 1" # s "FMRhodes1" # legato "q"\n')
    hint = oo.sounds_of(p)[7]
    assert "FMRhodes1" in hint
    assert "q" not in hint.split(", ")


def test_sound_hints_never_raise_on_an_orbit_with_no_quoted_name(tmp_path):
    p = _track(tmp_path, "d5 $ silence\n")
    assert oo.sounds_of(p)[5] == "?"


# --------------------------------------------------------------------------- #
# the setlist
# --------------------------------------------------------------------------- #

def test_the_real_setlist_loads_and_every_track_exists():
    """A setlist that silently drops a track silently drops a transition, so
    load_setlist raises rather than skipping."""
    setlist = oo.load_setlist()
    assert len(setlist) == 13
    for p, label in setlist:
        assert p.exists()
        assert label


def test_a_setlist_naming_a_missing_track_fails_loudly(tmp_path):
    bad = tmp_path / "s.txt"
    bad.write_text("live/does/not/exist.tidal   # nope\n")
    with pytest.raises(SystemExit):
        oo.load_setlist(bad)


def test_setlist_comments_and_blank_lines_are_ignored(tmp_path, monkeypatch):
    s = tmp_path / "s.txt"
    real = oo.load_setlist()[0][0].relative_to(oo.REPO)
    s.write_text(f"# heading\n\n{real}   # label here\n\n# trailing\n")
    got = oo.load_setlist(s)
    assert len(got) == 1
    assert got[0][1] == "label here"


# --------------------------------------------------------------------------- #
# integration against the REAL set — this is the test that would have caught a
# parser that only agrees with fixtures
# --------------------------------------------------------------------------- #

# Hand-measured by PLN + me on 2026-07-28, before this parser existed. If the
# parser and the hand count ever disagree, ONE of them is wrong and the tool must
# not be trusted until that is resolved.
HAND_MEASURED = {
    "bombe_dj":            {1, 2, 3, 4, 5, 7, 8, 9},
    "wap":                 {1, 2, 3, 4, 5, 7, 8, 9},
    "piment_bresilien":    {1, 2, 3, 4, 5, 7, 8, 10},
    "perfect":             {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12},
    "gimme_acid":          {1, 2, 3, 4, 5, 8, 9, 10, 11, 12},
    "vague_de_crime":      {1, 2, 3, 4, 5, 6, 7, 8, 10},
    "mafia_sans_serif":    {1, 2, 3, 4, 5, 7, 8},
    # d7 became d6 on 2026-07-30 (PLN's edit, committed in ce887b7: the Guitar
    # Sunshine block moved down a slot with ^91->^90 and ^59->^58, the same
    # consolidation he made in do_it_right). Re-derived by reading the file's
    # column-0 `dN` declarations, not by copying the parser's output — a hand
    # measurement that quotes the thing it checks is a tautology.
    "you_my_sunshine":     {1, 2, 3, 4, 5, 6, 8, 9, 11},
    "desire":              {1, 2, 3, 4, 5, 6, 7, 8, 9},
    "the_revolution_will_be_sampled": {1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12},
}


@pytest.mark.parametrize("stem,want", sorted(HAND_MEASURED.items()))
def test_the_parser_agrees_with_the_hand_measurement(stem, want):
    got = oo.orbits_of(oo.resolve(stem))
    assert got == want, f"{stem}: parser {sorted(got)} vs hand {sorted(want)}"


def test_the_ear_report_reproduces_vague_de_crime_to_bombe_dj():
    """The ORIGINAL bug report, as a test. PLN heard the crimewave synth survive
    into the next track; the mechanism says d6 and d10 are the survivors."""
    assert oo.orphans(oo.resolve("vague_de_crime"), oo.resolve("bombe_dj")) == {6, 10}


def test_d6_of_vague_de_crime_really_is_the_crimewave_sound_he_heard():
    assert "crimewave" in oo.sounds_of(oo.resolve("vague_de_crime"))[6]


def test_every_transition_in_the_real_set_is_analysable():
    setlist = oo.load_setlist()
    for (a, _), (b, _) in zip(setlist, setlist[1:]):
        orph = oo.orphans(a, b)
        assert isinstance(orph, set)
        assert all(1 <= n <= oo.MAX_ORBIT for n in orph)


def test_orbits_1_to_5_never_orphan_anywhere_in_the_set():
    """Every track in the set declares d1-d5, so the core (drums/bass) can never
    ghost. Worth pinning: if a future track drops one, the ghost lands on the
    loudest possible orbit and this test says so before the stage does."""
    setlist = oo.load_setlist()
    for (a, la), (b, lb) in zip(setlist, setlist[1:]):
        assert not (oo.orphans(a, b) & {1, 2, 3, 4, 5}), f"{la} -> {lb}"


def test_chain_and_matrix_render_without_raising(capsys):
    setlist = oo.load_setlist()
    assert oo.cmd_chain(setlist) == 0
    assert oo.cmd_matrix(setlist) == 0
    out = capsys.readouterr().out
    assert "ORPHAN ORBITS" in out
    assert "RESET POINTS" in out or "No track covers" in out


def test_chain_json_is_valid_and_complete():
    import json as _json
    setlist = oo.load_setlist()
    oo.cmd_chain(setlist, as_json=True)


def test_pair_exits_nonzero_when_there_are_ghosts(capsys):
    """Exit code is the machine-readable answer, so a gig-up self-test (#44) can
    gate on it."""
    assert oo.cmd_pair("vague_de_crime", "bombe_dj") == 1
    assert oo.cmd_pair("bombe_dj", "wap") == 0


def test_check_tracks_reads_the_SAME_setlist_file():
    """Two tools consume this list — orphan-orbits (which orbits ghost) and
    check-tracks.sh (does every orbit sound). They must not drift.

    check-tracks.sh pointed at a bare `setlist_opal2026.txt` in the repo ROOT, which
    has never existed, so the pre-gig gate could not run at all — it would just say
    "no such file" for every track. Pinned here because a broken gate is invisible
    until the day you need it.
    """
    sh = (oo.REPO / "tools" / "check-tracks.sh").read_text()
    assert "armada/setlist_opal2026.txt" in sh
    assert oo.SETLIST.exists()


def test_the_setlist_survives_the_shell_parse_too():
    """The setlist annotates each path with codename + BPM after a '#'. The shell
    reader has to strip TRAILING comments, not just full-line ones, or every track
    reports "no such file"."""
    import re as _re
    import subprocess
    out = subprocess.run(
        ["sed", "-e", "s/#.*//", "-e", "s/[[:space:]]*$//", str(oo.SETLIST)],
        capture_output=True, text=True, check=True).stdout
    rows = [ln for ln in out.splitlines() if ln.strip()]
    assert len(rows) == 13
    for r in rows:
        assert not _re.search(r"[#\[]", r), f"comment leaked into {r!r}"
        assert (oo.REPO / r).exists(), r


def test_resolve_finds_a_track_by_bare_name():
    assert oo.resolve("gimme_acid").name == "gimme_acid.tidal"


def test_resolve_fails_loudly_on_an_unknown_name():
    with pytest.raises(SystemExit):
        oo.resolve("definitely-not-a-track-xyz")
