# SPDX-License-Identifier: Apache-2.0
"""Display frames (display.py) → the SPI bitstream for the NeoPixel chain (wiring.md §3).

The chain: pixel 0 is the tail icon LED (NeoPixel Mini, Adafruit 1612), then the 50 ring LEDs (Mini Skinny strip,
Adafruit 2969), starting at the first LED after the USB-C pocket on −Y. display.py decides every colour; this
module only orders, caps and encodes them.

- **No gamma here.** display.py's values are linear drive values (the site applies the eye's gamma only to its
  drawing), and sim/power_sim.py budgets the ring on those drive values.
- **Brightness cap 30 %** (wiring.md §3, sim/design.json `ring_brightness_cap`): every channel is scaled to 30 %,
  rounded. The worst case is ≈ 0.97 A, under F1. The power sim's rest 0.10 / speaking 0.35 are display.py's
  0.35 / 1.0 after this cap.
- **Encoding:** SPI at 6.4 MHz, one SPI byte per data bit (1.25 µs), MSB first, as Adafruit's neopixel_spi does:
  0 → 0b11000000 (≈ 0.31 µs high), 1 → 0b11110000 (≈ 0.63 µs high). Only the icon LED sees this timing: it
  re-drives the data for the strip. (VERIFY on a scope: the RK3566's real SPI clock, and that the 1612 batch
  accepts it.)
- **Colour order GRB** for both parts (VERIFY: WS2812B / SK6812-class parts are GRB, but batches vary).
- **Reset:** 300 µs low before and after each frame (WS2812B V5 needs ≥ 280 µs). The leading zeros also hold MOSI
  low if the controller idles it high between transfers (VERIFY).
"""
from __future__ import annotations

from .display import RING_LEDS

CAP_PERCENT = 30
SPI_HZ = 6_400_000
BIT0, BIT1 = 0b11000000, 0b11110000
RESET_S = 300e-6
RESET = bytes(round(SPI_HZ * RESET_S / 8))           # 240 zero bytes
ORDER = (1, 0, 2)                                     # GRB: green, red, blue from an (r, g, b) tuple
ICON_ON = (255, 255, 255)                             # white behind the 0.4 mm skin; capped like the ring (VERIFY: visible in daylight)
CHAIN = 1 + RING_LEDS
_BYTE = [bytes(BIT1 if b & (0x80 >> k) else BIT0 for k in range(8)) for b in range(256)]


def cap(c: tuple[int, int, int]) -> tuple[int, int, int]:
    """One colour at the brightness cap, rounded half up (integer arithmetic, so tests can state exact bytes)."""
    return tuple((v * CAP_PERCENT + 50) // 100 for v in c)


def pixels(frame: list, icon: bool = False) -> list[tuple[int, int, int]]:
    """The whole chain, capped: the icon first, then the ring in display.py's order."""
    if len(frame) != RING_LEDS:
        raise ValueError(f"frame has {len(frame)} LEDs, the ring has {RING_LEDS}")
    for c in frame:
        if len(c) != 3 or not all(0 <= v <= 255 for v in c):
            raise ValueError(f"bad colour {c}")
    return [cap(ICON_ON if icon else (0, 0, 0))] + [cap(tuple(c)) for c in frame]


def grb(chain: list[tuple[int, int, int]]) -> bytes:
    """The 24-bit words the LEDs latch, in wire order."""
    return bytes(c[i] for c in chain for i in ORDER)


def encode(frame: list, icon: bool = False) -> bytes:
    """The SPI transfer for one frame: reset, 51 × 24 bit-bytes, reset."""
    return RESET + b"".join(_BYTE[b] for b in grb(pixels(frame, icon))) + RESET


def decode(spi: bytes) -> list[tuple[int, int, int]]:
    """The inverse of encode(), for tests and the sim: the (r, g, b) each LED in the chain receives."""
    body = spi[len(RESET):len(spi) - len(RESET)]
    words = bytes(int("".join("1" if x == BIT1 else "0" for x in body[i:i + 8]), 2) for i in range(0, len(body), 8))
    return [tuple(words[i + ORDER.index(k)] for k in range(3)) for i in range(0, len(words), 3)]


class Ring:
    """The chain on an SPI bus. Sends a frame only when it differs from the last one."""

    def __init__(self, spi):
        self.spi = spi
        self._last: bytes | None = None

    def show(self, frame: list, icon: bool = False) -> None:
        data = encode(frame, icon)
        if data != self._last:
            self.spi.write(data)
            self._last = data

    def off(self) -> None:
        self.show([(0, 0, 0)] * RING_LEDS, False)


# --- text, for the laptop demo and ring_test --sim -------------------------------------------
def _name(c: tuple[int, int, int]) -> str:
    from .display import PROVISIONAL
    r, g, b = c
    if c == (0, 0, 0):
        return "."
    if c == PROVISIONAL:
        return "a"
    m = max(c)
    if max(c) - min(c) < m * 0.2:
        ch = "w"
    elif b == m:
        ch = "b"
    elif g == m and r < g * 0.6:
        ch = "g"
    elif g < r * 0.3:
        ch = "r"
    elif g < r * 0.65:
        ch = "o"
    else:
        ch = "y"
    return ch.upper() if m >= 200 else ch


def text(frame: list, icon: bool = False, color: bool = False) -> str:
    """One line: the icon, a bar, then the ring. Letters: r o y g b = level colours, a = amber window, w = white or
    grey, . = dark; capitals are bright (speaking). With `color`, ANSI true-colour dots instead, brightened by the
    eye's gamma as the site draws them."""
    if not color:
        return ("I" if icon else "i") + "|" + "".join(_name(tuple(c)) for c in frame)

    def dot(c):
        m = max(c)
        if not m:
            return "\x1b[38;2;60;60;60m·\x1b[0m"
        k = (m / 255) ** (1 / 2.2) / (m / 255)
        r, g, b = (min(255, round(v * k)) for v in c)
        return f"\x1b[38;2;{r};{g};{b}m●\x1b[0m"
    return dot((255, 255, 255) if icon else (0, 0, 0)) + " " + "".join(dot(tuple(c)) for c in frame)
