"""Unit tests for pvlint rules.

Every test here is a REGRESSION: the positive cases are bugs that actually
silenced the rig, and the negative cases are real lines from the corpus that an
earlier version of a rule wrongly flagged. A rule that cries wolf on a pre-gig
gate is worse than no rule, so the negative cases carry equal weight.
"""

from __future__ import annotations

import os
import sys

import pytest

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(
    os.path.abspath(__file__)))))

from pvlint.core import Track, strip_comment  # noqa: E402
from pvlint.rules import check  # noqa: E402


def lint(src: str, rule: str | None = None):
    findings = check(Track(path="<test>", text=src))
    if rule:
        findings = [f for f in findings if f.rule == rule]
    return findings


# ---------------------------------------------------------------- core parse

def test_orbit_needs_no_same_line_dollar():
    """Haskell layout lets the `$` start the next line — and PLN writes that.

    The first ORBIT_RE required a same-line `$` and silently dropped d5 from
    you_my_sunshine's inventory while the track was playing it.
    """
    src = 'd5  -- The Voice of Love\n  $ midiOn "^58" (# n 1)\n  # cut 5\n'
    assert Track(path="t", text=src).declared_orbits() == {5}


def test_blank_line_splits_blocks():
    src = "d1 $ s \"bd\"\n\nd2 $ s \"sn\"\n"
    blocks = Track(path="t", text=src).blocks()
    assert len(blocks) == 2


def test_orbit_continuation_lines_are_captured():
    src = 'd1 $ s "bd"\n  # gain 1.2\n  # cut 1\n'
    orb = Track(path="t", text=src).orbits()[0]
    assert len(orb.lines) == 3


def test_strip_comment():
    assert strip_comment('d1 $ s "bd" -- kick').strip() == 'd1 $ s "bd"'


# ------------------------------------------------------------------- PV001

def test_pv001_flags_local_djfbus_shadow():
    """The 2026-07-28 bug: four orbits inaudible until a knob crossed centre."""
    src = 'let gF3 = (# djfbus 3 (range 0.05 0.95 "^51"))\n'
    f = lint(src, "PV001")
    assert len(f) == 1 and f[0].severity == "error"


def test_pv001_autofix_deletes_the_line():
    f = lint('let gF3 = (# djfbus 3 (range 0.05 0.95 "^51"))\n', "PV001")
    assert f[0].fix == ""


def test_pv001_ignores_a_local_gmask_without_djfbus():
    """Redefining gMask locally is a musical choice, not the footgun."""
    assert lint('let gMask = (midiOn "^41" (mask "t f"))\n', "PV001") == []


# ------------------------------------------------------------------- PV002

def test_pv002_flags_the_desire_bug():
    """`(|+ note 12 . (|* gain 0.6))` — killed nine orbits in one block."""
    src = 'd1 $ off 0.125 (|+ note 12 . (|* gain 0.6)) $ s "bd"\n'
    f = lint(src, "PV002")
    assert len(f) == 1 and f[0].severity == "error"


def test_pv002_autofix_adds_the_missing_parens():
    src = 'd1 $ off 0.125 (|+ note 12 . (|* gain 0.6)) $ s "bd"\n'
    f = lint(src, "PV002")
    assert f[0].fix is not None
    assert "((|+ note 12) . (|* gain 0.6))" in f[0].fix


def test_pv002_accepts_the_corrected_form():
    src = 'd1 $ off 0.125 ((|+ note 12) . (|* gain 0.6)) $ s "bd"\n'
    assert lint(src, "PV002") == []


@pytest.mark.parametrize("src", [
    '  $ whenTrap (|<| "k ~ ~ k . ~")',
    '  $ whenmod 16 15 (# gain "0 . 0 . [0 . 1] . 1")',
    '  $ every 4 (# gain "0 . 0 1")',
    '  $ always (|+ note "0 . <[-2 -4] [5 0]>")',
])
def test_pv002_ignores_mininotation_dots(src):
    """`.` inside a string is mini-notation grouping, NOT Haskell composition.

    All four of these are real corpus lines that the first, string-blind
    scanner reported as compile-killers.
    """
    assert lint(src + "\n", "PV002") == []


