"""pvlint rules — each one encodes a bug that actually silenced this rig.

A rule earns its place here by having cost a real evening. Nothing speculative:
if it has never made sound disappear, it is not a rule.

Severity contract
-----------------
  error   — this WILL produce silence or a failed compile. Blocks a gig check.
  warning — this can produce silence under a knob position we know occurs.
  info    — structural facts a human should see (orbit sets, cut groups).
"""

from __future__ import annotations

import os
import re
from typing import Callable, Iterable

from .core import Finding, Track, iter_code_lines, strip_comment

Rule = Callable[[Track], Iterable[Finding]]
RULES: list[Rule] = []


def rule(fn: Rule) -> Rule:
    RULES.append(fn)
    return fn


# --------------------------------------------------------------------------
# PV001 — a track redefines the global DJ-filter helpers with the old footgun
# --------------------------------------------------------------------------

LET_GF_RE = re.compile(r"^\s*let\s+(gF\d|gMask|gMute\d?|gM\d)\s*=")
DJFBUS_RE = re.compile(r"djfbus")


@rule
def pv001_local_djfbus_shadow(track: Track) -> Iterable[Finding]:
    """`let gF3 = (# djfbus 3 (range 0.05 0.95 "^51"))` — the 2026-07-28 bug.

    SuperDirt's dj-filter maps `djf` through `linexp(0, 0.5, 20, 10000)`, so
    bypass is 0.5 (the CENTRE) and 0.05 is a ~26 Hz low-pass. `range 0.05 0.95`
    puts a low or untouched knob at the silent end. BootTidal's `gDJF` was fixed
    to be safe-at-centre; a local `let` throws that fix away.

    Autofix: delete the line so the track inherits the corrected global helper.
    """
    for i, code in iter_code_lines(track):
        m = LET_GF_RE.match(code)
        if m and DJFBUS_RE.search(code):
            yield Finding(
                rule="PV001",
                severity="error",
                line=i,
                message=f"local `{m.group(1)}` shadows the fixed global helper "
                        f"with the djfbus footgun",
                detail="djf 0.05 is a ~26 Hz low-pass = silence; bypass is 0.5 "
                       "(centre). Delete this line to inherit BootTidal's "
                       "safe-at-centre gDJF.",
                fix="",
            )


# --------------------------------------------------------------------------
# PV002 — an operator section containing a top-level function composition
# --------------------------------------------------------------------------


def _paren_spans(s: str) -> list[tuple[int, int]]:
    """Every balanced (start, end_exclusive) paren span, outermost included."""
    stack: list[int] = []
    out: list[tuple[int, int]] = []
    for i, ch in enumerate(s):
        if ch == "(":
            stack.append(i)
        elif ch == ")" and stack:
            out.append((stack.pop(), i + 1))
    return out


def _top_level_dots(inner: str) -> list[int]:
    """Indices of ` . ` composition operators at depth 0, OUTSIDE string literals.

    String-awareness is not optional here. In Tidal mini-notation `.` is the
    grouping separator, so `"k ~ ~ k . ~"` and `(# gain "0 . 0 1")` are perfectly
    ordinary code. The first version of this scanner ignored quotes and reported
    all four as compile-killers — a linter that cries wolf on a pre-gig gate is
    worse than no linter, because the real finding gets scrolled past.
    """
    depth = 0
    in_str = False
    out: list[int] = []
    for i, ch in enumerate(inner):
        if in_str:
            if ch == '"' and (i == 0 or inner[i - 1] != "\\"):
                in_str = False
            continue
        if ch == '"':
            in_str = True
        elif ch in "([":
            depth += 1
        elif ch in ")]":
            depth -= 1
        elif ch == "." and depth == 0:
            before = inner[i - 1] if i else " "
            after = inner[i + 1] if i + 1 < len(inner) else " "
            # ` . ` is composition; `0.5` and `Sound.Tidal` are not.
            if before == " " and after == " ":
                out.append(i)
    return out


