"""Drawing helpers and overlay renderer for BADAS visualization."""

import cv2
import numpy as np
import shutil
import warnings
from pathlib import Path
from typing import List, Dict, Any, Optional, Tuple

try:
    from PIL import Image, ImageDraw, ImageFont
    _PIL_AVAILABLE = True
except ImportError:
    _PIL_AVAILABLE = False

_PIL_FONT_CACHE: dict = {}
_FONT_PATH_CACHE: dict = {}  # variant → path; populated once per variant


def _find_font_path(name_fragments: list) -> Optional[str]:
    """Find a font file whose name contains any of the given fragments (case-insensitive)."""
    try:
        import matplotlib.font_manager as fm
        for entry in fm.fontManager.ttflist:
            lower = entry.fname.lower()
            if any(frag.lower() in lower for frag in name_fragments):
                return entry.fname
    except Exception:
        pass
    return None


_FONT_VARIANTS = {
    'bold':    ['ubuntu-b', 'ubuntu bold', 'ubuntubold', 'ubuntu_b', 'DejaVuSans-Bold'],
    'regular': ['ubuntu-r', 'ubuntu regular', 'ubuntu_r', 'DejaVuSans.ttf', 'DejaVuSans'],
    'medium':  ['ubuntu-m', 'ubuntu medium', 'ubuntu_m', 'DejaVuSans'],
    'mono':    ['ubuntumono', 'ubuntu mono', 'DejaVuSansMono', 'dejavumono'],
}


def _get_font_path(variant: str) -> Optional[str]:
    """Return cached font path for *variant*, discovering it once via font_manager."""
    if variant not in _FONT_PATH_CACHE:
        _FONT_PATH_CACHE[variant] = _find_font_path(_FONT_VARIANTS.get(variant, []))
        if not any(_FONT_PATH_CACHE.values()):
            warnings.warn(
                "BADAS renderer: no TrueType fonts found via matplotlib.font_manager. "
                "Text overlays will fall back to OpenCV Hershey fonts.",
                stacklevel=3,
            )
    return _FONT_PATH_CACHE[variant]


def _get_pil_font(variant: str, size: int):
    """Return a cached PIL TrueType font, or None if unavailable."""
    if not _PIL_AVAILABLE:
        return None
    key = (variant, size)
    if key in _PIL_FONT_CACHE:
        return _PIL_FONT_CACHE[key]
    path = _get_font_path(variant)
    try:
        _PIL_FONT_CACHE[key] = ImageFont.truetype(path, size)
    except Exception:
        _PIL_FONT_CACHE[key] = None
    return _PIL_FONT_CACHE[key]


def _pil_word_width(font, word: str) -> int:
    """Return the pixel width of *word* using *font*."""
    try:
        return font.getbbox(word)[2]
    except AttributeError:
        return font.getsize(word)[0]


