"""lens — which measurement can SEE a given control, and on which orbits.

Why this module exists
----------------------
PLN's ask was "test all controls have impact on sound... even the crushes should change
noticeably". The trap is that the obvious measurement answers most of those questions
WRONG. rms is completely blind to a filter or a bitcrusher -- they rearrange the
spectrum and leave the level alone -- so an rms-based "did anything change?" reports a
confident NO on an effect that is working perfectly. Equally, a spectral centroid is
blind to a mute, and an aggregate of any kind is blind to the difference between a fade
and a sparse part.

So a general per-track acceptance test cannot use one measurement. It has to pick the
lens from what the track's own code does with that CC, which is exactly what this module
does -- and it does it as a PURE function of the source text, so it is unit-testable
without a rig, without audio, and without waiting eight seconds per assertion.

What it deliberately does NOT do
--------------------------------
It does not predict the DIRECTION of most effects. "crush 4 raises the centroid" is true
until someone crushes something that was already noise. The honest, general invariant is
`this control measurably moves the orbit it is wired to, on a lens that can see its
kind`, judged against the pattern's own cycle-to-cycle drift. Direction is asserted only
where the semantics are unambiguous and were paid for in blood: `djf` is bipolar with
bypass at the CENTRE (0.05 is a ~26 Hz low-pass, i.e. silence -- the #48 footgun), and a
mute must actually silence.

Controls whose effect no available lens can see -- `pan`, on a capture that sums to mono
-- are reported UNMEASURABLE, not "no impact". A test suite that reports a false failure
on a working control teaches you to ignore it, which is worse than not testing it.
"""

from __future__ import annotations

import re
import sys
from dataclasses import dataclass, field
from pathlib import Path

TOOLS = Path(__file__).resolve().parent.parent
if str(TOOLS) not in sys.path:
    sys.path.insert(0, str(TOOLS))

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

# CC 77-84 are MIDI-learned to Ardour's track gains and CC77 is the global gain --
# turning it down is total silence. They are excluded BY CONSTRUCTION here, not merely
# avoided by convention, so no future caller can opt back in by accident.
FORBIDDEN_CC = frozenset(range(77, 85))

CC_RE = re.compile(r'"\^(\d+)"')
LET_RE = re.compile(r'^\s*let\s+([a-zA-Z][a-zA-Z0-9_\']*)\s*=')