def test_pv002_ignores_decimal_points():
    assert lint('d1 $ s "bd" # (|* gain 0.6)\n', "PV002") == []


# ------------------------------------------------------------------- PV003

def test_pv003_flags_the_global_gain_cc():
    f = lint('d1 $ s "bd" # gain "^77"\n', "PV003")
    assert len(f) == 1 and f[0].severity == "warning"


@pytest.mark.parametrize("cc", [49, 51, 76, 78, 80, 84, 91])
def test_pv003_allows_every_other_cc_including_the_fader_bank(cc):
    """Reading a fader CC in a track is the documented convention, not a bug.

    Flagging the whole 77-84 bank produced 1109 corpus errors, all wrong.
    """
    assert lint(f'd1 $ s "bd" # gain "^{cc}"\n', "PV003") == []


# ------------------------------------------------------------------- PV004

def test_pv004_flags_shared_cut_group_across_orbits():
    """you_my_sunshine: d5 (voice) and d11 both `cut 5` — they truncate each other."""
    src = 'd5 $ s "voice" # cut 5\n\nd11 $ s "chop" # cut 5\n'
    f = lint(src, "PV004")
    assert {x.line for x in f} == {1, 3}


def test_pv004_one_finding_per_orbit_not_per_occurrence():
    src = 'd5 $ s "a" # cut 5\n  # cut 5\n\nd11 $ s "b" # cut 5\n'
    assert len(lint(src, "PV004")) == 2


def test_pv004_allows_distinct_groups():
    src = 'd5 $ s "a" # cut 5\n\nd11 $ s "b" # cut 11\n'
    assert lint(src, "PV004") == []


# ------------------------------------------------------------------- PV005

def test_pv005_flags_duplicate_orbit_in_one_block():
    src = 'd1 $ s "bd"\nd1 $ s "sn"\n'
    f = lint(src, "PV005")
    assert len(f) == 1 and f[0].line == 2


def test_pv005_allows_redeclaration_in_a_later_block():
    """Keeping several evaluable versions of an orbit is normal live practice."""
    src = 'd1 $ s "bd"\n\nd1 $ s "sn"\n'
    assert lint(src, "PV005") == []


# ------------------------------------------------------------------- PV006

def test_pv006_skips_when_sample_root_is_absent(monkeypatch, tmp_path):
    monkeypatch.setenv("PVLINT_SAMPLE_ROOTS", str(tmp_path / "nope"))
    assert lint('d1 $ s "ghostfolder:99"\n', "PV006") == []


def test_pv006_flags_index_past_end_of_folder(monkeypatch, tmp_path):
    d = tmp_path / "twohit"
    d.mkdir()
    (d / "a.wav").write_bytes(b"")
    (d / "b.wav").write_bytes(b"")
    monkeypatch.setenv("PVLINT_SAMPLE_ROOTS", str(tmp_path))
    f = lint('d1 $ s "twohit:5"\n', "PV006")
    assert len(f) == 1 and "5" in f[0].message


def test_pv006_accepts_in_range_index(monkeypatch, tmp_path):
    d = tmp_path / "twohit"
    d.mkdir()
    (d / "a.wav").write_bytes(b"")
    (d / "b.wav").write_bytes(b"")
    monkeypatch.setenv("PVLINT_SAMPLE_ROOTS", str(tmp_path))
    assert lint('d1 $ s "twohit:1"\n', "PV006") == []