def _draw_rounded_rect(
    img: np.ndarray, x1: int, y1: int, x2: int, y2: int,
    color: tuple, radius: int = 4
) -> None:
    """Draw a filled rounded rectangle on *img* in-place (BGR)."""
    r = max(0, min(radius, (x2 - x1) // 2, (y2 - y1) // 2))
    cv2.rectangle(img, (x1 + r, y1), (x2 - r, y2), color, -1)
    cv2.rectangle(img, (x1, y1 + r), (x2, y2 - r), color, -1)
    for cx, cy in ((x1 + r, y1 + r), (x2 - r, y1 + r),
                   (x1 + r, y2 - r), (x2 - r, y2 - r)):
        cv2.circle(img, (cx, cy), r, color, -1)


def _wrap_text_pil(text: str, font, max_px: int) -> List[str]:
    """Word-wrap *text* so each line fits within *max_px* pixels (PIL font)."""
    words = text.split()
    if not words:
        return []
    lines: List[str] = []
    current = words[0]
    for word in words[1:]:
        candidate = current + ' ' + word
        if _pil_word_width(font, candidate) <= max_px:
            current = candidate
        else:
            lines.append(current)
            current = word
    lines.append(current)
    return lines


def _layout_words_pil(
    description: str,
    font,
    x0: int,
    y0: int,
    max_w: int,
    line_h: int,
    max_y: int,
) -> List[Tuple[str, int, int]]:
    """Lay out *description* word-by-word, respecting ``\\n`` paragraph breaks.

    Returns a list of ``(word, x, y)`` tuples in pixel coordinates, suitable
    for karaoke rendering.  Words that would exceed *max_y* are omitted.
    """
    space_w = _pil_word_width(font, ' ')
    result: List[Tuple[str, int, int]] = []
    x, y = x0, y0

    for para in description.split('\n'):
        para = para.strip()
        if not para:
            continue
        if result:           # paragraph break → new line
            x = x0
            y += line_h
        for word in para.split():
            ww = _pil_word_width(font, word)
            if x > x0 and x + ww > x0 + max_w:
                x = x0
                y += line_h
            if y > max_y:
                return result
            result.append((word, x, y))
            x += ww + space_w

    return result


def _pil_put_text(frame: np.ndarray, items: list) -> None:
    """Draw multiple text items with PIL TTF fonts in-place on a BGR frame.

    Args:
        frame: BGR numpy array; modified in-place.
        items: List of (text, x, y, font, rgb_color, shadow) tuples.
               *x*, *y* are the top-left corner of the text bounding box.
               *rgb_color* is a 3-tuple (R, G, B).
               *shadow* is bool — if True a 2-px offset black shadow is drawn first.
    """
    if not items or not _PIL_AVAILABLE:
        return
    pil_img = Image.fromarray(frame[:, :, ::-1])   # BGR → RGB
    draw    = ImageDraw.Draw(pil_img)
    for text, x, y, font, rgb_color, shadow in items:
        if font is None:
            continue
        if shadow:
            draw.text((x + 2, y + 2), text, font=font, fill=(0, 0, 0))
        draw.text((x, y), text, font=font, fill=rgb_color)
    frame[:] = np.array(pil_img)[:, :, ::-1]       # RGB → BGR


# ── Module-level helpers ──────────────────────────────────────────────────────

def _find_active_word_idx(word_events: List[Dict], elapsed_s: float) -> Optional[int]:
    """Return the 0-based index of the word being spoken at *elapsed_s*.

    Returns the index of the last word whose start time is <= *elapsed_s*,
    so the highlight advances as each new word begins.  Returns None before
    the first word starts.
    """
    active = None
    for i, ev in enumerate(word_events):
        if ev["offset_s"] <= elapsed_s:
            active = i
        else:
            break
    return active


def _tts_duration(word_events: List[Dict]) -> float:
    """Return the end time of the last word in *word_events* (seconds)."""
    if not word_events:
        return 0.0
    last = word_events[-1]
    return last["offset_s"] + last["duration_s"]


def _is_hazard_event(pred: Dict[str, Any]) -> bool:
    """Return True if this prediction should trigger a hazard freeze pause.

    Only fires when there is an actual text description — bboxes/heatmaps
    from always_compute attribution do not constitute a hazard event on their own.
    """
    return bool(pred.get('description'))


_DESC_LABEL_PREFIXES = ("Action:", "Reasoning:", "Hazard:", "Context:", "Warning:", "Risk:")


def _tts_text_from_desc(description: str) -> str:
    """Format a labelled description for TTS synthesis.

    Keeps ``Action:`` / ``Reasoning:`` labels so the reader announces them,
    but joins the two sentences with ``...`` so edge-tts inserts a natural
    pause between the action command and the reasoning sentence.
    """
    parts = []
    for line in description.split('\n'):
        line = line.strip()
        if line:
            # Strip trailing period so the '...' separator doesn't stack with it
            line = line.rstrip('.')
            parts.append(line)
    return '... '.join(parts)


def _sanitize_text(text: str) -> str:
    """Replace common Unicode punctuation and drop remaining non-ASCII."""
    return (
        text
        .replace('\u2014', ' - ').replace('\u2013', '-')
        .replace('\u2019', "'").replace('\u2018', "'")
        .replace('\u201c', '"').replace('\u201d', '"')
        .replace('\u2026', '...')
        .encode('ascii', 'ignore').decode('ascii').strip()
    )


def _wrap_text_pixel_accurate(
    text: str,
    font: int,
    font_scale: float,
    thickness: int,
    max_px: int,
) -> List[str]:
    """Word-wrap *text* so each line fits within *max_px* pixels wide."""
    words = text.split()
    if not words:
        return []
    lines: List[str] = []
    current = words[0]
    for word in words[1:]:
        candidate = current + ' ' + word
        (w, _), _ = cv2.getTextSize(candidate, font, font_scale, thickness)
        if w <= max_px:
            current = candidate
        else:
            lines.append(current)
            current = word
    lines.append(current)
    return lines


def _risk_color_bgr(probability: float) -> tuple:
    """Return BGR colour matching the current risk level."""
    if probability >= 0.8:
        return (55, 55, 225)    # red
    elif probability >= 0.5:
        return (30, 145, 255)   # orange
    return (80, 200, 100)       # green


def _draw_gradient_bar(
    frame: np.ndarray,
    x: int, y: int, w: int, h: int,
    probability: float,
) -> None:
    """Draw a green→orange→red gradient probability bar into *frame* in-place."""
    if w <= 0 or h <= 0:
        return
    # Dark track
    cv2.rectangle(frame, (x, y), (x + w, y + h), (22, 25, 35), -1)
    fill = max(1, int(probability * w))
    # Vectorised colour ramp: green → orange → red (BGR keyframes)
    xs = np.arange(fill, dtype=np.float32) / max(w - 1, 1)
    t1 = np.clip(xs * 2.0, 0.0, 1.0)
    t2 = np.clip((xs - 0.5) * 2.0, 0.0, 1.0)
    B = (100 * (1 - t1) + 30 * t1) * (1 - t2) + 55 * t2
    G = (200 * (1 - t1) + 145 * t1) * (1 - t2) + 55 * t2
    R = (80  * (1 - t1) + 255 * t1) * (1 - t2) + 225 * t2
    colors = np.stack([B.clip(0, 255), G.clip(0, 255), R.clip(0, 255)], axis=1).astype(np.uint8)
    actual_h = min(h, frame.shape[0] - y)
    if actual_h <= 0:
        return
    frame[y:y + actual_h, x:x + fill] = np.tile(colors[np.newaxis], (actual_h, 1, 1))
    # Thin white indicator at fill edge
    if fill > 4:
        ind_x = min(x + fill - 1, x + w - 1)
        cv2.line(frame, (ind_x, max(0, y - 2)), (ind_x, min(frame.shape[0] - 1, y + h + 2)),
                 (240, 245, 255), 2)


def _draw_corner_marks(
    frame: np.ndarray,
    x1: int, y1: int, x2: int, y2: int,
    color: tuple,
    arm: int = 22,
    thick: int = 2,
) -> None:
    """Draw L-shaped corner brackets at the four corners of a rectangle."""
    pts = [
        ((x1, y1), (x1 + arm, y1), (x1, y1), (x1, y1 + arm)),
        ((x2 - arm, y1), (x2, y1), (x2, y1), (x2, y1 + arm)),
        ((x1, y2), (x1 + arm, y2), (x1, y2 - arm), (x1, y2)),
        ((x2 - arm, y2), (x2, y2), (x2, y2 - arm), (x2, y2)),
    ]
    shadow = (0, 0, 0)
    for (h0, h1, v0, v1) in pts:
        cv2.line(frame, (h0[0] + 2, h0[1] + 2), (h1[0] + 2, h1[1] + 2), shadow, thick + 1)
        cv2.line(frame, (v0[0] + 2, v0[1] + 2), (v1[0] + 2, v1[1] + 2), shadow, thick + 1)
        cv2.line(frame, h0, h1, color, thick)
        cv2.line(frame, v0, v1, color, thick)


def _check_ffmpeg() -> bool:
    return shutil.which('ffmpeg') is not None


def _check_nvenc() -> bool:
    """Test h264_nvenc availability. Expensive (~4s) on compute-only GPUs (A100, H100)
    that have NVENC libraries but no NVENC hardware — call only when GPU type suggests
    NVENC is likely available (consumer/workstation GPUs).
    """
    try:
        import av, io
        buf = io.BytesIO()
        with av.open(buf, 'w', format='mp4') as c:
            s = c.add_stream('h264_nvenc', rate=30)
            s.width = 32; s.height = 32; s.pix_fmt = 'yuv420p'
            f = av.VideoFrame(32, 32, 'rgb24')
            for p in s.encode(f): c.mux(p)
            for p in s.encode(None): c.mux(p)
        return True
    except Exception:
        return False


def _check_decord() -> bool:
    try:
        import decord  # noqa: F401
        return True
    except ImportError:
        return False


# ── OverlayRenderer ───────────────────────────────────────────────────────────

class OverlayRenderer:
    """Renders prediction overlays on video frames."""

    INIT_PERIOD = 2.0

    def __init__(self):
        self.font      = cv2.FONT_HERSHEY_DUPLEX
        self.font_mono = cv2.FONT_HERSHEY_SIMPLEX

    def draw_initialization_overlay(
        self,
        frame: np.ndarray,
        current_time: float,
        buffer_fill: int = 0,
        buffer_size: int = 16,
        init_period: float = None,
    ) -> np.ndarray:
        """Draw minimal initialization overlay while the buffer fills."""
        height, width = frame.shape[:2]

        if init_period is None:
            init_period = self.INIT_PERIOD

        # Dark vignette
        bg = frame.copy()
        cv2.rectangle(bg, (0, 0), (width, height), (8, 10, 14), -1)
        frame = cv2.addWeighted(frame, 0.35, bg, 0.65, 0)

        countdown = max(0.0, init_period - current_time)
        progress  = buffer_fill / buffer_size if buffer_size > 0 else 0.0
        clr = (80, 200, 100) if countdown < 0.5 else \
              (30, 145, 255) if countdown < 1.5 else (190, 195, 210)

        # Card
        cw = min(int(width * 0.30), 380)
        ch = min(int(height * 0.22), 160)
        cx, cy = width // 2, height // 2
        x0, y0 = cx - cw // 2, cy - ch // 2
        cv2.rectangle(frame, (x0, y0), (x0 + cw, y0 + ch), (16, 18, 26), -1)
        cv2.rectangle(frame, (x0, y0), (x0 + cw, y0 + ch), (45, 50, 65), 1)
        cv2.rectangle(frame, (x0, y0), (x0 + cw, y0 + 2), clr, -1)

        # Title
        title = "INITIALIZING"
        (tw, _), _ = cv2.getTextSize(title, self.font, 0.52, 1)
        cv2.putText(frame, title, (cx - tw // 2, y0 + int(ch * 0.32)),
                    self.font, 0.52, (130, 135, 155), 1, cv2.LINE_AA)

        # Countdown
        cd = f"{countdown:.1f}s"
        sc = max(0.8, min(1.1, ch / 145.0))
        (cw2, ch2), _ = cv2.getTextSize(cd, self.font, sc, 2)
        cv2.putText(frame, cd, (cx - cw2 // 2, cy + ch2 // 2),
                    self.font, sc, clr, 2, cv2.LINE_AA)

        # Progress bar
        bx = x0 + int(cw * 0.08)
        by = y0 + int(ch * 0.78)
        bw = int(cw * 0.84)
        bh = 4
        cv2.rectangle(frame, (bx, by), (bx + bw, by + bh), (30, 33, 45), -1)
        if progress > 0:
            cv2.rectangle(frame, (bx, by), (bx + int(bw * progress), by + bh), clr, -1)

        # Frames label
        ft = f"{buffer_fill} / {buffer_size}"
        (ftw, _), _ = cv2.getTextSize(ft, self.font_mono, 0.36, 1)
        cv2.putText(frame, ft, (cx - ftw // 2, by + bh + 14),
                    self.font_mono, 0.36, (80, 85, 100), 1, cv2.LINE_AA)

        return frame

    def draw_prediction_overlay(
        self,
        frame: np.ndarray,
        probability: float,
        raw_probability: float,
        current_time: float,
        latency_ms: float,
        led_state: str = None,
        model_info: str = "BADAS",
        show_banner: bool = True,
        show_dot: bool = True,
    ) -> np.ndarray:
        """Draw modern dark HUD on the bottom portion of the frame."""
        height, width = frame.shape[:2]

        panel_h   = int(height * 0.17)
        y0        = height - panel_h
        risk_color = _risk_color_bgr(probability)

        # ── Top banner for CAUTION / DANGER ───────────────────────────────────
        if show_banner and probability >= 0.5:
            bh = max(28, int(height * 0.04))
            banner_bg = (28, 18, 95) if probability >= 0.8 else (18, 70, 120)
            cv2.rectangle(frame, (0, 0), (width, bh), banner_bg, -1)
            cv2.rectangle(frame, (0, bh - 2), (width, bh), risk_color, -1)
            banner_txt = ("DANGER - HIGH COLLISION RISK" if probability >= 0.8
                          else "CAUTION - ELEVATED RISK")
            bsc = max(0.36, min(0.50, bh / 68.0))
            (bw_px, bh_px), _ = cv2.getTextSize(banner_txt, self.font, bsc, 1)
            cv2.putText(frame, banner_txt,
                        ((width - bw_px) // 2, (bh + bh_px) // 2),
                        self.font, bsc, risk_color, 1, cv2.LINE_AA)

        # ── Panel background ──────────────────────────────────────────────────
        # Gradient fade at the top edge of the panel
        fade_h = min(28, panel_h // 4)
        bg_col = np.array([10, 12, 18], dtype=np.float32)
        alphas = np.linspace(0.0, 0.88, fade_h, dtype=np.float32)[:, np.newaxis, np.newaxis]
        frame[y0:y0 + fade_h] = (
            frame[y0:y0 + fade_h].astype(np.float32) * (1.0 - alphas) + bg_col * alphas
        ).clip(0, 255).astype(np.uint8)
        # Solid dark panel — cv2.addWeighted avoids float32 intermediate allocation
        solid_h = height - (y0 + fade_h)
        if (not hasattr(self, '_dark_panel')
                or self._dark_panel.shape != (solid_h, width, 3)):
            self._dark_panel = np.full((solid_h, width, 3), [10, 12, 18], dtype=np.uint8)
        frame[y0 + fade_h:] = cv2.addWeighted(
            frame[y0 + fade_h:], 0.12, self._dark_panel, 0.88, 0)

        # Thin risk-coloured accent rule at panel top
        cv2.rectangle(frame, (0, y0), (width, y0 + 3), risk_color, -1)
        # Extra thin top-of-frame accent for DANGER state
        if probability >= 0.8:
            cv2.rectangle(frame, (0, 0), (width, 2), risk_color, -1)

        # ── Layout zones ──────────────────────────────────────────────────────
        lz     = int(width * 0.28)
        rz     = int(width * 0.73)
        mid_cy = y0 + panel_h // 2
        sc     = max(0.42, min(0.72, panel_h / 180.0))

        # ── LEFT: status indicator ────────────────────────────────────────────
        dot_r  = max(8, int(panel_h * 0.17))
        dot_cx = max(dot_r + 16, int(lz * 0.17))
        dot_cy = mid_cy

        # Soft glow ring (scoped to bounding box — avoids full-frame copy)
        _gr = dot_r + 6
        _gy1, _gy2 = max(0, dot_cy - _gr), min(height, dot_cy + _gr)
        _gx1, _gx2 = max(0, dot_cx - _gr), min(width, dot_cx + _gr)
        _roi = frame[_gy1:_gy2, _gx1:_gx2].copy()
        cv2.circle(_roi, (dot_cx - _gx1, dot_cy - _gy1), dot_r + 5, risk_color, -1)
        frame[_gy1:_gy2, _gx1:_gx2] = cv2.addWeighted(
            frame[_gy1:_gy2, _gx1:_gx2], 0.72, _roi, 0.28, 0)

        cv2.circle(frame, (dot_cx, dot_cy), dot_r, risk_color, -1)
        cv2.circle(frame, (dot_cx, dot_cy), dot_r, (200, 205, 218), 1)

        status   = "DANGER"  if probability >= 0.8 else \
                   "CAUTION" if probability >= 0.5 else "SAFE"
        sublabel = "HIGH RISK" if probability >= 0.8 else \
                   "ELEVATED"  if probability >= 0.5 else "NOMINAL"
        sx = dot_cx + dot_r + 10
        cv2.putText(frame, status,
                    (sx, mid_cy - int(panel_h * 0.07)),
                    self.font, sc, risk_color, 2, cv2.LINE_AA)
        cv2.putText(frame, sublabel,
                    (sx, mid_cy + int(panel_h * 0.19)),
                    self.font_mono, sc * 0.62, (105, 110, 128), 1, cv2.LINE_AA)

        # ── CENTER: probability ───────────────────────────────────────────────
        center_x = (lz + rz) // 2
        pct_sc   = max(0.58, min(1.05, panel_h / 132.0))

        lbl = "COLLISION RISK"
        (lw, lh), _ = cv2.getTextSize(lbl, self.font_mono, sc * 0.52, 1)
        cv2.putText(frame, lbl,
                    (center_x - lw // 2, mid_cy - int(panel_h * 0.27)),
                    self.font_mono, sc * 0.52, (75, 80, 98), 1, cv2.LINE_AA)

        pct = f"{probability * 100:.1f}%"
        (pw, ph), _ = cv2.getTextSize(pct, self.font, pct_sc, 2)
        cv2.putText(frame, pct,
                    (center_x - pw // 2, mid_cy - int(panel_h * 0.08)),
                    self.font, pct_sc, risk_color, 2, cv2.LINE_AA)

        # Gradient bar
        bm     = max(20, int((rz - lz) * 0.06))
        bar_x  = lz + bm
        bar_w  = rz - lz - 2 * bm
        bar_h_px = max(6, int(panel_h * 0.085))
        bar_y  = mid_cy + int(panel_h * 0.20)
        _draw_gradient_bar(frame, bar_x, bar_y, bar_w, bar_h_px, probability)

        # Tick marks at 50 % and 80 %
        for thr in (0.5, 0.8):
            tx = bar_x + int(thr * bar_w)
            cv2.line(frame, (tx, bar_y - 3), (tx, bar_y + bar_h_px + 3), (70, 74, 90), 1)

        # Raw probability (if meaningfully different from smoothed)
        if abs(probability - raw_probability) > 0.01:
            raw_t = f"raw {raw_probability * 100:.1f}%"
            (rw, _), _ = cv2.getTextSize(raw_t, self.font_mono, sc * 0.52, 1)
            cv2.putText(frame, raw_t,
                        (center_x - rw // 2, bar_y + bar_h_px + 14),
                        self.font_mono, sc * 0.52, (85, 88, 106), 1, cv2.LINE_AA)

        # ── RIGHT: metadata ───────────────────────────────────────────────────
        rx      = rz + 16
        rsc     = sc * 0.72
        row_gap = max(15, int(panel_h * 0.22))
        ry0     = mid_cy - row_gap
        for i, (txt, clr) in enumerate([
            (f"t = {current_time:.2f}s", (190, 195, 212)),
            (f"{latency_ms:.0f} ms",     (95, 100, 118)),
            (model_info,                 (58, 62, 78)),
        ]):
            cv2.putText(frame, txt, (rx, ry0 + i * row_gap),
                        self.font_mono, rsc, clr, 1, cv2.LINE_AA)

        # ── Top-right status dot ──────────────────────────────────────────────
        if show_dot:
            dr   = max(8, int(height * 0.014))
            dm   = dr + 8
            tr_x = width - dm
            tr_y = dm
            # Scoped glow — ROI only
            _dr = dr + 6
            _dy1, _dy2 = max(0, tr_y - _dr), min(height, tr_y + _dr)
            _dx1, _dx2 = max(0, tr_x - _dr), min(width, tr_x + _dr)
            _droi = frame[_dy1:_dy2, _dx1:_dx2].copy()
            cv2.circle(_droi, (tr_x - _dx1, tr_y - _dy1), dr + 5, risk_color, -1)
            frame[_dy1:_dy2, _dx1:_dx2] = cv2.addWeighted(
                frame[_dy1:_dy2, _dx1:_dx2], 0.72, _droi, 0.28, 0)
            cv2.circle(frame, (tr_x, tr_y), dr, risk_color, -1)
            cv2.circle(frame, (tr_x, tr_y), dr, (200, 205, 218), 1)

        return frame

    def draw_hazard_freeze(
        self,
        frame: np.ndarray,
        pred: Dict[str, Any],
        chars_visible: Optional[int] = None,
        active_word_idx: Optional[int] = None,
    ) -> np.ndarray:
        """
        Render the modern hazard freeze-frame overlay.

        Args:
            frame: BGR frame (any resolution).
            pred: Prediction dict with probability, timestamp, bbox, description.
            chars_visible: If set, reveal only this many characters of the
                           description — used for the letter-by-letter animation.
                           None means show full text.

        Returns:
            Annotated frame (same shape).
        """
        height, width = frame.shape[:2]

        # ── Dark vignette over the raw frame ──────────────────────────────────
        vignette = frame.copy()
        cv2.rectangle(vignette, (0, 0), (width, height), (6, 6, 10), -1)
        frame = cv2.addWeighted(frame, 0.42, vignette, 0.58, 0)

        # ── "TRIGGER WARNING" pill badge (centered, not full-width) ──────────
        badge_w = int(width * 0.40)
        badge_h = max(44, int(height * 0.072))
        badge_x = (width - badge_w) // 2
        badge_y = int(height * 0.015)

        cv2.rectangle(frame,
                      (badge_x, badge_y),
                      (badge_x + badge_w, badge_y + badge_h),
                      (38, 28, 160), -1)
        cv2.rectangle(frame,
                      (badge_x, badge_y),
                      (badge_x + badge_w, badge_y + badge_h),
                      (80, 60, 230), 2)
        # Top accent line
        cv2.rectangle(frame,
                      (badge_x, badge_y),
                      (badge_x + badge_w, badge_y + 2),
                      (100, 80, 255), -1)

        badge_text = "TRIGGER WARNING"
        btsc   = max(0.48, badge_h / 72.0)
        bthick = max(1, int(btsc * 1.8))
        (bw2, bh2), _ = cv2.getTextSize(badge_text, self.font, btsc, bthick)
        bx = badge_x + (badge_w - bw2) // 2
        by = badge_y + (badge_h + bh2) // 2
        cv2.putText(frame, badge_text, (bx + 2, by + 2),
                    self.font, btsc, (0, 0, 0), bthick + 1, cv2.LINE_AA)
        cv2.putText(frame, badge_text, (bx, by),
                    self.font, btsc, (230, 232, 248), bthick, cv2.LINE_AA)

        badge_bottom = badge_y + badge_h

        # ── Bounding boxes — corner-bracket style ────────────────────────────
        bboxes = pred.get('bboxes') or []
        sx, sy = width / 256.0, height / 256.0
        lw = max(2, int(width / 420))
        for rank, box in enumerate(bboxes):
            x1, y1, x2, y2 = box[:4]
            rx1, ry1 = int(x1 * sx), int(y1 * sy)
            rx2, ry2 = int(x2 * sx), int(y2 * sy)
            arm = max(14, int(min(rx2 - rx1, ry2 - ry1) * 0.16))
            # Primary box: full blue-purple; secondary boxes progressively dimmer
            alpha = max(0.35, 1.0 - rank * 0.3)
            color = (int(80 * alpha), int(70 * alpha), int(240 * alpha))
            _draw_corner_marks(frame, rx1, ry1, rx2, ry2,
                               color=color, arm=arm, thick=lw + 1 - rank)

        # ── BADAS-Reasoning description card ─────────────────────────────────
        description = pred.get('description') or ''
        if description:
            description = _sanitize_text(description)

        if description:
            # Apply chars_visible for the typing animation
            typing_active = (chars_visible is not None
                             and chars_visible < len(description))
            if typing_active:
                description = description[:chars_visible]

            hud_y  = height - int(height * 0.17)
            pad    = max(16, int(height * 0.024))   # generous padding
            margin = max(28, int(width  * 0.032))
            desc_w = width - 2 * margin

            # Prefer PIL TrueType fonts (Ubuntu); fall back to OpenCV Hershey
            _fs       = max(15, int(height * 0.024))
            _ts       = max(17, int(height * 0.030))
            body_pil  = _get_pil_font('regular', _fs)
            title_pil = _get_pil_font('bold',    _ts)
            use_pil   = body_pil is not None and title_pil is not None

            if use_pil:
                try:
                    bb           = body_pil.getbbox('Agpqy')
                    body_h       = bb[3] - bb[1]
                    body_top_off = bb[1]
                except AttributeError:
                    body_h, body_top_off = _fs, 0
                try:
                    tb             = title_pil.getbbox('BADAS-Reasoning')
                    title_h        = tb[3] - tb[1]
                    title_top_off  = tb[1]
                except AttributeError:
                    title_h, title_top_off = _ts, 0
                line_h = int(body_h * 2.2)
                # Split Action/Reasoning lines before word-wrapping each paragraph
                lines = []
                for para in description.split('\n'):
                    para = para.strip()
                    if para:
                        lines.extend(_wrap_text_pil(para, body_pil, desc_w - pad * 2))
            else:
                font_sc   = max(0.40, min(0.58, height / 1280.0))
                desc_font = self.font
                body_h    = cv2.getTextSize('Ag', desc_font, font_sc, 1)[0][1]
                line_h    = int(body_h * 2.4)
                title_h   = int(cv2.getTextSize('Ag', desc_font,
                                                 max(0.45, font_sc + 0.06), 2)[0][1] * 2.0)
                # Split Action/Reasoning lines before word-wrapping each paragraph
                lines = []
                for para in description.split('\n'):
                    para = para.strip()
                    if para:
                        lines.extend(_wrap_text_pixel_accurate(
                            para, desc_font, font_sc, 1, desc_w - pad * 3))

            # Blinking cursor while typing
            if typing_active and lines:
                lines[-1] = lines[-1] + '_'

            sep_gap = 10
            box_h   = pad + title_h + sep_gap + line_h * max(1, len(lines)) + pad

            desc_y = badge_bottom + max(6, int(height * 0.008))
            desc_y = max(badge_bottom + 4, min(desc_y, hud_y - box_h - 6))
            desc_x = margin

            # Card background (very dark, high opacity)
            card = frame.copy()
            cv2.rectangle(card,
                          (desc_x, desc_y),
                          (desc_x + desc_w, desc_y + box_h),
                          (10, 12, 20), -1)
            frame = cv2.addWeighted(frame, 0.06, card, 0.94, 0)

            # Card border + top accent
            cv2.rectangle(frame,
                          (desc_x, desc_y),
                          (desc_x + desc_w, desc_y + box_h),
                          (60, 55, 185), 1)
            cv2.rectangle(frame,
                          (desc_x, desc_y),
                          (desc_x + desc_w, desc_y + 2),
                          (80, 65, 230), -1)

            if use_pil:
                # Single BGR↔RGB conversion for all text in this card
                pil_items = []
                ty = desc_y + pad - title_top_off
                pil_items.append(
                    ('BADAS-Reasoning', desc_x + pad, ty, title_pil, (255, 175, 30), True))

                sep_y = desc_y + pad + title_h + 4
                cv2.line(frame,
                         (desc_x + pad, sep_y),
                         (desc_x + desc_w - pad, sep_y),
                         (38, 42, 58), 1)

                if active_word_idx is not None:
                    # ── Karaoke mode: TikTok-style word highlight ─────────────
                    word_layout = _layout_words_pil(
                        description, body_pil,
                        x0=desc_x + pad,
                        y0=sep_y + sep_gap - body_top_off,
                        max_w=desc_w - pad * 2,
                        line_h=line_h,
                        max_y=hud_y - pad - body_h,
                    )
                    h_pad, v_pad = 5, 3
                    for g_idx, (word, wx, wy) in enumerate(word_layout):
                        if g_idx == active_word_idx:
                            # Draw gold pill behind the active word
                            ww = _pil_word_width(body_pil, word)
                            rx1 = max(0,         wx - h_pad)
                            ry1 = max(0,         wy + body_top_off - v_pad)
                            rx2 = min(width - 1, wx + ww + h_pad)
                            ry2 = min(height - 1, wy + body_top_off + body_h + v_pad)
                            _draw_rounded_rect(
                                frame, rx1, ry1, rx2, ry2,
                                color=(0, 210, 255),   # BGR gold/yellow
                                radius=v_pad + 2,
                            )
                            color = (15, 15, 20)   # dark text on pill
                        else:
                            color = (210, 215, 230)
                        # Disable shadow on the active word: shadow is dark text
                        # offset by 2px — on a gold pill it creates a strikethrough look.
                        pil_items.append((word, wx, wy, body_pil, color, g_idx != active_word_idx))
                else:
                    # ── Typewriter mode (no TTS timing) ──────────────────────
                    for i, line in enumerate(lines):
                        ty_line = sep_y + sep_gap + i * line_h - body_top_off
                        if ty_line + body_h > hud_y - pad:
                            break
                        pil_items.append(
                            (line, desc_x + pad, ty_line, body_pil, (210, 215, 230), True))

                _pil_put_text(frame, pil_items)
            else:
                # cv2 Hershey fallback
                title_sc = max(0.45, font_sc + 0.06)
                ty = desc_y + pad + title_h
                cv2.putText(frame, 'BADAS-Reasoning', (desc_x + pad + 1, ty + 1),
                            desc_font, title_sc, (0, 0, 0), 2, cv2.LINE_AA)
                cv2.putText(frame, 'BADAS-Reasoning', (desc_x + pad, ty),
                            desc_font, title_sc, (30, 175, 255), 2, cv2.LINE_AA)

                sep_y = ty + sep_gap
                cv2.line(frame,
                         (desc_x + pad, sep_y),
                         (desc_x + desc_w - pad, sep_y),
                         (38, 42, 58), 1)

                for i, line in enumerate(lines):
                    ty_line = sep_y + line_h // 2 + (i + 1) * line_h
                    if ty_line > hud_y - 5:
                        break
                    cv2.putText(frame, line, (desc_x + pad + 1, ty_line + 1),
                                desc_font, font_sc, (0, 0, 0), 1, cv2.LINE_AA)
                    cv2.putText(frame, line, (desc_x + pad, ty_line),
                                desc_font, font_sc, (210, 215, 230), 1, cv2.LINE_AA)

        # ── Normal HUD on top (drawn last so it stays sharp) ─────────────────
        prob = pred.get('probability', 0.0)
        raw  = pred.get('raw_probability', prob)
        ts   = pred.get('timestamp', 0.0)
        frame = self.draw_prediction_overlay(
            frame,
            probability=prob,
            raw_probability=raw,
            current_time=ts,
            latency_ms=pred.get('latency_ms', pred.get('inference_ms', 0.0)),
            led_state='red',
            show_banner=False,   # badge already serves this purpose
        )

        return frame