@rule
def pv002_section_composition(track: Track) -> Iterable[Finding]:
    """`(|+ note 12 . (|* gain 0.6))` — the bug that killed desire.tidal.

    `.` binds looser than application, so this parses as
    `(|+) note (12 . (|* gain 0.6))`: a FUNCTION composed into a ValueMap. GHC
    rejects it with "'(.)' is applied to too few arguments", the whole block
    fails to compile, and every orbit in that block goes silent at once.

    The fix is one pair of parens: `((|+ note 12) . (|* gain 0.6))`.
    """
    for i, code in iter_code_lines(track):
        raw = track.lines[i - 1]
        for a, b in _paren_spans(code):
            inner = code[a + 1:b - 1]
            head = inner.lstrip()
            if not (head.startswith("|") or head.startswith("#")):
                continue
            dots = _top_level_dots(inner)
            if not dots:
                continue
            fix = None
            if len(dots) == 1:
                d = dots[0]
                left, right = inner[:d].rstrip(), inner[d + 1:].lstrip()
                new_inner = f"({left}) . {right}"
                candidate = code[:a + 1] + new_inner + code[b - 1:]
                # Only offer the fix if we can splice it back into the RAW line
                # (i.e. the span is not straddling a stripped comment).
                if code.strip() and code in raw or raw.strip() == code.strip():
                    fix = raw.replace(code.strip(), candidate.strip(), 1)
            yield Finding(
                rule="PV002",
                severity="error",
                line=i,
                message="operator section contains a top-level `.` — this "
                        "composes a function into a ValueMap",
                detail="`.` binds looser than application, so "
                       "`(|+ note 12 . f)` is `(|+) note (12 . f)`. GHC fails "
                       "the WHOLE block, silencing every orbit in it. "
                       "Wrap the section: `((|+ note 12) . f)`.",
                fix=fix,
            )


# --------------------------------------------------------------------------
# PV003 — CCs that are MIDI-learned to Ardour must never appear in a track
# --------------------------------------------------------------------------

CC_RE = re.compile(r'"\^(\d{1,3})"')
# CC77 ONLY. An earlier version of this rule flagged the whole 77-84 fader bank
# as "owned by Ardour" and produced 1109 errors across the corpus — every one of
# them wrong. BootTidal itself reads `^77` (`midiGGlobal = orDef 0.769 "^77" *
# 1.3`), and reading a fader CC in a track is the documented ParVagues
# convention. The hazard is not READING; it is that CC77 has TWO owners (Ardour's
# MIDI-learn and Tidal's global gain), so one physical fader can silence
# everything with no visible cause in either. That is task #53, and until it is
# resolved a track that reads ^77 directly deserves a note, not a failure.
GLOBAL_GAIN_CC = 77


@rule
def pv003_global_gain_cc(track: Track) -> Iterable[Finding]:
    """A track reading `"^77"` directly stacks on top of BootTidal's global gain.

    `midiGGlobal` already multiplies every `_gainG` stream by `^77`. A track that
    reads it again squares the fader: at 0.5 you get 0.25, and at the bottom you
    get silence twice over, with the cause invisible from Tidal.
    """
    for i, code in iter_code_lines(track):
        for m in CC_RE.finditer(code):
            if int(m.group(1)) == GLOBAL_GAIN_CC:
                yield Finding(
                    rule="PV003",
                    severity="warning",
                    line=i,
                    message='"^77" is already applied globally by midiGGlobal',
                    detail="BootTidal multiplies gain by ^77 for every stream. "
                           "Reading it again squares the fader. CC77 is also "
                           "MIDI-learned in Ardour — two owners, one control "
                           "(task #53).",
                )


# --------------------------------------------------------------------------
# PV004 — two orbits sharing one cut group choke each other
# --------------------------------------------------------------------------

CUT_RE = re.compile(r"#\s*cut\s+(\d+)")


