"""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.
    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.",
                )


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
