"""
Wi-Fi module — W1…W9.

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

Screens (class ⇄ handoff):
  W1Home       ⇄ W1-wifi.html            connected hero + 4-row action list
  W2Networks   ⇄ W2-wifi-networks.html   Connected / Saved / Nearby sections
  W3Password   ⇄ W3-wifi-password.html   D-pad QWERTY soft keyboard + strength
  W4Connecting ⇄ W4-wifi-connecting.html multi-phase progress card
  W5Failed     ⇄ W5-wifi-failed.html     error modal (consumer + X technical)
  W6Details    ⇄ W6-wifi-details.html    two-tier: simple + X reveals technical
  W7Off        ⇄ W7-wifi-off.html        Wi-Fi off empty state
  W8AddHidden  ⇄ W8-wifi-add-hidden.html hidden-network form
  W9NoAdapter  ⇄ W9-wifi-no-adapter.html no built-in Wi-Fi + dongle help

Language is intentionally consumer-first (signal = bars, never dBm). Any raw
technical value lives only behind an 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 that don't exist in the shared kit (soft keyboard,
signal bars in the rounded ascending style, toggle switch, section header,
network row, inline padlock) are defined here at module level.
"""

from __future__ import annotations

import pygame

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

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


# ─── module-level helpers ─────────────────────────────────────────────


def _wbars(level: int, *, total: int = 4, ok: bool = False,
           unit_w: int = 4, gap: int = 3, max_h: int = 22) -> pygame.Surface:
    """
    Wi-Fi signal bars in the handoff's rounded ascending style (distinct from
    widgets.rssi_bars which is a denser pair-list variant). `level` of `total`
    bars lit; `ok` colors lit bars cyan (active connection) else secondary.
    """
    w_total = unit_w * total + gap * (total - 1)
    s = pygame.Surface((w_total, max_h), pygame.SRCALPHA)
    lit = t.Color.ACCENT_CYAN if ok else t.Color.TEXT_SECONDARY
    for i in range(total):
        bh = max(3, int(max_h * (i + 1) / total))
        col = lit if i < level else t.Color.TEXT_DISABLED
        x = i * (unit_w + gap)
        pygame.draw.rect(s, col, (x, max_h - bh, unit_w, bh), border_radius=1)
    return s


