#!/usr/bin/env python3
"""lcxl3_language — the v3 visual language: role hue x value lightness, on RGB.

SETTLED WITH PLN 2026-08-29 (AskUserQuestion, four answers):
  rings A/B     hue = ROLE (per-orbit family colour), lightness = VALUE
  row C DJF     diverging BLUE <-> GREEN, bright cyan landmark at the bypass
                detent, intensity grows with cut amount, extremes pulse (danger:
                djf 0.05 is ~26 Hz lowpass = silence)
  OLED          stationary track HUD + temporary touched-control context in
                CORPUS terms ("d4 fx2 · 64", not "CC 29: 64")
  encoders      RELATIVE, driver-owned values

WHY THIS IS A SEPARATE MODULE AND NOT AN EDIT TO lcxl-leds.py
    BC constraint, PLN verbatim: "Ill move from lcxl 3 to older one, friend
    pulls parvagues has classic lcxl, so we need to be BC always." The classic
    surface keeps its settled 3-hue language in lcxl-leds.py untouched; this
    module exists only where a v3 is present, and everything it needs beyond
    the surface itself comes from lcxl_grid — the one authored table. Corpus,
    BootTidal, SC and Ardour never learn that v3 exists.

WHAT THE v2 LANGUAGE KEEPS (deliberately, same rules on both surfaces):
  dark = this control does nothing on this track    <- outranks everything
  the DJF bypass detent band stays 61-67            <- same SEMANTICS, new paint
  panic overlay outranks all

WHAT CHANGED AND WHY IT MAY CHANGE:
  v2 spent all three hues on VALUE because three hues was all it had; the memory
  records role-colours were tried and abandoned for ambiguity. Full RGB dissolves
  that trade: hue carries ROLE again, lightness carries VALUE. The per-orbit hue
  anchors below are DEMO values chosen tonight for legibility of the three blocs
  (warm = rhythm, purple = bass, cool = melodic); mapping them onto the fleet's
  OKLCH families in models.py is the intended follow-up, not done yet.
"""
from __future__ import annotations

import colorsys

import lcxl_grid as G

# ------------------------------------------------------------------ hues -----
# Per-orbit hue anchors (HSV degrees). Blocs follow the AUTHORED filter families
# (lcxl_grid._FILTER_FAMILY): warm = percs bloc (d1 d2 d3 d8), purple = bass
# (d4), cool blues = melodic (d5-d7), violets/rose = extras (d9-d12).
# 300-335 avoided: brand magenta is reserved fleet-wide (DESIGN.md).
# 130-175 avoided on steady controls: that band IS the DJF diverging scale.
ORBIT_HUE = {
    1: 25, 2: 50, 3: 75, 8: 100,          # rhythm bloc — warm sweep
    4: 280,                                # bass — purple
    5: 180, 6: 205, 7: 230,                # melodic bloc — cool sweep
    9: 250, 10: 270, 11: 340, 12: 0,       # extras
}
FAMILY_HUE = {1: 25, 2: 62, 3: 230}        # F1 kick · F2 percs · F3 bass+melodic

DJF_LO, DJF_HI = 61, 67                    # bypass detent band — SAME as v2
DJF_DANGER_LO, DJF_DANGER_HI = 8, 119      # beyond these, you are near silence
# PLN's second iteration, on the device (2026-08-29): the blue<->green diverging
# scale was "too subtle", and with no pot ridge the ZERO was unfindable. New
# scale, his spec: "blue to lows, green midrange to mark the 0 clearly as slow
# glow at deep green, then as you go up you go red".
#   LPF side   deep blue, brightening with cut
#   detent     DEEP GREEN, breathing slowly (one breath per BAR) — the 0 mark
#   HPF side   green -> yellow -> red as the cut grows
# And the extremes GLOW to the BEAT (sine, not a square blink — "a bit
# aggressive atm"). Rate comes from the track's setcps; phase is wall-clock, so
# it breathes AT the tempo without claiming to know Tidal's cycle position.
HUE_ZERO, HUE_LPF, HUE_HPF_END = 135, 220, 0
REST_BTN, FLASH_BTN = 0.16, 1.0            # button brightness: rest / pressed


def _rgb(hue: float, sat: float, val: float) -> tuple[int, int, int]:
    """HSV -> the surface's 7-bit RGB triplet."""
    r, g, b = colorsys.hsv_to_rgb((hue % 360) / 360.0, sat, min(1.0, val))
    return int(r * 127), int(g * 127), int(b * 127)


def _lightness(value: int) -> float:
    """0..127 -> ring lightness. Never fully dark: dark means UNMAPPED only."""
    return 0.10 + 0.90 * (max(0, min(127, value)) / 127.0)


