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


@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}")


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


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.
    """
    helpers: dict[str, dict[int, tuple[str, str, str]]] = {}
    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": "%"}