def test_pv006_does_not_pool_indices_across_folders(monkeypatch, tmp_path):
    """d8 of you_my_sunshine: n=24 belongs to org_jungle_breaks, not breaks165.

    Pooling every index in an orbit against every folder invented a finding.
    """
    one = tmp_path / "breaks165"
    one.mkdir()
    (one / "a.wav").write_bytes(b"")
    many = tmp_path / "org_jungle_breaks"
    many.mkdir()
    for i in range(30):
        (many / f"{i}.wav").write_bytes(b"")
    monkeypatch.setenv("PVLINT_SAMPLE_ROOTS", str(tmp_path))
    src = 'd8 $ s "org_jungle_breaks:24" # s "breaks165"\n'
    assert lint(src, "PV006") == []


# ------------------------------------------------------------------- PV007

def test_pv007_reports_the_orbit_inventory():
    src = 'd1 $ s "bd"\n\nd7 $ s "sn"\n'
    f = lint(src, "PV007")
    assert len(f) == 1 and "d1,d7" in f[0].message


# ------------------------------------------------------------------- PV008

def test_pv008_flags_one_button_driving_two_orbits():
    """The real perfect.tidal bug: d1 kept ^42 after d2 was given ^42.

    One press fires both. Nothing errors, nothing goes silent — it only shows up
    as an unexplained extra layer while a button is held.
    """
    src = ('d1 $ midiOn "^42" (<| "k k")\n\n'
           'd2 $ midiOn "^42" (mask "t f")\n')
    f = lint(src, "PV008")
    assert len(f) == 2                        # one per orbit needing an edit
    assert "^42" in f[0].message and "d1, d2" in f[0].message


def test_pv008_is_a_warning_not_an_error():
    """PLN 2026-08-01, on lime_tacos' ^44 driving d3 (hats) and d6 (cymbals):

        "its ok when two percs are linked, e.g. here, change rythm of both 3 and
         6. [...] im fine with this, not a NOGO, just a WARN?"

    A shared button is a deliberate idiom — one gesture re-rhythms a percussion
    group — and the source is IDENTICAL to the accidental case this rule was
    written for. A check that cannot tell intent apart must not hold the gig gate
    shut. This test exists so nobody "tightens" it back to error without meeting
    that argument first.
    """
    src = ('d3 $ midiOn "^44" (mask "t(4,8,1)")\n\n'
           'd6 $ midiOn "^44" (<| "~ cy ~ cy")\n')
    f = lint(src, "PV008")
    assert len(f) == 2
    assert all(x.severity == "warning" for x in f), \
        "PV008 must stay advisory — PLN's percussion-linking idiom is not a defect"


def test_pv008_one_orbit_using_a_button_many_times_is_fine():
    """Three references from ONE orbit is one gesture, not a collision."""
    src = ('d1 $ midiOn "^42" (<| "k k")\n'
           '  $ midiOff "^42" (<| "k ~")\n'
           '  $ midiOn ("^41" - "^42") (<| "k*2")\n')
    assert lint(src, "PV008") == []


def test_pv008_ignores_the_family_controls():
    """gMute1-3 and gF1-3 are per-FAMILY by design — sharing them is correct.

    Measured across the corpus: gF1 -> d1/d2/d3/d8, gF2 -> d4, gF3 -> leads.
    A rule that flagged those would fire on every track in the set.
    """
    src = ('d1 $ midiOn "^73" (mask "f*16")\n\n'
           'd2 $ midiOn "^73" (mask "f*16")\n\n'
           'd3 $ midiOn "^49" (# lpf 400)\n\n'
           'd4 $ midiOn "^49" (# lpf 400)\n')
    assert lint(src, "PV008") == []


def test_pv008_ignores_knobs():
    """Knob sharing is a different question (columns), not a button collision."""
    src = ('d1 $ midiOn "^29" (# crush 4)\n\n'
           'd2 $ midiOn "^29" (# crush 4)\n')
    assert lint(src, "PV008") == []


def test_pv008_ignores_commented_out_gestures():
    """A commented gesture is exactly how #94 parks an over-budget control.

    If this flagged, every track the migrator touched would fail the gate — the
    cry-wolf failure that makes a pre-gig check worthless.
    """
    src = ('d1 $ s "bd"\n'
           '  -- $ midiOn "^42" (<| "k k")\n\n'
           'd2 $ midiOn "^42" (mask "t f")\n')
    assert lint(src, "PV008") == []