@rule
def pv004_cut_group_collision(track: Track) -> Iterable[Finding]:
    """Two orbits in the same `cut` group truncate each other to clicks.

    `cut N` means "only one voice at a time in group N". Across orbits that is
    almost always accidental, and the symptom is the nastiest kind: the sound is
    THERE, it just stops early, so it reads as a fade or a bad sample rather
    than as a routing mistake.
    """
    owners: dict[int, list[tuple[int, int]]] = {}
    for orb in track.orbits():
        for ln_off, ln in enumerate(orb.lines):
            for m in CUT_RE.finditer(strip_comment(ln)):
                owners.setdefault(int(m.group(1)), []).append(
                    (orb.number, orb.start + ln_off)
                )
    for group, uses in sorted(owners.items()):
        orbs = sorted({o for o, _ in uses})
        if len(orbs) > 1:
            where = ", ".join(f"d{o}" for o in orbs)
            # One finding per ORBIT, not per `# cut` occurrence: an orbit that
            # sets the same group on three lines is one mistake, not three.
            first_line_of = {}
            for o, line in uses:
                first_line_of.setdefault(o, line)
            for _, line in sorted(first_line_of.items()):
                yield Finding(
                    rule="PV004",
                    severity="warning",
                    line=line,
                    message=f"cut group {group} is shared by {where}",
                    detail="Orbits in one cut group cut each other off. If that "
                           "is deliberate, say so in a comment; otherwise give "
                           "each orbit its own group.",
                )


# --------------------------------------------------------------------------
# PV005 — a duplicate `dN` declaration silently discards the earlier one
# --------------------------------------------------------------------------


@rule
def pv005_duplicate_orbit(track: Track) -> Iterable[Finding]:
    """Declaring `d5` twice in one file: the last one silently wins.

    Easy to create while editing live (copy a block, forget to renumber) and
    invisible on screen because both look correct in isolation.
    """
    # Scoped to a BLOCK, deliberately. Re-declaring `d5` in a *later* block is
    # normal ParVagues practice — a track file keeps several evaluable versions
    # of an orbit and you pick one live. Flagging that produced 576 findings on
    # the corpus, all of them noise. Within ONE block it is a real mistake: the
    # second declaration silently wins and the first is dead code.
    from .core import ORBIT_RE

    seen: dict[int, int] = {}
    for i, ln in enumerate(track.lines, start=1):
        if ln.strip() == "":
            seen = {}
            continue
        m = ORBIT_RE.match(ln)
        if not m:
            continue
        num = int(m.group(2))
        if num in seen:
            yield Finding(
                rule="PV005",
                severity="warning",
                line=i,
                message=f"d{num} is declared twice in the same block (first at "
                        f"line {seen[num]})",
                detail="Within one block the later declaration silently wins "
                       "and the earlier is dead code. For two layers use "
                       "`superimpose` or a different orbit.",
            )
        else:
            seen[num] = i


# --------------------------------------------------------------------------
# PV006 — a sample index past the end of its folder is silence, not an error
# --------------------------------------------------------------------------

# `"foldername"` or `"foldername:3"` or `"foldername/2"` as a bare sound literal.
SOUND_RE = re.compile(r'"([a-zA-Z][a-zA-Z0-9_]{2,})(?::(\d+))?(?:/\d+)?"')
N_PATTERN_RE = re.compile(r'#\s*n\s+"([^"]*)"')
INT_RE = re.compile(r"\d+")

# Keyed by (folder, roots): the roots are configurable (PVLINT_SAMPLE_ROOTS),
# so caching on the folder name alone leaks one test's tmp_path into the next.
_SAMPLE_CACHE: dict[tuple[str, tuple[str, ...]], int | None] = {}


def _folder_size(name: str, roots: list[str]) -> int | None:
    """Number of audio files in a sample folder, or None if not found."""
    key = (name, tuple(roots))
    if key in _SAMPLE_CACHE:
        return _SAMPLE_CACHE[key]
    size = None
    for root in roots:
        d = os.path.join(root, name)
        if os.path.isdir(d):
            size = sum(
                1
                for f in os.listdir(d)
                if f.lower().endswith((".wav", ".aif", ".aiff", ".flac", ".ogg"))
            )
            break
    _SAMPLE_CACHE[key] = size
    return size


def sample_roots() -> list[str]:
    env = os.environ.get("PVLINT_SAMPLE_ROOTS")
    if env:
        return [p for p in env.split(":") if p]
    home = os.path.expanduser("~")
    return [
        os.path.join(home, "Work", "Sound", "Samples"),
        os.path.join(home, ".local", "share", "SuperCollider", "downloaded-quarks",
                     "Dirt-Samples"),
    ]