def _breath(phase: float) -> float:
    """0..1 sine breathing, smooth at both ends."""
    import math
    return 0.5 + 0.5 * math.sin(2 * math.pi * phase)


def djf_colour(value: int, beat: float = 0.0, bar: float = 0.0) -> tuple[int, int, int]:
    """PLN's diverging DJF scale: blue lows / breathing deep-green zero / to red."""
    v = max(0, min(127, value))
    if DJF_LO <= v <= DJF_HI:
        return _rgb(HUE_ZERO, 1.0, 0.25 + 0.40 * _breath(bar))   # the slow glow 0
    if v < DJF_LO:
        t = (DJF_LO - v) / DJF_LO
        hue, val = HUE_LPF, 0.30 + 0.70 * t
    else:
        t = (v - DJF_HI) / (127 - DJF_HI)
        hue, val = HUE_ZERO * (1.0 - t), 0.30 + 0.70 * t         # green->yellow->red
    if v <= DJF_DANGER_LO or v >= DJF_DANGER_HI:
        val *= 0.40 + 0.60 * _breath(beat)                        # beat-glow, not blink
    return _rgb(hue, 1.0, val)


def animated(v2cc: int, value: int) -> bool:
    """Cells that breathe: a DJF at its zero mark, or in the danger zone."""
    role, _ = G.CC_ROLE.get(v2cc, ("", 0))
    if role != "family_filter":
        return False
    v = max(0, min(127, value))
    return (DJF_LO <= v <= DJF_HI) or v <= DJF_DANGER_LO or v >= DJF_DANGER_HI


def ring_colour(v2cc: int, value: int, mapped: bool,
                beat: float = 0.0, bar: float = 0.0) -> tuple[int, int, int]:
    """Colour for any knob/fader cell, in v2/corpus terms."""
    if not mapped:
        return (0, 0, 0)                                # dark = unmapped, always
    role, who = G.CC_ROLE.get(v2cc, ("fx", 0))
    if role == "family_filter":
        return djf_colour(value, beat, bar)
    if v2cc in G.ARDOUR_CCS:
        # Ardour owns this value; we cannot claim a lightness we do not know.
        return _rgb(ORBIT_HUE.get(who, 0), 0.9, 0.30)
    return _rgb(ORBIT_HUE.get(who, 0), 1.0, _lightness(value))


def button_colour(v2cc: int, pressed: bool, mapped: bool) -> tuple[int, int, int]:
    """Buttons: role hue, brightness = state. Latch truth lives in Tidal — the
    driver only sees the press, so rest/flash is what it can honestly paint."""
    if not mapped:
        return (0, 0, 0)
    role, who = G.CC_ROLE.get(v2cc, ("gate", 0))
    hue = FAMILY_HUE.get(who, 0) if role == "family_mute" else ORBIT_HUE.get(who, 0)
    return _rgb(hue, 1.0, FLASH_BTN if pressed else REST_BTN)


# ------------------------------------------------------------------ OLED -----

# The families, named once. gDJF splits by BLOC and gMute by ROLE — the two are
# genuinely different partitions of the surface (see `mute role map`), so they
# get two tables, not one shared guess.
FAMILY_NAME = {1: "percs", 2: "bass", 3: "melodic"}
MUTE_NAME = {1: "kick", 2: "percs", 3: "bass+mel"}


def touch_lines(v2cc: int, value: int,
                ctx: str | None = None) -> tuple[str, str, str]:
    """(title, param, value) for the temporary touched-control overlay.

    `ctx` is what the control DOES in this track — "# crushbus", the gate's
    pattern — parsed from the .tidal. PLN: showing the cell name there "is less
    useful than what it is (sample name or effect like 'rose:4' or '# crush')".
    """
    # ROW ORDER IS ERGONOMICS, measured from PLN's chair (2026-08-29): seated,
    # row 2 reads clearly and row 3 hides behind the screen's bottom bezel. So
    # the row that matters goes SECOND, never third — and the cell name ("C1")
    # is dropped from DJF overlays entirely: "i know the button positions ahah".
    role, who = G.CC_ROLE.get(v2cc, ("?", 0))
    lab = ctx or G.label(v2cc)
    if role == "family_filter":
        return (f"DJF {FAMILY_NAME.get(who, '?')}", djf_readout(value), "")
    if role == "family_mute":
        return (f"MUTE {MUTE_NAME.get(who, '?')}", str(value), "")
    title = f"d{who} {role}" if isinstance(who, int) and who else lab
    if role in ("gate", "gate2"):
        return (title, "ON" if value > 0 else "off", lab)
    return (title, str(value), lab)