# ------------------------------------------------------------------- PV009

def test_pv009_flags_bare_multiplied_gain():
    """wap.tidal:91 — `|* gain "^17"` with ^17 seeded to 0 = layer never arrives."""
    src = 'd9 $ superimpose ((|* gain "^17") . (# cut 91))\n  $ n "0"\n'
    f = lint(src, "PV009")
    assert len(f) == 1
    assert f[0].severity == "info"          # usually deliberate; must not cry wolf
    assert '"^17"' in f[0].message and "no range at all" in f[0].message


def test_pv009_flags_range_starting_at_zero():
    """desire.tidal:75 — `|* gain (range 0 1.5 "^17")`."""
    src = 'd9 $ superimpose ((|* gain (range 0 1.5 "^17")) . (# n 5))\n'
    f = lint(src, "PV009")
    assert len(f) == 1 and "ranged from 0" in f[0].message


def test_pv009_ignores_a_neutral_low_end():
    """`range 1 1.5` is unity at seed — the prescribed fix, must not be flagged."""
    assert lint('d9 $ (|* gain (range 1 1.5 "^17")) $ n "0"\n', "PV009") == []


def test_pv009_ignores_hash_assignment():
    """`#` sets a value; only MULTIPLICATION can annihilate the pattern.

    `# crush (range 16 3.5 "^53")` at seed 0 is simply crush 0, not silence.
    Flagging these would bury the real finding under hundreds of false ones.
    """
    assert lint('d5 $ n "0" # crushbus 51 (range 16 4.5 "^53")\n', "PV009") == []
    assert lint('d5 $ n "0" # gain (range 0 1.5 "^33")\n', "PV009") == []


def test_pv009_ignores_the_djf_knobs_which_seed_to_centre():
    """C1-C3 seed to 0.5, not 0, so multiplying by them is not annihilation."""
    assert lint('d1 $ (|* gain "^49") $ n "0"\n', "PV009") == []


def test_pv009_ignores_commented_lines():
    assert lint('d9 $ n "0"\n  -- $ (|* gain "^17")\n', "PV009") == []


# ------------------------------------------------------------------- PV010

def test_pv010_flags_a_gesture_latched_on_row_e():
    """The revolution bug PLN heard: a `ply` on the latching button.

    One press and the drums stay multiplied — *"drums in revolution seem way too
    fast ... yea def an inversion"*.
    """
    f = lint('d8 $ midiOn "^60" (ply "<2 <4 [4 8]>>")\n', "PV010")
    assert len(f) == 1
    assert f[0].severity == "warning"
    assert "^60" in f[0].message and "^92" in f[0].message
    assert "latching" in f[0].message


def test_pv010_flags_a_gate_on_the_momentary_row():
    """The mirror image: a mask that only holds while a finger holds it."""
    f = lint('d8 $ midiOff "^92" (mask "t(4,8,1)")\n', "PV010")
    assert len(f) == 1
    assert "^92" in f[0].message and "^60" in f[0].message


def test_pv010_accepts_the_convention():
    src = ('d8 $ midiOn "^92" (ply "1 <2!3 4>")\n'
           '  $ midiOff "^60" (mask "t(4,8,1)")\n')
    assert lint(src, "PV010") == []


def test_pv010_a_gate_composed_with_a_gesture_is_a_gate():
    """`mask "t(8,16,1)" . chop 16` is a latched break-gate that chops.

    Reading it as ambiguous is what hid revolution's d8 pair from the first scan.
    """
    assert lint('d8 $ midiOff "^60" (mask "t(8,16,1)" . chop 16)\n', "PV010") == []
    f = lint('d8 $ midiOff "^92" (mask "t(8,16,1)" . chop 16)\n', "PV010")
    assert len(f) == 1 and "gate" in f[0].message