@rule
def pv006_sample_index_out_of_range(track: Track) -> Iterable[Finding]:
    """`# n "12"` on a 10-sample folder plays NOTHING and logs nothing useful.

    SuperDirt wraps or drops out-of-range indices depending on the path taken;
    either way the musical result is "that layer didn't come in" with no error
    the performer can see. Since the folders are on disk, this is checkable
    before the gig instead of during it.

    Skipped silently when the sample root cannot be resolved, so the linter
    stays usable on a machine that is not the rig.
    """
    roots = sample_roots()
    if not any(os.path.isdir(r) for r in roots):
        return
    for orb in track.orbits():
        code = orb.code()
        folders = {m.group(1) for m in SOUND_RE.finditer(code)}

        # An index is only attributable to a folder in two unambiguous cases:
        #   "folder:N"          -> N belongs to that folder, always
        #   a single-folder orbit -> its `# n "..."` can only mean that folder
        # Pooling every index against every folder (the first version of this
        # rule) invented a finding on d8 of you_my_sunshine, where n=24 belongs
        # to org_jungle_breaks and breaks165 has one sample. A false positive on
        # a pre-gig gate is how a real one gets ignored, so we under-report here
        # on purpose.
        per_folder: dict[str, set[int]] = {f: set() for f in folders}
        for m in SOUND_RE.finditer(code):
            if m.group(2):
                per_folder.setdefault(m.group(1), set()).add(int(m.group(2)))
        if len(folders) == 1:
            only = next(iter(folders))
            for m in N_PATTERN_RE.finditer(code):
                per_folder[only].update(int(x) for x in INT_RE.findall(m.group(1)))

        for folder in sorted(folders):
            indices = per_folder.get(folder) or set()
            if not indices:
                continue
            size = _folder_size(folder, roots)
            if not size:
                continue
            over = sorted(i for i in indices if i >= size)
            if over:
                yield Finding(
                    rule="PV006",
                    severity="warning",
                    line=orb.start,
                    message=f'd{orb.number}: index {over[0]} is past the end of '
                            f'"{folder}" ({size} samples, max n={size - 1})',
                    detail=f"out-of-range indices: {over}. An index past the "
                           "end plays nothing and reports nothing — the layer "
                           "just never arrives.",
                )


# --------------------------------------------------------------------------
# PV007 — structural facts worth printing (orbit set drives orphan detection)
# --------------------------------------------------------------------------


@rule
def pv007_orbit_inventory(track: Track) -> Iterable[Finding]:
    """Report the declared orbit set.

    `dN` replaces orbit N and says NOTHING about the others, so switching to a
    track that declares fewer orbits leaves the previous track's extras playing
    underneath — audible, immune to the new track's gains, still filtered by the
    global helpers. The set is the input to that cross-track check.
    """
    orbs = sorted(track.declared_orbits())
    if orbs:
        yield Finding(
            rule="PV007",
            severity="info",
            line=1,
            message="declares orbits " + ",".join(f"d{o}" for o in orbs),
            detail="Switching FROM a track with a larger orbit set leaves "
                   "ghosts. See RUNBOOK.md 'orphan orbits'.",
        )


# --------------------------------------------------------------------------
# PV008 — one physical button driving two orbits
# --------------------------------------------------------------------------

# DERIVED from the one authored grid (#97). Hardcoding these was how the HUD's
# copy went stale after the 2026-07-29 remap; a lint rule silently disagreeing
# with the migrator about which CCs are buttons would be the same bug wearing a
# green checkmark. Family controls (gF1-3, gMute1-3, gPanic) come from the grid
# too, so "shared on purpose" stays defined in exactly one place.
try:
    import os as _os
    import sys as _sys
    _sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
    import lcxl_grid as _grid
    BUTTON_CCS = _grid.BUTTON_CCS
    FAMILY_CCS = _grid.FAMILY_CCS
