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