# effect keyword -> (kind, lens). Ordered: the FIRST match in a line wins, so the more
# specific names must precede the substrings they contain (octersub before octer, and
# every *bus before its bare parameter).
EFFECT_LENS: list[tuple[str, str, str]] = [
    # -- spectrum: the only lens that sees a filter or a distortion --
    ("midiDJF",         "filter",   "centroid"),
    ("djfbus",          "filter",   "centroid"),
    ("djf",             "filter",   "centroid"),
    ("hpfbus",          "filter",   "centroid"),
    ("crushbus",        "crush",    "centroid"),
    ("crush",           "crush",    "centroid"),
    ("distortbus",      "drive",    "centroid"),
    ("squizbus",        "squiz",    "centroid"),
    ("squiz",           "squiz",    "centroid"),
    ("triodebus",       "drive",    "centroid"),
    ("ringbus",         "ring",     "centroid"),
    ("hpf",             "filter",   "centroid"),
    ("hcutoff",         "filter",   "centroid"),
    ("lpf",             "filter",   "centroid"),
    ("cutoff",          "filter",   "centroid"),
    ("resonance",       "filter",   "centroid"),
    ("lpq",             "filter",   "centroid"),
    ("coarse",          "crush",    "centroid"),
    ("modIndex",        "fm",       "centroid"),
    ("fmmod",           "fm",       "centroid"),
    ("accelerate",      "pitch",    "centroid"),
    ("speed",           "pitch",    "centroid"),
    # -- the sub-octave family ADDS energy underneath; brightness is the wrong lens --
    ("octersubsubbus",  "sub",      "low_ratio"),
    ("octersubbus",     "sub",      "low_ratio"),
    ("octerbus",        "octave",   "high_ratio"),
    # -- level. `# silence` / `const silence` is the corpus's real mute idiom --
    ("silence",         "mute",     "rms"),
    ("gMute",           "mute",     "rms"),
    ("gain",            "level",    "rms"),
    ("amp",             "level",    "rms"),
    # -- density: a `ply` that fires changes the ONSET RATE and almost nothing else --
    ("striate",         "density",  "onsets"),
    ("chop",            "density",  "onsets"),
    ("ply",             "density",  "onsets"),
    ("gMask",           "density",  "onsets"),
    ("mask",            "density",  "onsets"),
    ("degradeBy",       "density",  "onsets"),
    ("segment",         "density",  "onsets"),
    ("stut",            "density",  "onsets"),
    ("euclid",          "density",  "onsets"),
    ("note",            "pitch",    "centroid"),
    ("up",              "pitch",    "centroid"),
    # A gated `# n 95` swaps which SAMPLE plays -- a different sound entirely, so its
    # brightness is what moves. (For a synth `n` is pitch; either way, centroid.)
    ("n ",              "sample",   "centroid"),
    ("slice",           "density",  "onsets"),
    ("juxBy",           "density",  "onsets"),
    # -- time: a reverb or delay extends ACTIVITY, which only the time axis shows --
    ("roombus",         "space",    "duty"),
    ("lesliebus",       "space",    "duty"),
    ("lratebus",        "space",    "duty"),
    ("lsizebus",        "space",    "duty"),
    ("leslie",          "space",    "duty"),
    ("lrate",           "space",    "duty"),
    ("lsize",           "space",    "duty"),
    ("delaybus",        "space",    "duty"),
    ("delayfb",         "space",    "duty"),
    ("delayt",          "space",    "duty"),
    ("delay",           "space",    "duty"),
    ("room",            "space",    "duty"),
    ("legato",          "space",    "duty"),
    ("sustain",         "space",    "duty"),
    ("release",         "space",    "duty"),
    ("loopAt",          "space",    "duty"),
    # -- rearrangement: same material, different event times --
    ("scramble",        "density",  "onsets"),
    ("shuffle",         "density",  "onsets"),
    ("hurry",           "density",  "onsets"),
    ("fast",            "density",  "onsets"),
    ("slow",            "density",  "onsets"),
    ("jux",             "density",  "onsets"),
    ("rot",             "density",  "onsets"),
    # -- unmeasurable on a mono-summed capture. Named explicitly so it reports
    #    UNMEASURABLE rather than a false NO_IMPACT. --
    ("pan",             "pan",      ""),
    ("orbit",           "routing",  ""),
]

# Anything that merely GATES another function ("apply f while the button is down")
# tells us nothing about the lens -- the wrapped function does. So these are skipped
# when scanning for an effect keyword, and only used to decide the probe values.
GATES = ("midiOn", "midiOff", "midiG", "novaOn", "novaOff", "range",
         "sometimesBy", "someCyclesBy", "whenmod", "every", "off", "superimpose")

DJ_FILTERS = frozenset({49, 50, 51})

# CC93 is the PANIC gate (gPanic reads "^93"; the SC bridge sets it from the
# 73+74+91+92 chord). Sending it high silences every gPanic'd stream. It is a global
# kill switch, not a musical control, so an acceptance test must never sweep it —
# "probe every control" plus "one control is a kill switch" is how a self-test
# silences a rehearsal and gets blamed on the rig. Reported, never probed.
NEVER_PROBE = frozenset({93})


@dataclass
class Control:
    """One CC as this track actually wires it."""

    cc: int
    orbits: list[int] = field(default_factory=list)
    kind: str = "unknown"
    lens: str = ""
    evidence: str = ""

    @property
    def bipolar(self) -> bool:
        """True when the control's neutral is its CENTRE, not its bottom."""
        return self.cc in DJ_FILTERS or self.kind == "filter" and self.cc in DJ_FILTERS

    @property
    def rest(self) -> int:
        """The value that means "not doing anything".

        For a DJ filter that is 64, and getting this wrong is not a cosmetic error:
        djf 0.05 is a ~26 Hz low-pass, so parking a filter at 0 to establish a
        "baseline" would measure silence and call the track broken.
        """
        return 64 if self.cc in DJ_FILTERS else 0

    @property
    def probes(self) -> list[int]:
        """Values to try, most-likely-to-show-an-effect first."""
        if self.cc in DJ_FILTERS:
            return [10, 118]        # closing low-pass, then climbing high-pass
        return [127, 96]

    @property
    def measurable(self) -> bool:
        return bool(self.lens)

    def __str__(self) -> str:
        orb = ",".join(f"d{o}" for o in self.orbits) or "?"
        return (f"CC{self.cc:<3d} {self.kind:<8s} -> {self.lens or 'UNMEASURABLE':<10s} "
                f"on {orb}")