def _lock_icon(size: int, color) -> pygame.Surface:
    """Tiny padlock glyph (no ph-lock in the sprite); drawn from primitives."""
    s = pygame.Surface((size, size), pygame.SRCALPHA)
    body_h = int(size * 0.55)
    body_y = size - body_h
    body_w = int(size * 0.78)
    body_x = (size - body_w) // 2
    # shackle (arc as an open ring)
    cx = size // 2
    r = int(size * 0.26)
    pygame.draw.circle(s, color, (cx, body_y), r, max(1, size // 12))
    # mask off the bottom half of the ring so it reads as a shackle
    pygame.draw.rect(s, (0, 0, 0, 0), (0, body_y, size, size - body_y))
    pygame.draw.circle(s, color, (cx, body_y), r, max(1, size // 12))
    pygame.draw.rect(s, (0, 0, 0, 0), (cx - r - 1, body_y + 1, (r + 1) * 2, size))
    # body
    pygame.draw.rect(s, color, (body_x, body_y, body_w, body_h),
                     border_radius=max(1, size // 8))
    return s


def _section_header(surf, y, label, count) -> int:
    """
    Uppercase eyebrow + "· N" count, with a hairline rule filling the row.
    Returns the y just below the header. Matches .net-section.
    """
    lab = fonts.render(t.Type.EYEBROW, label, t.Color.TEXT_MUTED, upper=True)
    surf.blit(lab, (SAFE, y))
    x = SAFE + lab.get_width() + 8
    cnt = fonts.render(t.Type.META_SM, f"· {count}", t.Color.TEXT_MUTED)
    surf.blit(cnt, (x, y + (lab.get_height() - cnt.get_height()) // 2))
    x += cnt.get_width() + 10
    line_y = y + lab.get_height() // 2
    if x < STAGE_W - SAFE:
        pygame.draw.line(surf, t.Color.BORDER_SOFT, (x, line_y),
                         (STAGE_W - SAFE, line_y), 1)
    return y + lab.get_height() + 6


def _toggle_switch(surf, x, y, on: bool, *, w_px: int = 40, h_px: int = 22):
    """Pill toggle. On = rose→cyan gradient track + knob right."""
    if on:
        track = render.rounded_gradient((w_px, h_px), t.Color.ACCENT_ROSE,
                                        t.Color.ACCENT_CYAN, 999, 135)
        surf.blit(track, (x, y))
        knob_r = (h_px - 4) // 2
        kx = x + w_px - 2 - knob_r * 2
        pygame.draw.circle(surf, t.Color.TEXT_PRIMARY,
                           (kx + knob_r, y + h_px // 2), knob_r)
    else:
        track = render.rounded_fill((w_px, h_px), t.Color.BORDER_SOFT, 999)
        surf.blit(track, (x, y))
        pygame.draw.rect(surf, t.Color.BORDER_BRIGHT, (x, y, w_px, h_px),
                         width=1, border_radius=999)
        knob_r = (h_px - 4) // 2
        pygame.draw.circle(surf, t.Color.TEXT_SECONDARY,
                           (x + 2 + knob_r, y + h_px // 2), knob_r)


def _net_row(surf, rect, *, ssid, tags, lead_level=None, connected=False,
             saved=False, band=None, focused=False, dim=False):
    """
    Slim 44px network row for W2: lead (bars or check) + SSID (ellipsised) +
    tag cluster (lock/open + band pill + SAVED) + chevron.
    `tags` is a list of (text, color) extra tags; band drawn as a tinted pill.
    """
    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_gradient((rect.w, rect.h), t.Color.SURFACE_BASE,
                                       t.Color.BG_MID, radius, 160)
        pygame.draw.rect(body, t.Color.BORDER_SOFT, (0, 0, rect.w, rect.h),
                         width=1, border_radius=radius)

    pad = 14
    cy = rect.h // 2
    # lead (24px slot)
    lead_x = pad
    if connected:
        ic = icons.icon("ph-check-circle", 16, t.Color.SUCCESS)
        body.blit(ic, (lead_x + (24 - 16) // 2, cy - 8))
    elif lead_level is not None:
        bars = _wbars(lead_level, ok=False, unit_w=3, gap=2, max_h=14)
        body.blit(bars, (lead_x + (24 - bars.get_width()) // 2,
                         cy - bars.get_height() // 2))

    # right cluster (measured right→left): chevron, tags
    rx = rect.w - pad
    chev = icons.icon("ph-caret-right", 12, t.Color.TEXT_MUTED)
    rx -= chev.get_width()
    body.blit(chev, (rx, cy - chev.get_height() // 2))
    rx -= 10

    if saved:
        sv = fonts.render(t.Type.META_SM, "SAVED", t.Color.SUCCESS)
        rx -= sv.get_width()
        body.blit(sv, (rx, cy - sv.get_height() // 2))
        rx -= 8
    for txt, col in reversed(tags):
        seg = fonts.render(t.Type.META_SM, txt, col)
        rx -= seg.get_width()
        body.blit(seg, (rx, cy - seg.get_height() // 2))
        rx -= 8
    if band is not None:
        is24 = band.startswith("2.4")
        bcol = t.Color.ACCENT_PEACH if is24 else t.Color.ACCENT_CYAN
        blab = fonts.render(t.Type.META_SM, band, bcol)
        pill_w = blab.get_width() + 12
        pill_h = 16
        rx -= pill_w
        pill = pygame.Surface((pill_w, pill_h), pygame.SRCALPHA)
        pygame.draw.rect(pill, t.with_alpha(bcol, 0.10), (0, 0, pill_w, pill_h),
                         border_radius=4)
        pygame.draw.rect(pill, t.with_alpha(bcol, 0.30), (0, 0, pill_w, pill_h),
                         width=1, border_radius=4)
        pill.blit(blab, ((pill_w - blab.get_width()) // 2,
                         (pill_h - blab.get_height()) // 2))
        body.blit(pill, (rx, cy - pill_h // 2))
        rx -= 8
    # lock / security marker (left of the tag cluster), drawn if not "open"
    # caller encodes "open" by passing it as a tag; default rows get a lock
    if not connected and not saved and not tags and band is None:
        pass

    # SSID fills the middle, clipped to the space before rx
    ssid_x = pad + 24 + 12
    ssid_s = fonts.render(t.Type.STATE, ssid, t.Color.TEXT_PRIMARY)
    maxw = max(10, rx - ssid_x - 8)
    if ssid_s.get_width() > maxw:
        ssid_s = ssid_s.subsurface((0, 0, maxw, ssid_s.get_height()))
    body.blit(ssid_s, (ssid_x, cy - ssid_s.get_height() // 2))

    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 _action_row(surf, rect, *, label, icon_name, meta=None, focused=False,
                dim=False, danger=False):
    """
    W1-style action row: icon + label + optional mono meta + chevron.
    52px tall. Danger tints the icon error-rose.
    """
    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_gradient((rect.w, rect.h), t.Color.SURFACE_BASE,
                                       t.Color.BG_MID, radius, 160)
        pygame.draw.rect(body, t.Color.BORDER_SOFT, (0, 0, rect.w, rect.h),
                         width=1, border_radius=radius)

    cy = rect.h // 2
    icol = (t.Color.ERROR if danger else
            (t.Color.ACCENT_ROSE if focused else t.Color.TEXT_SECONDARY))
    ic = icons.icon(icon_name, 22, icol)
    body.blit(ic, (16, cy - 11))

    lcol = t.Color.ERROR if danger else t.Color.TEXT_PRIMARY
    lab = fonts.render(t.Type.BODY_STRONG, label, lcol)
    body.blit(lab, (16 + 22 + 16, cy - lab.get_height() // 2))

    rx = rect.w - 16
    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 meta:
        mcol = t.Color.ACCENT_CYAN if focused else t.Color.TEXT_MUTED
        ms = fonts.render(t.Type.META_SM, meta, mcol)
        rx -= ms.get_width()
        body.blit(ms, (rx, cy - ms.get_height() // 2))

    if dim and not focused:
        body.set_alpha(int(255 * 0.6))

    if focused:
        w.draw_focused_card(surf, rect, radius, scale=1.03, base_surface=body)
    else:
        surf.blit(body, rect.topleft)


# ─── soft keyboard (W3 / used by editable fields) ─────────────────────
#
# Grid layout mirrors W3-wifi-password.html exactly:
#   r1: q w e r t y u i o p              (10)
#   r2: a s d f g h j k l  ⌫             (10)
#   r3: ⇧ z x c v b n m  !?,             (9)
#   r4: ?123 , [space] . ↵done           (5)
# Each key carries (value, kind). kind ∈ letter/fn/space/done/shift/back.
# Navigation is 2-D and column-proportional so wide keys are reachable.

_KB_ROWS = [
    [("q", "letter"), ("w", "letter"), ("e", "letter"), ("r", "letter"),
     ("t", "letter"), ("y", "letter"), ("u", "letter"), ("i", "letter"),
     ("o", "letter"), ("p", "letter")],
    [("a", "letter"), ("s", "letter"), ("d", "letter"), ("f", "letter"),
     ("g", "letter"), ("h", "letter"), ("j", "letter"), ("k", "letter"),
     ("l", "letter"), ("⌫", "back")],
    [("⇧", "shift"), ("z", "letter"), ("x", "letter"), ("c", "letter"),
     ("v", "letter"), ("b", "letter"), ("n", "letter"), ("m", "letter"),
     ("!?,", "sym")],
    [("?123", "sym"), (",", "letter"), ("space", "space"), (".", "letter"),
     ("↵ done", "done")],
]

# Proportional column weights per row (match the grid-template-columns).
_KB_WEIGHTS = [
    [1] * 10,
    [1] * 10,
    [1.4, 1, 1, 1, 1, 1, 1, 1, 1.4],
    [1.6, 1, 5, 1, 1.6],
]

# Symbols / numbers layer (Y… no — the "?123" key toggles here). Same grid
# shape as the letter layout so _kb_geometry/_kb_height are unchanged; the
# "ABC" key (kind "sym") toggles back to letters. Wi-Fi passwords routinely
# contain digits and punctuation, so this layer is load-bearing, not cosmetic.
_KB_SYM_ROWS = [
    [("1", "letter"), ("2", "letter"), ("3", "letter"), ("4", "letter"),
     ("5", "letter"), ("6", "letter"), ("7", "letter"), ("8", "letter"),
     ("9", "letter"), ("0", "letter")],
    [("@", "letter"), ("#", "letter"), ("$", "letter"), ("&", "letter"),
     ("-", "letter"), ("+", "letter"), ("(", "letter"), (")", "letter"),
     ("/", "letter"), ("⌫", "back")],
    [("*", "letter"), ('"', "letter"), ("'", "letter"), (":", "letter"),
     (";", "letter"), ("!", "letter"), ("?", "letter"), ("%", "letter"),
     ("=", "letter")],
    [("ABC", "sym"), ("_", "letter"), ("space", "space"), (".", "letter"),
     ("↵ done", "done")],
]
_KB_SYM_WEIGHTS = _KB_WEIGHTS

_KB_PAD = 8
_KB_GAP = 5
_KB_KEY_H = 44          # bigger, legible keys (was 34) — matches the game-part
                        # Search keyboard's feel; fits above the footer (kb_y≈232)
# Letter keys use the same big Space Grotesk face as the Search keyboard.
_KB_KEY_STYLE = t.TextStyle(t.SPACE_GROTESK_SEMIBOLD, 18, "SemiBold", -0.2)


def _kb_geometry(kb_x, kb_y, kb_w, layout=_KB_ROWS, weights_set=_KB_WEIGHTS):
    """
    Resolve every key's pixel rect from the proportional weights.
    Returns a list of rows; each row is a list of (rect, value, kind).
    """
    inner_x = kb_x + _KB_PAD
    inner_w = kb_w - _KB_PAD * 2
    rows = []
    y = kb_y + _KB_PAD
    for r, row in enumerate(layout):
        weights = weights_set[r]
        n = len(row)
        gaps_total = _KB_GAP * (n - 1)
        unit = (inner_w - gaps_total) / sum(weights)
        x = inner_x
        out = []
        for c, (val, kind) in enumerate(row):
            kw = int(unit * weights[c])
            out.append((pygame.Rect(int(x), y, kw, _KB_KEY_H), val, kind))
            x += kw + _KB_GAP
        rows.append(out)
        y += _KB_KEY_H + _KB_GAP
    return rows


def _kb_height():
    return _KB_PAD * 2 + len(_KB_ROWS) * _KB_KEY_H + (len(_KB_ROWS) - 1) * _KB_GAP


def _draw_key(surf, rect, val, kind, *, focused=False, shift_on=False):
    radius = t.Radius.CHIP
    is_fn = kind in ("fn", "sym", "back", "shift", "done", "space")
    if focused:
        body = render.rounded_gradient((rect.w, rect.h), t.Color.SURFACE_ELEVATED,
                                       t.Color.SURFACE_BASE, radius, 160)
    elif kind == "done":
        body = pygame.Surface((rect.w, rect.h), pygame.SRCALPHA)
        pygame.draw.rect(body, t.with_alpha(t.Color.ACCENT_CYAN, 0.10),
                         (0, 0, rect.w, rect.h), border_radius=radius)
        pygame.draw.rect(body, t.with_alpha(t.Color.ACCENT_CYAN, 0.40),
                         (0, 0, rect.w, rect.h), width=1, border_radius=radius)
    elif kind == "shift" and shift_on:
        body = render.rounded_gradient((rect.w, rect.h),
                                       t.with_alpha(t.Color.ACCENT_CYAN, 0.25)[:3],
                                       t.Color.SURFACE_BASE, radius, 160)
        pygame.draw.rect(body, t.with_alpha(t.Color.ACCENT_CYAN, 0.40),
                         (0, 0, rect.w, rect.h), width=1, border_radius=radius)
    elif is_fn:
        body = render.rounded_fill((rect.w, rect.h), t.Color.BG_MID, radius)
        pygame.draw.rect(body, t.Color.BORDER_SOFT, (0, 0, rect.w, rect.h),
                         width=1, border_radius=radius)
    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)

    # Glyph keys (backspace / shift / done) use Phosphor icons rather than
    # Unicode chars, because the bundled Inter/JetBrains TTFs don't carry ⌫⇧↵
    # (they'd render as tofu boxes). Everything else is plain ASCII text.
    cx, cy = rect.w // 2, rect.h // 2
    if kind == "back":
        ic = icons.icon("ph-caret-left", 16, t.Color.TEXT_SECONDARY)
        body.blit(ic, (cx - ic.get_width() // 2, cy - ic.get_height() // 2))
    elif kind == "shift" and not shift_on:
        ic = icons.icon("ph-caret-up", 14, t.Color.TEXT_SECONDARY)
        body.blit(ic, (cx - ic.get_width() // 2, cy - ic.get_height() // 2))
    elif kind == "shift" and shift_on:
        ls = fonts.render(t.Type.META_SM, "ABC", t.Color.ACCENT_CYAN)
        body.blit(ls, (cx - ls.get_width() // 2, cy - ls.get_height() // 2))
    elif kind == "done":
        ic = icons.icon("ph-check", 14, t.Color.ACCENT_CYAN)
        ls = fonts.render(t.Type.LABEL_SM, "done", t.Color.ACCENT_CYAN)
        cw = ic.get_width() + 5 + ls.get_width()
        ix = cx - cw // 2
        body.blit(ic, (ix, cy - ic.get_height() // 2))
        body.blit(ls, (ix + ic.get_width() + 5, cy - ls.get_height() // 2))
    else:
        label = val
        if kind == "letter" and shift_on and len(val) == 1 and val.isalpha():
            label = val.upper()
        if kind == "space":
            col = t.Color.TEXT_SECONDARY
            style = t.Type.BODY_SM
        elif is_fn:
            col = t.Color.TEXT_SECONDARY
            style = t.Type.META_SM if kind == "sym" else t.Type.BODY_SM
        else:
            col = t.Color.TEXT_PRIMARY
            style = _KB_KEY_STYLE
        ls = fonts.render(style, label, col)
        body.blit(ls, (cx - ls.get_width() // 2, cy - ls.get_height() // 2))

    if focused:
        w.draw_focused_card(surf, rect, radius, scale=1.0, base_surface=body)
    else:
        surf.blit(body, rect.topleft)


# ═══════════════════════════════════════════════════════════════════════
# W1 — Wi-Fi home (connected)
# ═══════════════════════════════════════════════════════════════════════


class W1Home:
    """Connected-network hero + 4-row action list (mirrors S2 grammar)."""
    name = "wifi"

    def __init__(self, app):
        self.app = app
        self.ssid = "Home 5G"
        self.security = "WPA2"
        self.ip = "192.168.1.42"
        self.signal_level = 4
        self.connected = True
        self.band = "5 GHz"
        # On a live box, show the real connected network instead of the demo.
        st = backend.wifi_status()
        if st is not None:
            self.connected = bool(st.get("connected"))
            self.ssid = st.get("ssid") or "—"
            self.ip = st.get("ip") or ""
            self.signal_level = st.get("lead", 0)
            self.band = st.get("band") or ""
        # rows: (key, label, icon, meta, dim, danger)
        self.rows = [
            ("networks", "Available networks", "ph-list-magnifying-glass",
             None, False, False),
            ("details", "Network details", "ph-info", self.band or None,
             False, False),
            ("forget", "Forget this network", "ph-trash", None, True, False),
            ("off", "Turn Wi-Fi off", "ph-wifi-slash", None, True, False),
        ]
        self.focus = 1  # "Network details" focused per the handoff

    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][0])
        elif action == "b":
            self.app.pop()

    def _open(self, key):
        if key == "networks":
            self.app.push(W2Networks(self.app))
        elif key == "details":
            self.app.push(W6Details(self.app, ssid=self.ssid))
        elif key == "forget":
            if backend.live_enabled():
                backend.call_async("wifi.forget", backend.wifi_forget, self.ssid)
            self.app.toast(f"Forgot {self.ssid}")
            self.app.pop()
        elif key == "off":
            if backend.live_enabled():
                backend.call_async("wifi.radio", backend.wifi_set_radio, False)
            self.app.push(W7Off(self.app))

    def update(self, dt_ms):
        pass

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

        # hero: gradient glyph + SSID + live sub + signal bars on the right
        hero_y = 60
        gbox = 56
        box = render.rounded_gradient((gbox, gbox), t.Color.ACCENT_ROSE,
                                      t.Color.ACCENT_CYAN, 14, 135)
        glyph = icons.icon("ph-wifi-high", 30, t.Color.BG_MID)
        box.blit(glyph, ((gbox - 30) // 2, (gbox - 30) // 2))
        surf.blit(box, (SAFE, hero_y))

        tx = SAFE + gbox + 16
        title = fonts.render(t.Type.HERO, self.ssid, t.Color.TEXT_PRIMARY)
        surf.blit(title, (tx, hero_y + 2))

        # subline: live mint dot + Connected · ip (cyan mono). No WPA2/channel —
        # security type isn't something an ordinary user needs on the home view.
        sy = hero_y + 2 + title.get_height() + 8
        sx = tx
        dot_r = 3
        state_col = t.Color.SUCCESS if self.connected else t.Color.TEXT_MUTED
        pygame.draw.circle(surf, state_col, (sx + dot_r, sy + 9), dot_r)
        sx += dot_r * 2 + 6
        live = fonts.render(t.Type.BODY,
                            "Connected" if self.connected else "Not connected",
                            state_col)
        surf.blit(live, (sx, sy)); sx += live.get_width()
        if self.connected and self.ip:
            sep1 = fonts.render(t.Type.BODY, " · ", t.Color.TEXT_MUTED)
            surf.blit(sep1, (sx, sy)); sx += sep1.get_width()
            ipv = fonts.render(t.Type.CAPTION, self.ip, t.Color.ACCENT_CYAN)
            surf.blit(ipv, (sx, sy + 2))

        # right: big signal bars + meta
        bars = _wbars(self.signal_level, ok=True, unit_w=4, gap=3, max_h=22)
        bx = STAGE_W - SAFE - bars.get_width()
        surf.blit(bars, (bx, hero_y + 6))
        rssi = fonts.render(t.Type.META_SM, "strong", t.Color.TEXT_MUTED)
        surf.blit(rssi, (STAGE_W - SAFE - rssi.get_width(), hero_y + 6 + 22 + 6))

        # divider under hero
        dy = hero_y + 100
        pygame.draw.line(surf, t.Color.BORDER_SOFT, (SAFE, dy),
                         (STAGE_W - SAFE, dy), 1)

        # action list
        row_h = 52
        gap = 8
        ry = dy + 12
        for i, (key, label, icon_name, meta, dim, danger) in enumerate(self.rows):
            rect = pygame.Rect(SAFE, ry, STAGE_W - SAFE * 2, row_h)
            _action_row(surf, rect, label=label, icon_name=icon_name, meta=meta,
                        focused=(i == self.focus), dim=dim, danger=danger)
            ry += row_h + gap

        w.footer_hints(surf, [("A", "select"), ("B", "back")])


# ═══════════════════════════════════════════════════════════════════════
# W2 — Available networks (Connected / Saved / Nearby)
# ═══════════════════════════════════════════════════════════════════════


def _scan_buckets(rows):
    """netops.scan() rows → (connected, saved, nearby) in the W2 row shape.
    Tags are derived from security: open networks flag 'open' (warning),
    enterprise (802.1X) flag 'Enterprise'."""
    connected, saved, nearby = [], [], []
    for r in rows:
        if r.get("open") and not r.get("saved") and not r.get("connected"):
            tags = [("open", t.Color.WARNING)]
        elif "802.1X" in (r.get("security") or ""):
            tags = [("Enterprise", t.Color.ACCENT_PEACH)]
        else:
            tags = []
        row = dict(ssid=r["ssid"], lead=r.get("lead", 0),
                   connected=bool(r.get("connected")), saved=bool(r.get("saved")),
                   band=r.get("band") or "", tags=tags)
        if row["connected"]:
            connected.append(row)
        elif row["saved"]:
            saved.append(row)
        else:
            nearby.append(row)
    return connected, saved, nearby


class W2Networks:
    """Three sections of networks; A connect, X add hidden, Y refresh.

    Lists come from a live nmcli scan when the backend is enabled; otherwise
    the built-in demo data stands in (dev box, no NetworkManager)."""
    name = "wifi-networks"

    def __init__(self, app):
        self.app = app
        self.elapsed = 0.0
        # On a LIVE device, start EMPTY and show "scanning…" until the real
        # nmcli scan returns — otherwise the demo rows flash for a frame before
        # the scan lands. Demo data is for the dev box only.
        if backend.live_enabled():
            self.connected, self.saved, self.nearby = [], [], []
        else:
            self.connected = [
                dict(ssid="Home 5G", lead=4, connected=True, saved=False,
                     band="5 GHz", tags=[]),
            ]
            self.saved = [
                dict(ssid="Home 2.4G", lead=3, connected=False, saved=True,
                     band="2.4 GHz", tags=[]),
                dict(ssid="PortMaster_AP", lead=2, connected=False, saved=True,
                     band="5 GHz", tags=[]),
            ]
            self.nearby = [
                dict(ssid="Starbucks-Guest", lead=4, connected=False, saved=False,
                     band="2.4 GHz", tags=[("open", t.Color.WARNING)]),
                dict(ssid="eduroam", lead=3, connected=False, saved=False,
                     band="5 GHz", tags=[("Enterprise", t.Color.ACCENT_PEACH)]),
                dict(ssid="TP-Link_2837", lead=2, connected=False, saved=False,
                     band="2.4 GHz", tags=[]),
                dict(ssid="DIRECT-roku-882", lead=1, connected=False, saved=False,
                     band="2.4 GHz", tags=[]),
            ]
        # build a flat list for focus traversal
        self.items = self.connected + self.saved + self.nearby
        self.focus = min(len(self.connected), max(0, len(self.items) - 1))
        self.scroll = 0                    # px scroll offset of the list
        self._scan_job = None              # in-flight async nmcli scan
        self._begin_scan(rescan=True)      # live box → fill from a real scan

    def _begin_scan(self, *, rescan):
        """Kick an off-thread nmcli scan when live; no-op on the dev box."""
        if self._scan_job is not None or not backend.live_enabled():
            return
        self._scan_job = backend.call_async("wifi.scan", backend.wifi_scan,
                                            rescan=rescan)

    def _apply_scan(self, rows):
        """Replace the buckets with a real scan result (rows may be empty →
        genuinely no networks; None means 'not live' and is ignored upstream)."""
        self.connected, self.saved, self.nearby = _scan_buckets(rows)
        self.items = self.connected + self.saved + self.nearby
        if self.focus >= len(self.items):
            self.focus = max(0, len(self.items) - 1)

    def on_action(self, action):
        if action == "up":
            self.focus = max(0, self.focus - 1)
        elif action == "down":
            self.focus = min(len(self.items) - 1, self.focus + 1)
        elif action == "a":
            if not self.items:
                return
            item = self.items[self.focus]
            if item["connected"]:
                self.app.push(W6Details(self.app, ssid=item["ssid"]))
            elif item["saved"]:
                # saved profile → reconnect with no password needed
                self.app.push(W4Connecting(self.app, ssid=item["ssid"]))
            else:
                # nearby protected network → password; open → connect directly
                if any(txt == "open" for txt, _ in item["tags"]):
                    self.app.push(W4Connecting(self.app, ssid=item["ssid"]))
                else:
                    self.app.push(W3Password(self.app, ssid=item["ssid"]))
        elif action == "x":
            self.app.push(W8AddHidden(self.app))
        elif action == "y":
            self.elapsed = 0.0
            self._begin_scan(rescan=True)
            self.app.toast("Refreshing networks…")
        elif action == "b":
            self.app.pop()

    def update(self, dt_ms):
        self.elapsed += dt_ms
        if self._scan_job is not None and self._scan_job.done:
            rows = self._scan_job.result
            self._scan_job = None
            if rows is not None:               # None = not live → keep demo
                self._apply_scan(rows)

    def draw(self, surf):
        scanning = self._scan_job is not None
        # center chip: spinner + "scanning…" while a scan is in flight, else
        # the real count of networks we can see.
        center = self._scan_chip(len(self.items), scanning)
        w.status_bar(surf, title="Available networks", back=True, center=center)

        CONTENT_TOP, CONTENT_BOTTOM = 58, 440   # keep clear of the footer (448)
        view_h = CONTENT_BOTTOM - CONTENT_TOP
        row_h = 44
        hdr_h = fonts.render(t.Type.EYEBROW, "X", t.Color.TEXT_MUTED).get_height() + 6

        # 1) lay the headers + rows out in content space (y from 0)
        groups = [("Connected", self.connected, len(self.connected)),
                  ("Saved", self.saved, len(self.saved)),
                  ("Nearby", self.nearby, len(self.nearby))]
        layout, cy, idx = [], 0, 0
        for label, group, count in groups:
            layout.append(("header", (label, count), cy, hdr_h))
            cy += hdr_h
            for item in group:
                layout.append(("row", (idx, item), cy, row_h))
                cy += row_h + 6
                idx += 1
            cy += 6
        content_h = cy
        max_scroll = max(0, content_h - view_h)

        # 2) scroll so the focused row stays inside the viewport
        for kind, payload, yy, hh in layout:
            if kind == "row" and payload[0] == self.focus:
                if yy < self.scroll:
                    self.scroll = yy
                elif yy + hh > self.scroll + view_h:
                    self.scroll = yy + hh - view_h
                break
        self.scroll = max(0, min(self.scroll, max_scroll))

        # 3) draw clipped to the viewport so rows never bleed into the footer
        prev_clip = surf.get_clip()
        surf.set_clip(pygame.Rect(0, CONTENT_TOP, STAGE_W, view_h))
        for kind, payload, yy, hh in layout:
            sy = CONTENT_TOP + yy - self.scroll
            if sy + hh < CONTENT_TOP or sy > CONTENT_BOTTOM:
                continue
            if kind == "header":
                label, count = payload
                _section_header(surf, sy, label, count)
            else:
                i, item = payload
                rect = pygame.Rect(SAFE, sy, STAGE_W - SAFE * 2 - 6, row_h)
                _net_row(surf, rect, ssid=item["ssid"], tags=item["tags"],
                         lead_level=item["lead"], connected=item["connected"],
                         saved=item["saved"], band=item["band"],
                         focused=(i == self.focus),
                         dim=(not item["connected"] and i != self.focus))
        surf.set_clip(prev_clip)

        # 4) scrollbar reflecting the real scroll position
        sb_x = STAGE_W - SAFE + 1
        pygame.draw.line(surf, t.with_alpha(t.Color.BORDER_SOFT, 0.4),
                         (sb_x, CONTENT_TOP), (sb_x, CONTENT_BOTTOM), 3)
        if max_scroll > 0:
            thumb_h = max(30, int(view_h * view_h / content_h))
            ty = CONTENT_TOP + int((view_h - thumb_h) * self.scroll / max_scroll)
            pygame.draw.line(surf, t.Color.TEXT_SECONDARY,
                             (sb_x, ty), (sb_x, ty + thumb_h), 3)

        # empty list: either mid-scan ("searching…") or a finished empty scan
        if not self.items:
            if scanning:
                head, tail = "Searching for networks…", ""
            else:
                head, tail = "No networks found", "Press Y to scan again"
            msg = fonts.render(t.Type.BODY, head, t.Color.TEXT_SECONDARY)
            surf.blit(msg, ((STAGE_W - msg.get_width()) // 2, CONTENT_TOP + 60))
            if tail:
                sub = fonts.render(t.Type.BODY_SM, tail, t.Color.TEXT_MUTED)
                surf.blit(sub, ((STAGE_W - sub.get_width()) // 2, CONTENT_TOP + 84))

        w.footer_hints(surf, [("A", "connect"), ("X", "add hidden"),
                              ("Y", "refresh"), ("B", "back")])

    def _scan_chip(self, found, scanning):
        ic = icons.icon("ph-spinner-gap", 14, t.Color.ACCENT_CYAN)
        text = "scanning…" if scanning else f"{found} found"
        lbl = fonts.render(t.Type.CAPTION, text, t.Color.ACCENT_CYAN)
        gap = 6
        s = pygame.Surface((ic.get_width() + gap + lbl.get_width(),
                            max(ic.get_height(), lbl.get_height())), pygame.SRCALPHA)
        s.blit(ic, (0, (s.get_height() - ic.get_height()) // 2))
        s.blit(lbl, (ic.get_width() + gap, (s.get_height() - lbl.get_height()) // 2))
        return s


# ═══════════════════════════════════════════════════════════════════════
# W3 — Password entry (D-pad QWERTY soft keyboard)
# ═══════════════════════════════════════════════════════════════════════


class W3Password:
    """
    On-screen QWERTY. D-pad moves key-to-key, A types the focused key, X
    deletes, Y shows/hides. The password field + strength bar reflect state.
    """
    name = "wifi-password"

    def __init__(self, app, ssid="Home 5G"):
        self.app = app
        self.ssid = ssid
        self.password = ""             # MUST start empty — what the user types is
                                       # exactly what nmcli receives (a pre-seed
                                       # would be prepended → "wrong password")
        self.show = False
        self.shift = False
        self.symbols = False        # ?123 toggles the numbers/symbols layer
        # focus on the keyboard grid: (row, col). Start on 'd' like the handoff.
        self.kb_focus = (1, 2)
        self.min_len = 8

    # ── geometry resolved each frame from the field stack height ──────
    def _kb_origin(self):
        kb_w = STAGE_W - SAFE * 2
        kb_h = _kb_height()
        kb_y = 448 - 8 - kb_h
        return SAFE, kb_y, kb_w

    def _rows(self):
        kb_x, kb_y, kb_w = self._kb_origin()
        if self.symbols:
            return _kb_geometry(kb_x, kb_y, kb_w, _KB_SYM_ROWS, _KB_SYM_WEIGHTS)
        return _kb_geometry(kb_x, kb_y, kb_w)

    def on_action(self, action):
        rows = self._rows()
        r, c = self.kb_focus
        r = max(0, min(r, len(rows) - 1))
        c = max(0, min(c, len(rows[r]) - 1))
        if action == "up":
            if r > 0:
                r -= 1
                c = self._closest_col(rows, r, rows[self.kb_focus[0]][c][0].centerx)
        elif action == "down":
            if r < len(rows) - 1:
                r += 1
                c = self._closest_col(rows, r, rows[self.kb_focus[0]][c][0].centerx)
        elif action == "left":
            c = max(0, c - 1)
        elif action == "right":
            c = min(len(rows[r]) - 1, c + 1)
        elif action == "a":
            self._press_key(rows[r][c])
        elif action == "x":
            self.password = self.password[:-1]
        elif action == "y":
            self.show = not self.show
        elif action == "b":
            self.app.pop()
            return
        self.kb_focus = (r, c)

    def _closest_col(self, rows, r, target_x):
        best, bestd = 0, 1e9
        for i, (rect, _, _) in enumerate(rows[r]):
            d = abs(rect.centerx - target_x)
            if d < bestd:
                best, bestd = i, d
        return best

    def _press_key(self, key):
        rect, val, kind = key
        if kind == "letter":
            ch = val.upper() if (self.shift and val.isalpha()) else val
            self.password += ch
            if self.shift:
                self.shift = False  # one-shot shift like a phone keyboard
        elif kind == "space":
            self.password += " "
        elif kind == "back":
            self.password = self.password[:-1]
        elif kind == "shift":
            self.shift = not self.shift
        elif kind == "sym":
            self.symbols = not self.symbols
        elif kind == "done":
            self.app.push(W4Connecting(self.app, ssid=self.ssid,
                                       password=self.password))

    def update(self, dt_ms):
        pass

    # ── strength model (consumer words, never entropy bits) ───────────
    def _strength(self):
        n = len(self.password)
        if n == 0:
            return 0, "Too short", t.Color.TEXT_MUTED
        if n < self.min_len:
            return 1, "Weak", t.Color.WARNING
        if n < 12:
            return 3, "Good", t.Color.ACCENT_CYAN
        return 4, "Strong", t.Color.SUCCESS

    def draw(self, surf):
        w.status_bar(surf, title=f"Connect to {self.ssid}", back=True)

        y = 56
        # network strip
        strip_h = 52
        strip = render.rounded_gradient((STAGE_W - SAFE * 2, strip_h),
                                        t.Color.SURFACE_BASE, t.Color.BG_MID, 10, 160)
        pygame.draw.rect(strip, t.Color.BORDER_SOFT, (0, 0, STAGE_W - SAFE * 2, strip_h),
                         width=1, border_radius=10)
        ibox = 32
        chip = 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], 8, 135)
        chip.blit(icons.icon("ph-wifi-high", 18, t.Color.ACCENT_ROSE),
                  ((ibox - 18) // 2, (ibox - 18) // 2))
        strip.blit(chip, (12, (strip_h - ibox) // 2))
        mx = 12 + ibox + 12
        nm = fonts.render(t.Type.LABEL_SM, self.ssid, t.Color.TEXT_PRIMARY)
        strip.blit(nm, (mx, 10))
        info_y = 10 + nm.get_height() + 3
        pygame.draw.circle(strip, t.Color.SUCCESS, (mx + 3, info_y + 6), 3)
        info = fonts.render(t.Type.META_SM, "Protected with a password",
                            t.Color.TEXT_MUTED)
        strip.blit(info, (mx + 12, info_y))
        bars = _wbars(4, ok=True, unit_w=3, gap=2, max_h=14)
        strip.blit(bars, (STAGE_W - SAFE * 2 - 12 - bars.get_width(),
                          (strip_h - bars.get_height()) // 2))
        surf.blit(strip, (SAFE, y))
        y += strip_h + 8

        # password field (rose border + soft glow)
        field_h = 38
        field = pygame.Surface((STAGE_W - SAFE * 2, field_h), pygame.SRCALPHA)
        pygame.draw.rect(field, t.with_alpha(t.Color.BG_DEEP, 0.55),
                         (0, 0, STAGE_W - SAFE * 2, field_h), border_radius=10)
        pygame.draw.rect(field, t.Color.ACCENT_ROSE,
                         (0, 0, STAGE_W - SAFE * 2, field_h), width=2, border_radius=10)
        lbl = fonts.render(t.Type.META_SM, "PASSWORD", t.Color.TEXT_MUTED)
        field.blit(lbl, (12, (field_h - lbl.get_height()) // 2))
        shown = self.password if self.show else "•" * len(self.password)
        val_style = t.TextStyle(t.JETBRAINS_MONO, 16, "Regular", 2.0)
        val = fonts.render(val_style, shown, t.Color.TEXT_PRIMARY)
        vx = 12 + lbl.get_width() + 12
        maxw = (STAGE_W - SAFE * 2) - vx - 44
        if val.get_width() > maxw:
            val = val.subsurface((val.get_width() - maxw, 0, maxw, val.get_height()))
        field.blit(val, (vx, (field_h - val.get_height()) // 2))
        # blinking cursor
        cur_x = vx + val.get_width() + 2
        pygame.draw.rect(field, t.Color.ACCENT_CYAN,
                         (min(cur_x, STAGE_W - SAFE * 2 - 44), (field_h - 16) // 2, 2, 16))
        # show/hide toggle pill
        tg_d = 26
        tgx = STAGE_W - SAFE * 2 - 12 - tg_d
        tg = pygame.Surface((tg_d, tg_d), pygame.SRCALPHA)
        tcol = t.Color.ACCENT_CYAN if self.show else t.Color.TEXT_SECONDARY
        pygame.draw.circle(tg, t.with_alpha(t.Color.SURFACE_GLASS, 0.6),
                           (tg_d // 2, tg_d // 2), tg_d // 2)
        pygame.draw.circle(tg, t.with_alpha(tcol, 0.4),
                           (tg_d // 2, tg_d // 2), tg_d // 2, 1)
        tglab = fonts.render(t.Type.META_SM, "abc" if self.show else "•••", tcol)
        tg.blit(tglab, ((tg_d - tglab.get_width()) // 2,
                        (tg_d - tglab.get_height()) // 2))
        field.blit(tg, (tgx, (field_h - tg_d) // 2))
        surf.blit(field, (SAFE, y))
        y += field_h + 5

        # strength bar
        filled, word, wcol = self._strength()
        bx = SAFE
        for i in range(4):
            col = (wcol if i < filled else t.Color.BORDER_SOFT)
            pygame.draw.rect(surf, col, (bx + i * 31, y + 6, 28, 3), border_radius=999)
        sx = bx + 4 * 31 + 4
        wlab = fonts.render(t.Type.META_SM, word, wcol)
        surf.blit(wlab, (sx, y + 1))
        sx += wlab.get_width() + 8
        cnt = fonts.render(t.Type.META_SM,
                           f"· {len(self.password)} / {self.min_len} min",
                           t.Color.TEXT_MUTED)
        surf.blit(cnt, (sx, y + 1))
        hint = fonts.render(t.Type.META_SM, "A insert · X delete · Y show",
                            t.Color.TEXT_MUTED)
        surf.blit(hint, (STAGE_W - SAFE - hint.get_width(), y + 1))
        y += 16

        # keyboard panel
        kb_x, kb_y, kb_w = self._kb_origin()
        panel = pygame.Surface((kb_w, _kb_height()), pygame.SRCALPHA)
        pygame.draw.rect(panel, t.with_alpha(t.Color.BG_DEEP, 0.4),
                         (0, 0, kb_w, _kb_height()), border_radius=10)
        pygame.draw.rect(panel, t.Color.BORDER_SOFT, (0, 0, kb_w, _kb_height()),
                         width=1, border_radius=10)
        surf.blit(panel, (kb_x, kb_y))
        rows = self._rows()
        for r, row in enumerate(rows):
            for c, (rect, val, kind) in enumerate(row):
                _draw_key(surf, rect, val, kind,
                          focused=(self.kb_focus == (r, c)), shift_on=self.shift)

        w.footer_hints(surf, [("A", "insert"), ("X", "delete"),
                              ("Y", "show"), ("B", "cancel")])


# ═══════════════════════════════════════════════════════════════════════
# W4 — Connecting (multi-phase)
# ═══════════════════════════════════════════════════════════════════════


class W4Connecting:
    """
    Centered progress card over a ghosted networks backdrop, 4 phases in
    plain English. Advances automatically; B cancels. On the last phase it
    can resolve to W6 (success path) — here it just self-advances visually.
    """
    name = "wifi-connecting"

    PHASES = [
        "Talking to your router",
        "Checking password",
        "Getting your network address",
        "Testing the internet",
    ]

    def __init__(self, app, ssid="Home 5G", *, password=None, fail=False):
        self.app = app
        self.ssid = ssid
        self.password = password
        self.fail = fail            # demo-only: stalls on phase 2 then opens W5
        self.elapsed = 0.0
        self.phase = 2             # start mid-flow like the handoff snapshot
        self.phase_t = 0.0
        self.attempt = 1
        self.timings = ["0.6 s", "1.1 s", "0.8 s", "queued"]
        self._resolved = False
        # Live attempt: do the real nmcli connect off the render thread. The
        # animated phases below are the waiting affordance; the REAL result
        # (not the timer) decides success vs failure. On a dev box (not live)
        # _job stays None and the timed simulation drives the flow as before.
        self._job = None
        if backend.live_enabled():
            self._job = backend.call_async("wifi.connect", backend.wifi_connect,
                                           ssid, password)

    def on_action(self, action):
        if action == "b":
            self.app.pop()

    def _succeed(self):
        self._resolved = True
        self.app.pop()
        self.app.push(W6Details(self.app, ssid=self.ssid))

    def _fail(self, error=None):
        self._resolved = True
        self.app.push(W5Failed(self.app, ssid=self.ssid, error=error))

    def update(self, dt_ms):
        if self._resolved:
            return
        self.elapsed += dt_ms
        self.phase_t += dt_ms

        if self._job is not None:
            # Live: animate phases up to the last one, then hold the spinner
            # until the real connect resolves. Never resolve on the timer.
            if self.phase_t >= 1100:
                self.phase_t = 0.0
                self.phase = min(self.phase + 1, len(self.PHASES) - 1)
            if self._job.done:
                res = self._job.result
                if res is None:
                    self._job = None      # not live after all → fall to sim
                elif res.get("ok"):
                    self._succeed()
                else:
                    self._fail(res.get("error"))
            return

        # Demo/dev simulation: advance ~every 1.1s and resolve on the timer.
        if self.phase_t >= 1100:
            self.phase_t = 0.0
            if self.fail and self.phase >= 1:
                self._fail()
                return
            if self.phase < len(self.PHASES) - 1:
                self.phase += 1
            else:
                self._succeed()

    def draw(self, surf):
        w.status_bar(surf, title="Connecting…", back=False)

        # ghost networks backdrop (faint rows)
        gy = 60
        for i in range(8):
            h = 44 if i in (3, 5) else 36
            ghost = pygame.Surface((STAGE_W - SAFE * 2, h), pygame.SRCALPHA)
            pygame.draw.rect(ghost, t.with_alpha(t.Color.SURFACE_BASE, 0.5),
                             (0, 0, STAGE_W - SAFE * 2, h), border_radius=8)
            ghost.set_alpha(46)
            surf.blit(ghost, (SAFE, gy))
            gy += h + 8
        w.scrim(surf, alpha=0.45)

        # card
        cw, ch = 440, 250
        rect = pygame.Rect((STAGE_W - cw) // 2, (STAGE_H - ch) // 2, cw, ch)
        w.modal_card(surf, rect)
        surf.blit(render.gradient_border((cw, ch), t.Radius.CARD), rect.topleft)

        pad = 22
        # head: conic spinner + title + sub
        cx = rect.x + pad
        cy = rect.y + pad
        w.conic_ring(surf, cx + 18, cy + 18, 16, (self.elapsed % 1100) / 1100,
                     thickness=4)
        tx = cx + 18 + 16 + 14
        # "Connecting to <ssid(rose)>"
        lead = fonts.render(t.Type.TITLE, "Connecting to ", t.Color.TEXT_PRIMARY)
        surf.blit(lead, (tx, cy))
        ssid_s = fonts.render(t.Type.TITLE, self.ssid, t.Color.ACCENT_ROSE)
        if tx + lead.get_width() + ssid_s.get_width() <= rect.right - pad:
            surf.blit(ssid_s, (tx + lead.get_width(), cy))
        else:
            # wrap ssid below
            surf.blit(ssid_s, (tx, cy + lead.get_height()))
        sub = fonts.render(t.Type.META_SM, "Protected with a password",
                           t.Color.ACCENT_CYAN)
        surf.blit(sub, (tx, cy + lead.get_height() + 4))

        # phases
        py = rect.y + 80
        for i, label in enumerate(self.PHASES):
            ic_cx = rect.x + pad + 11
            ic_cy = py + 11
            if i < self.phase:
                pygame.draw.circle(surf, t.with_alpha(t.Color.SUCCESS, 0.15),
                                   (ic_cx, ic_cy), 11)
                ck = icons.icon("ph-check", 14, t.Color.SUCCESS)
                surf.blit(ck, (ic_cx - 7, ic_cy - 7))
                timing_col = t.Color.SUCCESS
                label_col = t.Color.TEXT_SECONDARY
            elif i == self.phase:
                w.conic_ring(surf, ic_cx, ic_cy, 8, (self.elapsed % 1100) / 1100,
                             thickness=3)
                timing_col = t.Color.ACCENT_CYAN
                label_col = t.Color.TEXT_PRIMARY
            else:
                pygame.draw.circle(surf, t.with_alpha(t.Color.TEXT_MUTED, 0.14),
                                   (ic_cx, ic_cy), 11)
                cg = icons.icon("ph-circle", 14, t.Color.TEXT_MUTED)
                surf.blit(cg, (ic_cx - 7, ic_cy - 7))
                timing_col = t.Color.TEXT_MUTED
                label_col = t.Color.TEXT_SECONDARY
            lab = fonts.render(t.Type.BODY_SM, label, label_col)
            surf.blit(lab, (rect.x + pad + 22 + 12, py + 11 - lab.get_height() // 2))
            tim = fonts.render(t.Type.META_SM, self.timings[i], timing_col)
            surf.blit(tim, (rect.right - pad - tim.get_width(),
                            py + 11 - tim.get_height() // 2))
            py += 30

        # bottom strip: attempt pill + auto-retry + cancel
        by = rect.bottom - pad - 16
        pill_lab = fonts.render(t.Type.META_SM, f"attempt {self.attempt} / 3",
                                t.Color.TEXT_SECONDARY)
        pw = pill_lab.get_width() + 16
        pill = render.rounded_fill((pw, 18), t.with_alpha(t.Color.BG_DEEP, 0.4)[:3], 999)
        pygame.draw.rect(pill, t.Color.BORDER_SOFT, (0, 0, pw, 18), width=1,
                         border_radius=999)
        pill.blit(pill_lab, ((pw - pill_lab.get_width()) // 2, 1))
        surf.blit(pill, (rect.x + pad, by))
        rt = fonts.render(t.Type.META_SM, "auto-retry · 9 s timeout",
                          t.Color.TEXT_MUTED)
        surf.blit(rt, (rect.x + pad + pw + 10, by + 1))
        cancel = fonts.render(t.Type.META_SM, "B cancel", t.Color.TEXT_SECONDARY)
        surf.blit(cancel, (rect.right - pad - cancel.get_width(), by + 1))


# ═══════════════════════════════════════════════════════════════════════
# W5 — Connect failed (modal, consumer + X technical)
# ═══════════════════════════════════════════════════════════════════════


class W5Failed:
    """
    Error modal. Default copy is plain ("Didn't connect / wrong password /
    tried 3 times / signal strong"). X reveals the technical diagnostic
    (phase + raw auth error code) — the only place a code appears.
    """
    name = "wifi-failed"

    def __init__(self, app, ssid="Home 5G", error=None):
        self.app = app
        self.ssid = ssid
        self.error = error          # REAL nmcli error (None on dev/sim)
        # heuristic: does the failure actually look like a bad password? Only
        # then do we say so — otherwise we'd cry "wrong password" at a timeout
        # / missing-secret / driver issue.
        e = (error or "").lower()
        self.auth_like = (error is None) or any(
            k in e for k in ("password", "auth", "secret", "802.1", "psk", "key"))
        self.advanced = False
        # buttons: 0 Details(toggle technical) · 1 Forget · 2 Try again(focus)
        self.focus = 2

    def on_action(self, action):
        if action in ("left",):
            self.focus = max(0, self.focus - 1)
        elif action == "right":
            self.focus = min(2, self.focus + 1)
        elif action == "x":
            self.advanced = not self.advanced
        elif action == "a":
            if self.focus == 0:
                self.advanced = not self.advanced
            elif self.focus == 1:
                if backend.live_enabled():
                    backend.call_async("wifi.forget", backend.wifi_forget,
                                       self.ssid)
                self.app.toast(f"Forgot {self.ssid}")
                self.app.pop()
            else:  # try again → back to password
                self.app.pop()
                self.app.push(W3Password(self.app, ssid=self.ssid))
        elif action == "b":
            self.app.pop()

    def update(self, dt_ms):
        pass

    def draw(self, surf):
        # ghosted W3 underlay (rows + keyboard block)
        for yy, hh in ((60, 36), (104, 36)):
            g = pygame.Surface((STAGE_W - SAFE * 2, hh), pygame.SRCALPHA)
            pygame.draw.rect(g, t.with_alpha(t.Color.SURFACE_BASE, 0.5),
                             (0, 0, STAGE_W - SAFE * 2, hh), border_radius=8)
            g.set_alpha(36)
            surf.blit(g, (SAFE, yy))
        kbg = pygame.Surface((STAGE_W - SAFE * 2, 180), pygame.SRCALPHA)
        pygame.draw.rect(kbg, t.with_alpha(t.Color.BG_DEEP, 0.4),
                         (0, 0, STAGE_W - SAFE * 2, 180), border_radius=10)
        kbg.set_alpha(36)
        surf.blit(kbg, (SAFE, 152))
        w.scrim(surf, alpha=0.62)

        cw = 420
        ch = 286
        rect = pygame.Rect((STAGE_W - cw) // 2, (STAGE_H - ch) // 2, cw, ch)
        w.modal_card(surf, rect)
        # error-tinted border (dusty rose → peach, never red)
        border = render.gradient_border((cw, ch), t.Radius.CARD)
        surf.blit(border, rect.topleft)

        pad = 22
        # head: glyph + title
        gbox = 38
        gchip = pygame.Surface((gbox, gbox), pygame.SRCALPHA)
        pygame.draw.rect(gchip, t.with_alpha(t.Color.ERROR, 0.16),
                         (0, 0, gbox, gbox), border_radius=12)
        gchip.blit(icons.icon("ph-warning-circle", 22, t.Color.ERROR),
                   ((gbox - 22) // 2, (gbox - 22) // 2))
        surf.blit(gchip, (rect.x + pad, rect.y + pad))
        h2 = fonts.render(t.Type.TITLE, "Didn't connect", t.Color.ERROR)
        surf.blit(h2, (rect.x + pad + gbox + 12,
                       rect.y + pad + (gbox - h2.get_height()) // 2))

        # body copy — honest: only blame the password when it actually looks
        # like an auth failure; otherwise say we couldn't connect and show why.
        by = rect.y + pad + gbox + 14
        lead_txt = "Wrong password for " if self.auth_like else "Couldn't connect to "
        tip = ("A slipped character is the usual cause."
               if self.auth_like else "See the reason below — not a password issue.")
        line = fonts.render(t.Type.BODY, lead_txt, t.Color.TEXT_SECONDARY)
        surf.blit(line, (rect.x + pad, by))
        ss = fonts.render(t.Type.BODY_STRONG, self.ssid, t.Color.TEXT_PRIMARY)
        surf.blit(ss, (rect.x + pad + line.get_width(), by))
        dot = fonts.render(t.Type.BODY, ".", t.Color.TEXT_SECONDARY)
        surf.blit(dot, (rect.x + pad + line.get_width() + ss.get_width(), by))
        by += line.get_height() + 2
        l2 = fonts.render(t.Type.BODY, tip, t.Color.TEXT_SECONDARY)
        surf.blit(l2, (rect.x + pad, by))
        by += l2.get_height() + 10

        # diagnostic strip — the REAL nmcli error (not a hardcoded code)
        diag_h = 44
        diag = pygame.Surface((cw - pad * 2, diag_h), pygame.SRCALPHA)
        pygame.draw.rect(diag, t.with_alpha(t.Color.BG_DEEP, 0.55),
                         (0, 0, cw - pad * 2, diag_h), border_radius=8)
        pygame.draw.rect(diag, t.Color.BORDER_SOFT, (0, 0, cw - pad * 2, diag_h),
                         width=1, border_radius=8)
        maxw = cw - pad * 2 - 24
        reason = self.error or ("authentication failed"
                                if self.auth_like else "connection failed")
        r1 = fonts.render(t.Type.META_SM, reason,
                          t.Color.ERROR if self.auth_like else t.Color.ACCENT_PEACH)
        if r1.get_width() > maxw:
            r1 = r1.subsurface((0, 0, maxw, r1.get_height()))
        diag.blit(r1, (12, 8))
        hint2 = ("from NetworkManager" if self.error else "tried 3 times")
        d2 = fonts.render(t.Type.META_SM, hint2, t.Color.TEXT_MUTED)
        diag.blit(d2, (12, 8 + r1.get_height() + 4))
        surf.blit(diag, (rect.x + pad, by))
        by += diag_h + 12

        # three buttons
        gap = 8
        total_w = cw - pad * 2
        bw0 = int((total_w - gap * 2) * 0.30)
        bw1 = int((total_w - gap * 2) * 0.32)
        bw2 = total_w - gap * 2 - bw0 - bw1
        bh = 42
        bx = rect.x + pad
        labels = [("Details", "ph-file-text", bw0),
                  ("Forget network", "ph-trash", bw1),
                  ("Try again", "ph-arrow-clockwise", bw2)]
        for i, (lab, icon_name, bwi) in enumerate(labels):
            brect = pygame.Rect(bx, by, bwi, bh)
            self._small_btn(surf, brect, lab, icon_name, focused=(i == self.focus))
            bx += bwi + gap
        by += bh + 10

        # in-modal key hints (below the buttons, inside the card)
        keys = fonts.render(t.Type.META_SM,
                            "A try again   X details   B cancel", t.Color.TEXT_MUTED)
        surf.blit(keys, (rect.centerx - keys.get_width() // 2, by))

    def _small_btn(self, surf, rect, label, icon_name, *, focused):
        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_ROSE 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() + 6 + 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() + 6, 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)


# ═══════════════════════════════════════════════════════════════════════
# W6 — Network details (two-tier: simple default + X technical)
# ═══════════════════════════════════════════════════════════════════════


class W6Details:
    """
    Default = SSID hero + "Connected · strong signal" + 4-bar + two rows:
    "Reconnect automatically" (toggle, focused) and "Forget this network".
    X reveals the technical tier (band/channel/IP/MAC/DHCP etc.) the way
    diagnostics does — those values exist only behind the toggle.
    """
    name = "wifi-details"

    def __init__(self, app, ssid="Home 5G"):
        self.app = app
        self.ssid = ssid
        self.auto = True            # "Reconnect automatically"
        self.advanced = False       # X toggles the technical tier
        self.focus = 0              # 0 = toggle row, 1 = forget row

    def on_action(self, action):
        if action == "up":
            self.focus = max(0, self.focus - 1)
        elif action == "down":
            self.focus = min(1, self.focus + 1)
        elif action == "a":
            if self.focus == 0:
                self.auto = not self.auto
            else:
                if backend.live_enabled():
                    backend.call_async("wifi.forget", backend.wifi_forget,
                                       self.ssid)
                self.app.toast(f"Forgot {self.ssid}")
                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):
        pass

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

        # hero (cyan-tinted panel) — generous
        hero_y = 60
        hero_h = 96
        hero = pygame.Surface((STAGE_W - SAFE * 2, hero_h), pygame.SRCALPHA)
        pygame.draw.rect(hero, t.with_alpha(t.Color.ACCENT_CYAN, 0.10),
                         (0, 0, STAGE_W - SAFE * 2, hero_h), border_radius=14)
        pygame.draw.rect(hero, t.with_alpha(t.Color.ACCENT_CYAN, 0.30),
                         (0, 0, STAGE_W - SAFE * 2, hero_h), width=1, border_radius=14)
        sq = render.rounded_gradient((56, 56), t.Color.ACCENT_ROSE,
                                     t.Color.ACCENT_CYAN, 14, 135)
        sq.blit(icons.icon("ph-wifi-high", 30, t.Color.BG_MID), (13, 13))
        hero.blit(sq, (18, (hero_h - 56) // 2))
        mx = 18 + 56 + 18
        name = fonts.render(t.Type.HERO, self.ssid, t.Color.TEXT_PRIMARY)
        hero.blit(name, (mx, 22))
        # state line
        sy = 22 + name.get_height() + 6
        pygame.draw.circle(hero, t.Color.SUCCESS, (mx + 3, sy + 8), 3)
        st1 = fonts.render(t.Type.BODY_STRONG, "Connected", t.Color.SUCCESS)
        hero.blit(st1, (mx + 12, sy))
        st2 = fonts.render(t.Type.BODY, " · strong signal", t.Color.TEXT_SECONDARY)
        hero.blit(st2, (mx + 12 + st1.get_width(), sy))
        # big bars on the right
        bars = _wbars(4, ok=True, unit_w=5, gap=3, max_h=32)
        hero.blit(bars, (STAGE_W - SAFE * 2 - 22 - bars.get_width(),
                         (hero_h - 32) // 2))
        surf.blit(hero, (SAFE, hero_y))

        y = hero_y + hero_h + 16

        # two rows
        row_h = 56
        # row 0: reconnect automatically (toggle)
        r0 = pygame.Rect(SAFE, y, STAGE_W - SAFE * 2, row_h)
        self._toggle_row(surf, r0, icon_name="ph-arrow-clockwise",
                         label="Reconnect automatically",
                         sub="join this network whenever it's nearby",
                         on=self.auto, focused=(self.focus == 0))
        y += row_h + 8
        # row 1: forget (danger)
        r1 = pygame.Rect(SAFE, y, STAGE_W - SAFE * 2, row_h)
        self._chevron_row(surf, r1, icon_name="ph-trash",
                          label="Forget this network",
                          sub="remove the password from this device",
                          focused=(self.focus == 1), danger=True)
        y += row_h + 12

        # technical tier (only behind X)
        if self.advanced:
            self._technical(surf, y)

        x_hint = "show technical details" if not self.advanced else "hide technical"
        w.footer_hints(surf, [("A", "toggle"), ("X", x_hint), ("B", "back")])

    def _toggle_row(self, surf, rect, *, icon_name, label, sub, on, focused):
        radius = 10
        if focused:
            body = render.rounded_gradient((rect.w, rect.h), t.Color.SURFACE_ELEVATED,
                                           t.Color.SURFACE_BASE, radius, 160)
        else:
            body = render.rounded_gradient((rect.w, rect.h), t.Color.SURFACE_BASE,
                                           t.Color.BG_MID, radius, 160)
            pygame.draw.rect(body, t.Color.BORDER_SOFT, (0, 0, rect.w, rect.h),
                             width=1, border_radius=radius)
        self._row_icon(body, icon_name, focused, danger=False)
        mx = 16 + 32 + 14
        lab = fonts.render(t.Type.BODY_STRONG, label, t.Color.TEXT_PRIMARY)
        sub_s = fonts.render(t.Type.BODY_SM, sub, t.Color.TEXT_MUTED)
        stack_h = lab.get_height() + 2 + sub_s.get_height()
        my = (rect.h - stack_h) // 2
        body.blit(lab, (mx, my))
        body.blit(sub_s, (mx, my + lab.get_height() + 2))
        # switch on the right (drawn onto body via a temp; toggle helper needs surf)
        sw_w, sw_h = 40, 22
        _toggle_switch(body, rect.w - 16 - sw_w, (rect.h - sw_h) // 2, on,
                       w_px=sw_w, h_px=sw_h)
        if focused:
            w.draw_focused_card(surf, rect, radius, scale=1.0, base_surface=body)
        else:
            surf.blit(body, rect.topleft)

    def _chevron_row(self, surf, rect, *, icon_name, label, sub, focused, danger):
        radius = 10
        if focused:
            body = render.rounded_gradient((rect.w, rect.h), t.Color.SURFACE_ELEVATED,
                                           t.Color.SURFACE_BASE, radius, 160)
        else:
            body = render.rounded_gradient((rect.w, rect.h), t.Color.SURFACE_BASE,
                                           t.Color.BG_MID, radius, 160)
            pygame.draw.rect(body, t.Color.BORDER_SOFT, (0, 0, rect.w, rect.h),
                             width=1, border_radius=radius)
        self._row_icon(body, icon_name, focused, danger=danger)
        mx = 16 + 32 + 14
        lcol = t.Color.ERROR if danger else t.Color.TEXT_PRIMARY
        lab = fonts.render(t.Type.BODY_STRONG, label, lcol)
        sub_s = fonts.render(t.Type.BODY_SM, sub, t.Color.TEXT_MUTED)
        stack_h = lab.get_height() + 2 + sub_s.get_height()
        my = (rect.h - stack_h) // 2
        body.blit(lab, (mx, my))
        body.blit(sub_s, (mx, my + lab.get_height() + 2))
        chev = icons.icon("ph-caret-right", 14,
                          t.Color.TEXT_PRIMARY if focused else t.Color.TEXT_MUTED)
        body.blit(chev, (rect.w - 16 - chev.get_width(),
                         (rect.h - chev.get_height()) // 2))
        if focused:
            w.draw_focused_card(surf, rect, radius, scale=1.0, base_surface=body)
        else:
            surf.blit(body, rect.topleft)

    def _row_icon(self, body, icon_name, focused, *, danger):
        box = 32
        ibg = pygame.Surface((box, box), pygame.SRCALPHA)
        if focused:
            chip = render.rounded_gradient(
                (box, box), t.with_alpha(t.Color.ACCENT_ROSE, 0.18)[:3],
                t.with_alpha(t.Color.ACCENT_CYAN, 0.18)[:3], 8, 135)
            ibg.blit(chip, (0, 0))
            pygame.draw.rect(ibg, t.with_alpha(t.Color.ACCENT_ROSE, 0.40),
                             (0, 0, box, box), width=1, border_radius=8)
        else:
            pygame.draw.rect(ibg, t.with_alpha((255, 255, 255), 0.04),
                             (0, 0, box, box), border_radius=8)
            pygame.draw.rect(ibg, t.Color.BORDER_SOFT, (0, 0, box, box),
                             width=1, border_radius=8)
        icol = (t.Color.ERROR if danger else
                (t.Color.ACCENT_ROSE if focused else t.Color.TEXT_SECONDARY))
        ibg.blit(icons.icon(icon_name, 16, icol), ((box - 16) // 2, (box - 16) // 2))
        body.blit(ibg, (16, (body.get_height() - box) // 2))

    def _technical(self, surf, y):
        """Behind-X technical card — the only place numbers/codes appear."""
        rows = [
            ("band", "5 GHz", None),
            ("channel", "36", None),
            ("link speed", "433 Mbps", "cyan"),
            ("ip address", "192.168.1.42", None),
            ("router", "192.168.1.1", None),
            ("dns", "192.168.1.1", None),
            ("mac (this device)", "A4:C3:F0:1E:2B:77", None),
            ("dhcp lease", "23 h left", None),
        ]
        # split into two columns
        card_w = STAGE_W - SAFE * 2
        avail_h = 448 - y - 8
        rect = pygame.Rect(SAFE, y, card_w, max(60, avail_h))
        body = render.rounded_gradient((rect.w, rect.h), t.Color.SURFACE_BASE,
                                       t.Color.BG_MID, 10, 160)
        pygame.draw.rect(body, t.Color.BORDER_SOFT, (0, 0, rect.w, rect.h),
                         width=1, border_radius=10)
        hdr = fonts.render(t.Type.EYEBROW, "TECHNICAL DETAILS", t.Color.TEXT_MUTED,
                           upper=True)
        body.blit(hdr, (12, 8))
        col_w = (rect.w - 24) // 2
        line_h = 16
        gy = 8 + hdr.get_height() + 6
        half = (len(rows) + 1) // 2
        for ci in range(2):
            cx = 12 + ci * col_w
            chunk = rows[ci * half:(ci + 1) * half]
            for ri, (k, v, state) in enumerate(chunk):
                ry = gy + ri * line_h
                ks = fonts.render(t.Type.META_SM, k, t.Color.TEXT_MUTED)
                body.blit(ks, (cx, ry))
                vcol = t.Color.ACCENT_CYAN if state == "cyan" else t.Color.TEXT_SECONDARY
                vs = fonts.render(t.Type.META_SM, v, vcol)
                vx = cx + col_w - 8 - vs.get_width()
                body.blit(vs, (vx, ry))
        surf.blit(body, rect.topleft)


# ═══════════════════════════════════════════════════════════════════════
# W7 — Wi-Fi off
# ═══════════════════════════════════════════════════════════════════════


class W7Off:
    """Off empty state: slashed glyph + head + sub + 'Turn on Wi-Fi' CTA."""
    name = "wifi-off"

    def __init__(self, app):
        self.app = app
        self.focus = 0  # only the CTA

    def on_action(self, action):
        if action == "a":
            if backend.live_enabled():
                backend.call_async("wifi.radio", backend.wifi_set_radio, True)
            self.app.toast("Turning Wi-Fi on…")
            # replace with the networks list once on
            self.app.pop()
            self.app.push(W2Networks(self.app))
        elif action == "b":
            self.app.pop()

    def update(self, dt_ms):
        pass

    def draw(self, surf):
        w.status_bar(surf, title="Wi-Fi", back=True)
        # "off" warning chip (with drawn dot) near the right cluster
        off = w.chip("off", t.Color.WARNING, dot=True)
        # placed just left of battery cluster region
        surf.blit(off, (STAGE_W - SAFE - 150 - off.get_width(), 18))

        cx = STAGE_W // 2
        # glyph card 96×96
        gy = 120
        card = render.rounded_gradient((96, 96), t.Color.SURFACE_BASE,
                                       t.Color.BG_MID, 24, 160)
        pygame.draw.rect(card, t.Color.BORDER_SOFT, (0, 0, 96, 96), width=1,
                         border_radius=24)
        card.blit(icons.icon("ph-wifi-slash", 56, t.Color.TEXT_DISABLED),
                  ((96 - 56) // 2, (96 - 56) // 2))
        card.set_alpha(int(255 * 0.85))
        surf.blit(card, (cx - 48, gy))

        head = fonts.render(t.Type.HERO, "Wi-Fi is off", t.Color.TEXT_PRIMARY)
        surf.blit(head, (cx - head.get_width() // 2, gy + 96 + 16))

        sub_lines = ["Turn on to scan, connect and let Manager rejoin",
                     "saved networks automatically."]
        sysub = gy + 96 + 16 + head.get_height() + 8
        for ln in sub_lines:
            s = fonts.render(t.Type.BODY, ln, t.Color.TEXT_SECONDARY)
            surf.blit(s, (cx - s.get_width() // 2, sysub))
            sysub += s.get_height() + 2

        # CTA
        btn_w, btn_h = 200, 46
        brect = pygame.Rect(cx - btn_w // 2, sysub + 10, btn_w, btn_h)
        w.button(surf, brect, "Turn on Wi-Fi", icon_name="ph-wifi-high",
                 focused=True)

        # adapter meta card (mono, muted) — established meta pattern
        meta_y = brect.bottom + 14
        meta_lines = [
            ("connection", "ready to turn on"),
            ("saved", "2 networks will auto-rejoin"),
        ]
        mw = 360
        mh = 8 + len(meta_lines) * 16 + 4
        mcard = pygame.Surface((mw, mh), pygame.SRCALPHA)
        pygame.draw.rect(mcard, t.with_alpha(t.Color.BG_DEEP, 0.55), (0, 0, mw, mh),
                         border_radius=8)
        pygame.draw.rect(mcard, t.Color.BORDER_SOFT, (0, 0, mw, mh), width=1,
                         border_radius=8)
        for i, (k, v) in enumerate(meta_lines):
            ry = 6 + i * 16
            ks = fonts.render(t.Type.META_SM, k, t.Color.TEXT_MUTED)
            mcard.blit(ks, (12, ry))
            vcol = t.Color.SUCCESS if "auto-rejoin" in v else t.Color.TEXT_SECONDARY
            vs = fonts.render(t.Type.META_SM, v, vcol)
            mcard.blit(vs, (130, ry))
        surf.blit(mcard, (cx - mw // 2, meta_y))

        w.footer_hints(surf, [("A", "turn on"), ("B", "back")])


# ═══════════════════════════════════════════════════════════════════════
# W8 — Add hidden network (form)
# ═══════════════════════════════════════════════════════════════════════


class W8AddHidden:
    """
    Hidden-network form: SSID field (focusable → keyboard), security
    segmented (Open/WPA2/WPA3/EAP), password field, auto-rejoin toggle, and
    Cancel / Add & connect CTAs. D-pad moves between focusable rows.
    """
    name = "wifi-add-hidden"

    SECURITY = ["Open", "WPA2", "WPA3", "EAP"]

    def __init__(self, app):
        self.app = app
        self.ssid = "aahead"
        self.password = ""
        self.security = 1           # WPA2
        self.auto = True
        # focus index over: 0 ssid, 1 security, 2 password, 3 auto, 4 cancel, 5 add
        self.focus = 0

    def on_action(self, action):
        if action == "up":
            self.focus = max(0, self.focus - 1)
        elif action == "down":
            self.focus = min(5, self.focus + 1)
        elif action == "left":
            if self.focus == 1:
                self.security = max(0, self.security - 1)
            elif self.focus in (4, 5):
                self.focus = 4
        elif action == "right":
            if self.focus == 1:
                self.security = min(len(self.SECURITY) - 1, self.security + 1)
            elif self.focus in (4, 5):
                self.focus = 5
        elif action == "a":
            if self.focus == 0:
                self.app.toast("Edit network name — keyboard")
            elif self.focus == 2:
                self.app.push(W3Password(self.app, ssid=self.ssid or "hidden network"))
            elif self.focus == 3:
                self.auto = not self.auto
            elif self.focus == 4:
                self.app.pop()
            elif self.focus == 5:
                if self.ssid:
                    self.app.push(W4Connecting(self.app, ssid=self.ssid))
                else:
                    self.app.toast("Enter a network name first")
        elif action == "b":
            self.app.pop()

    def update(self, dt_ms):
        pass

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

        y = 56
        # info banner
        banner_h = 50
        banner = pygame.Surface((STAGE_W - SAFE * 2, banner_h), pygame.SRCALPHA)
        pygame.draw.rect(banner, t.with_alpha(t.Color.ACCENT_CYAN, 0.06),
                         (0, 0, STAGE_W - SAFE * 2, banner_h), border_radius=8)
        pygame.draw.rect(banner, t.with_alpha(t.Color.ACCENT_CYAN, 0.30),
                         (0, 0, STAGE_W - SAFE * 2, banner_h), width=1, border_radius=8)
        banner.blit(icons.icon("ph-info", 14, t.Color.ACCENT_CYAN), (12, 10))
        b1 = fonts.render(t.Type.BODY_SM,
                          "Use this for networks that don't show up in the list.",
                          t.Color.TEXT_SECONDARY)
        banner.blit(b1, (12 + 14 + 8, 9))
        b2 = fonts.render(t.Type.BODY_SM,
                          "Most networks are easier to add from Available networks.",
                          t.Color.TEXT_MUTED)
        banner.blit(b2, (12 + 14 + 8, 9 + b1.get_height() + 3))
        surf.blit(banner, (SAFE, y))
        y += banner_h + 10

        # field: network name
        y = self._field(surf, y, "Network name · required", self.ssid,
                        focused=(self.focus == 0), end="A insert · X delete",
                        cursor=(self.focus == 0))

        # security segmented
        seg_lbl = fonts.render(t.Type.META_SM, "SECURITY", t.Color.TEXT_MUTED)
        surf.blit(seg_lbl, (SAFE + 2, y))
        y += seg_lbl.get_height() + 4
        seg_h = 38
        seg_w = STAGE_W - SAFE * 2
        seg_bg = render.rounded_fill((seg_w, seg_h), t.Color.BG_MID, 10)
        pygame.draw.rect(seg_bg, t.Color.BORDER_SOFT, (0, 0, seg_w, seg_h),
                         width=1, border_radius=10)
        n = len(self.SECURITY)
        opt_w = (seg_w - 6) // n
        for i, opt in enumerate(self.SECURITY):
            ox = 3 + i * opt_w
            sel = (i == self.security)
            if sel:
                osurf = render.rounded_gradient((opt_w - 2, seg_h - 6),
                                                t.Color.SURFACE_ELEVATED,
                                                t.Color.SURFACE_BASE, 7, 160)
                seg_bg.blit(osurf, (ox, 3))
            col = t.Color.ACCENT_CYAN if sel else t.Color.TEXT_SECONDARY
            ol = fonts.render(t.Type.BODY_SM, opt, col)
            seg_bg.blit(ol, (ox + (opt_w - ol.get_width()) // 2,
                             (seg_h - ol.get_height()) // 2))
        surf.blit(seg_bg, (SAFE, y))
        if self.focus == 1:
            surf.blit(render.gradient_border((seg_w, seg_h), 10), (SAFE, y))
        y += seg_h + 10

        # password field (placeholder when empty)
        pw_show = self.password if self.password else "enter password…"
        y = self._field(surf, y, "Password · required",
                        pw_show, focused=(self.focus == 2),
                        end="tap to edit", placeholder=(not self.password),
                        cursor=False)

        # auto-rejoin toggle row
        tg_h = 46
        tgrect = pygame.Rect(SAFE, y, STAGE_W - SAFE * 2, tg_h)
        self._toggle_strip(surf, tgrect, "Auto-rejoin",
                           "remember and rejoin when in range",
                           self.auto, focused=(self.focus == 3))
        y += tg_h + 10

        # CTA row
        cta_y = 448 - 8 - 44
        gap = 10
        total = STAGE_W - SAFE * 2
        cw0 = int((total - gap) * 0.38)
        cw1 = total - gap - cw0
        w.button(surf, pygame.Rect(SAFE, cta_y, cw0, 44), "Cancel",
                 focused=(self.focus == 4))
        # "Add & connect" reads disabled-ish until ssid present
        add_focused = (self.focus == 5)
        addr = pygame.Rect(SAFE + cw0 + gap, cta_y, cw1, 44)
        w.button(surf, addr, "Add & connect", focused=add_focused)
        if not self.ssid and not add_focused:
            veil = pygame.Surface((cw1, 44), pygame.SRCALPHA)
            veil.fill((*t.Color.BG_DEEP, 90))
            surf.blit(veil, addr.topleft)

        w.footer_hints(surf, [("A", "edit field"), ("B", "cancel")])

    def _field(self, surf, y, label, value, *, focused, end, placeholder=False,
               cursor=False):
        lbl = fonts.render(t.Type.META_SM, label.upper(), t.Color.TEXT_MUTED)
        surf.blit(lbl, (SAFE + 2, y))
        y += lbl.get_height() + 4
        fh = 44
        fw = STAGE_W - SAFE * 2
        field = pygame.Surface((fw, fh), pygame.SRCALPHA)
        pygame.draw.rect(field, t.with_alpha(t.Color.BG_DEEP, 0.55), (0, 0, fw, fh),
                         border_radius=10)
        if not focused:
            pygame.draw.rect(field, t.Color.BORDER_SOFT, (0, 0, fw, fh), width=1,
                             border_radius=10)
        vcol = t.Color.TEXT_MUTED if placeholder else t.Color.TEXT_PRIMARY
        vs = fonts.render(t.TextStyle(t.JETBRAINS_MONO, 14, "Regular", 0.0),
                          value, vcol)
        field.blit(vs, (14, (fh - vs.get_height()) // 2))
        if cursor:
            pygame.draw.rect(field, t.Color.ACCENT_CYAN,
                             (14 + vs.get_width() + 2, (fh - 16) // 2, 2, 16))
        es = fonts.render(t.Type.META_SM, end, t.Color.TEXT_MUTED)
        field.blit(es, (fw - 12 - es.get_width(), (fh - es.get_height()) // 2))
        surf.blit(field, (SAFE, y))
        if focused:
            surf.blit(render.gradient_border((fw, fh), 10), (SAFE, y))
        return y + fh + 10

    def _toggle_strip(self, surf, rect, label, sub, on, *, focused):
        radius = 10
        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=radius)
        if not focused:
            pygame.draw.rect(body, t.Color.BORDER_SOFT, (0, 0, rect.w, rect.h),
                             width=1, border_radius=radius)
        lab = fonts.render(t.Type.BODY_SM, label, t.Color.TEXT_PRIMARY)
        sub_s = fonts.render(t.Type.META_SM, sub, t.Color.TEXT_MUTED)
        stack_h = lab.get_height() + 2 + sub_s.get_height()
        my = (rect.h - stack_h) // 2
        body.blit(lab, (12, my))
        body.blit(sub_s, (12, my + lab.get_height() + 2))
        _toggle_switch(body, rect.w - 12 - 32, (rect.h - 18) // 2, on,
                       w_px=32, h_px=18)
        surf.blit(body, rect.topleft)
        if focused:
            surf.blit(render.gradient_border((rect.w, rect.h), radius), rect.topleft)


# ═══════════════════════════════════════════════════════════════════════
# W9 — No Wi-Fi adapter
# ═══════════════════════════════════════════════════════════════════════


class W9NoAdapter:
    """
    R36S has no built-in Wi-Fi. Big slashed glyph + plain head/sub + mint
    reassurance pill + three known-good dongle chips (buying help). No MAC,
    no rfkill, no driver names beyond the chip family.
    """
    name = "wifi-no-adapter"

    def __init__(self, app):
        self.app = app
        self.focus = 0

    def on_action(self, action):
        if action == "a":
            self.app.toast("Compatibility list — coming soon")
        elif action == "b":
            self.app.pop()

    def update(self, dt_ms):
        pass

    def draw(self, surf):
        w.status_bar(surf, title="Wi-Fi", back=True)
        na = w.chip("no adapter", t.Color.TEXT_MUTED)
        surf.blit(na, (STAGE_W - SAFE - 150 - na.get_width(), 18))

        cx = STAGE_W // 2
        # big slashed glyph card 104×104
        gy = 96
        card = render.rounded_gradient((104, 104), t.Color.SURFACE_BASE,
                                       t.Color.BG_MID, 26, 160)
        pygame.draw.rect(card, t.Color.BORDER_SOFT, (0, 0, 104, 104), width=1,
                         border_radius=26)
        card.blit(icons.icon("ph-wifi-high", 60, t.Color.TEXT_DISABLED),
                  ((104 - 60) // 2, (104 - 60) // 2))
        # diagonal slash
        pygame.draw.line(card, t.with_alpha(t.Color.TEXT_MUTED, 0.55),
                         (18, 78), (86, 30), 2)
        card.set_alpha(int(255 * 0.85))
        surf.blit(card, (cx - 52, gy))

        head = fonts.render(t.Type.HERO, "No Wi-Fi adapter", t.Color.TEXT_PRIMARY)
        surf.blit(head, (cx - head.get_width() // 2, gy + 104 + 14))

        # sub (two lines, with bold dongle phrase)
        sy = gy + 104 + 14 + head.get_height() + 8
        l1 = fonts.render(t.Type.BODY, "R36S doesn't have built-in Wi-Fi.",
                          t.Color.TEXT_SECONDARY)
        surf.blit(l1, (cx - l1.get_width() // 2, sy))
        sy += l1.get_height() + 2
        l2a = fonts.render(t.Type.BODY_STRONG, "Plug a USB Wi-Fi dongle",
                           t.Color.TEXT_PRIMARY)
        l2b = fonts.render(t.Type.BODY, " into the OTG port to add wireless.",
                           t.Color.TEXT_SECONDARY)
        l2w = l2a.get_width() + l2b.get_width()
        surf.blit(l2a, (cx - l2w // 2, sy))
        surf.blit(l2b, (cx - l2w // 2 + l2a.get_width(), sy))
        sy += l2a.get_height() + 12

        # mint reassurance pill
        rea_ic = icons.icon("ph-game-controller", 13, t.Color.SUCCESS)
        rea_txt = fonts.render(t.Type.BODY_SM,
                               "Games and saves work fine without a network.",
                               t.Color.SUCCESS)
        pill_w = 14 + rea_ic.get_width() + 8 + rea_txt.get_width() + 14
        pill_h = 28
        pill = pygame.Surface((pill_w, pill_h), pygame.SRCALPHA)
        pygame.draw.rect(pill, t.with_alpha(t.Color.SUCCESS, 0.08),
                         (0, 0, pill_w, pill_h), border_radius=999)
        pygame.draw.rect(pill, t.with_alpha(t.Color.SUCCESS, 0.30),
                         (0, 0, pill_w, pill_h), width=1, border_radius=999)
        pill.blit(rea_ic, (14, (pill_h - rea_ic.get_height()) // 2))
        pill.blit(rea_txt, (14 + rea_ic.get_width() + 8,
                            (pill_h - rea_txt.get_height()) // 2))
        surf.blit(pill, (cx - pill_w // 2, sy))
        sy += pill_h + 12

        # dongle chips (known-good families + "more")
        chips = [("RTL8723BU", "works"), ("RTL8188FU", "works"),
                 ("MT7601U", "works"), ("+ more in compatibility list", None)]
        chip_surfs = []
        for name, ok in chips:
            nlab = fonts.render(t.Type.BODY_SM, name, t.Color.TEXT_SECONDARY)
            ok_lab = (fonts.render(t.Type.META_SM, f"· {ok}", t.Color.SUCCESS)
                      if ok else None)
            cw = 12 + nlab.get_width() + (6 + ok_lab.get_width() if ok_lab else 0) + 12
            ch = 24
            cs = pygame.Surface((cw, ch), pygame.SRCALPHA)
            pygame.draw.rect(cs, t.with_alpha(t.Color.BG_DEEP, 0.4), (0, 0, cw, ch),
                             border_radius=999)
            pygame.draw.rect(cs, t.Color.BORDER_SOFT, (0, 0, cw, ch), width=1,
                             border_radius=999)
            cs.blit(nlab, (12, (ch - nlab.get_height()) // 2))
            if ok_lab:
                cs.blit(ok_lab, (12 + nlab.get_width() + 6,
                                 (ch - ok_lab.get_height()) // 2))
            chip_surfs.append(cs)

        # lay out chips centered, wrapping to two rows
        gap = 6
        max_w = STAGE_W - SAFE * 2
        rows, cur, cur_w = [], [], 0
        for cs in chip_surfs:
            if cur and cur_w + cs.get_width() + gap > max_w:
                rows.append((cur, cur_w))
                cur, cur_w = [], 0
            cur.append(cs)
            cur_w += cs.get_width() + gap
        if cur:
            rows.append((cur, cur_w))
        ry = sy
        for row_chips, row_w in rows:
            rx = cx - (row_w - gap) // 2
            for cs in row_chips:
                surf.blit(cs, (rx, ry))
                rx += cs.get_width() + gap
            ry += 24 + gap

        w.footer_hints(surf, [("A", "compatibility list"), ("B", "back")])
