# SPDX-License-Identifier: Apache-2.0
"""What the lights show (ADR-0009). Shared by firmware, the site and the phone view.

Eye: one light, wired to the microphone rail, lit exactly while the microphones can hear (no firmware involved).
Battery: shown on the band for 3 s at power-on and while the eye is held.
Room off the record (ceiling 0, by voice or by pressing the eye), or the mics cut: the whole ring breathes red,
slowly, so anyone at the table sees it at once and it can't be mistaken for one person's red segment (ADR-0015).
A raise for others shows the objection window as an amber arc counting down round the ring (SPEC §6.3.4).

Ring (people): one continuous strip round the seam (50 LEDs, ADR-0014) behind a translucent TPU band. Software splits
it into one segment per participant, in order of first speech; the last LED of each segment stays dark as a
divider. A segment fills like a level meter: ¼ outcomes, ½ gist, ¾ attributed, full for off the record (red)
and verbatim (blue). Colour gives the level at a glance; the fill carries it for colour-blind viewers.
`column()` is the four-step glyph used in legends and the spec.
"""
from __future__ import annotations

import math

LEVEL = {
    0: (4, "red"),      # off the record: a full red column, "nothing kept"
    1: (1, "orange"),   # outcomes
    2: (2, "yellow"),   # gist
    3: (3, "green"),    # attributed
    4: (4, "blue"),     # verbatim
    5: (4, "blue"),     # audio (also blue; audio is never a default)
}
RGB = {"red": (255, 40, 40), "orange": (255, 120, 0), "yellow": (255, 210, 0), "green": (40, 210, 90), "blue": (40, 120, 255)}
IDLE, SPEAKING = 0.35, 1.0
ROOM_OFF = tuple(int(v * 0.15) for v in RGB["red"])   # (38, 6, 6): the low point of the room-off breath
BREATH_S, BREATH_GAIN = 4.0, 1.6                         # room off breathes 0.15 → 0.39 of red over 4 s (ADR-0015)
PROVISIONAL = (90, 45, 0)                                # amber: the objection window, counting down round the ring
IDLE_PIXEL = (10, 10, 10)                                # powered and listening, nobody heard yet: one dim pixel at the nose

# Haptics (ADR-0015): the ERM disc says what the lights say, for anyone who can't see the ring.
# Each pattern alternates on/off milliseconds, starting with on.
HAPTIC = {
    "lowered": [400],                       # the room is off the record
    "still-off": [60],                      # a press while already off: still off
    "provisional": [80, 120, 80],           # back on, provisionally: anyone can say "no" for the window
    "window-closed": [80, 120, 80, 120, 80],
    "reverted": [400],                      # someone objected: off again
    "note-ready": [40, 80, 40],             # the meeting's note is on the tag
    "battery": [40],                        # a hold: the ring shows the battery
    "own-lowered": [150],                   # a voice lowered or forgot its own words
    "charging": [200],
    "battery-low": [300, 200, 300],
}


def breath(t: float) -> float:
    """Triangle wave 1 → 1 + BREATH_GAIN → 1 over BREATH_S (exact arithmetic, so Python and JS agree bit for bit)."""
    ph = (t % BREATH_S) / BREATH_S
    return 1 + BREATH_GAIN * (1 - abs(2 * ph - 1))


def column(level: int | None, speaking: bool) -> list[tuple[int, int, int]]:
    """Four LED colours, bottom bar first. None = no participant on this window."""
    if level is None:
        return [(0, 0, 0)] * 4
    n, name = LEVEL[level]
    k = SPEAKING if speaking else IDLE
    c = tuple(int(v * k) for v in RGB[name])
    return [c if i < n else (0, 0, 0) for i in range(4)]


RING_LEDS = 50