# An ALL-FALSE mask is a MUTE, not a density gate, and the difference decides the
# lens: `mask "f*16"` silences the stream (measure rms), `mask "t!7 f"` drops one
# event in eight (measure onsets). Same keyword, opposite measurements — the
# distinction lives in the mask PATTERN, not the function name.
#
# This was found by running the plan against gimme_acid: gMute1/2/3 are three
# identical `mask "f*16"` helpers and they came back classified as mute, density,
# density. rms would have been the wrong lens on two of PLN's three mutes.
MUTE_MASK = re.compile(r'\bmask\s+\(?\s*"([^"]*)"')
# Repeat/multiplier suffixes carry DIGITS that are not values: `f*16` is sixteen
# falses, and a naive "does it contain a 1" test reads that 16 as a true and calls a
# full mute a density gate. Strip the suffixes before looking at the values.
MASK_SUFFIX = re.compile(r"[*!@/]\s*[\d.]+")


def _is_mute_mask(text: str) -> bool:
    """True when a `mask` argument is all-false, i.e. the stream is silenced."""
    for body in MUTE_MASK.findall(text):
        values = MASK_SUFFIX.sub("", body)
        if "t" not in values and "1" not in values:
            return True
    return False


def _effect_in(text: str) -> tuple[str, str, str] | None:
    """First effect keyword in a line, as (keyword, kind, lens).

    Scans by POSITION rather than by table order so that in
    `midiOn "^59" (ply 4) # crushbus 1 "^60"` the keyword nearest the CC wins -- table
    order alone would have handed every such line to whichever effect happened to sit
    higher in the list.
    """
    if _is_mute_mask(text):
        return ("mask(all-false)", "mute", "rms")
    best = None
    for kw, kind, lens in EFFECT_LENS:
        for m in re.finditer(rf"\b{re.escape(kw)}\b", text):
            if best is None or m.start() < best[0]:
                best = (m.start(), kw, kind, lens)
    return (best[1], best[2], best[3]) if best else None


# A gated mini-notation swap -- `midiOn "^41" (<| "k k k <k k*2>")` -- replaces the
# pattern rather than applying an effect. There is no effect keyword to find, but the
# thing it changes is WHEN events happen, so onsets is the lens. Requires a multi-token
# string so `# n "3"` isn't mistaken for a rhythm.
PATTERN_SWAP = re.compile(r'"[^"]*[a-z~\]][ ][a-z~\[<][^"]*"')

LOOKAHEAD = 2


def _scan(text: str) -> dict[int, tuple[str, str, str]]:
    """CC -> (kind, lens, evidence) for every CC referenced in `text`.

    Scans a small WINDOW, not a single line. Tidal expressions wrap, and PLN wraps them
    often -- `midiOn "^57" (` with the `# crushbus` on the following line is ordinary
    style. A strictly line-by-line scan left a third of the corpus unclassified, and an
    unclassified control is one the acceptance test silently skips: precisely the
    controls most likely to be broken, quietly excluded from the test that would notice.
    """
    lines = [strip_comment(ln) for ln in text.splitlines()]
    out: dict[int, tuple[str, str, str]] = {}
    for i, code in enumerate(lines):
        ccs = [int(c) for c in CC_RE.findall(code)]
        if not ccs:
            continue
        window = "\n".join(lines[i:i + 1 + LOOKAHEAD])
        hit = _effect_in(code) or _effect_in(window)
        if hit is None and PATTERN_SWAP.search(window):
            hit = ("<|", "pattern", "onsets")
        for cc in ccs:
            if cc in FORBIDDEN_CC:
                continue
            if hit and (cc not in out or out[cc][0] == "unknown"):
                out[cc] = (hit[1], hit[2], code.strip()[:90])
            elif cc not in out:
                out[cc] = ("unknown", "", code.strip()[:90])
    return out