# ------------------------------------------------------------------ tempo ----
# The tempo can BE a knob. sept1.tidal says
#     # cps ((range 60 180 "^29")/60/4)
# and ^29 is row-B knob 1 in v2 numbering, so B1 IS the tempo — live, on the
# surface, with no code edit. PLN's transition move, verbatim (2026-08-30):
# "start holding kick mute, move kick to the 120bpm position (i used the center
# notch, now ill rely on OLED), drop kick mute at end of a bar for a smooth
# 60->120 transition".
#
# The v3 encoders have no centre ridge, so the SCREEN has to be the notch. That
# move needs exactly three things, and they fit the two rows he can read:
#   1. BPM, not wire units.
#   2. WHICH landmark is near and FROM WHICH SIDE — an arrow that vanishes when
#      he is on it. That is the ridge his thumb used to find.
#   3. While a mute is held: how many beats until the bar ends, because the
#      drop lands on a bar line or it does not land at all.
TEMPO_LANDMARKS = (60, 70, 75, 80, 85, 90, 95, 100, 105, 110, 115, 120, 125,
                   128, 130, 135, 140, 145, 150, 160, 170, 174, 180)
# 60-180 over 128 steps is 0.94 BPM per step, so "exactly 120" is NOT reachable:
# it sits between step 63 (119.5) and step 64 (120.4). The lock window is one
# half-step, and off-lock keeps a decimal — the screen must never round its way
# into claiming a tempo the pattern is not playing. (To make round numbers
# reachable, the TRACK can quantise: `# cps ((quantise 1 (range 60 180 "^29"))
# /60/4)` — then two steps both land on 120 and the detent is real.)
TEMPO_LOCK = 0.5
TEMPO_HUE = 40                             # gold — tempo is nobody's orbit


def tempo_lock(bpm: float) -> tuple[float, bool]:
    """(nearest landmark, are we on it)."""
    near = min(TEMPO_LANDMARKS, key=lambda t: abs(t - bpm))
    return near, abs(near - bpm) <= TEMPO_LOCK


def tempo_lines(bpm: float, held: str = "",
                beats_left: int | None = None) -> tuple[str, str, str]:
    """The tempo knob's overlay. Row 2 carries the number he steers by."""
    near, locked = tempo_lock(bpm)
    if locked:
        # "~120" when the knob cannot land exactly there (63 -> 119.5, 64 ->
        # 120.4 over a 60-180 range). The tilde is the whole truth in one
        # character: this is as close to 120 as this control gets. Dropping it
        # would print a round number the pattern is not playing.
        val = f"{'' if abs(near - bpm) < 0.05 else '~'}{near:g} BPM"
    else:
        val = f"{bpm:.1f} {'^' if bpm < near else 'v'}{near:g}"
    if not held:
        return ("TEMPO" + (" LOCK" if locked else ""), val, "")
    # A drop is staged: a mute is down and the tempo is moving. Row 1 says
    # whether to wait, row 2 counts the bar out — the release cue, not decor.
    if locked and beats_left is not None:
        return (f"DROP {held}"[:17], f"{val}  >{beats_left}"[:17], "")
    return (f"HOLD {held}"[:17], val, "")


def tempo_colour(bpm: float, beat: float, bar: float,
                 staged: bool = False) -> tuple[int, int, int]:
    """Gold while hunting, deep green once locked — breathing at the BAR, or at
    the BEAT when a mute is held, so the ring itself counts the drop in."""
    near, locked = tempo_lock(bpm)
    if locked:
        return _rgb(135, 1.0, 0.25 + 0.45 * _breath(beat if staged else bar))
    lo, hi = TEMPO_LANDMARKS[0], TEMPO_LANDMARKS[-1]
    t = max(0.0, min(1.0, (bpm - lo) / (hi - lo)))
    return _rgb(TEMPO_HUE, 0.9, 0.20 + 0.55 * t)


def tempo_label(lo: float, hi: float, width: int = 16) -> str:
    """The tempo knob's name on its own display target.

    Not `d1 # cps`: the grid calls row-B knob 1 orbit 1's fx cell, and the
    parsed context would read "# cps" — both true about the SYNTAX and wrong
    about the CONTROL, which steers the whole track's clock, not d1's.
    """
    return f"TEMPO {lo:g}-{hi:g}"[:width]


def mute_summary(held: list[int] | tuple[int, ...]) -> str:
    """PLN: "when i press mute buttons i expect more like 'Mute\\n{kick,percs,
    kick+percs,mel,kick+mel,all}' dep on which are held together or single"."""
    ids = sorted(set(held))
    if not ids:
        return ""
    if len(ids) >= len(MUTE_NAME):
        return "all"
    return "+".join(MUTE_NAME.get(i, f"F{i}") for i in ids)