except Exception:                                  # pragma: no cover
    # pvlint must stay importable on its own — it is the pre-gig gate, and a
    # missing sibling module is not a reason to leave PLN without a check.
    _grid = None
    BUTTON_CCS = set(range(41, 45)) | set(range(57, 61)) | set(range(73, 77)) | set(range(89, 93))
    FAMILY_CCS = {49, 50, 51, 73, 74, 75, 93}

CC_IN_CODE = re.compile(r'"\^(\d+)"')


@rule
def pv008_button_drives_two_orbits(track: Track) -> Iterable[Finding]:
    """One button, two orbits: pressing d2's gate also fires d1's.

    Earned on 2026-07-29, during the phase-2 button remap (#94). The migrator
    left an over-budget control where it was — which LOOKS conservative and is
    the opposite. Once ^42 became d2's button, d1's surviving reference to ^42
    meant one press drove both, and perfect.tidal shipped that way until a
    hand-written check found it.

    The symptom is why this is a rule and not a note: nothing errors, nothing is
    silent, and the extra layer only appears when a specific button is held. On
    stage that reads as "the track is broken today", not as a mapping bug.

    Family controls are excluded on purpose: gF1-3 and gMute1-3 are per-FAMILY
    by design (measured across the corpus), so sharing them is correct.
    """
    owners: dict[int, dict[int, int]] = {}      # cc -> {orbit: first line}
    for orb in track.orbits():
        if not 1 <= orb.number <= 8:
            continue
        for ln_off, ln in enumerate(orb.lines):
            for m in CC_IN_CODE.finditer(strip_comment(ln)):
                cc = int(m.group(1))
                if cc in BUTTON_CCS and cc not in FAMILY_CCS:
                    owners.setdefault(cc, {}).setdefault(
                        orb.number, orb.start + ln_off)
    for cc, per_orbit in sorted(owners.items()):
        if len(per_orbit) < 2:
            continue
        where = ", ".join(f"d{o}" for o in sorted(per_orbit))
        # One finding per ORBIT sharing it, anchored at that orbit's first use,
        # so each place needing an edit is reported once (as PV004 does).
        for _orbit, line in sorted(per_orbit.items()):
            yield Finding(
                rule="PV008",
                severity="error",
                line=line,
                message=f"button ^{cc} is driven by {where}",
                detail="One press fires both orbits. Nothing errors and nothing "
                       "is silent, so this only shows up as an unexplained extra "
                       "layer mid-set. Give the over-budget orbit its own control, "
                       "or comment the surplus gesture out with a FIXME until "
                       "gSel can fold it onto one control.",
            )


# --------------------------------------------------------------------------
# PV009 — gain MULTIPLIED by a control that seeds to zero: silent until touched
# --------------------------------------------------------------------------

MUL_RANGED = re.compile(r'\|([*/])\s*(\w+)\s*\(\s*range\s+([\d.]+)\s+[\d.]+\s+"\^(\d+)"')
MUL_BARE = re.compile(r'\|([*/])\s*(\w+)\s+"\^(\d+)"')

# BootTidal's seed policy: A/B knobs and C4-C8 -> 0 ("effect amount off"),
# C1-C3 -> 0.5 (DJF centre), faders -> 1, buttons -> 0. So a control that seeds
# to 0 is any knob outside C1-C3, and any button.
SEEDS_TO_ZERO = ((set(range(13, 21)) | set(range(29, 37)) | set(range(52, 57)))
                 | set(range(41, 45)) | set(range(57, 61))
                 | set(range(73, 77)) | set(range(89, 93)))


