"""
Input / Controls module — It1, It2, G1, G2, G3, G4.

Recreates the Claude Design handoff (final-GHVscC) Input/Controls flow in
pygame, matching the H1 console grammar (status bar + hero/lists + footer)
and the diagnostics/Wi-Fi two-tier "X · show technical details" pattern.

Screens (class ⇄ handoff):
  It1InputTest  ⇄ It1-input-test.html  live pad highlight + stick drift +
                                        FN/Exit hotkey check + quick tests
  It2FnExit     ⇄ It2-fn-exit.html     Exit-hotkey repair wizard (ready combos)
  G1Controllers ⇄ G1-controllers.html  player slots + connected-controller list
  G2Mapping     ⇄ G2-mapping.html      visual gamepad diagram + preset segments
  G3Calibration ⇄ G3-calibration.html  analog calibration (CONSUMER FIRST)
  G4Hotkeys     ⇄ G4-hotkeys.html       hotkey reference table + eng gesture row

Language is intentionally consumer-first. Especially G3: NO Hz, NO device
paths, NO raw drift floats in the default view — it is "your L-stick rests
off-center; we'll set a deadzone so games ignore it" + a "twist your stick"
progress CTA + a "drift fixed" confirmation. Any numbers/paths live ONLY
behind the X "show technical details" toggle.

Every class shares the screen shape: name, __init__(self, app, ...),
on_action(self, action), update(self, dt_ms), draw(self, surf). Actions are
strings: up/down/left/right/a/b/x/y/l/r.

This module only defines screens + local helpers; it never edits main.py or
widgets.py. Helpers the shared kit lacks (pad diagram, stick radar, deadzone
control, key badge, slot/controller rows, preset pill, hotkey row) are
defined here at module level.
"""

from __future__ import annotations

import math
import os

import pygame

from ..design import tokens as t
from ..shared import widgets as w
from ..shared import fonts, icons, render

SAFE = t.Space.SAFE_AREA
STAGE_W = t.VIEWPORT_WIDTH
STAGE_H = t.VIEWPORT_HEIGHT


# ─── small shared helpers ─────────────────────────────────────────────