def test_pv010_exempts_d1_to_d3_which_own_only_one_button():
    """Row F for d1-d3 is gMute1/2/3, so those columns have ONE cell.

    A gesture on ^41 is not an inversion — there is nowhere else to put it.
    """
    assert lint('d1 $ midiOn "^41" (ply 2)\n', "PV010") == []
    assert lint('d3 $ midiOn "^43" (fast 2)\n', "PV010") == []


def test_pv010_ignores_arithmetic_and_comments():
    """Two-control forms have no single role; commented lines are not code."""
    assert lint('d5 $ midiOn ("^89" - "^53") (ply 2)\n', "PV010") == []
    assert lint('d8 $ n "0"\n  -- $ midiOn "^60" (ply 4)\n', "PV010") == []


# ------------------------------------------------------------------- PV011

def test_pv011_flags_one_fx_slot_shared_by_two_orbits():
    """take_5_drops really does put d4's bass and d7's choir on crushbus 41."""
    src = ('d4 $ n "0" # crushbus 41 (range 16 3.5 "^52")\n\n'
           'd7 $ n "0" # crushbus 41 (range 16 3.5 "^55")\n')
    f = lint(src, "PV011")
    assert len(f) == 2                        # one per orbit needing an edit
    assert "crushbus slot 41" in f[0].message and "d4, d7" in f[0].message


def test_pv011_is_an_error_because_you_cannot_hear_the_mistake():
    """PLN 2026-08-02, having just played the exact case in this file's fixture.

    He heard d7 come out muffled in take_5_drops, went looking, and found it
    sharing crushbus 41 with the bass on d4:

      *"two effects on same bus is a redflag as it means fighting for bus! ...
        any shared bus (^41 on d4 and d7) as PV error"*

    Deliberately the OPPOSITE call from PV008, where he demoted a shared button
    to a warning. Both tests exist so a future refactor cannot quietly align
    them: a shared button is audible intent, a shared bus is an inaudible bug.
    """
    src = ('d4 $ n "0" # crushbus 41 (range 16 3.5 "^52")\n\n'
           'd7 $ n "0" # crushbus 41 (range 16 3.5 "^55")\n')
    assert all(x.severity == "error" for x in lint(src, "PV011"))


def test_pv011_different_effects_may_reuse_a_number():
    """The slot namespace is per effect — PLN: "diff bus shared button is ok"."""
    src = ('d4 $ n "0" # crushbus 41 (range 16 3.5 "^52")\n\n'
           'd7 $ n "0" # djfbus 41 (range 0 1 "^55")\n')
    assert lint(src, "PV011") == []


def test_pv011_one_orbit_may_name_its_own_slot_twice():
    src = ('d4 $ n "0" # crushbus 41 (range 16 3.5 "^52")\n'
           '  # crushbus 41 (range 16 3.5 "^52")\n')
    assert lint(src, "PV011") == []


def test_pv011_ignores_commented_lines():
    src = ('d4 $ n "0" # crushbus 41 (range 16 3.5 "^52")\n\n'
           'd7 $ n "0" -- # crushbus 41 (range 16 3.5 "^55")\n')
    assert lint(src, "PV011") == []


# --------------------------------------------------------------- PV012/PV013

def test_pv012_flags_the_kick_muting_on_family_two():
    """The exact shape found in 12 of 13 OPAL tracks on 2026-07-30.

    d1 filtered in column 1 but muted in column 2 — so F1, the button PLN
    reaches for to drop the kick, dropped nothing.
    """
    f = lint('d1 $ gF1 $ gMute2 -- KICK: Sub thud, 4otf\n', "PV012")
    assert len(f) == 1
    assert f[0].severity == "warning"
    assert "d1 mutes on gM2" in f[0].message
    assert "wants gM1" in f[0].message and "^73" in f[0].message


def test_pv012_flags_percs_sitting_on_the_kicks_button():
    """The bulk of the migration: d2/d3/d8 had to VACATE gM1 for the kick."""
    for orbit in ("d2", "d3", "d8"):
        f = lint(f'{orbit} $ gF1 $ gM1\n', "PV012")
        assert len(f) == 1, orbit
        assert "wants gM2" in f[0].message, orbit