@rule
def pv009_gain_multiplied_by_zero_seed(track: Track) -> Iterable[Finding]:
    """`|* gain "^17"` — the layer is SILENT until that knob is raised.

    Found 2026-07-29 by PLN's ear, after the remap: "its the 17 superimpose that
    melts too !! does our xfade screw the superimposes?" It does not. `^17` seeds
    to 0 (BootTidal's "effect amount off"), and gain times zero is silence, so the
    superimposed layer never arrives. The 4-cycle `dN` crossfade then eases from
    the previously-audible state into that silence, which is what reads as a melt
    and is why the xfade looked guilty.

    This is USUALLY DELIBERATE — a bring-in fader for a layer — which is exactly
    why it is `info` and not a warning. The problem it solves is knowing WHICH
    knob is holding a layer at zero, without discovering it mid-set. It matters
    most right after a remap: CCs move, physical knobs do not, so a control left
    at 0 silently acquires a new victim.

    `#` params are unaffected (`# crush (range 16 3.5 "^53")` at seed 0 just sets
    a value); only multiplicative application can annihilate a pattern.
    """
    for i, code in iter_code_lines(track):
        for m, kind, cc_i, lo_i in ((MUL_RANGED, "ranged from 0", 4, 3),
                                    (MUL_BARE, "no range at all", 3, None)):
            for hit in m.finditer(code):
                cc = int(hit.group(cc_i))
                if cc not in SEEDS_TO_ZERO:
                    continue
                if lo_i is not None and float(hit.group(lo_i)) != 0.0:
                    continue
                op, param = hit.group(1), hit.group(2)
                yield Finding(
                    rule="PV009",
                    severity="info",
                    line=i,
                    message=f'|{op} {param} "^{cc}" is silent at the seeded value '
                            f'({kind})',
                    detail=f"^{cc} seeds to 0, and {param} multiplied by 0 removes "
                           f"the layer entirely — it will not be heard until that "
                           f"control is raised. Usually intended as a bring-in "
                           f"fader; listed so no one hunts it by ear. A remap moves "
                           f"the CC but not the knob, so check this one after any "
                           f"surface change.",
                )


# --------------------------------------------------------------------------
# PV010 — a GATE on the momentary button row, or a GESTURE on the latching one
# --------------------------------------------------------------------------

@rule
def pv010_button_role_on_wrong_row(track: Track) -> Iterable[Finding]:
    """`midiOn "^60" (ply ...)` — a multiplier latched ON until you press again.

    PLN's convention, stated twice in one session and confirmed by ear both times:

      *"should ALWAYS be the mask midiOff on ^60 and the ply or other
        multiplier/nassim button effect on the push-release 92"*

      row E (41-44, 57-60), latching   -> GATES    (mask/struct)
      row F (73-76, 89-92), momentary  -> GESTURES (ply/fast/stut/chop/slice)

    Earned the hard way: the phase-2 remap (5910aac) assigned each orbit's two
    button cells in ENCOUNTER ORDER, and tracks conventionally write the gesture
    line above the gate line, so it swapped 32 pairs across 13 files. PLN heard it
    before any check did — *"drums in revolution seem way too fast ... yea def an
    inversion"*. "Way too fast" is the signature of a `ply` on the latching row:
    one press and the drums stay multiplied. The mirror image is a `mask` that
    only gates while a finger holds it down.

    Neither case errors, and neither is silent — which is why it needs a rule.

    NOT flagged, deliberately: orbits d1-d3. Their row-F cells (73,74,75) are the
    per-family mutes gMute1/2/3, so those columns own exactly ONE button. A
    gesture on ^41/^42/^43 is not an inversion, it is the only cell that exists.

    The role judgement is `tools/button_roles.py`, shared with the two tools that
    ASSIGN cells (migrate-columns.py) and REPAIR them (fix-button-roles.py) — one
    rule, three consumers, no chance to drift.
    """
    try:
        import lcxl_grid as _g
        import button_roles as _br
    except ImportError:
        return
    for orb in track.orbits():
        home = _g.orbit_home(orb.number)
        gate_cc, gest_cc = home.get(_br.GATE_ROLE), home.get(_br.GESTURE_ROLE)
        if gate_cc is None or gest_cc is None:
            continue  # single-slot column: the convention cannot apply
        found = list(_br.sites(orb.lines, {gate_cc, gest_cc}))
        roles = {cc: role for _i, cc, _c, _e, role in found if role is not None}
        for ln_off, cc, _c, _e, role in found:
            if role is None or cc == home[role]:
                continue
            # Two controls of the SAME role cannot both have their own cell: an
            # orbit owns one latching and one momentary button, full stop. That is
            # a budget limit, not a mistake, and must not read as one — a lint that
            # nags about the impossible is a lint that gets ignored, and this one
            # has to stay trustworthy enough to gate a gig.
            if roles.get(home[role]) == role:
                yield Finding(
                    rule="PV010",
                    severity="info",
                    line=orb.start + ln_off,
                    message=f"d{orb.number}: two {_br.describe(role)}s share one "
                            f"column (^{cc} and ^{home[role]}) — only one can be "
                            f"on the right row",
                    detail="An orbit owns one latching button and one momentary "
                           "one, so a second gesture (or a second gate) has to sit "
                           "on the other row. Not fixable by renumbering: either "
                           "accept it, or fold the pair onto one control once gSel "
                           "lands (#54).",
                )
                continue
            row = "momentary row F" if cc == gest_cc else "latching row E"
            cure = ("a latched multiplier: one press and it stays on"
                    if role == _br.GESTURE_ROLE else
                    "a gate that only holds while you hold the button")
            yield Finding(
                rule="PV010",
                severity="warning",
                line=orb.start + ln_off,
                message=f"d{orb.number}: {_br.describe(role)} on ^{cc} ({row}) "
                        f"— wants ^{home[role]}",
                detail=f"This gives you {cure}. Gates latch on row E, "
                       f"gestures are held on row F. "
                       f"Fix: python3 tools/fix-button-roles.py --apply",
            )