def _key_badge(text, *, round_=False, size=18, color=None, border=None,
               style=None) -> pygame.Surface:
    """
    SELECT/START-style mono key cap (rectangular) or a round ABXY cap.
    Used in footers, combo pickers, and the hotkey sheet. Bundled fonts
    lack ⌫⇧↵ etc. so callers pass plain ASCII or D-pad arrows handled by
    _dpad_badge.
    """
    color = color or t.Color.TEXT_SECONDARY
    border = border or t.Color.TEXT_MUTED
    if round_:
        d = max(size, 20)
        s = pygame.Surface((d, d), pygame.SRCALPHA)
        pygame.draw.circle(s, border, (d // 2, d // 2), d // 2, 2)
        lbl = fonts.render(style or t.TextStyle(t.SPACE_GROTESK_SEMIBOLD, 11,
                           "SemiBold", 0.0), text, color)
        s.blit(lbl, ((d - lbl.get_width()) // 2, (d - lbl.get_height()) // 2))
        return s
    lbl = fonts.render(style or t.TextStyle(t.JETBRAINS_MONO, 9, "Regular", 0.5),
                       text, color)
    pw, ph = lbl.get_width() + 12, size
    s = pygame.Surface((pw, ph), pygame.SRCALPHA)
    pygame.draw.rect(s, border, (0, 0, pw, ph), width=1, border_radius=4)
    s.blit(lbl, ((pw - lbl.get_width()) // 2, (ph - lbl.get_height()) // 2))
    return s


def _dpad_badge(arrow, *, size=18, color=None, border=None) -> pygame.Surface:
    """
    A D-pad direction key cap. The bundled fonts don't carry ↑↓←→ glyphs so
    we draw the triangle from primitives inside a rounded cap.
    """
    color = color or t.Color.TEXT_SECONDARY
    border = border or t.Color.TEXT_MUTED
    pw, ph = size + 2, size
    s = pygame.Surface((pw, ph), pygame.SRCALPHA)
    pygame.draw.rect(s, border, (0, 0, pw, ph), width=1, border_radius=4)
    cx, cy = pw / 2, ph / 2
    r = size * 0.22
    if arrow == "up":
        pts = [(cx, cy - r), (cx - r, cy + r), (cx + r, cy + r)]
    elif arrow == "down":
        pts = [(cx, cy + r), (cx - r, cy - r), (cx + r, cy - r)]
    elif arrow == "left":
        pts = [(cx - r, cy), (cx + r, cy - r), (cx + r, cy + r)]
    else:  # right
        pts = [(cx + r, cy), (cx - r, cy - r), (cx - r, cy + r)]
    pygame.draw.polygon(s, color, pts)
    return s


def _combo(keys, *, gap=4, badge_size=18, color=None, border=None):
    """
    Build a horizontal combo of key caps joined by "+". `keys` items are
    str (mono cap), ("round", "A") for an ABXY cap, or ("dpad", "up").
    Returns (surface, width).
    """
    caps = []
    for k in keys:
        if isinstance(k, tuple) and k[0] == "round":
            caps.append(_key_badge(k[1], round_=True, size=badge_size + 2,
                                   color=color, border=border))
        elif isinstance(k, tuple) and k[0] == "dpad":
            caps.append(_dpad_badge(k[1], size=badge_size, color=color,
                                    border=border))
        else:
            caps.append(_key_badge(k, size=badge_size, color=color, border=border))
    plus = fonts.render(t.Type.META_SM, "+", t.Color.TEXT_MUTED)
    h = max(c.get_height() for c in caps)
    total = sum(c.get_width() for c in caps) + (len(caps) - 1) * (gap * 2 +
             plus.get_width())
    s = pygame.Surface((max(total, 1), h), pygame.SRCALPHA)
    x = 0
    for i, c in enumerate(caps):
        if i > 0:
            s.blit(plus, (x + gap, (h - plus.get_height()) // 2))
            x += gap * 2 + plus.get_width()
        s.blit(c, (x, (h - c.get_height()) // 2))
        x += c.get_width()
    return s, total


def _footer(surf, hints):
    """
    Compact footer that fits 3-4 hints. `hints` items are
    (glyph, label) where glyph is a single A/B/X/Y char. Mirrors
    widgets.footer_hints but tightens the outer gap so 4 hints fit 640px.
    """
    pygame.draw.line(surf, t.Color.BORDER_SOFT, (0, 448), (640, 448), 1)
    parts = [(w._btn_glyph(g), fonts.render(t.Type.CAPTION, l, t.Color.TEXT_SECONDARY))
             for g, l in hints]
    gap_inner = 7
    gap_outer = t.Space.L if len(parts) >= 4 else t.Space.XL
    total = sum(g.get_width() + gap_inner + l.get_width() for g, l in parts)
    total += gap_outer * (len(parts) - 1)
    x = (640 - total) // 2
    cy = 448 + t.Space.FOOTER_HINT_HEIGHT // 2
    for g, l in parts:
        surf.blit(g, (x, cy - g.get_height() // 2))
        x += g.get_width() + gap_inner
        surf.blit(l, (x, cy - l.get_height() // 2))
        x += l.get_width() + gap_outer


def _panel(size, *, focused=False, radius=None):
    """A card-depth rounded panel surface (the .card-depth / .ctrl-row look)."""
    radius = t.Radius.CARD if radius is None else radius
    wd, ht = size
    if focused:
        body = render.rounded_gradient((wd, ht), t.Color.SURFACE_ELEVATED,
                                       t.Color.SURFACE_BASE, radius, 160)
    else:
        body = render.rounded_gradient((wd, ht), t.Color.SURFACE_BASE,
                                       t.Color.BG_MID, radius, 160)
        pygame.draw.rect(body, t.Color.BORDER_SOFT, (0, 0, wd, ht),
                         width=1, border_radius=radius)
    return body


# ─── gamepad pad diagram (shared It1 + G2) ────────────────────────────
#
# Coordinates mirror the handoff SVG (viewBox 520×240). We render at native
# resolution onto a transparent surface so it can be blitted/centered. The
# pad body, D-pad, ABXY diamond, twin sticks, SELECT/START, FN, and the
# L1/R1/L2/R2 shoulder bars are all drawn from primitives — no SVG asset.

# ABXY diamond positions (offsets from the diamond center), Phosphor order.
_DIAMOND = {"top": (0, -22), "right": (22, 0), "bottom": (0, 22), "left": (-22, 0)}


def _pad_diagram(*, highlight=None, abxy_labels=None, ls_drift=None,
                 rs_drift=None, ls_ring=None, rs_ring=None, callout=None,
                 listen=None, scale=1.0) -> pygame.Surface:
    """
    Draw the R36S pad. Returns a transparent surface (520*scale × 240*scale).

    highlight   : key id to light with the rose→cyan glow. One of
                  A/B/X/Y/L1/R1/L2/R2/SELECT/START/FN/DUP/DDOWN/DLEFT/DRIGHT.
    abxy_labels : dict {"top","right","bottom","left": "A"/"B"/"X"/"Y"} to
                  relabel the diamond (A/B swap, presets). Default Phosphor
                  layout {top:X,right:A,bottom:B,left:Y}.
    ls_drift    : (dx, dy) px offset for the L-stick dot (peach drift viz).
    rs_drift    : (dx, dy) px offset for the R-stick dot.
    ls_ring/rs_ring : color to tint the stick gate ring (e.g. peach=drift).
    callout     : (key, text) — dashed arrow + label to a focused button.
    listen      : text for the cyan "press any button" chip (top-left).
    """
    W0, H0 = 520, 240
    base = pygame.Surface((W0, H0), pygame.SRCALPHA)

    soft = t.Color.BORDER_SOFT
    bright = t.Color.BORDER_BRIGHT
    body_top, body_bot = t.Color.SURFACE_ELEVATED, t.Color.BG_MID
    up = t.Color.BG_UP
    deep = t.Color.BG_DEEP
    sec = t.Color.TEXT_SECONDARY
    muted = t.Color.TEXT_MUTED

    def hot_circle(cx, cy, r):
        """rose→cyan filled disc for a highlighted round element."""
        d = r * 2
        g = render.rounded_gradient((d, d), t.Color.ACCENT_ROSE,
                                    t.Color.ACCENT_CYAN, d, 135)
        # glow
        glow = pygame.Surface((d + 16, d + 16), pygame.SRCALPHA)
        pygame.draw.circle(glow, t.with_alpha(t.Color.ACCENT_ROSE, 0.45),
                           (d // 2 + 8, d // 2 + 8), r + 6)
        base.blit(glow, (cx - r - 8, cy - r - 8))
        base.blit(g, (cx - r, cy - r))
        pygame.draw.circle(base, t.Color.ACCENT_ROSE, (cx, cy), r, 2)

    def hot_rect(rect, radius):
        g = render.rounded_gradient((rect.w, rect.h), t.Color.ACCENT_ROSE,
                                    t.Color.ACCENT_CYAN, radius, 135)
        glow = pygame.Surface((rect.w + 16, rect.h + 16), pygame.SRCALPHA)
        pygame.draw.rect(glow, t.with_alpha(t.Color.ACCENT_ROSE, 0.35),
                         (0, 0, rect.w + 16, rect.h + 16), border_radius=radius + 6)
        base.blit(glow, (rect.x - 8, rect.y - 8))
        base.blit(g, rect.topleft)

    hi = (highlight or "").upper()

    # ── triggers L2 / R2 (top edge) ──
    for key, rx in (("L2", 58), ("R2", 388)):
        r = pygame.Rect(rx, 6, 74, 14)
        if hi == key:
            hot_rect(r, 4)
            tcol = t.Color.BG_MID
        else:
            pygame.draw.rect(base, up, r, border_radius=4)
            pygame.draw.rect(base, soft, r, width=1, border_radius=4)
            tcol = sec
        lbl = fonts.render(t.Type.META_SM, key, tcol)
        base.blit(lbl, (r.centerx - lbl.get_width() // 2,
                        r.centery - lbl.get_height() // 2))

    # ── shoulders L1 / R1 ──
    for key, rx in (("L1", 40), ("R1", 370)):
        r = pygame.Rect(rx, 28, 110, 18)
        if hi == key:
            hot_rect(r, 9)
            tcol = t.Color.BG_MID
        else:
            pygame.draw.rect(base, up, r, border_radius=9)
            pygame.draw.rect(base, soft, r, width=1, border_radius=9)
            tcol = sec
        lbl = fonts.render(t.Type.META_SM, key, tcol)
        base.blit(lbl, (r.centerx - lbl.get_width() // 2,
                        r.centery - lbl.get_height() // 2))

    # ── screen area (decorative) ──
    scr = pygame.Rect(170, 14, 180, 38)
    pygame.draw.rect(base, deep, scr, border_radius=8)
    pygame.draw.rect(base, soft, scr, width=1, border_radius=8)
    sl = fonts.render(t.Type.META_SM, "R36S", muted)
    base.blit(sl, (scr.centerx - sl.get_width() // 2,
                   scr.centery - sl.get_height() // 2))

    # ── body silhouette (rounded blob via a fat rounded-rect) ──
    body_rect = pygame.Rect(50, 60, 420, 160)
    body_surf = render.rounded_gradient((body_rect.w, body_rect.h),
                                        body_top, body_bot, 70, 180)
    base.blit(body_surf, body_rect.topleft)
    pygame.draw.rect(base, soft, body_rect, width=1, border_radius=70)

    # ── D-pad (plus shape) ──
    dcx, dcy = 120, 130
    arm = 12
    span = 32
    # vertical bar + horizontal bar
    dv = pygame.Rect(dcx - arm, dcy - span, arm * 2, span * 2)
    dh = pygame.Rect(dcx - span, dcy - arm, span * 2, arm * 2)
    for r in (dv, dh):
        pygame.draw.rect(base, t.Color.BG_MID, r, border_radius=4)
    # highlight a pressed direction
    dmap = {"DUP": pygame.Rect(dcx - arm, dcy - span, arm * 2, span - arm + 4),
            "DDOWN": pygame.Rect(dcx - arm, dcy + arm - 4, arm * 2, span - arm + 4),
            "DLEFT": pygame.Rect(dcx - span, dcy - arm, span - arm + 4, arm * 2),
            "DRIGHT": pygame.Rect(dcx + arm - 4, dcy - arm, span - arm + 4, arm * 2)}
    if hi in dmap:
        hot_rect(dmap[hi], 4)
    pygame.draw.rect(base, bright, dv, width=1, border_radius=4)
    pygame.draw.rect(base, bright, dh, width=1, border_radius=4)

    # ── ABXY diamond ──
    dia_cx, dia_cy = 400, 130
    labels = abxy_labels or {"top": "X", "right": "A", "bottom": "B", "left": "Y"}
    # optional soft ring behind a focused button (G2 ring-glow on 'right')
    for pos, (ox, oy) in _DIAMOND.items():
        bx, by = dia_cx + ox, dia_cy + oy
        lbl_txt = labels[pos]
        is_hot = (hi == lbl_txt)
        if is_hot:
            hot_circle(bx, by, 14)
            tcol = t.Color.BG_MID
            tstyle = t.TextStyle(t.SPACE_GROTESK_BOLD, 15, "Bold", 0.0)
        else:
            pygame.draw.circle(base, t.Color.BG_MID, (bx, by), 14)
            pygame.draw.circle(base, soft, (bx, by), 14, 1)
            tcol = sec
            tstyle = t.TextStyle(t.SPACE_GROTESK_SEMIBOLD, 15, "SemiBold", 0.0)
        gl = fonts.render(tstyle, lbl_txt, tcol)
        base.blit(gl, (bx - gl.get_width() // 2, by - gl.get_height() // 2))

    # ── twin analog sticks ──
    def stick(cx, cy, label, drift, ring):
        pygame.draw.circle(base, t.Color.BG_MID, (cx, cy), 22)
        pygame.draw.circle(base, bright, (cx, cy), 22, 1)
        pygame.draw.circle(base, t.Color.BG_UP, (cx, cy), 14)
        ring_col = ring or soft
        pygame.draw.circle(base, ring_col, (cx, cy), 14, 1)
        if drift is not None:
            dx, dy = drift
            pygame.draw.circle(base, t.Color.ACCENT_PEACH,
                               (cx + dx, cy + dy), 3)
        else:
            ll = fonts.render(t.TextStyle(t.JETBRAINS_MONO, 8, "Regular", 0.0),
                              label, muted)
            base.blit(ll, (cx - ll.get_width() // 2, cy - ll.get_height() // 2))

    stick(190, 178, "LS", ls_drift, ls_ring)
    stick(330, 178, "RS", rs_drift, rs_ring)

    # ── SELECT / START ──
    for key, rx in (("SELECT", 226), ("START", 262)):
        r = pygame.Rect(rx, 120, 32, 8)
        if hi == key:
            hot_rect(r, 4)
        else:
            pygame.draw.rect(base, up, r, border_radius=4)
            pygame.draw.rect(base, soft, r, width=1, border_radius=4)
        lbl = fonts.render(t.TextStyle(t.JETBRAINS_MONO, 8, "Regular", 0.0),
                           key, muted)
        base.blit(lbl, (r.centerx - lbl.get_width() // 2, 130))

    # ── FN button ──
    fn = (338, 124)
    if hi == "FN":
        hot_circle(fn[0], fn[1], 6)
    else:
        pygame.draw.circle(base, up, fn, 6)
        pygame.draw.circle(base, soft, fn, 6, 1)
    fnl = fonts.render(t.TextStyle(t.JETBRAINS_MONO, 8, "Regular", 0.0), "FN", muted)
    base.blit(fnl, (fn[0] - fnl.get_width() // 2, 138))

    # ── callout (dashed arrow + label to a focused button) ──
    if callout is not None:
        _key, text = callout
        # anchor near the diamond's top-left, mirroring the handoff path
        ax, ay = 380, 76
        _dashed_line(base, (398, 116), (ax, ay), t.Color.ACCENT_ROSE)
        _dashed_line(base, (ax, ay), (320, 76), t.Color.ACCENT_ROSE)
        pygame.draw.circle(base, t.Color.ACCENT_ROSE, (ax, ay), 2)
        cl = fonts.render(t.Type.LABEL_SM, text, t.Color.TEXT_PRIMARY)
        base.blit(cl, (316 - cl.get_width(), ay - cl.get_height() // 2))

    # ── listen chip (cyan, top-left) ──
    if listen is not None:
        chip = _live_chip(listen)
        base.blit(chip, (16, 8))

    if abs(scale - 1.0) < 1e-3:
        return base
    return pygame.transform.smoothscale(
        base, (int(W0 * scale), int(H0 * scale)))


def _dashed_line(surf, p0, p1, color, *, dash=3, gap=3, width=1):
    x0, y0 = p0
    x1, y1 = p1
    dist = math.hypot(x1 - x0, y1 - y0)
    if dist < 1:
        return
    ux, uy = (x1 - x0) / dist, (y1 - y0) / dist
    n = int(dist // (dash + gap)) + 1
    for i in range(n):
        sx = x0 + ux * i * (dash + gap)
        sy = y0 + uy * i * (dash + gap)
        ex = sx + ux * dash
        ey = sy + uy * dash
        pygame.draw.line(surf, color, (sx, sy), (ex, ey), width)


def _live_chip(text, *, color=None, pulse=True) -> pygame.Surface:
    """Cyan pill: small dot + mono label (the .badge / .listen chip)."""
    color = color or t.Color.ACCENT_CYAN
    lbl = fonts.render(t.Type.META_SM, text, color)
    dot_r = 3
    pad_x = 10
    gap = 8
    pw = pad_x * 2 + dot_r * 2 + gap + lbl.get_width()
    ph = 22
    s = pygame.Surface((pw, ph), pygame.SRCALPHA)
    pygame.draw.rect(s, t.with_alpha(color, 0.10), (0, 0, pw, ph), border_radius=999)
    pygame.draw.rect(s, t.with_alpha(color, 0.35), (0, 0, pw, ph), width=1,
                     border_radius=999)
    pygame.draw.circle(s, color, (pad_x + dot_r, ph // 2), dot_r)
    s.blit(lbl, (pad_x + dot_r * 2 + gap, (ph - lbl.get_height()) // 2))
    return s


# ─── quick-test tile (It1) ────────────────────────────────────────────


_TEST_GLYPH = {"ok": "ph-check", "warn": "ph-warning", "pending": "ph-circle"}
_TEST_COLOR = {"ok": t.Color.SUCCESS, "warn": t.Color.ACCENT_PEACH,
               "pending": t.Color.TEXT_MUTED}


def _test_tile(surf, rect, label, sub, state):
    body = pygame.Surface((rect.w, rect.h), pygame.SRCALPHA)
    pygame.draw.rect(body, t.with_alpha(t.Color.BG_DEEP, 0.4),
                     (0, 0, rect.w, rect.h), border_radius=8)
    pygame.draw.rect(body, t.Color.BORDER_SOFT, (0, 0, rect.w, rect.h),
                     width=1, border_radius=8)
    pad = 8
    col = _TEST_COLOR[state]
    ic = icons.icon(_TEST_GLYPH[state], 11, col)
    body.blit(ic, (pad, pad + 1))
    lab = fonts.render(t.Type.LABEL_SM, label, t.Color.TEXT_PRIMARY)
    body.blit(lab, (pad + ic.get_width() + 6, pad))
    sub_col = col if state in ("ok", "warn") else t.Color.TEXT_MUTED
    sub_s = fonts.render(t.Type.META_SM, sub, sub_col)
    maxw = rect.w - pad * 2
    if sub_s.get_width() > maxw:
        sub_s = sub_s.subsurface((0, 0, maxw, sub_s.get_height()))
    body.blit(sub_s, (pad, pad + lab.get_height() + 4))
    surf.blit(body, rect.topleft)


# ═══════════════════════════════════════════════════════════════════════
# It1 — Input test (live pad highlight + drift + FN/Exit check)
# ═══════════════════════════════════════════════════════════════════════


class It1InputTest:
    """
    Press-each-button tester. The pad diagram lights the most-recent button;
    the "Recent presses" list logs them. Four quick tests summarize
    all-buttons / L-drift / R-clean / FN+Exit. A/B/X/Y/L/R + d-pad on this
    screen feed the highlight + log so the user verifies hardware live.
    """
    name = "input-test"

    # cycle order used to auto-demo the live highlight when idle (headless)
    _DEMO = ["A", "L1", "DDOWN", "R1", "B", "X", "Y"]

    def __init__(self, app):
        self.app = app
        self.last = "A"                  # currently-highlighted button
        self.elapsed = 0.0
        self._demo_i = 0
        # recent presses log: (when, key, desc, tone, delta)
        self.log = [
            ("just now", "A", "south", "rose", None),
            ("0.4 s", "L1", "shoulder L", "cyan", None),
            ("1.2 s", "LS", "drift", "peach", "+3,-2"),
            ("1.8 s", "SEL", "select", "cyan", None),
            ("2.4 s", "D↓", "d-pad down", "cyan", None),
        ]
        # quick tests
        self.tests = [
            ("All buttons", "14 of 14 detected", "ok"),
            ("L-stick drift", "resting off-center · fix in Calibration", "warn"),
            ("R-stick clean", "resting at center", "ok"),
            ("FN + Exit", "press SEL+START to test", "pending"),
        ]

    # map an action to a pad key + log entry
    _ACT = {"a": ("A", "south", "rose"), "b": ("B", "east", "cyan"),
            "x": ("X", "north", "cyan"), "y": ("Y", "west", "cyan"),
            "l": ("L1", "shoulder L", "cyan"), "r": ("R1", "shoulder R", "cyan"),
            "up": ("DUP", "d-pad up", "cyan"), "down": ("DDOWN", "d-pad down", "cyan"),
            "left": ("DLEFT", "d-pad left", "cyan"),
            "right": ("DRIGHT", "d-pad right", "cyan")}

    def on_action(self, action):
        if action == "b":
            self.app.pop()
            return
        if action == "y":
            self.app.push(G3Calibration(self.app))
            return
        if action == "x":
            # reset the test (clears the log, keeps current highlight)
            self.log = []
            self.app.toast("Test reset — press each button")
            return
        if action in self._ACT:
            key, desc, tone = self._ACT[action]
            self.last = key
            disp = {"DUP": "D↑", "DDOWN": "D↓", "DLEFT": "D←",
                    "DRIGHT": "D→"}.get(key, key)
            self.log.insert(0, ("just now", disp, desc, tone, None))
            self.log = self.log[:5]

    def update(self, dt_ms):
        self.elapsed += dt_ms

    def draw(self, surf):
        w.status_bar(surf, title="Test buttons & sticks", back=True)

        # ── hero: title + listening badge + meta ──
        hy = 56
        title = fonts.render(t.Type.TITLE, "Press each button", t.Color.TEXT_PRIMARY)
        surf.blit(title, (SAFE, hy))
        badge = _live_chip("listening · P1")
        bx = SAFE + title.get_width() + 12
        surf.blit(badge, (bx, hy + (title.get_height() - badge.get_height()) // 2))
        meta = fonts.render(t.Type.META_SM, "R36S internal · 14 keys + 2 sticks",
                            t.Color.TEXT_MUTED)
        surf.blit(meta, (STAGE_W - SAFE - meta.get_width(),
                         hy + (title.get_height() - meta.get_height()) // 2))

        # ── pad + live list row ──
        row_y = hy + title.get_height() + 8
        row_h = 196
        gap = 10
        total_w = STAGE_W - SAFE * 2
        pad_w = int((total_w - gap) * 0.58)
        live_w = total_w - gap - pad_w

        # pad panel
        pad_rect = pygame.Rect(SAFE, row_y, pad_w, row_h)
        surf.blit(_panel((pad_rect.w, pad_rect.h)), pad_rect.topleft)
        diagram = _pad_diagram(highlight=self.last, ls_drift=(3, -2),
                               ls_ring=t.with_alpha(t.Color.ACCENT_PEACH, 0.5),
                               rs_ring=t.with_alpha(t.Color.SUCCESS, 0.4))
        dscale = min((pad_rect.w - 16) / diagram.get_width(),
                     (pad_rect.h - 16) / diagram.get_height())
        dsurf = pygame.transform.smoothscale(
            diagram, (int(diagram.get_width() * dscale),
                      int(diagram.get_height() * dscale)))
        surf.blit(dsurf, (pad_rect.centerx - dsurf.get_width() // 2,
                          pad_rect.centery - dsurf.get_height() // 2))

        # live list panel
        live_rect = pygame.Rect(SAFE + pad_w + gap, row_y, live_w, row_h)
        surf.blit(_panel((live_rect.w, live_rect.h)), live_rect.topleft)
        self._draw_live(surf, live_rect)

        # ── quick tests row (4 tiles) ──
        ty = row_y + row_h + 8
        th = 44
        tgap = 6
        tw = (total_w - tgap * 3) // 4
        for i, (label, sub, state) in enumerate(self.tests):
            tr = pygame.Rect(SAFE + i * (tw + tgap), ty, tw, th)
            _test_tile(surf, tr, label, sub, state)

        # ── helper banner ──
        hb_y = ty + th + 6
        hb_h = 34
        self._draw_helper(surf, pygame.Rect(SAFE, hb_y, total_w, hb_h))

        _footer(surf, [("Y", "calibration"), ("X", "reset"), ("B", "back")])

    def _draw_live(self, surf, rect):
        pad = 10
        # header
        hic = icons.icon("ph-broadcast", 12, t.Color.ACCENT_CYAN)
        surf.blit(hic, (rect.x + pad, rect.y + pad))
        htitle = fonts.render(t.Type.META_SM, "RECENT PRESSES", t.Color.TEXT_MUTED,
                              upper=True)
        surf.blit(htitle, (rect.x + pad + hic.get_width() + 6,
                           rect.y + pad + (hic.get_height() - htitle.get_height()) // 2))
        y = rect.y + pad + max(hic.get_height(), htitle.get_height()) + 8
        tone_col = {"rose": t.Color.ACCENT_ROSE, "cyan": t.Color.ACCENT_CYAN,
                    "peach": t.Color.ACCENT_PEACH}
        for i, (when, key, desc, tone, delta) in enumerate(self.log[:5]):
            line_h = 28
            ry = y + i * line_h
            col = tone_col.get(tone, t.Color.ACCENT_CYAN)
            if i == 0:
                # latest row gets a faint rose wash + left accent bar
                wash = pygame.Surface((rect.w - pad, line_h - 4), pygame.SRCALPHA)
                pygame.draw.rect(wash, t.with_alpha(t.Color.ACCENT_ROSE, 0.06),
                                 (0, 0, rect.w - pad, line_h - 4), border_radius=6)
                pygame.draw.rect(wash, t.Color.ACCENT_ROSE, (0, 0, 2, line_h - 4))
                surf.blit(wash, (rect.x + pad - 4, ry - 2))
            ts = fonts.render(t.Type.META_SM, when, t.Color.TEXT_MUTED)
            surf.blit(ts, (rect.x + pad, ry + (line_h - 4 - ts.get_height()) // 2 - 1))
            # key pill
            kx = rect.x + pad + 52
            kp = _kv_pill(key, col)
            surf.blit(kp, (kx, ry + (line_h - 4 - kp.get_height()) // 2 - 1))
            # desc + optional delta
            dx = kx + kp.get_width() + 8
            ds = fonts.render(t.Type.META_SM, desc, t.Color.TEXT_SECONDARY)
            avail = rect.right - pad - dx
            if ds.get_width() > avail:
                ds = ds.subsurface((0, 0, max(avail, 1), ds.get_height()))
            surf.blit(ds, (dx, ry + (line_h - 4 - ds.get_height()) // 2 - 1))
            if delta:
                dl = fonts.render(t.Type.META_SM, delta, t.Color.TEXT_MUTED)
                surf.blit(dl, (rect.right - pad - dl.get_width(),
                               ry + (line_h - 4 - dl.get_height()) // 2 - 1))

    def _draw_helper(self, surf, rect):
        body = pygame.Surface((rect.w, rect.h), pygame.SRCALPHA)
        pygame.draw.rect(body, t.with_alpha(t.Color.ACCENT_ROSE, 0.06),
                         (0, 0, rect.w, rect.h), border_radius=8)
        pygame.draw.rect(body, t.with_alpha(t.Color.ACCENT_ROSE, 0.30),
                         (0, 0, rect.w, rect.h), width=1, border_radius=8)
        pad = 12
        ic = icons.icon("ph-info", 13, t.Color.ACCENT_ROSE)
        body.blit(ic, (pad, (rect.h - 13) // 2))
        lead = fonts.render(t.Type.BODY_SM, "Button not responding?",
                            t.Color.TEXT_PRIMARY)
        rest = fonts.render(t.Type.BODY_SM,
                            " Hold it 2s — if nothing lights up here, the button "
                            "is stuck, not a setting.", t.Color.TEXT_SECONDARY)
        tx = pad + ic.get_width() + 10
        cy = rect.h // 2
        body.blit(lead, (tx, cy - lead.get_height() // 2))
        body.blit(rest, (tx + lead.get_width(), cy - rest.get_height() // 2))
        surf.blit(body, rect.topleft)


def _kv_pill(text, color) -> pygame.Surface:
    """Small tinted mono pill used in the It1 recent-press log."""
    lbl = fonts.render(t.Type.META_SM, text, color)
    pw, ph = lbl.get_width() + 12, 16
    s = pygame.Surface((pw, ph), pygame.SRCALPHA)
    pygame.draw.rect(s, t.with_alpha(color, 0.10), (0, 0, pw, ph), border_radius=4)
    pygame.draw.rect(s, t.with_alpha(color, 0.30), (0, 0, pw, ph), width=1,
                     border_radius=4)
    s.blit(lbl, ((pw - lbl.get_width()) // 2, (ph - lbl.get_height()) // 2))
    return s


# ─── combo option card (It2) ──────────────────────────────────────────


def _combo_option(surf, rect, keys, title, sub, *, active=False, focused=False):
    """
    A pick-list option: key combo + title/sub + radio check. Active = filled
    gradient body; focused active = the rose→cyan glow border.
    """
    radius = t.Radius.BUTTON
    if active:
        body = render.rounded_gradient((rect.w, rect.h), t.Color.SURFACE_ELEVATED,
                                       t.Color.SURFACE_BASE, radius, 160)
    else:
        body = pygame.Surface((rect.w, rect.h), pygame.SRCALPHA)
        pygame.draw.rect(body, t.with_alpha(t.Color.BG_DEEP, 0.45),
                         (0, 0, rect.w, rect.h), border_radius=radius)
        pygame.draw.rect(body, t.Color.BORDER_SOFT, (0, 0, rect.w, rect.h),
                         width=1, border_radius=radius)
    pad = 12
    cy = rect.h // 2
    # combo on the left
    combo_s, _ = _combo(keys, badge_size=20)
    body.blit(combo_s, (pad, cy - combo_s.get_height() // 2))

    # radio check on the right
    chk_r = 8
    chk_cx = rect.w - pad - chk_r
    if active:
        ring = render.rounded_gradient((chk_r * 2, chk_r * 2), t.Color.ACCENT_ROSE,
                                       t.Color.ACCENT_CYAN, chk_r * 2, 135)
        body.blit(ring, (chk_cx - chk_r, cy - chk_r))
        pygame.draw.circle(body, t.Color.SURFACE_BASE, (chk_cx, cy), chk_r - 4)
    else:
        pygame.draw.circle(body, t.Color.BORDER_BRIGHT, (chk_cx, cy), chk_r, 2)

    # title + sub between combo and check
    tx = pad + combo_s.get_width() + 12
    avail = chk_cx - chk_r - 8 - tx
    ttl = fonts.render(t.Type.BODY_SM, title, t.Color.TEXT_PRIMARY)
    if ttl.get_width() > avail:
        ttl = ttl.subsurface((0, 0, max(avail, 1), ttl.get_height()))
    sub_s = fonts.render(t.Type.META_SM, sub, t.Color.TEXT_MUTED)
    if sub_s.get_width() > avail:
        sub_s = sub_s.subsurface((0, 0, max(avail, 1), sub_s.get_height()))
    stack_h = ttl.get_height() + 1 + sub_s.get_height()
    sy = (rect.h - stack_h) // 2
    body.blit(ttl, (tx, sy))
    body.blit(sub_s, (tx, sy + ttl.get_height() + 1))

    if active and focused:
        w.draw_focused_card(surf, rect, radius, scale=1.0, base_surface=body)
    elif focused:
        surf.blit(body, rect.topleft)
        surf.blit(render.gradient_border((rect.w, rect.h), radius), rect.topleft)
    else:
        surf.blit(body, rect.topleft)


def _em_chip(text) -> pygame.Surface:
    """Cyan emulator chip (PSX / GBA …) for the It2 'will apply to' strip."""
    lbl = fonts.render(t.Type.META_SM, text, t.Color.ACCENT_CYAN)
    pw, ph = lbl.get_width() + 16, 18
    s = pygame.Surface((pw, ph), pygame.SRCALPHA)
    pygame.draw.rect(s, t.with_alpha(t.Color.ACCENT_CYAN, 0.10), (0, 0, pw, ph),
                     border_radius=999)
    pygame.draw.rect(s, t.with_alpha(t.Color.ACCENT_CYAN, 0.30), (0, 0, pw, ph),
                     width=1, border_radius=999)
    s.blit(lbl, ((pw - lbl.get_width()) // 2, (ph - lbl.get_height()) // 2))
    return s


def _cta_button(surf, rect, label, icon_name, *, focused=False):
    """Wide CTA used by It2 (and G3-style screens)."""
    radius = t.Radius.BUTTON
    if focused:
        body = render.rounded_gradient((rect.w, rect.h), t.Color.SURFACE_ELEVATED,
                                       t.Color.SURFACE_BASE, radius, 160)
    else:
        body = render.rounded_fill((rect.w, rect.h), t.Color.SURFACE_BASE, radius)
        pygame.draw.rect(body, t.Color.BORDER_SOFT, (0, 0, rect.w, rect.h),
                         width=1, border_radius=radius)
    icol = t.Color.ACCENT_CYAN if focused else t.Color.TEXT_SECONDARY
    ic = icons.icon(icon_name, 14, icol)
    lab = fonts.render(t.Type.BODY_SM, label, t.Color.TEXT_PRIMARY)
    cw = ic.get_width() + 8 + lab.get_width()
    cx = (rect.w - cw) // 2
    cy = rect.h // 2
    body.blit(ic, (cx, cy - ic.get_height() // 2))
    body.blit(lab, (cx + ic.get_width() + 8, cy - lab.get_height() // 2))
    if focused:
        w.draw_focused_card(surf, rect, radius, scale=1.0, base_surface=body)
    else:
        surf.blit(body, rect.topleft)


# ═══════════════════════════════════════════════════════════════════════
# It2 — Fix Exit hotkey wizard
# ═══════════════════════════════════════════════════════════════════════


class It2FnExit:
    """
    Repair wizard for the in-game Exit hotkey. Four ready-made combos framed
    in "feels like X" language; D-pad picks one, A applies to all emulators.
    Transparent about scope (lists affected emulators) + backup reassurance.
    """
    name = "input-fnexit"

    def __init__(self, app):
        self.app = app
        # (keys, title, sub) — keys are caps for _combo()
        self.combos = [
            (["SELECT", "START"], "SELECT + START", "like Switch · most common"),
            (["SELECT", ("round", "B")], "SELECT + B", "ArkOS default"),
            (["L1", "R1", "SEL"], "L1 + R1 + SELECT", "3-finger · won't misfire"),
            (["FN", ("round", "B")], "FN + B", "classic R36S · needs FN key"),
        ]
        self.selected = 0       # which combo is bound (active)
        self.focus = 0          # which combo the d-pad is on
        self.emulators = ["PSX", "GBA", "SNES", "PSP", "N64", "+1"]
        # CTA focus is separate; A always applies the selected combo.

    def on_action(self, action):
        n = len(self.combos)
        if action == "up":
            self.focus = max(0, self.focus - 2)
        elif action == "down":
            self.focus = min(n - 1, self.focus + 2)
        elif action == "left":
            if self.focus % 2 == 1:
                self.focus -= 1
        elif action == "right":
            if self.focus % 2 == 0 and self.focus + 1 < n:
                self.focus += 1
        elif action == "a":
            self.selected = self.focus
            self.app.toast(f"Applied {self.combos[self.selected][1]} to all emulators")
        elif action == "x":
            self.app.toast("Record a custom combo — coming soon")
        elif action == "y":
            self.app.toast("Undone — restored previous Exit combo")
        elif action == "b":
            self.app.pop()

    def update(self, dt_ms):
        pass

    def draw(self, surf):
        w.status_bar(surf, title="Fix Exit hotkey", back=True)

        # ── hero: peach warning glyph + title + sub ──
        hy = 54
        gbox = 40
        chip = pygame.Surface((gbox, gbox), pygame.SRCALPHA)
        pygame.draw.rect(chip, t.with_alpha(t.Color.ACCENT_PEACH, 0.18),
                         (0, 0, gbox, gbox), border_radius=10)
        pygame.draw.rect(chip, t.with_alpha(t.Color.ACCENT_PEACH, 0.40),
                         (0, 0, gbox, gbox), width=1, border_radius=10)
        chip.blit(icons.icon("ph-warning", 22, t.Color.ACCENT_PEACH),
                  ((gbox - 22) // 2, (gbox - 22) // 2))
        surf.blit(chip, (SAFE, hy))
        tx = SAFE + gbox + 12
        title = fonts.render(t.Type.TITLE, "Pick how you exit a game",
                             t.Color.TEXT_PRIMARY)
        surf.blit(title, (tx, hy))
        lead = fonts.render(t.Type.BODY_SM, "Different firmwares disagree. ",
                            t.Color.TEXT_SECONDARY)
        strong = fonts.render(t.Type.LABEL_SM, "Pick what feels natural",
                              t.Color.TEXT_PRIMARY)
        rest = fonts.render(t.Type.BODY_SM, " — we'll apply it to all emulators.",
                            t.Color.TEXT_SECONDARY)
        sy = hy + title.get_height() + 3
        surf.blit(lead, (tx, sy))
        surf.blit(strong, (tx + lead.get_width(), sy))
        surf.blit(rest, (tx + lead.get_width() + strong.get_width(), sy))

        # ── problem strip ──
        y = hy + gbox + 8
        total_w = STAGE_W - SAFE * 2
        prob_h = 40
        self._draw_problem(surf, pygame.Rect(SAFE, y, total_w, prob_h))
        y += prob_h + 10

        # ── combo picker ──
        pick_title = fonts.render(t.Type.EYEBROW, "BIND EXIT TO", t.Color.TEXT_MUTED,
                                  upper=True)
        surf.blit(pick_title, (SAFE, y))
        y += pick_title.get_height() + 8
        opt_h = 50
        ogap = 8
        ow = (total_w - ogap) // 2
        for i, (keys, ttl, sub) in enumerate(self.combos):
            r, c = divmod(i, 2)
            orect = pygame.Rect(SAFE + c * (ow + ogap), y + r * (opt_h + ogap),
                                ow, opt_h)
            _combo_option(surf, orect, keys, ttl, sub,
                          active=(i == self.selected), focused=(i == self.focus))
        y += opt_h * 2 + ogap + 10

        # ── affected emulators strip ──
        aff_h = 38
        self._draw_affect(surf, pygame.Rect(SAFE, y, total_w, aff_h))
        y += aff_h + 10

        # ── CTA row ──
        cta_h = 42
        cgap = 10
        left_w = int((total_w - cgap) * 0.42)
        right_w = total_w - cgap - left_w
        _cta_button(surf, pygame.Rect(SAFE, y, left_w, cta_h),
                    "Test in a game", "ph-play-circle", focused=False)
        _cta_button(surf, pygame.Rect(SAFE + left_w + cgap, y, right_w, cta_h),
                    "Apply to all emulators", "ph-check", focused=True)

        _footer(surf, [("A", "apply"), ("X", "custom"), ("Y", "undo"),
                       ("B", "back")])

    def _draw_problem(self, surf, rect):
        body = pygame.Surface((rect.w, rect.h), pygame.SRCALPHA)
        pygame.draw.rect(body, t.with_alpha(t.Color.ERROR, 0.06),
                         (0, 0, rect.w, rect.h), border_radius=10)
        pygame.draw.rect(body, t.with_alpha(t.Color.ERROR, 0.35),
                         (0, 0, rect.w, rect.h), width=1, border_radius=10)
        pad = 12
        ic = icons.icon("ph-warning-circle", 16, t.Color.ERROR)
        body.blit(ic, (pad, (rect.h - 16) // 2))
        # right-aligned "noticed N min ago"
        ago = fonts.render(t.Type.META_SM, "noticed 6 min ago", t.Color.TEXT_MUTED)
        body.blit(ago, (rect.w - pad - ago.get_width(),
                        (rect.h - ago.get_height()) // 2))
        # message: "Right now nothing happens when you press Select+Start…"
        tx = pad + ic.get_width() + 10
        cy = rect.h // 2
        a = fonts.render(t.Type.BODY_SM, "Right now ", t.Color.TEXT_SECONDARY)
        b = fonts.render(t.Type.LABEL_SM, "nothing happens", t.Color.ERROR)
        cpart = fonts.render(t.Type.BODY_SM,
                             " on Select+Start — the Exit hotkey isn't bound.",
                             t.Color.TEXT_SECONDARY)
        x = tx
        body.blit(a, (x, cy - a.get_height() // 2)); x += a.get_width()
        body.blit(b, (x, cy - b.get_height() // 2)); x += b.get_width()
        body.blit(cpart, (x, cy - cpart.get_height() // 2))
        surf.blit(body, rect.topleft)

    def _draw_affect(self, surf, rect):
        body = pygame.Surface((rect.w, rect.h), pygame.SRCALPHA)
        pygame.draw.rect(body, t.with_alpha(t.Color.BG_DEEP, 0.4),
                         (0, 0, rect.w, rect.h), border_radius=10)
        pygame.draw.rect(body, t.Color.BORDER_SOFT, (0, 0, rect.w, rect.h),
                         width=1, border_radius=10)
        pad = 14
        cy = rect.h // 2
        lead = fonts.render(t.Type.BODY_SM, "Will apply to ", t.Color.TEXT_PRIMARY)
        cnt = fonts.render(t.Type.LABEL_SM, "6 emulators", t.Color.ACCENT_CYAN)
        x = pad
        body.blit(lead, (x, cy - lead.get_height() // 2)); x += lead.get_width()
        body.blit(cnt, (x, cy - cnt.get_height() // 2)); x += cnt.get_width() + 12
        # chips
        for em in self.emulators:
            chip = _em_chip(em)
            if x + chip.get_width() > rect.w - pad - 150:
                break
            body.blit(chip, (x, cy - chip.get_height() // 2))
            x += chip.get_width() + 5
        # reassurance, right-aligned
        auto = fonts.render(t.Type.META_SM, "backup made · undo any time",
                            t.Color.TEXT_MUTED)
        body.blit(auto, (rect.w - pad - auto.get_width(),
                         cy - auto.get_height() // 2))
        surf.blit(body, rect.topleft)


# ─── G1 slot card + controller row ────────────────────────────────────


def _battery_mini(pct, color) -> pygame.Surface:
    """Tiny battery glyph + percent (the .b-mini in G1 slots)."""
    bw, bh = 22, 8
    lbl = fonts.render(t.Type.META_SM, f"{pct}%", color)
    s = pygame.Surface((bw + 4 + 4 + lbl.get_width(), max(bh, lbl.get_height())),
                       pygame.SRCALPHA)
    cy = s.get_height() // 2
    pygame.draw.rect(s, t.Color.TEXT_SECONDARY, (0, cy - bh // 2, bw, bh),
                     width=1, border_radius=2)
    pygame.draw.rect(s, t.Color.TEXT_SECONDARY, (bw, cy - 2, 2, 4))
    fillw = max(1, int((bw - 4) * pct / 100))
    pygame.draw.rect(s, color, (2, cy - bh // 2 + 2, fillw, bh - 4))
    s.blit(lbl, (bw + 4 + 4, cy - lbl.get_height() // 2))
    return s


def _slot_card(surf, rect, *, slot, status, status_color, name, meta_kind,
               empty=False):
    """
    Player-slot tile (P1..P4). Occupied = card-depth; empty = dashed border.
    meta_kind: None | ("text", str) | ("battery", pct, color, suffix).
    """
    radius = 10
    if empty:
        body = pygame.Surface((rect.w, rect.h), pygame.SRCALPHA)
        _dashed_rect(body, pygame.Rect(0, 0, rect.w, rect.h), t.Color.BORDER_SOFT,
                     radius=radius)
    else:
        body = _panel((rect.w, rect.h), radius=radius)
    pad = 9
    # tag row: slot id (left) + status (right)
    sid = fonts.render(t.Type.META_SM, slot, t.Color.TEXT_MUTED)
    body.blit(sid, (pad, pad))
    if status:
        # small dot + status text for occupied; plain for empty
        if not empty:
            dot_r = 3
            stx = fonts.render(t.Type.META_SM, status, status_color)
            total = dot_r * 2 + 5 + stx.get_width()
            sx = rect.w - pad - total
            pygame.draw.circle(body, status_color, (sx + dot_r, pad + sid.get_height() // 2),
                               dot_r)
            body.blit(stx, (sx + dot_r * 2 + 5, pad))
        else:
            stx = fonts.render(t.Type.META_SM, status, t.Color.TEXT_MUTED)
            body.blit(stx, (rect.w - pad - stx.get_width(), pad))
    # name
    ny = pad + sid.get_height() + 4
    ncol = t.Color.TEXT_MUTED if empty else t.Color.TEXT_PRIMARY
    nstyle = t.Type.LABEL_SM if not empty else t.Type.BODY_SM
    name_s = fonts.render(nstyle, name, ncol)
    maxw = rect.w - pad * 2
    if name_s.get_width() > maxw:
        name_s = name_s.subsurface((0, 0, maxw, name_s.get_height()))
    body.blit(name_s, (pad, ny))
    # meta
    my = ny + name_s.get_height() + 4
    if meta_kind is not None:
        if meta_kind[0] == "text":
            ms = fonts.render(t.Type.META_SM, meta_kind[1], t.Color.TEXT_MUTED)
            body.blit(ms, (pad, my))
        elif meta_kind[0] == "battery":
            _, pct, col, suffix = meta_kind
            bm = _battery_mini(pct, col)
            body.blit(bm, (pad, my))
            if suffix:
                sx = fonts.render(t.Type.META_SM, suffix, t.Color.TEXT_MUTED)
                body.blit(sx, (pad + bm.get_width() + 8, my +
                               (bm.get_height() - sx.get_height()) // 2))
    surf.blit(body, rect.topleft)


def _dashed_rect(surf, rect, color, *, radius=0, dash=4, gap=4, width=1):
    """Dashed rounded-ish border (corners approximated as straight segments)."""
    x, y, ww, hh = rect
    # top + bottom
    for cx in range(x + radius, x + ww - radius, dash + gap):
        pygame.draw.line(surf, color, (cx, y), (min(cx + dash, x + ww - radius), y), width)
        pygame.draw.line(surf, color, (cx, y + hh - 1),
                         (min(cx + dash, x + ww - radius), y + hh - 1), width)
    for cy in range(y + radius, y + hh - radius, dash + gap):
        pygame.draw.line(surf, color, (x, cy), (x, min(cy + dash, y + hh - radius)), width)
        pygame.draw.line(surf, color, (x + ww - 1, cy),
                         (x + ww - 1, min(cy + dash, y + hh - radius)), width)


def _ctrl_row(surf, rect, *, name, slot, meta_segs, chip=None, chip_kind=None,
              icon_name="ph-game-controller", focused=False, dim=False):
    """
    G1 controller row: icon chip + name(+slot badge) + mono meta + optional
    action chip + chevron. meta_segs: list of (text, color). chip_kind:
    None|"drift" tints the chip peach.
    """
    radius = 10
    body = _panel((rect.w, rect.h), focused=focused, radius=radius)
    pad = 14
    cy = rect.h // 2
    # icon chip (40px)
    ibox = 40
    chipbg = pygame.Surface((ibox, ibox), pygame.SRCALPHA)
    if focused:
        grad = render.rounded_gradient((ibox, ibox),
                                       t.with_alpha(t.Color.ACCENT_ROSE, 0.18)[:3],
                                       t.with_alpha(t.Color.ACCENT_CYAN, 0.18)[:3],
                                       10, 135)
        chipbg.blit(grad, (0, 0))
        pygame.draw.rect(chipbg, t.with_alpha(t.Color.ACCENT_ROSE, 0.40),
                         (0, 0, ibox, ibox), width=1, border_radius=10)
        icol = t.Color.ACCENT_ROSE
    else:
        pygame.draw.rect(chipbg, t.with_alpha((255, 255, 255), 0.04),
                         (0, 0, ibox, ibox), border_radius=10)
        pygame.draw.rect(chipbg, t.Color.BORDER_SOFT, (0, 0, ibox, ibox),
                         width=1, border_radius=10)
        icol = t.Color.TEXT_SECONDARY
    chipbg.blit(icons.icon(icon_name, 22, icol), ((ibox - 22) // 2, (ibox - 22) // 2))
    body.blit(chipbg, (pad, cy - ibox // 2))

    tx = pad + ibox + 14
    # right cluster (measured right→left): chevron + chip
    rx = rect.w - pad
    chev = icons.icon("ph-caret-right", 14,
                      t.Color.TEXT_PRIMARY if focused else t.Color.TEXT_MUTED)
    rx -= chev.get_width()
    body.blit(chev, (rx, cy - chev.get_height() // 2))
    rx -= 8
    if chip:
        ch = _action_chip(chip, kind=chip_kind)
        rx -= ch.get_width()
        body.blit(ch, (rx, cy - ch.get_height() // 2))
        rx -= 8

    # name + slot badge
    name_s = fonts.render(t.Type.BODY, name, t.Color.TEXT_PRIMARY)
    body.blit(name_s, (tx, cy - 11 - name_s.get_height() + 4))
    nx = tx + name_s.get_width() + 8
    if slot:
        badge = _slot_badge(slot)
        body.blit(badge, (nx, cy - 11 - badge.get_height() + 5))
    # meta line
    mx = tx
    my = cy + 3
    for i, (txt, col) in enumerate(meta_segs):
        if i > 0:
            sep = fonts.render(t.Type.META_SM, " · ", t.Color.TEXT_MUTED)
            body.blit(sep, (mx, my)); mx += sep.get_width()
        seg = fonts.render(t.Type.META_SM, txt, col)
        if mx + seg.get_width() > rx - 6:
            seg = seg.subsurface((0, 0, max(1, rx - 6 - mx), seg.get_height()))
        body.blit(seg, (mx, my)); mx += seg.get_width()

    if dim and not focused:
        body.set_alpha(int(255 * 0.6))
    if focused:
        w.draw_focused_card(surf, rect, radius, scale=1.0, base_surface=body)
    else:
        surf.blit(body, rect.topleft)


def _slot_badge(text) -> pygame.Surface:
    lbl = fonts.render(t.Type.META_SM, text, t.Color.ACCENT_CYAN)
    pw, ph = lbl.get_width() + 12, 16
    s = pygame.Surface((pw, ph), pygame.SRCALPHA)
    pygame.draw.rect(s, t.with_alpha(t.Color.ACCENT_CYAN, 0.12), (0, 0, pw, ph),
                     border_radius=4)
    s.blit(lbl, ((pw - lbl.get_width()) // 2, (ph - lbl.get_height()) // 2))
    return s


def _action_chip(text, *, kind=None) -> pygame.Surface:
    """Pill action chip (remap / calibrate). kind="drift" → peach."""
    if kind == "drift":
        col = t.Color.WARNING
        bgc = t.with_alpha(t.Color.ACCENT_PEACH, 0.08)
        bc = t.with_alpha(t.Color.ACCENT_PEACH, 0.40)
    else:
        col = t.Color.TEXT_SECONDARY
        bgc = t.with_alpha(t.Color.SURFACE_GLASS, 0.40)
        bc = t.Color.BORDER_SOFT
    lbl = fonts.render(t.Type.META_SM, text, col)
    pw, ph = lbl.get_width() + 16, 20
    s = pygame.Surface((pw, ph), pygame.SRCALPHA)
    pygame.draw.rect(s, bgc, (0, 0, pw, ph), border_radius=999)
    pygame.draw.rect(s, bc, (0, 0, pw, ph), width=1, border_radius=999)
    s.blit(lbl, ((pw - lbl.get_width()) // 2, (ph - lbl.get_height()) // 2))
    return s


# ═══════════════════════════════════════════════════════════════════════
# G1 — Controllers list
# ═══════════════════════════════════════════════════════════════════════


class G1Controllers:
    """
    Player-slot strip (P1..P4 at a glance) over a connected-controller list.
    A opens the focused controller; X quick-remap; Y identify (rumble);
    the focused row already warns about drift → drives the user to G3.
    """
    name = "controllers"

    def __init__(self, app):
        self.app = app
        # slots P1..P4
        self.slots = [
            dict(slot="P1", status="active", status_color=t.Color.SUCCESS,
                 name="R36S internal", meta=("text", "USB · wired"), empty=False),
            dict(slot="P2", status="bonded", status_color=t.Color.ACCENT_CYAN,
                 name="8BitDo Pro 2",
                 meta=("battery", 28, t.Color.WARNING, "BT"), empty=False),
            dict(slot="P3", status="— empty", status_color=t.Color.TEXT_MUTED,
                 name="none", meta=("text", "assign · A"), empty=True),
            dict(slot="P4", status="— empty", status_color=t.Color.TEXT_MUTED,
                 name="none", meta=("text", "assign · A"), empty=True),
        ]
        # controller list rows
        self.rows = [
            dict(name="R36S internal", slot="P1",
                 meta=[("preset: Switch · A<->B", t.Color.TEXT_SECONDARY),
                       ("L-stick drift", t.Color.WARNING)],
                 chip="calibrate", chip_kind="drift", target="calibrate"),
            dict(name="8BitDo Pro 2", slot="P2",
                 meta=[("preset: Xbox", t.Color.TEXT_SECONDARY),
                       ("BT · bonded", t.Color.TEXT_SECONDARY)],
                 chip="remap", chip_kind=None, target="mapping"),
            dict(name="Pair another controller", slot=None,
                 meta=[("BT scan · USB hot-plug", t.Color.TEXT_SECONDARY)],
                 chip=None, chip_kind=None, icon="ph-plus-circle", target="pair"),
        ]
        self.focus = 0

    def on_action(self, action):
        if action == "up":
            self.focus = max(0, self.focus - 1)
        elif action == "down":
            self.focus = min(len(self.rows) - 1, self.focus + 1)
        elif action == "a":
            self._open(self.rows[self.focus])
        elif action == "x":
            self.app.push(G2Mapping(self.app))
        elif action == "y":
            self.app.toast("Identifying P1 — rumble + LED blink")
        elif action == "b":
            self.app.pop()

    def _open(self, row):
        if row["target"] == "calibrate":
            self.app.push(G3Calibration(self.app))
        elif row["target"] == "mapping":
            self.app.push(G2Mapping(self.app))
        else:
            self.app.toast("Pair a controller — coming soon")

    def update(self, dt_ms):
        pass

    def draw(self, surf):
        w.status_bar(surf, title="Controllers", back=True)

        total_w = STAGE_W - SAFE * 2
        # ── slot strip ──
        sy = 56
        sh = 64
        sgap = 8
        sw = (total_w - sgap * 3) // 4
        for i, sl in enumerate(self.slots):
            sr = pygame.Rect(SAFE + i * (sw + sgap), sy, sw, sh)
            _slot_card(surf, sr, slot=sl["slot"], status=sl["status"],
                       status_color=sl["status_color"], name=sl["name"],
                       meta_kind=sl["meta"], empty=sl["empty"])

        # ── controller list ──
        ly = sy + sh + 10
        row_h = 64
        rgap = 8
        for i, row in enumerate(self.rows):
            rr = pygame.Rect(SAFE, ly + i * (row_h + rgap), total_w, row_h)
            _ctrl_row(surf, rr, name=row["name"], slot=row["slot"],
                      meta_segs=row["meta"], chip=row["chip"],
                      chip_kind=row["chip_kind"],
                      icon_name=row.get("icon", "ph-game-controller"),
                      focused=(i == self.focus),
                      dim=(i != self.focus))

        _footer(surf, [("A", "open"), ("X", "remap"), ("Y", "identify"),
                       ("B", "back")])


# ─── G2 preset selector + A/B swap chip + summary pill ────────────────


def _preset_bar(surf, rect, presets, selected, *, focused=None):
    """
    Rounded segmented control with N equal segments. Selected = elevated
    fill; selected+focused = rose→cyan glow border. Returns nothing.
    """
    radius = 999
    track = render.rounded_fill((rect.w, rect.h), t.Color.BG_MID, radius)
    pygame.draw.rect(track, t.Color.BORDER_SOFT, (0, 0, rect.w, rect.h),
                     width=1, border_radius=radius)
    n = len(presets)
    inner_pad = 3
    seg_w = (rect.w - inner_pad * 2) / n
    seg_h = rect.h - inner_pad * 2
    for i, label in enumerate(presets):
        sx = inner_pad + i * seg_w
        sel = (i == selected)
        if sel:
            seg = render.rounded_fill((int(seg_w) - 2, int(seg_h)),
                                      t.Color.SURFACE_ELEVATED, radius)
            track.blit(seg, (int(sx) + 1, inner_pad))
        col = t.Color.TEXT_PRIMARY if sel else t.Color.TEXT_SECONDARY
        lab = fonts.render(t.Type.BODY_SM, label, col)
        # clip label to segment
        if lab.get_width() > seg_w - 6:
            lab = lab.subsurface((0, 0, int(seg_w - 6), lab.get_height()))
        track.blit(lab, (int(sx + (seg_w - lab.get_width()) / 2),
                         (rect.h - lab.get_height()) // 2))
    surf.blit(track, rect.topleft)
    if focused is not None:
        sx = rect.x + inner_pad + focused * seg_w
        fr = pygame.Rect(int(sx) + 1, rect.y + inner_pad, int(seg_w) - 2, int(seg_h))
        surf.blit(render.gradient_border((fr.w, fr.h), radius), fr.topleft)


def _swap_chip(surf, rect, *, on=True):
    """A/B swap toggle chip: 'A<->B' label + B→A swatch + on/off state."""
    body = render.rounded_fill((rect.w, rect.h), t.Color.BG_MID, 999)
    pygame.draw.rect(body, t.Color.BORDER_SOFT, (0, 0, rect.w, rect.h),
                     width=1, border_radius=999)
    pad = 10
    cy = rect.h // 2
    x = pad
    lab = fonts.render(t.Type.META_SM, "A<->B", t.Color.TEXT_SECONDARY)
    body.blit(lab, (x, cy - lab.get_height() // 2)); x += lab.get_width() + 8
    # swatch: B (rose ring) → A (cyan ring)
    def ring(letter, col):
        d = 18
        s = pygame.Surface((d, d), pygame.SRCALPHA)
        pygame.draw.circle(s, col, (d // 2, d // 2), d // 2 - 1, 2)
        gl = fonts.render(t.TextStyle(t.SPACE_GROTESK_SEMIBOLD, 10, "SemiBold", 0.0),
                          letter, col)
        s.blit(gl, ((d - gl.get_width()) // 2, (d - gl.get_height()) // 2))
        return s
    rb = ring("B", t.Color.ACCENT_ROSE)
    body.blit(rb, (x, cy - rb.get_height() // 2)); x += rb.get_width() + 3
    arr = fonts.render(t.Type.META_SM, ">", t.Color.TEXT_MUTED)
    body.blit(arr, (x, cy - arr.get_height() // 2)); x += arr.get_width() + 3
    ra = ring("A", t.Color.ACCENT_CYAN)
    body.blit(ra, (x, cy - ra.get_height() // 2)); x += ra.get_width() + 8
    state = fonts.render(t.Type.META_SM, "on" if on else "off",
                         t.Color.ACCENT_CYAN if on else t.Color.TEXT_MUTED)
    body.blit(state, (x, cy - state.get_height() // 2))
    surf.blit(body, rect.topleft)


def _summary_pill(label, value, value_color) -> pygame.Surface:
    """`label · value` mono pill for the G2 below-pad summary."""
    a = fonts.render(t.Type.META_SM, label + " · ", t.Color.TEXT_MUTED)
    b = fonts.render(t.Type.META_SM, value, value_color)
    pw = a.get_width() + b.get_width() + 20
    ph = 18
    s = pygame.Surface((pw, ph), pygame.SRCALPHA)
    pygame.draw.rect(s, t.with_alpha(t.Color.BG_DEEP, 0.6), (0, 0, pw, ph),
                     border_radius=999)
    pygame.draw.rect(s, t.Color.BORDER_SOFT, (0, 0, pw, ph), width=1,
                     border_radius=999)
    x = 10
    s.blit(a, (x, (ph - a.get_height()) // 2)); x += a.get_width()
    s.blit(b, (x, (ph - b.get_height()) // 2))
    return s


# ═══════════════════════════════════════════════════════════════════════
# G2 — Gamepad mapping
# ═══════════════════════════════════════════════════════════════════════


class G2Mapping:
    """
    Visual gamepad mapping. A 5-segment preset selector (Switch / Xbox /
    DualSense / 8BitDo / Custom) over the R36S pad diagram; the focused
    button shows a rose→cyan ring + dashed callout. Left/Right move presets,
    A selects, X toggles A/B swap, Y drills into per-button remap.
    """
    name = "gamepad-mapping"

    PRESETS = ["Switch", "Xbox", "DualSense", "8BitDo", "Custom"]

    # diamond labels per preset (which face button sits where). Phosphor
    # positions: top/right/bottom/left. Switch swaps A/B vs Xbox.
    _LAYOUTS = {
        "Switch": {"top": "X", "right": "A", "bottom": "B", "left": "Y"},
        "Xbox": {"top": "Y", "right": "B", "bottom": "A", "left": "X"},
        "DualSense": {"top": "X", "right": "B", "bottom": "A", "left": "Y"},
        "8BitDo": {"top": "X", "right": "A", "bottom": "B", "left": "Y"},
        "Custom": {"top": "X", "right": "A", "bottom": "B", "left": "Y"},
    }

    def __init__(self, app):
        self.app = app
        self.selected = 0       # active preset
        self.focus = 0          # preset under the d-pad
        self.ab_swap = True

    def on_action(self, action):
        if action == "left":
            self.focus = max(0, self.focus - 1)
        elif action == "right":
            self.focus = min(len(self.PRESETS) - 1, self.focus + 1)
        elif action == "a":
            self.selected = self.focus
            self.app.toast(f"{self.PRESETS[self.selected]} layout applied")
        elif action == "x":
            self.ab_swap = not self.ab_swap
            self.app.toast("A/B swap on" if self.ab_swap else "A/B swap off")
        elif action == "y":
            # Fix a scrambled pad: relaunch into the raw remap (no gptokeyb)
            self.app.request_remap()
        elif action == "b":
            self.app.pop()

    def update(self, dt_ms):
        pass

    def draw(self, surf):
        w.status_bar(surf, title="Gamepad mapping", back=True)

        total_w = STAGE_W - SAFE * 2
        # ── preset row: segmented + swap chip ──
        py = 56
        ph = 32
        swap_w = 130
        pgap = 8
        preset_w = total_w - swap_w - pgap
        _preset_bar(surf, pygame.Rect(SAFE, py, preset_w, ph), self.PRESETS,
                    self.selected, focused=self.focus)
        _swap_chip(surf, pygame.Rect(SAFE + preset_w + pgap, py, swap_w, ph),
                   on=self.ab_swap)

        # ── pad diagram ──
        layout = dict(self._LAYOUTS[self.PRESETS[self.selected]])
        if self.ab_swap:
            # swap whichever faces carry A and B
            inv = {v: k for k, v in layout.items()}
            pa, pb = inv["A"], inv["B"]
            layout[pa], layout[pb] = "B", "A"
        # focused button = whatever is in the 'right'/east slot (accept)
        focus_label = layout["right"]
        pad_top = py + ph + 6
        pad_h = 232
        diagram = _pad_diagram(highlight=focus_label, abxy_labels=layout,
                               callout=(focus_label, f"{focus_label} · accept"),
                               listen="press any button to highlight")
        dscale = min(total_w / diagram.get_width(), pad_h / diagram.get_height())
        dsurf = pygame.transform.smoothscale(
            diagram, (int(diagram.get_width() * dscale),
                      int(diagram.get_height() * dscale)))
        surf.blit(dsurf, ((STAGE_W - dsurf.get_width()) // 2, pad_top))

        # ── summary pills ──
        sumy = pad_top + dsurf.get_height() + 6
        layout_name = self.PRESETS[self.selected]
        pills = [
            _summary_pill("layout", layout_name, t.Color.SUCCESS),
            _summary_pill("A<->B", "on" if self.ab_swap else "off",
                          t.Color.SUCCESS if self.ab_swap else t.Color.TEXT_MUTED),
            _summary_pill("L2/R2", "digital", t.Color.WARNING),
            _summary_pill("mapped", "12 / 14", t.Color.TEXT_SECONDARY),
        ]
        x = SAFE
        for p in pills:
            surf.blit(p, (x, sumy))
            x += p.get_width() + 6
        right = fonts.render(t.Type.META_SM, "0 conflicts · saved 2m",
                             t.Color.TEXT_MUTED)
        rx = STAGE_W - SAFE - right.get_width()
        if rx > x + 6:   # only draw if it clears the pills
            surf.blit(right, (rx, sumy + (18 - right.get_height()) // 2))

        _footer(surf, [("A", "preset"), ("X", "swap A/B"), ("Y", "remap"),
                       ("B", "back")])


# ─── G3 stick radar (consumer visual — no numbers) ────────────────────


def _stick_radar(size, *, drift=(18, -14), fill_frac=0.0, done=False,
                 trail=None) -> pygame.Surface:
    """
    The single focal calibration visual. A circular gate with a crosshair, a
    soft peach deadzone halo at the drift offset, a rose→cyan position dot,
    and a faded center marker (where it SHOULD rest). NO text, NO numbers.

    drift     : (dx, dy) px offset of the resting dot from center (at size 200).
    fill_frac : 0..1 — how much of the "twist your stick" sweep is recorded
                (draws a rose→cyan arc around the ring).
    done      : True → the gate glows mint and the dot snaps to dead center.
    trail     : optional list of (dx, dy) sampled rim points to plot faintly
                while recording (the range we're learning).
    """
    px = size
    s = pygame.Surface((px, px), pygame.SRCALPHA)
    scale = px / 200.0
    cx = cy = px / 2
    R = 92 * scale

    ring_col = t.Color.SUCCESS if done else t.Color.BORDER_SOFT
    # outer ring
    pygame.draw.circle(s, ring_col, (cx, cy), int(R), max(1, int(1.2 * scale + 0.5)))

    # recording sweep arc (rose→cyan, from top clockwise)
    if fill_frac > 0 and not done:
        steps = max(2, int(360 * fill_frac / 4))
        for i in range(steps):
            a0 = -90 + (360 * fill_frac) * i / steps
            a1 = -90 + (360 * fill_frac) * (i + 1) / steps
            col = render._lerp(t.Color.ACCENT_ROSE, t.Color.ACCENT_CYAN, i / max(steps, 1))
            r0, r1 = math.radians(a0), math.radians(a1)
            thick = 3 * scale
            pts = [(cx + (R + thick) * math.cos(r0), cy + (R + thick) * math.sin(r0)),
                   (cx + (R + thick) * math.cos(r1), cy + (R + thick) * math.sin(r1)),
                   (cx + (R - thick) * math.cos(r1), cy + (R - thick) * math.sin(r1)),
                   (cx + (R - thick) * math.cos(r0), cy + (R - thick) * math.sin(r0))]
            pygame.draw.polygon(s, col, pts)

    # crosshair
    cross = t.Color.BORDER_SOFT
    pygame.draw.line(s, cross, (cx, cy - 80 * scale), (cx, cy + 80 * scale), 1)
    pygame.draw.line(s, cross, (cx - 80 * scale, cy), (cx + 80 * scale, cy), 1)

    if done:
        # success bloom + centered dot
        glow = pygame.Surface((px, px), pygame.SRCALPHA)
        pygame.draw.circle(glow, t.with_alpha(t.Color.SUCCESS, 0.18),
                           (int(cx), int(cy)), int(36 * scale))
        s.blit(glow, (0, 0))
        d = int(9 * scale)
        dot = render.rounded_gradient((d * 2, d * 2), t.Color.ACCENT_ROSE,
                                      t.Color.ACCENT_CYAN, d * 2, 135)
        s.blit(dot, (int(cx - d), int(cy - d)))
        return s

    dx, dy = drift[0] * scale, drift[1] * scale
    # soft deadzone halo at the drift offset
    halo_r = int(24 * scale)
    halo = pygame.Surface((halo_r * 2, halo_r * 2), pygame.SRCALPHA)
    for rr in range(halo_r, 0, -1):
        a = int(0.32 * 255 * (rr / halo_r) ** 2 * 0.5)
        pygame.draw.circle(halo, t.with_alpha(t.Color.ACCENT_PEACH, a / 255.0)[:3] +
                           (a,), (halo_r, halo_r), halo_r - rr + 1)
    s.blit(halo, (int(cx + dx - halo_r), int(cy + dy - halo_r)))

    # faint recorded-rim trail
    if trail:
        for (tx, ty) in trail:
            pygame.draw.circle(s, t.with_alpha(t.Color.ACCENT_CYAN, 0.35),
                               (int(cx + tx * scale), int(cy + ty * scale)),
                               max(1, int(2 * scale)))

    # offset line center → dot
    pygame.draw.line(s, t.with_alpha(t.Color.ACCENT_PEACH, 0.7),
                     (cx, cy), (cx + dx, cy + dy), max(1, int(2 * scale)))
    # the resting position dot (rose→cyan, glowing)
    d = int(9 * scale)
    glow = pygame.Surface((d * 2 + 16, d * 2 + 16), pygame.SRCALPHA)
    pygame.draw.circle(glow, t.with_alpha(t.Color.ACCENT_ROSE, 0.5),
                       (d + 8, d + 8), d + 5)
    s.blit(glow, (int(cx + dx - d - 8), int(cy + dy - d - 8)))
    dot = render.rounded_gradient((d * 2, d * 2), t.Color.ACCENT_ROSE,
                                  t.Color.ACCENT_CYAN, d * 2, 135)
    s.blit(dot, (int(cx + dx - d), int(cy + dy - d)))
    pygame.draw.circle(s, t.Color.ACCENT_ROSE, (int(cx + dx), int(cy + dy)), d, 1)

    # faded center marker (where it should rest)
    _dashed_circle(s, (cx, cy), int(3 * scale), t.with_alpha((255, 255, 255), 0.15))
    return s


def _dashed_circle(surf, center, r, color, *, segs=8):
    cx, cy = center
    for i in range(segs):
        if i % 2:
            continue
        a0 = (360 / segs) * i
        a1 = (360 / segs) * (i + 1)
        pygame.draw.arc(surf, color, (cx - r, cy - r, r * 2, r * 2),
                        math.radians(a0), math.radians(a1), 2)


def _pill_cta(surf, rect, *, badge, label, focused=True):
    """
    The big rounded gradient-bordered CTA (G3 "Start — takes 15 s"). Always
    drawn with the rose→cyan glow border + a round letter badge.
    """
    radius = 999
    body = render.rounded_gradient((rect.w, rect.h),
                                   t.with_alpha(t.Color.SURFACE_GLASS, 0.5)[:3],
                                   t.with_alpha(t.Color.SURFACE_BASE, 0.7)[:3],
                                   radius, 135)
    # badge (round letter)
    pad = 22
    cy = rect.h // 2
    bd = 22
    if badge:
        ring = pygame.Surface((bd, bd), pygame.SRCALPHA)
        pygame.draw.circle(ring, t.with_alpha(t.Color.ACCENT_ROSE, 0.5),
                           (bd // 2, bd // 2), bd // 2 - 1, 2)
        gl = fonts.render(t.TextStyle(t.SPACE_GROTESK_BOLD, 11, "Bold", 0.0),
                          badge, t.Color.ACCENT_ROSE)
        ring.blit(gl, ((bd - gl.get_width()) // 2, (bd - gl.get_height()) // 2))
    lab = fonts.render(t.TextStyle(t.SPACE_GROTESK_SEMIBOLD, 15, "SemiBold", -0.2),
                       label, t.Color.TEXT_PRIMARY)
    cw = (bd + 12 if badge else 0) + lab.get_width()
    cx = (rect.w - cw) // 2
    if badge:
        body.blit(ring, (cx, cy - bd // 2)); cx += bd + 12
    body.blit(lab, (cx, cy - lab.get_height() // 2))
    surf.blit(body, rect.topleft)
    # glow + gradient border
    glow, gpos = render.focus_glow(rect, radius)
    surf.blit(glow, gpos)
    surf.blit(render.gradient_border((rect.w, rect.h), radius), rect.topleft)


# ═══════════════════════════════════════════════════════════════════════
# G3 — Stick calibration (CONSUMER FIRST)
# ═══════════════════════════════════════════════════════════════════════


class G3Calibration:
    """
    Analog calibration, consumer-first. Default view: one headline ("Your
    left stick rests off-center"), one plain explanation, ONE radar visual
    (no numbers), one reassurance ("Right stick is fine"), one CTA ("Start —
    takes 15 s"). A starts a 3-phase flow: recording ("Twist your stick in a
    full circle…") → done ("Drift fixed"). X reveals the ONLY place numbers
    and device paths appear (deadzone %, sampling rate, raw axes, event path).
    """
    name = "stick-calibration"

    # phases: idle → recording → done
    IDLE, RECORDING, DONE = 0, 1, 2

    def __init__(self, app):
        self.app = app
        self.phase = self.IDLE
        self.record_t = 0.0
        self.advanced = False       # X → technical readout (numbers live here)
        self._trail = []

    def on_action(self, action):
        if action == "a":
            if self.phase == self.IDLE:
                self.phase = self.RECORDING
                self.record_t = 0.0
                self._trail = []
            elif self.phase == self.DONE:
                self.app.toast("Saved — drift fixed")
                self.app.pop()
        elif action == "x":
            self.advanced = not self.advanced
            self.app.toast("Technical details on" if self.advanced
                           else "Technical details off")
        elif action == "b":
            self.app.pop()

    def update(self, dt_ms):
        if self.phase == self.RECORDING:
            self.record_t += dt_ms
            # plot a moving rim sample so the radar "learns the range"
            ang = (self.record_t / 1500.0) * 2 * math.pi
            self._trail.append((math.cos(ang) * 80, math.sin(ang) * 80))
            self._trail = self._trail[-48:]
            if self.record_t >= 3000:   # ~3s sweep (visual; spec says ~15s)
                self.phase = self.DONE

    def draw(self, surf):
        w.status_bar(surf, title="Stick calibration", back=True)

        cx = STAGE_W // 2
        if self.phase == self.IDLE:
            self._draw_idle(surf, cx)
        elif self.phase == self.RECORDING:
            self._draw_recording(surf, cx)
        else:
            self._draw_done(surf, cx)

        if self.advanced:
            self._draw_technical(surf)

        x_hint = "hide technical" if self.advanced else "show technical details"
        if self.phase == self.IDLE:
            _footer(surf, [("A", "start"), ("X", x_hint), ("B", "later")])
        elif self.phase == self.DONE:
            _footer(surf, [("A", "save"), ("X", x_hint), ("B", "back")])
        else:
            _footer(surf, [("X", x_hint), ("B", "cancel")])

    # ── states ────────────────────────────────────────────────────────
    def _draw_idle(self, surf, cx):
        adv = self.advanced
        hy = 64
        title = fonts.render(t.Type.HERO, "Your left stick rests off-center",
                             t.Color.TEXT_PRIMARY)
        surf.blit(title, (cx - title.get_width() // 2, hy))
        sub1 = fonts.render(t.Type.BODY, "It pushes the game when you're not "
                            "touching it.", t.Color.TEXT_SECONDARY)
        sub2 = fonts.render(t.Type.BODY, "We'll learn its range and ignore the "
                            "wobble.", t.Color.TEXT_SECONDARY)
        sy = hy + title.get_height() + 6
        surf.blit(sub1, (cx - sub1.get_width() // 2, sy))
        surf.blit(sub2, (cx - sub2.get_width() // 2, sy + sub1.get_height() + 2))

        # shrink the radar when the technical card needs room below
        radar = _stick_radar(112 if adv else 170, drift=(18, -14))
        ry = sy + sub1.get_height() * 2 + (4 if adv else 14)
        surf.blit(radar, (cx - radar.get_width() // 2, ry))

        # reassurance line
        oy = ry + radar.get_height() + (4 if adv else 12)
        self._other_line(surf, cx, oy)

        # CTA
        cta_label = "Start — takes 15 s"
        clab_w = fonts.measure(t.TextStyle(t.SPACE_GROTESK_SEMIBOLD, 15, "SemiBold",
                               -0.2), cta_label)[0]
        cta_w = clab_w + 22 + 12 + 56
        cta_h = 42 if adv else 48
        cta_rect = pygame.Rect(cx - cta_w // 2, oy + (8 if adv else 28),
                               cta_w, cta_h)
        _pill_cta(surf, cta_rect, badge="A", label=cta_label)

    def _draw_recording(self, surf, cx):
        hy = 70
        title = fonts.render(t.Type.HERO, "Twist your stick in a full circle",
                             t.Color.TEXT_PRIMARY)
        surf.blit(title, (cx - title.get_width() // 2, hy))
        sub = fonts.render(t.Type.BODY, "Roll it around the edge a few times — "
                           "we're learning how far it reaches.",
                           t.Color.TEXT_SECONDARY)
        sy = hy + title.get_height() + 8
        surf.blit(sub, (cx - sub.get_width() // 2, sy))

        frac = min(1.0, self.record_t / 3000.0)
        radar = _stick_radar(170, drift=(10, -8), fill_frac=frac, trail=self._trail)
        ry = sy + sub.get_height() + 18
        surf.blit(radar, (cx - radar.get_width() // 2, ry))

        # progress bar + percent-free label ("keep going…")
        py = ry + radar.get_height() + 16
        bar_w = 240
        w.progress_bar(surf, cx - bar_w // 2, py, bar_w, frac, h=6)
        msg = "Almost there…" if frac > 0.6 else "Keep rolling…"
        ml = fonts.render(t.Type.BODY_SM, msg, t.Color.ACCENT_CYAN)
        surf.blit(ml, (cx - ml.get_width() // 2, py + 12))

    def _draw_done(self, surf, cx):
        adv = self.advanced
        hy = 70 if adv else 78
        # success glyph chip
        gbox = 48
        chip = render.rounded_gradient((gbox, gbox),
                                       t.with_alpha(t.Color.SUCCESS, 0.22)[:3],
                                       t.with_alpha(t.Color.ACCENT_CYAN, 0.18)[:3],
                                       12, 135)
        pygame.draw.rect(chip, t.with_alpha(t.Color.SUCCESS, 0.40),
                         (0, 0, gbox, gbox), width=1, border_radius=12)
        chip.blit(icons.icon("ph-check-circle", 26, t.Color.SUCCESS),
                  ((gbox - 26) // 2, (gbox - 26) // 2))
        surf.blit(chip, (cx - gbox // 2, hy))

        title = fonts.render(t.Type.HERO, "Drift fixed", t.Color.TEXT_PRIMARY)
        ty = hy + gbox + 10
        surf.blit(title, (cx - title.get_width() // 2, ty))
        sub = fonts.render(t.Type.BODY, "Your left stick now rests at center. "
                           "Games will ignore the wobble.", t.Color.TEXT_SECONDARY)
        sy = ty + title.get_height() + 8
        surf.blit(sub, (cx - sub.get_width() // 2, sy))

        radar = _stick_radar(120 if adv else 150, done=True)
        ry = sy + sub.get_height() + (8 if adv else 16)
        surf.blit(radar, (cx - radar.get_width() // 2, ry))

        # save CTA
        cta_label = "Save & done"
        clab_w = fonts.measure(t.TextStyle(t.SPACE_GROTESK_SEMIBOLD, 15, "SemiBold",
                               -0.2), cta_label)[0]
        cta_w = clab_w + 22 + 12 + 48
        cta_rect = pygame.Rect(cx - cta_w // 2,
                               ry + radar.get_height() + (10 if adv else 14),
                               cta_w, 44 if adv else 46)
        _pill_cta(surf, cta_rect, badge="A", label=cta_label)

    def _other_line(self, surf, cx, y):
        dot_r = 3
        lead = fonts.render(t.Type.BODY_SM, "Right stick", t.Color.TEXT_SECONDARY)
        rest = fonts.render(t.Type.BODY_SM, " is fine — won't be touched.",
                            t.Color.TEXT_MUTED)
        total = dot_r * 2 + 7 + lead.get_width() + rest.get_width()
        x = cx - total // 2
        pygame.draw.circle(surf, t.Color.SUCCESS, (x + dot_r, y + lead.get_height() // 2),
                           dot_r)
        x += dot_r * 2 + 7
        surf.blit(lead, (x, y)); x += lead.get_width()
        surf.blit(rest, (x, y))

    def _draw_technical(self, surf):
        """
        The ONLY place numbers / device paths appear — behind X. A compact
        card pinned above the footer with the dense readout the spec named:
        deadzone %, sampling rate, raw axes, /dev/input path.
        """
        cw = 380
        ch = 86
        rect = pygame.Rect((STAGE_W - cw) // 2, 448 - 6 - ch, cw, ch)
        body = pygame.Surface((rect.w, rect.h), pygame.SRCALPHA)
        pygame.draw.rect(body, t.with_alpha(t.Color.BG_DEEP, 0.92),
                         (0, 0, rect.w, rect.h), border_radius=10)
        pygame.draw.rect(body, t.Color.BORDER_SOFT, (0, 0, rect.w, rect.h),
                         width=1, border_radius=10)
        pad = 10
        top = 8
        hic = icons.icon("ph-terminal-window", 12, t.Color.TEXT_MUTED)
        body.blit(hic, (pad, top))
        ht = fonts.render(t.Type.EYEBROW, "TECHNICAL", t.Color.TEXT_MUTED, upper=True)
        body.blit(ht, (pad + hic.get_width() + 6, top +
                       (hic.get_height() - ht.get_height()) // 2))
        rows = [
            ("device", "/dev/input/event2"),
            ("sampling", "240 Hz"),
            ("raw axes", "x +0.038  y -0.041  max 0.96"),
            ("deadzone", "L 0.12 applied · R 0.08"),
        ]
        gy = top + max(hic.get_height(), ht.get_height()) + 4
        for i, (k, v) in enumerate(rows):
            ry = gy + i * 14
            ks = fonts.render(t.Type.META_SM, k, t.Color.TEXT_MUTED)
            body.blit(ks, (pad, ry))
            vs = fonts.render(t.Type.META_SM, v, t.Color.TEXT_SECONDARY)
            body.blit(vs, (pad + 70, ry))
        surf.blit(body, rect.topleft)


# ═══════════════════════════════════════════════════════════════════════
# G4 — Hotkey reference
# ═══════════════════════════════════════════════════════════════════════


class G4Hotkeys:
    """
    Two-column hotkey sheet: Global (Manager scope) vs In-game (RetroArch).
    The Exit combo gets a dedicated accent box at the top of Global; the
    engineering gesture (SELECT+START+X · hold 3s) is the last Global row.
    D-pad moves the focus highlight; A edits a combo, Y prints a PDF.
    Combos render as key caps (mono / round / d-pad arrow) — no tofu glyphs.
    """
    name = "hotkeys"

    def __init__(self, app):
        self.app = app
        # each row: dict(keys, label, sub, kind) kind ∈ normal/exit/eng
        self.global_rows = [
            dict(keys=["SELECT", "START"], label="Quit AAhead Control",
                 sub="Return to launcher (EmulationStation)", kind="normal"),
            dict(keys=["F1"], label="Open this hotkey sheet", sub=None,
                 kind="normal"),
            dict(keys=[("round", "B")], label="Back / cancel", sub=None,
                 kind="normal"),
            dict(keys=["SELECT", ("dpad", "up")], label="Brightness up",
                 sub="SELECT+down = down · VOL keys for sound", kind="normal"),
            dict(keys=["POWER"], label="Sleep", sub="Hold 3 s for shutdown menu",
                 kind="normal"),
            dict(keys=["SELECT", ("round", "X")], label="Toggle scanline overlay",
                 sub=None, kind="normal"),
            dict(keys=["SELECT", "START", ("round", "X")],
                 label="Engineering corner",
                 sub="advanced · hold 3 s · for support sessions", kind="eng"),
        ]
        self.ingame_rows = [
            dict(keys=["HOTKEY", ("round", "B")], label="Exit emulator", sub=None,
                 kind="normal"),
            dict(keys=["HOTKEY", ("round", "A")], label="RetroArch menu", sub=None,
                 kind="normal"),
            dict(keys=["HOTKEY", ("round", "L")], label="Load state · current slot",
                 sub=None, kind="normal"),
            dict(keys=["HOTKEY", ("round", "R")], label="Save state · current slot",
                 sub=None, kind="normal"),
            dict(keys=["HOTKEY", ("dpad", "left")], label="Previous save slot",
                 sub="right = next · slot index shown on save", kind="normal"),
            dict(keys=["HOTKEY", ("round", "X")], label="Screenshot", sub=None,
                 kind="normal"),
            dict(keys=["HOTKEY", ("round", "Y")], label="Toggle fast-forward",
                 sub=None, kind="normal"),
            dict(keys=["HOTKEY", "START"], label="Reset game", sub=None,
                 kind="normal"),
        ]
        # flat focus across both columns: left column then right column
        self.focus = 1   # "Open this hotkey sheet" focused per the handoff

    def _all(self):
        return self.global_rows + self.ingame_rows

    def on_action(self, action):
        ng = len(self.global_rows)
        ni = len(self.ingame_rows)
        total = ng + ni
        in_left = self.focus < ng
        col_i = self.focus if in_left else self.focus - ng
        if action == "up":
            if col_i > 0:
                self.focus -= 1
        elif action == "down":
            if in_left and col_i < ng - 1:
                self.focus += 1
            elif not in_left and col_i < ni - 1:
                self.focus += 1
        elif action == "left":
            if not in_left:
                self.focus = min(col_i, ng - 1)
        elif action == "right":
            if in_left:
                self.focus = ng + min(col_i, ni - 1)
        elif action == "a":
            row = self._all()[self.focus]
            self.app.toast(f"Edit '{row['label']}' — coming soon")
        elif action == "y":
            self.app.toast("Saved hotkey sheet → PDF")
        elif action == "b":
            self.app.pop()

    def update(self, dt_ms):
        pass

    def draw(self, surf):
        w.status_bar(surf, title="Hotkeys", back=True)

        # ── hero ──
        hy = 52
        title = fonts.render(t.Type.TITLE, "Hotkey reference", t.Color.TEXT_PRIMARY)
        surf.blit(title, (SAFE, hy))
        py = hy + title.get_height() + 3
        lead = fonts.render(t.Type.BODY_SM, "Everything you can do from anywhere. ",
                            t.Color.TEXT_SECONDARY)
        surf.blit(lead, (SAFE, py))
        ref = fonts.render(t.Type.META_SM, "Hotkey = SELECT", t.Color.ACCENT_CYAN)
        surf.blit(ref, (SAFE + lead.get_width(), py + (lead.get_height() -
                        ref.get_height()) // 2))
        tail = fonts.render(t.Type.BODY_SM, " on R36S — long-press the orange button.",
                            t.Color.TEXT_SECONDARY)
        surf.blit(tail, (SAFE + lead.get_width() + ref.get_width(), py))

        # ── two columns ──
        col_top = py + lead.get_height() + 8
        col_bottom = 442
        total_w = STAGE_W - SAFE * 2
        cgap = 10
        col_w = (total_w - cgap) // 2
        ng = len(self.global_rows)
        self._draw_col(surf, pygame.Rect(SAFE, col_top, col_w,
                       col_bottom - col_top), "ph-broadcast", t.Color.ACCENT_CYAN,
                       "Global · everywhere", self.global_rows,
                       focus_local=(self.focus if self.focus < ng else None),
                       note="no conflicts with games — Manager only uses SELECT combos.")
        self._draw_col(surf, pygame.Rect(SAFE + col_w + cgap, col_top, col_w,
                       col_bottom - col_top), "ph-game-controller",
                       t.Color.ACCENT_PEACH, "In-game · RetroArch", self.ingame_rows,
                       focus_local=(self.focus - ng if self.focus >= ng else None),
                       note="remap per-core in RetroArch -> Input; defaults shown.")

        _footer(surf, [("A", "edit combo"), ("Y", "print PDF"), ("B", "back")])

    def _draw_col(self, surf, rect, icon_name, icon_color, heading, rows, *,
                  focus_local, note):
        body = _panel((rect.w, rect.h), radius=10)
        pad = 12
        # header
        hic = icons.icon(icon_name, 14, icon_color)
        body.blit(hic, (pad, pad))
        ht = fonts.render(t.Type.EYEBROW, heading, t.Color.TEXT_MUTED, upper=True)
        # clip heading if needed
        maxw = rect.w - pad * 2 - hic.get_width() - 6 - 24
        if ht.get_width() > maxw:
            ht = ht.subsurface((0, 0, maxw, ht.get_height()))
        body.blit(ht, (pad + hic.get_width() + 8,
                       pad + (hic.get_height() - ht.get_height()) // 2))
        cnt = fonts.render(t.Type.META_SM, str(len(rows)), t.Color.TEXT_MUTED)
        body.blit(cnt, (rect.w - pad - cnt.get_width(), pad +
                        (hic.get_height() - cnt.get_height()) // 2))

        # rows region
        ry = pad + max(hic.get_height(), ht.get_height()) + 8
        note_h = 26
        avail = rect.h - ry - note_h - pad
        n = len(rows)
        row_h = avail // n
        for i, row in enumerate(rows):
            self._draw_hk_row(body, pygame.Rect(pad - 4, ry + i * row_h,
                              rect.w - pad * 2 + 8, row_h), row,
                              focused=(focus_local == i))

        # note (dashed top border)
        ny = rect.h - pad - note_h + 6
        w.dashed_hline(body, pad, rect.w - pad, ny - 4, t.Color.BORDER_SOFT)
        ns = fonts.render(t.Type.META_SM, note, t.Color.TEXT_MUTED)
        if ns.get_width() > rect.w - pad * 2:
            ns = ns.subsurface((0, 0, rect.w - pad * 2, ns.get_height()))
        body.blit(ns, (pad, ny))

        surf.blit(body, rect.topleft)

    def _draw_hk_row(self, surf, rect, row, *, focused=False):
        kind = row["kind"]
        inner = rect.inflate(0, -4)
        if kind == "exit":
            bg = render.rounded_gradient((inner.w, inner.h),
                                         t.with_alpha(t.Color.ACCENT_ROSE, 0.10)[:3],
                                         t.with_alpha(t.Color.ACCENT_CYAN, 0.08)[:3],
                                         6, 135)
            surf.blit(bg, inner.topleft)
            pygame.draw.rect(surf, t.with_alpha(t.Color.ACCENT_ROSE, 0.35),
                             inner, width=1, border_radius=6)
        elif focused:
            bg = render.rounded_gradient((inner.w, inner.h),
                                         t.with_alpha(t.Color.BORDER_BRIGHT, 0.30)[:3],
                                         t.with_alpha(t.Color.BG_MID, 0.4)[:3], 6, 135)
            surf.blit(bg, inner.topleft)
            surf.blit(render.gradient_border((inner.w, inner.h), 6), inner.topleft)

        pad = 8
        cy = rect.centery
        # combo caps
        keys = row["keys"]
        combo_s, _ = _combo(keys, badge_size=16)
        surf.blit(combo_s, (inner.x + pad, cy - combo_s.get_height() // 2))
        # label (+ optional sub)
        lx = inner.x + pad + combo_s.get_width() + 10
        lab_col = (t.Color.ACCENT_ROSE if kind == "exit" else t.Color.TEXT_PRIMARY)
        lab_style = t.Type.LABEL_SM if kind == "exit" else t.Type.BODY_SM
        lab = fonts.render(lab_style, row["label"], lab_col)
        avail = inner.right - pad - lx
        sub = row.get("sub")
        if sub:
            sub_s = fonts.render(t.Type.META_SM, sub,
                                 t.Color.TEXT_SECONDARY if kind == "exit"
                                 else t.Color.TEXT_MUTED)
            if lab.get_width() > avail:
                lab = lab.subsurface((0, 0, max(1, avail), lab.get_height()))
            if sub_s.get_width() > avail:
                sub_s = sub_s.subsurface((0, 0, max(1, avail), sub_s.get_height()))
            stack_h = lab.get_height() + 1 + sub_s.get_height()
            ly = cy - stack_h // 2
            surf.blit(lab, (lx, ly))
            surf.blit(sub_s, (lx, ly + lab.get_height() + 1))
        else:
            if lab.get_width() > avail:
                lab = lab.subsurface((0, 0, max(1, avail), lab.get_height()))
            surf.blit(lab, (lx, cy - lab.get_height() // 2))


# ═══════════════════════════════════════════════════════════════════════
# RawRemap — fix a scrambled pad (the missing backend behind G2 → Y).
# Runs WITHOUT gptokeyb (main.py --remap, relaunched by launch.sh) so it reads
# the RAW joystick. Lights each target on the SHARED pad diagram, records what
# the pad actually sends, and writes a correct SDL gamecontrollerdb line.
# ═══════════════════════════════════════════════════════════════════════

# (sdl_key, prompt, pad-diagram highlight, kind, stick-ring)
_REMAP_TARGETS = [
    ("a", "Press  A", "A", "btn", None),
    ("b", "Press  B", "B", "btn", None),
    ("x", "Press  X", "X", "btn", None),
    ("y", "Press  Y", "Y", "btn", None),
    ("dpup", "D-pad  UP", "DUP", "btn", None),
    ("dpdown", "D-pad  DOWN", "DDOWN", "btn", None),
    ("dpleft", "D-pad  LEFT", "DLEFT", "btn", None),
    ("dpright", "D-pad  RIGHT", "DRIGHT", "btn", None),
    ("leftshoulder", "Press  L1", "L1", "btn", None),
    ("rightshoulder", "Press  R1", "R1", "btn", None),
    ("lefttrigger", "Press  L2", "L2", "btn", None),
    ("righttrigger", "Press  R2", "R2", "btn", None),
    ("back", "Press  SELECT", "SELECT", "btn", None),
    ("start", "Press  START", "START", "btn", None),
    ("guide", "Press  FN", "FN", "btn", None),
    ("leftstick", "Click the LEFT stick (L3)", None, "btn", "L"),
    ("rightstick", "Click the RIGHT stick (R3)", None, "btn", "R"),
    ("leftx", "Push LEFT stick  RIGHT", None, "axis", "L"),
    ("lefty", "Push LEFT stick  DOWN", None, "axis", "L"),
    ("rightx", "Push RIGHT stick  RIGHT", None, "axis", "R"),
    ("righty", "Push RIGHT stick  DOWN", None, "axis", "R"),
]
_REMAP_ORDER = [x[0] for x in _REMAP_TARGETS]
_REMAP_HAT = {(0, 1): "h0.1", (1, 0): "h0.2", (0, -1): "h0.4", (-1, 0): "h0.8"}


class RawRemap:
    """Raw controller remap. Driven by raw pygame events (App.run_remap), not
    the gptokeyb action layer — so it can capture the true button/axis indices
    of a pad whose stock SDL mapping is wrong/missing."""
    name = "input-remap"
    SETTLE = 550
    TIMEOUT = 6000
    AXIS_FIRE = 0.7

    def __init__(self, app):
        self.app = app
        js = pygame.joystick.Joystick(0) if pygame.joystick.get_count() else None
        self.js = js
        self.dev = js.get_name() if js else "(no gamepad)"
        self.guid = js.get_guid() if (js and hasattr(js, "get_guid")) else "?"
        self.naxes = js.get_numaxes() if js else 0
        self.nbtn = js.get_numbuttons() if js else 0
        print(f"REMAP start name={self.dev!r} guid={self.guid} "
              f"axes={self.naxes} buttons={self.nbtn}", flush=True)
        self.results = {}
        self.idx = 0
        self.last = "—"
        self.done = False
        self.done_at = 0
        self.line = ""
        self.applied = None
        self.should_exit = False
        self.armed_at = pygame.time.get_ticks() + self.SETTLE

    # raw events come from App.run_remap (not on_action)
    def handle_raw(self, ev):
        now = pygame.time.get_ticks()
        if ev.type == pygame.JOYBUTTONDOWN:
            self.last = f"button {ev.button}"
        elif ev.type == pygame.JOYHATMOTION and ev.value != (0, 0):
            self.last = f"hat {ev.value}"
        elif ev.type == pygame.JOYAXISMOTION and abs(ev.value) > 0.5:
            self.last = f"axis {ev.axis} = {ev.value:+.2f}"
        if self.done:
            if ev.type == pygame.JOYBUTTONDOWN and now - self.done_at > 1200:
                self.applied = self._apply()
                self.should_exit = True
            return
        if now < self.armed_at:
            return
        kind = _REMAP_TARGETS[self.idx][3]
        cap = None
        if kind == "btn" and ev.type == pygame.JOYBUTTONDOWN:
            cap = f"b{ev.button}"
        elif kind == "btn" and ev.type == pygame.JOYHATMOTION and ev.value != (0, 0):
            cap = _REMAP_HAT.get(ev.value)
        elif kind == "axis" and ev.type == pygame.JOYAXISMOTION and abs(ev.value) > self.AXIS_FIRE:
            cap = f"a{ev.axis}" + ("~" if ev.value < 0 else "")
        if cap:
            key = _REMAP_TARGETS[self.idx][0]
            self.results[key] = cap
            print(f"REMAP {key} = {cap}", flush=True)
            self._advance(now)

    def on_action(self, action):       # raw mode ignores the gptokeyb layer
        pass

    def _advance(self, now):
        self.idx += 1
        self.armed_at = now + self.SETTLE
        if self.idx >= len(_REMAP_TARGETS):
            self.done = True
            self.done_at = now
            self.line = self._save()

    def _save(self):
        parts = [self.guid, self.dev or "GO-Super Gamepad"]
        parts += [f"{k}:{self.results[k]}" for k in _REMAP_ORDER if k in self.results]
        line = ",".join(parts) + ",platform:Linux,"
        try:
            with open(os.path.join(os.getcwd(), "padmap_result.txt"), "w") as fh:
                fh.write(line + "\n")
        except Exception as exc:                       # noqa: BLE001
            print(f"REMAP save failed: {exc!r}", flush=True)
        print("REMAP RESULT: " + line, flush=True)
        return line

    def _gptk_analog(self):
        """Turn the captured raw axes into gptokeyb analog lines that compensate
        this pad's scramble. gptokeyb reads RAW axes for analog (axes 0/1 =
        left_analog, 2/3 = right_analog), so we map each physical direction to the
        right slot: physical LEFT stick → arrows, physical RIGHT stick → Home/End."""
        def parse(v):
            if not isinstance(v, str) or not v.startswith("a"):
                return None
            inv = v.endswith("~")
            return int(v[1:-1] if inv else v[1:]), inv

        slot = {(0, True): "left_analog_left", (0, False): "left_analog_right",
                (1, True): "left_analog_up", (1, False): "left_analog_down",
                (2, True): "right_analog_left", (2, False): "right_analog_right",
                (3, True): "right_analog_up", (3, False): "right_analog_down"}
        out = {}

        def assign(cap, key_pos, key_neg):
            # cap=(axis, inv): pushing the positive physical dir (right/down) moves
            # `axis` with sign `inv`; the negative dir is the opposite sign.
            if not cap:
                return
            axis, inv = cap
            if (axis, inv) in slot:
                out[slot[(axis, inv)]] = key_pos
            if (axis, not inv) in slot:
                out[slot[(axis, not inv)]] = key_neg

        assign(parse(self.results.get("leftx")), "right", "left")
        assign(parse(self.results.get("lefty")), "down", "up")
        assign(parse(self.results.get("rightx")), "end", "home")
        assign(parse(self.results.get("righty")), "end", "home")
        order = ["left_analog_up", "left_analog_down", "left_analog_left",
                 "left_analog_right", "right_analog_up", "right_analog_down",
                 "right_analog_left", "right_analog_right"]
        return [f"{k} = {out[k]}" for k in order if k in out]

    def _apply(self):
        """Apply 'from the console': write the compensated analog block into the
        gptk file(s) gptokeyb actually reads (this app + the AAhead launcher), so
        the sticks read correctly on next launch. Backs up once, dedups the old
        analog lines. (gptokeyb uses RAW axes for analog — the SDL gamecontrollerdb
        only fixes buttons / SDL2 emulators, so we fix it here, not there.)"""
        block = self._gptk_analog()
        if not block:
            print("REMAP apply: no axis data captured", flush=True)
            return None
        marker = "# analog compensation — AAhead controller remap"
        here = os.getcwd()
        targets = [os.path.join(here, "aahead.gptk"),          # merged shell gptk
                   os.path.join(here, "r36s-manager.gptk"),    # standalone control
                   os.path.join(os.path.dirname(here), "aahead", "aahead.gptk")]
        written = []
        for tgt in targets:
            if not os.path.isfile(tgt):
                continue
            try:
                with open(tgt) as fh:
                    orig = fh.readlines()
                bak = tgt + ".aahead.bak"
                if not os.path.exists(bak):
                    with open(bak, "w") as fh:
                        fh.writelines(orig)
                kept = [ln for ln in orig
                        if not ln.strip().startswith(("left_analog_", "right_analog_"))
                        and marker not in ln]
                if kept and not kept[-1].endswith("\n"):
                    kept[-1] += "\n"
                with open(tgt, "w") as fh:
                    fh.writelines(kept)
                    fh.write("\n" + marker + "\n" + "\n".join(block) + "\n")
                written.append(tgt)
                print(f"REMAP apply: wrote analog block to {tgt}", flush=True)
            except Exception as exc:                   # noqa: BLE001
                print(f"REMAP apply failed for {tgt}: {exc!r}", flush=True)
        return written or None

    def update(self, dt_ms):
        if self.done or self.js is None:
            return
        now = pygame.time.get_ticks()
        if now > self.armed_at + self.TIMEOUT:
            print(f"REMAP {_REMAP_TARGETS[self.idx][0]} = (skipped)", flush=True)
            self._advance(now)

    def draw(self, surf):
        w.status_bar(surf, title="Fix controller", back=False)
        total_w = STAGE_W - SAFE * 2
        if self.js is None:
            surf.blit(fonts.render(t.Type.TITLE, "No gamepad detected",
                      t.Color.ACCENT_PEACH), (SAFE, 120))
            return

        hy = 56
        if not self.done:
            _key, prompt, elem, kind, stick = _REMAP_TARGETS[self.idx]
            title = fonts.render(t.Type.TITLE, prompt,
                                 t.Color.ACCENT_PEACH if kind == "axis" else t.Color.TEXT_PRIMARY)
        else:
            elem = stick = None
            title = fonts.render(t.Type.TITLE, "Mapping captured", t.Color.SUCCESS)
        surf.blit(title, (SAFE, hy))
        chip = (_live_chip(f"{self.idx + 1} / {len(_REMAP_TARGETS)}") if not self.done
                else _live_chip("saved", color=t.Color.SUCCESS))
        surf.blit(chip, (SAFE + title.get_width() + 12,
                         hy + (title.get_height() - chip.get_height()) // 2))
        meta = fonts.render(t.Type.META_SM,
                            f"{self.dev} · {self.naxes} axes · {self.nbtn} buttons",
                            t.Color.TEXT_MUTED)
        surf.blit(meta, (STAGE_W - SAFE - meta.get_width(),
                         hy + (title.get_height() - meta.get_height()) // 2))

        ring = t.with_alpha(t.Color.ACCENT_ROSE, 0.85)
        diagram = _pad_diagram(highlight=elem,
                               ls_ring=ring if stick == "L" else None,
                               rs_ring=ring if stick == "R" else None,
                               listen=(None if self.done else "press the lit control"))
        pad_top = hy + title.get_height() + 8
        dscale = min(total_w / diagram.get_width(), 246 / diagram.get_height())
        dsurf = pygame.transform.smoothscale(
            diagram, (int(diagram.get_width() * dscale), int(diagram.get_height() * dscale)))
        surf.blit(dsurf, ((STAGE_W - dsurf.get_width()) // 2, pad_top))

        y = pad_top + dsurf.get_height() + 8
        if not self.done:
            now = pygame.time.get_ticks()
            pygame.draw.rect(surf, t.with_alpha(t.Color.TEXT_PRIMARY, 0.08),
                             (SAFE, y, total_w, 4), border_radius=2)
            if now > self.armed_at:
                frac = max(0.0, 1.0 - (now - self.armed_at) / self.TIMEOUT)
                if frac > 0:
                    pygame.draw.rect(surf, t.Color.ACCENT_ROSE,
                                     (SAFE, y, max(1, int(total_w * frac)), 4), border_radius=2)
        else:
            surf.blit(fonts.render(t.Type.BODY_SM,
                      "All controls captured — this fixes every app, port & emulator.",
                      t.Color.ACCENT_CYAN), (SAFE, y))

        li = fonts.render(t.Type.META_SM, f"last input: {self.last}", t.Color.ACCENT_CYAN)
        surf.blit(li, (STAGE_W - SAFE - li.get_width(),
                       STAGE_H - t.Space.FOOTER_HINT_HEIGHT - 18))
        pygame.draw.line(surf, t.Color.BORDER_SOFT, (0, 448), (640, 448), 1)
        ftxt = ("press any button to APPLY & return" if self.done
                else "follow the lit control · do nothing to skip")
        fs = fonts.render(t.Type.CAPTION, ftxt, t.Color.TEXT_SECONDARY)
        surf.blit(fs, ((640 - fs.get_width()) // 2,
                       448 + (t.Space.FOOTER_HINT_HEIGHT - fs.get_height()) // 2))