def mute_lines(held: list[int] | tuple[int, ...]) -> tuple[str, str, str]:
    """One overlay for the whole held SET, not last-press-wins.

    The old behaviour showed `MUTE kick / 127` — true about one button and
    silent about the other two, which is the least useful thing a screen can be
    while three fingers are down.
    """
    ids = sorted(set(held))
    if not ids:
        return ("MUTE", "none", "")
    head = "MUTE" if len(ids) == 1 else f"MUTE x{len(ids)}"
    return (head, mute_summary(ids)[:17], "")


def control_label(v2cc: int, ctx: str | None = None,
                  mapped: bool = True, width: int = 16) -> str:
    """ONE line for the firmware's OWN per-control overlay (target == CC index).

    This is a different job from `touch_lines`. There we own three fields and the
    value; here the firmware draws the value itself (arrangement 4 = "Parameter
    Name and Numeric Parameter Value") and gives us exactly one field: the name.
    So every character goes to identity — orbit first, because that is what PLN
    thinks in, then what the control DOES in this track.

    Faders and A1-A4 have no `^NN` in the .tidal at all: they are Ardour-learned
    levels, so `ctx` is None for them by construction and the label has to come
    from the grid's role table. They get an explicit `[ard]` mark, because "this
    fader is Ardour's, not Tidal's" is the single most useful thing the screen
    can tell you about it mid-set.

    An unmapped control says so. `dark means unmapped` is the surface's oldest
    rule and the screen must not contradict the LED beside it.
    """
    cell = G.label(v2cc)
    if not mapped:
        return f"{cell} unmapped"[:width]
    role, who = G.CC_ROLE.get(v2cc, ("", 0))
    if role == "family_filter":
        return f"DJF {FAMILY_NAME.get(who, '?')}"[:width]
    if role == "family_mute":
        return f"MUTE {MUTE_NAME.get(who, '?')}"[:width]
    orbit = f"d{who}" if isinstance(who, int) and who else cell
    what = ctx or role or ""
    label = f"{orbit} {what}".strip()
    if v2cc in G.ARDOUR_CCS:
        mark = " [ard]"
        label = label[:width - len(mark)] + mark
    return label[:width]


def djf_hz(value: int) -> tuple[str, float]:
    """('LPF'|'HPF'|'ZERO', cutoff Hz) straight from BootTidal's gDJF ranges."""
    vn = max(0, min(127, value)) / 127.0
    if DJF_LO <= value <= DJF_HI:
        return "ZERO", 0.0
    if value < DJF_LO:
        return "LPF", 180.0 + 19820.0 * (2.0 * vn)
    return "HPF", 20.0 + 7980.0 * (2.0 * (vn - 0.5))


def _fmt_hz(hz: float) -> str:
    return f"{hz/1000:.1f}k" if hz >= 1000 else f"{int(hz)}"


# Zero-mark animation frames for the OLED value line. 0x1E is the charset's
# heart. Cycled at ~3 fps by the driver while a DJF rests at its zero.
ZERO_FRAMES = ("\x1e   0   \x1e", "    0    ", "\x1e   0   \x1e", "   (0)   ")


def djf_readout(value: int) -> str:
    side, hz = djf_hz(value)
    if side == "ZERO":
        return ZERO_FRAMES[0]
    s = f"{side} {_fmt_hz(hz)}Hz"
    if value <= DJF_DANGER_LO or value >= DJF_DANGER_HI:
        s += " !!"
    return s


def home_lines(track: str, detail: str = "") -> tuple[str, str, str]:
    """The stationary track HUD, rows by PLN's chair ergonomics: the name, then
    the row that matters (tempo + time played — the overstretch check), and the
    ParVagues drip at the bottom where the bezel eats it anyway."""
    return (track.upper()[:16], detail[:16], "ParVagues")


# ---------------------------------------------------------------- mapping ----

def mapped_ccs(track_ccs: set[int], orbits: set[int]) -> set[int]:
    """Everything that is LIVE for a track, in v2 terms.

    A track file's ^NN set is not the whole story: the family filters/mutes live
    in BootTidal (boot helpers are invisible in track files), so they are live on
    EVERY track; and the level cells belong to Ardour for every orbit the track
    actually uses. PANIC_CC is a chord, not a cell — never painted directly.
    """
    live = set(track_ccs) | set(G.FAMILY_CCS)
    for n in orbits:
        cc = G.CELL_TO_CC.get(("D", n)) if n <= 8 else G.CELL_TO_CC.get(("A", n - 8))
        if cc:
            live.add(cc)
    return live