# --------------------------------------------------------------------------- #
# BootTidal's own g* helpers
# --------------------------------------------------------------------------- #
# The reason this exists: gimme_acid applies gF1/gF2/gF3 on SIX orbits, and the
# first per-track plan reported "0 bipolar, no CC 49/50/51 at all". Those helpers
# are defined in BootTidal.hs, not in the track, so a scan of the .tidal file alone
# is blind to the three DJ filters — the controls PLN reaches for most. Same for
# gM1/gM2/gM3, which is every mute in the rig.
#
# PARSED, never copied (parsers-over-copy): BootTidal is the source of truth and it
# moves — gF1 was rewired onto gDJF, and midiGGlobal was retired on 2026-07-29. A
# hardcoded table would rot into a confidently wrong report, which is worse than no
# report. If BootTidal.hs is missing the functions below return {} and per-track
# analysis degrades to what the file itself says, rather than failing.

BOOTTIDAL = Path(__file__).resolve().parents[2] / "BootTidal.hs"

# `    gF1 = gDJF "^49"` / `    gM1 = gMask . gMute1`. A definition WITH a parameter
# (`gDJF ch = ...`) is deliberately excluded here: it has no CC of its own, its
# caller supplies one. Those are collected separately, as lens providers.
BOOT_DEF_RE = re.compile(r"^\s+(g[A-Za-z0-9_']*)\s*=\s*(\S.*)$")
BOOT_FUNC_RE = re.compile(r"^\s+(g[A-Za-z0-9_']*)\s+([a-z][A-Za-z0-9_']*)\s*=\s*(\S.*)$")
_BOOT_CACHE: dict[str, dict[str, dict[int, tuple[str, str, str]]]] = {}


def boot_helpers(path: Path | None = None) -> dict[str, dict[int, tuple[str, str, str]]]:
    """helper name -> {cc: (kind, lens, evidence)} for every g* in BootTidal.hs.

    Resolves composition (`gM1 = gMask . gMute1` inherits both CCs) and borrows the
    lens from a parameterised helper when one is applied (`gF1 = gDJF "^49"` gets
    filter/centroid from gDJF's lpf+hpf body, since "gDJF" itself is not a keyword
    any table would know).
    """
    p = path or BOOTTIDAL
    key = str(p)
    if key in _BOOT_CACHE:
        return _BOOT_CACHE[key]
    out: dict[str, dict[int, tuple[str, str, str]]] = {}
    try:
        text = p.read_text()
    except OSError:
        _BOOT_CACHE[key] = out
        return out

    funcs: dict[str, str] = {}      # parameterised: name -> body (lens provider)
    defs: dict[str, str] = {}       # zero-arg: name -> rhs
    # A continuation must LOOK like one. Appending every indented line to whatever was
    # defined last made `gM3 = gMask . gMute3` swallow the unrelated Launchpad block
    # that follows it and report CC 7 and CC 9 as mutes. Real Haskell continuations
    # open with an operator.
    cont = re.compile(r"^\s+[.#$(),+|]")
    last: tuple[dict, str] | None = None
    for raw in text.splitlines():
        code = strip_comment(raw)
        if not code.strip():
            last = None                 # a blank line ends any continuation
            continue
        mf = BOOT_FUNC_RE.match(code)
        if mf:
            funcs[mf.group(1)] = funcs.get(mf.group(1), "") + " " + mf.group(3)
            last = (funcs, mf.group(1))
            continue
        md = BOOT_DEF_RE.match(code)
        if md:
            defs[md.group(1)] = md.group(2)
            last = (defs, md.group(1))
            continue
        if last is not None and cont.match(code):
            # gDJF's hpf line sits on its own line and carries half the lens evidence,
            # so continuations have to be folded in — into the RIGHT definition.
            store, name = last
            store[name] += " " + code.strip()
        else:
            last = None

    def classify(name: str, rhs: str) -> tuple[str, str, str] | None:
        text_for_lens = rhs
        for fname, fbody in funcs.items():
            if re.search(rf"\b{re.escape(fname)}\b", rhs):
                text_for_lens = f"{fbody} {rhs}"
                break
        return _effect_in(text_for_lens)

    def ccs_of(name: str, depth: int = 0) -> dict[int, tuple[str, str, str]]:
        if depth > 6 or name not in defs:
            return {}
        rhs = defs[name]
        hit = classify(name, rhs)
        got: dict[int, tuple[str, str, str]] = {}
        for c in CC_RE.findall(rhs):
            cc = int(c)
            if cc in FORBIDDEN_CC:
                continue
            got[cc] = ((hit[1], hit[2], f"BootTidal {name} = {rhs.strip()[:70]}")
                       if hit else ("unknown", "", f"BootTidal {name}"))
        for other in defs:
            if other != name and re.search(rf"\b{re.escape(other)}\b", rhs):
                for cc, meta in ccs_of(other, depth + 1).items():
                    got.setdefault(cc, meta)
        return got

    for name in defs:
        got = ccs_of(name)
        if got:
            out[name] = got
    _BOOT_CACHE[key] = out
    return out