# --------------------------------------------------------------------------
# PV011 — one global FX bus slot shared by two orbits
# --------------------------------------------------------------------------

FXBUS_RE = re.compile(r"#\s*(\w+)bus\s+(\d+)")


@rule
def pv011_fx_bus_shared(track: Track) -> Iterable[Finding]:
    """`# crushbus 41` on d4 AND d7 — one effect instance, last writer wins.

    PLN's ask, right after PV004 caught a shared cut group he had not expected:
    *"putain a unexpected cut shared, well done! like shared busses they screw
    things. can we lint and flag these? diff bus shared button is ok, but to be
    detected too"*.

    A `<fx>bus N` is a SLOT in SuperDirt, not a per-orbit effect. Two orbits
    naming the same number get one shared instance, so whichever event lands last
    sets the parameter for both — a knob meant for the choir also crushes the
    bass, and the amount you hear depends on event density rather than on the
    knob. Different effects on the same NUMBER (`crushbus 41` vs `djfbus 41`) are
    fine and are not flagged: the slot namespace is per effect.

    A warning, not an error: sharing a bus is a legitimate way to glue two orbits
    into one colour. The point is that it should be a decision, not a surprise.
    """
    owners: dict[tuple[str, int], list[tuple[int, int]]] = {}
    for orb in track.orbits():
        for ln_off, ln in enumerate(orb.lines):
            for m in FXBUS_RE.finditer(strip_comment(ln)):
                key = (m.group(1), int(m.group(2)))
                owners.setdefault(key, []).append((orb.number, orb.start + ln_off))
    for (fx, num), uses in sorted(owners.items()):
        orbs = sorted({o for o, _ in uses})
        if len(orbs) < 2:
            continue
        where = ", ".join(f"d{o}" for o in orbs)
        first_line_of: dict[int, int] = {}
        for o, line in uses:
            first_line_of.setdefault(o, line)
        for _, line in sorted(first_line_of.items()):
            yield Finding(
                rule="PV011",
                severity="warning",
                line=line,
                message=f"{fx}bus slot {num} is shared by {where}",
                detail=f"One {fx} instance serves both orbits, so the last event "
                       f"to land sets the amount for both — the knob you turn for "
                       f"one is heard on the other. Give each orbit its own slot "
                       f"number, or say in a comment that the glue is deliberate.",
            )


# --------------------------------------------------------------------------
# PV012/PV013 — the family MUTE an orbit reaches for
# --------------------------------------------------------------------------

MUTE_REF_RE = re.compile(r"\bg(?:M|Mute)([123])\b")


def _mute_of(orb) -> tuple[int, int] | None:
    """(family index, 1-indexed line) of the orbit's first live gM reference."""
    for off, ln in enumerate(orb.lines):
        m = MUTE_REF_RE.search(strip_comment(ln))
        if m:
            return int(m.group(1)), orb.start + off
    return None