def test_pv012_accepts_the_authored_map():
    """m1=d1 alone, m2=other percs, m3=bass+melodics. No finding on any of these."""
    ok = ('d1 $ gF1 $ gM1\n\n'
          'd2 $ gF1 $ gM2\n\n'
          'd3 $ gF1 $ gM2\n\n'
          'd8 $ gF1 $ gM2\n\n'
          'd4 $ gF2 $ gM3\n\n'
          'd5 $ gF3 $ gM3\n\n'
          'd11 $ gF3 $ gM3\n')
    assert lint(ok, "PV012") == []


def test_pv012_does_not_mirror_the_filter_index():
    """The regression this rule exists to prevent.

    gM used to be `gMask . gMute`, so the corpus drifted toward gM<N> == gF<N>.
    d2 filters on gF1 and mutes on gM2 — index MISMATCH, and CORRECT, because
    filters group the rhythm section as a bloc while mutes split the kick out.
    A rule that compared the two indices would flag this valid line.
    """
    assert lint('d2 $ gF1 $ gM2\n', "PV012") == []
    assert lint('d4 $ gF2 $ gM3\n', "PV012") == []


def test_pv012_ignores_commented_mutes():
    src = 'd8 $ gF1 $ gM2\n  -- $ gF1 $ gM1 -- La Boite a breaks\n'
    assert lint(src, "PV012") == []


def test_pv013_flags_an_orbit_with_no_mute_at_all():
    """PLN: "no mute is oversight!" — six of these were in the OPAL set."""
    f = lint('d8 $ gF1\n  $ midiOn "^92" (ply "1 <2!3 4>")\n', "PV013")
    assert len(f) == 1
    assert f[0].severity == "warning"
    assert "d8 declares no family mute" in f[0].message
    assert "gM2" in f[0].message and "^74" in f[0].message


def test_pv013_silent_when_a_mute_is_present():
    assert lint('d8 $ gF1 $ gM2\n', "PV013") == []


def test_pv013_a_commented_mute_does_not_count():
    """It is commented out, so the surface still cannot drop the orbit."""
    f = lint('d8 $ gF1\n  -- $ gM2\n', "PV013")
    assert len(f) == 1


# ------------------------------------------------------------------- PV014

def test_pv014_flags_a_bus_slot_numbered_for_another_orbit():
    """PLN's exact example: "bus 41 (d4's) on d7 here"."""
    f = lint('d7 $ n "0" # crushbus 41 (range 16 3.5 "^55")\n', "PV014")
    assert len(f) == 1
    assert f[0].severity == "warning"
    assert "d7 uses crushbus 41" in f[0].message
    assert "d4's" in f[0].message


def test_pv014_accepts_both_conforming_forms():
    """<orbit><slot> AND the bare one-slot form, which 894 refs in live/ use."""
    assert lint('d4 $ n "0" # crushbus 41 (range 16 3.5 "^32")\n', "PV014") == []
    assert lint('d4 $ n "0" # crushbus 42 (range 16 3.5 "^32")\n', "PV014") == []
    assert lint('d4 $ n "0" # crushbus 4 (range 16 3.5 "^32")\n', "PV014") == []


def test_pv014_handles_two_digit_orbits():
    """d10 owns 101/102 — the rule must not read 101 as "d1's slot 01"."""
    assert lint('d10 $ n "0" # crushbus 101 (range 16 3.5 "^18")\n', "PV014") == []
    f = lint('d11 $ n "0" # crushbus 101 (range 16 3.5 "^18")\n', "PV014")
    assert len(f) == 1 and "d10's" in f[0].message


def test_pv014_ignores_comments():
    """A commented-out bus is not a bus — the take_5_drops d7 case PLN parked."""
    assert lint('d7 $ n "0"\n  -- # crushbus 41 (range 16 3.5 "^55")\n', "PV014") == []