def controls(track: Track) -> list[Control]:
    """Every CC this track binds, with the lens that can see it and the orbits it hits.

    Two passes, because ParVagues tracks bind CCs in two places: directly inside a `dN`
    block, and inside a `let gF3 = ...` helper that several orbits then apply. Attributing
    a helper's CC only to the line that defines it would say "CC51 affects no orbits",
    which is exactly backwards -- in you_my_sunshine that one helper owned FOUR.
    """
    # BootTidal's helpers first, so a track's own `let` of the same name SHADOWS
    # them — which is what Tidal does, and several tracks redefine gMute locally.
    helpers: dict[str, dict[int, tuple[str, str, str]]] = dict(boot_helpers())
    for line in track.lines:
        code = strip_comment(line)
        m = LET_RE.match(code)
        if m and CC_RE.search(code):
            helpers[m.group(1)] = _scan(code)

    found: dict[int, Control] = {}

    def record(cc: int, kind: str, lens: str, ev: str, orbit: int | None) -> None:
        c = found.get(cc)
        if c is None:
            c = found[cc] = Control(cc=cc, kind=kind, lens=lens, evidence=ev)
        elif c.kind == "unknown" and kind != "unknown":
            c.kind, c.lens, c.evidence = kind, lens, ev
        if orbit is not None and orbit not in c.orbits:
            c.orbits.append(orbit)

    for orb in track.orbits():
        code = orb.code()
        for cc, (kind, lens, ev) in _scan(code).items():
            record(cc, kind, lens, ev, orb.number)
        for name, ccs in helpers.items():
            if re.search(rf"\b{re.escape(name)}\b", code):
                for cc, (kind, lens, ev) in ccs.items():
                    record(cc, kind, lens, ev, orb.number)

    # A CC defined in a helper that no orbit applies is still a binding worth reporting
    # -- it is dead wiring, and dead wiring on a control surface is a live trap.
    for ccs in helpers.values():
        for cc, (kind, lens, ev) in ccs.items():
            record(cc, kind, lens, ev, None)

    for c in found.values():
        c.orbits.sort()
    return sorted(found.values(), key=lambda c: c.cc)


def measurable(track: Track) -> list[Control]:
    """Only the controls an audio test can honestly judge, and that are safe to move."""
    return [c for c in controls(track)
            if c.measurable and c.orbits and c.cc not in FORBIDDEN_CC]


def lens_value(shape, lens: str) -> float:
    """Read one lens off a Shape. Keeps the AT from growing a getattr soup."""
    return {
        "centroid": lambda s: s.centroid_hz,
        "rms": lambda s: s.rms_db,
        "low_ratio": lambda s: s.low_ratio * 100.0,
        "high_ratio": lambda s: s.high_ratio * 100.0,
        "onsets": lambda s: s.onsets_per_s,
        "duty": lambda s: s.duty * 100.0,
    }[lens](shape)


LENS_UNITS = {"centroid": "Hz", "rms": "dB", "low_ratio": "%",
              "high_ratio": "%", "onsets": "/s", "duty": "%"}
