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


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