def ring_frame(levels: list, speaking: set, n: int = RING_LEDS, room_off: bool = False, t: float = 0.0,
               provisional: float | None = None) -> list[tuple[int, int, int]]:
    """levels: one entry per participant (None = slot unused); speaking: indices talking now.
    room_off: off the record (or the mics cut), so the whole ring breathes red and the segments step aside.
    provisional: fraction of the objection window left; that share of the ring shows amber, counting down.
    With nobody heard yet, one dim pixel at the nose says the device is on, so dark never means anything."""
    if room_off:
        k = breath(t)
        return [tuple(int(v * 0.15 * k) for v in RGB["red"])] * n
    out = [(0, 0, 0)] * n
    k = len(levels)
    if not k:
        out[n // 2] = IDLE_PIXEL
    seg = n // k if k else 0
    for i, lv in enumerate(levels):
        if lv is None:
            continue
        bars, name = LEVEL[lv]
        fill = max(1, math.ceil((seg - 1) * bars / 4)) if seg > 1 else 1
        c = tuple(int(v * (SPEAKING if i in speaking else IDLE)) for v in RGB[name])
        for j in range(fill):
            out[i * seg + j] = c
    if provisional is not None:
        for j in range(math.ceil(n * max(0.0, min(1.0, provisional)))):
            out[j] = PROVISIONAL
    return out


def led_bearings(n: int = RING_LEDS, start: float = 180.0) -> list[float]:
    """Bearing of each LED seen from the ring's centre, degrees: 0 = head, +90 = the +Y flank, ±180 = tail. The strip
    starts at the tail and runs once round; the CAD's layout.json gives exact positions, this is the even fallback."""
    return [((start - 360.0 * (i + 0.5) / n) + 180.0) % 360.0 - 180.0 for i in range(n)]


def seat_frame(people: list, speaking: set, n: int = RING_LEDS, bearings: list | None = None, arc: int | None = None) -> list[tuple[int, int, int]]:
    """ADR-0015: each person's lights on the side of the ring that faces them, so everyone can see their own.
    people: one (level, theta) per participant, theta = the voice's angle from the head in degrees (0–180) as the
    head and tail microphones measure it. Two microphones can't tell left from right, so each person gets two mirrored
    arcs, at +theta and −theta: whichever side they sit on, one arc faces them. An arc fills like a level meter."""
    bearings = bearings or led_bearings(n)
    k = sum(1 for p in people if p and p[0] is not None and p[1] is not None)
    w = arc or max(4, min(8, n // max(1, 2 * k) - 1))
    out = [(0, 0, 0)] * n
    if not k:
        out[n // 2] = IDLE_PIXEL
        return out
    order = sorted(range(len(people)), key=lambda i: i in speaking)          # speakers drawn last, on top
    for i in order:
        p = people[i]
        if not p or p[0] is None or p[1] is None:
            continue
        lv, theta = p
        bars, name = LEVEL[lv]
        c = tuple(int(v * (SPEAKING if i in speaking else IDLE)) for v in RGB[name])
        fill = max(1, math.ceil(w * bars / 4))
        for side in (1, -1):
            target = side * abs(theta)
            near = sorted(range(n), key=lambda j: (abs(((bearings[j] - target) + 180) % 360 - 180), j))[:w]
            near.sort(key=lambda j: abs(((bearings[j] - side * 180) + 180) % 360 - 180))   # fill from the tail side
            for j in near[:fill]:
                out[j] = c
            for j in near[fill:]:
                if out[j] == (0, 0, 0):
                    out[j] = tuple(v // 8 for v in c)                         # the empty part of the meter, faint
    return out


def battery_frame(charge: float, n: int = RING_LEDS) -> list[tuple[int, int, int]]:
    """The whole ring as one gauge, for 3 s at power-on and while the eye is held."""
    q = max(1, min(4, math.ceil(charge * 4)))
    c = (70, 200, 90) if q >= 3 else (255, 170, 30) if q == 2 else (235, 50, 40)
    fill = max(1, math.ceil(n * min(max(charge, 0), 1)))
    return [c if i < fill else (0, 0, 0) for i in range(n)]


def battery_column(charge: float) -> list[tuple[int, int, int]]:
    """Shown in legends for 3 s at power-on and while the eye is held (ADR-0009).
    Bars = charge in quarters; green at 3-4 bars, amber at 2, red at 1. The eye itself has one light, wired to the mic rail."""
    n = max(1, min(4, math.ceil(charge * 4)))
    c = (70, 200, 90) if n >= 3 else (255, 170, 30) if n == 2 else (235, 50, 40)
    return [c if i < n else (0, 0, 0) for i in range(4)]