@rule
def pv012_mute_family_mismatch(track: Track) -> Iterable[Finding]:
    """d1 on gM2, or d4 on gM3 — the mute button does not match the orbit's role.

    PLN authored the map 2026-07-30 (#105):

      *"I want the kick, now on d1/fader1, to have its mute on F1 botrow button
        ... however i want the mutes consistent across all all tracks:
        m1 d1 / m2 other percs / m3 all bass+melodics"*

    The corpus had drifted into mirroring gM<N> onto gF<N>, because gM<N> used to
    be `gMask . gMute<N>` and gMask has since been retired. That mirror is wrong
    for the hand: the FILTERS want the rhythm section as one bloc (you sweep the
    drums together) while the MUTES want the kick split out on its own button (you
    drop the kick alone). Two gestures, two groupings — which is why this rule
    reads lcxl_grid.mute_family() and NOT the orbit's gF index.

    Consistency here is muscle memory, not tidiness: mid-set PLN reaches for F1 to
    drop the kick without looking. A track where F1 mutes the hats instead is a
    surprise at exactly the wrong moment.

    Fix mechanically with `python3 tools/fix-mute-roles.py --apply`.
    """
    if _grid is None:                      # grid unavailable: cannot judge
        return
    for orb in track.orbits():
        got = _mute_of(orb)
        if got is None:
            continue
        fam, line = got
        want = _grid.mute_family(orb.number)
        if fam == want:
            continue
        cc_got = _grid.CELL_TO_CC[("F", fam)]
        cc_want = _grid.CELL_TO_CC[("F", want)]
        yield Finding(
            rule="PV012",
            severity="warning",
            line=line,
            message=f"d{orb.number} mutes on gM{fam} (F{fam}, ^{cc_got}) "
                    f"but its role wants gM{want} (F{want}, ^{cc_want})",
            detail=f"The authored map is m1=d1 (the kick alone), m2=other percs, "
                   f"m3=bass+melodics — tools/lcxl_grid.py _MUTE_FAMILY. On this "
                   f"track F{want} will not drop d{orb.number} and F{fam} will drop "
                   f"it unexpectedly. Fix with "
                   f"`python3 tools/fix-mute-roles.py --apply {track.path}`.",
        )


@rule
def pv013_no_family_mute(track: Track) -> Iterable[Finding]:
    """An orbit with no gM at all — a stem you cannot drop from the surface.

    PLN, 2026-07-30, on finding six of these in the OPAL set:
    *"no mute is oversight!"*

    Not always true — a one-shot, a riser, or a scratch block can reasonably have
    no gate, which is why this is a warning and why `fix-mute-roles.py` only fills
    the setlist automatically and merely REPORTS the rest for him to eyeball. But
    in a track being performed it is almost always an omission: the family mute is
    the fastest way to take a layer out, and an orbit without one can only be
    stopped by editing and re-evaluating the block, mid-set, by hand.

    Adding one is boot-safe: gMute<N> is `midiOn "^7N" (mask "f*16")` and every
    button CC seeds to 0, so the mask is never applied until the button is pressed.
    """
    if _grid is None:
        return
    for orb in track.orbits():
        if _mute_of(orb) is not None:
            continue
        want = _grid.mute_family(orb.number)
        cc = _grid.CELL_TO_CC[("F", want)]
        yield Finding(
            rule="PV013",
            severity="warning",
            line=orb.start,
            message=f"d{orb.number} declares no family mute "
                    f"(role wants gM{want} = F{want}, ^{cc})",
            detail=f"Nothing on the surface can drop this orbit — taking it out "
                   f"means editing the block live. Add `$ gM{want}` after the gF, "
                   f"or `python3 tools/fix-mute-roles.py --fill --apply "
                   f"{track.path}`. If the omission is deliberate (a one-shot or a "
                   f"riser), say so in a comment so the next reader knows.",
        )


def check(track: Track, enabled: set[str] | None = None) -> list[Finding]:
    out: list[Finding] = []
    for fn in RULES:
        for f in fn(track):
            if enabled is None or f.rule in enabled:
                out.append(f)
    out.sort(key=lambda f: (f.line, f.rule))
    return out
