"""Lirelle main window — transcript bank + sidebar for future sections.

Phase D MVP scope:
- Sidebar with: Transcripts (active), Rewrite/Adapt/Settings (placeholders for v2)
- Transcripts pane: search field, today/week/all filters, scrollable list of cards
- Each card: timestamp · language → text → copy / paste-at-cursor / open-file actions
- Live refresh when new dictations arrive (poll index.json mtime every 1.5s)
- Dark warm theme matching the overlay capsule
"""

from __future__ import annotations

import subprocess
import threading
from datetime import datetime, timedelta

import objc  # type: ignore
from AppKit import (  # type: ignore
    NSApplication,
    NSBackingStoreBuffered,
    NSBezelStyleRounded,
    NSBezelStyleShadowlessSquare,
    NSBezierPath,
    NSButton,
    NSColor,
    NSGradient,
    NSFont,
    NSFontWeightBold,
    NSFontWeightMedium,
    NSFontWeightRegular,
    NSFontWeightSemibold,
    NSLineBreakByTruncatingTail,
    NSLineBreakByWordWrapping,
    NSMakeRect,
    NSMakeSize,
    NSMutableAttributedString,
    NSPasteboard,
    NSPasteboardTypeString,
    NSScrollView,
    NSSearchField,
    NSStackView,
    NSStackViewGravityTrailing,
    NSTextField,
    NSTrackingActiveAlways,
    NSTrackingArea,
    NSTrackingMouseEnteredAndExited,
    NSUserInterfaceLayoutOrientationHorizontal,
    NSView,
    NSWindow,
    NSWindowStyleMaskClosable,
    NSWindowStyleMaskFullSizeContentView,
    NSWindowStyleMaskMiniaturizable,
    NSWindowStyleMaskResizable,
    NSWindowStyleMaskTitled,
)
from Foundation import NSAttributedString, NSObject, NSTimer  # type: ignore
from PyObjCTools import AppHelper  # type: ignore

# ── brand tokens ───────────────────────────────────────────────────────
# ── Design tokens (from Lirelle Meetings.html prototype) ──
# These match the locked warm-coal palette and were tuned by the designer
# against five reference layouts. Don't drift these without re-spec'ing.
BG = (0x0A / 255, 0x08 / 255, 0x08 / 255)
SIDEBAR_BG = (0x05 / 255, 0x04 / 255, 0x03 / 255)
CARD_BG = (0x14 / 255, 0x12 / 255, 0x10 / 255)
CARD_BG_HOVER = (0x1B / 255, 0x18 / 255, 0x14 / 255)
CARD_BORDER = (0x1F / 255, 0x1C / 255, 0x18 / 255)
TEXT = (0xEF / 255, 0xEA / 255, 0xE4 / 255)
INK = (0x88 / 255, 0x8F / 255, 0x8A / 255)
GOLD = (0xEB / 255, 0xB6 / 255, 0x38 / 255)
WARM = (0xFF / 255, 0x9B / 255, 0x50 / 255)  # amber falloff for the moon-orb halo
RED = (0xEF / 255, 0x53 / 255, 0x50 / 255)
ORANGE = (0xFF / 255, 0x9B / 255, 0x50 / 255)
GREEN = (0x6F / 255, 0xBF / 255, 0x73 / 255)
INK_DIM = (0x5C / 255, 0x61 / 255, 0x5E / 255)
HAIRLINE = (0x17 / 255, 0x14 / 255, 0x0F / 255)
SEL_FILL = (0x1B / 255, 0x18 / 255, 0x14 / 255)
# Status pill softs — pre-blended against BG so AppKit doesn't need alpha
# compositing on per-pixel buffers.
GOLD_SOFT = (0x29 / 255, 0x20 / 255, 0x12 / 255)
RED_SOFT = (0x2E / 255, 0x12 / 255, 0x11 / 255)
ORANGE_SOFT = (0x2E / 255, 0x1C / 255, 0x11 / 255)
GREEN_SOFT = (0x14 / 255, 0x1F / 255, 0x14 / 255)
# Gold @ 10% pre-blended against BG — used for selected row in TINT mode
# AND as the blockquote fill in markdown.
GOLD_TINT = (0x20 / 255, 0x19 / 255, 0x0D / 255)

WIN_W = 1180
WIN_H = 760
SIDEBAR_W = 240
MIN_WIN_W = 820
MIN_WIN_H = 600
PAD = 28  # design base unit ("--pad: 28")
TITLEBAR_OFFSET = 28  # macOS title bar height — content must start below it
                      # because window has NSWindowStyleMaskFullSizeContentView
                      # (transparent title bar lays on top of content view).

SECTIONS = [
    # (id, label, enabled, sf-symbol icon, group). Group "main" or "account"
    # — used to render the "ACCOUNT" section header between them.
    ("transcripts", "Transcripts", True,  "list.bullet",        "main"),
    ("meetings",    "Meetings",    True,  "mic.fill",           "main"),
    ("rewrite",     "Rewrite",     False, "pencil",             "main"),
    ("adapt",       "Adapt",       False, "wand.and.stars",     "main"),
    ("account",     "Account",     True,  "person.crop.circle", "account"),
    ("settings",    "Settings",    True,  "gearshape",          "account"),
]

# Plan catalogue surfaced in the Account pane. Prices in ₽ (Robokassa). When MAS
# distribution ships we'll override these dynamically from StoreKit product info,
# but for direct download the values are stable.
# Plan catalogue derived from licensing.py constants — single source of
# truth for prices, word cap, and the yearly-discount %. If you bump
# DAILY_WORD_CAP or PLAN_PRICE_RUB_* there, this catalogue updates on next
# import. No more drift between code, marketing copy, and the actual
# Robokassa charge amount.
from .licensing import (
    DAILY_WORD_CAP,
    PLAN_FREE,
    PLAN_LITE,
    PLAN_PLUS,
    PLAN_PRICE_RUB_MONTHLY,
    PLAN_PRICE_RUB_YEARLY,
    yearly_discount_pct,
)


def _format_thousands(n: int) -> str:
    """Russian thousands separator (NBSP) — '2000' → '2 000'."""
    return f"{n:,}".replace(",", " ")


class _BankWindowDelegate(NSObject):
    """Window delegate — currently only used for live re-layout on resize.
    CR #15 — the legacy AppKit pane refresh hooks (_start_refresh /
    _stop_refresh / _reload_transcripts) were deleted; the WebShell pane
    binders run their own focus-based refresh inside the WebView."""
    def initWithBank_(self, bank):  # type: ignore
        self = objc.super(_BankWindowDelegate, self).init()
        if self is None:
            return None
        self._bank = bank
        return self

    def windowDidResize_(self, notification):  # noqa: N802, ARG002
        # Panes & sidebar items use absolute frames computed from WIN_W/WIN_H,
        # so we rebuild them against the new content size whenever the user
        # drags the window edge.
        self._bank._relayout_for_resize()


def _ns_color(rgb: tuple[float, float, float], a: float = 1.0):
    return NSColor.colorWithCalibratedRed_green_blue_alpha_(*rgb, a)


def _attr_str(s: str, *, size: float, color: tuple[float, float, float],
              weight: float = NSFontWeightRegular, mono: bool = False,
              letter_spacing: float = 0.0, alpha: float = 1.0):
    if mono:
        font = NSFont.monospacedSystemFontOfSize_weight_(size, weight)
    else:
        font = NSFont.systemFontOfSize_weight_(size, weight)
    attrs = {
        "NSFont": font,
        "NSColor": _ns_color(color, alpha),
    }
    if letter_spacing:
        attrs["NSKern"] = letter_spacing
    return NSAttributedString.alloc().initWithString_attributes_(s, attrs)


class MoonOrb(NSView):
    """Small luminous gold ball — 'almost full moon, lit from lower-right' vibe.
    Composed of: warm radial gradient base + cream specular highlight + soft inner
    glow + thin crescent shadow in the upper-left + big CALayer halo around it.

    Layered semi-transparent passes give it a hand-painted lantern feel instead of
    looking like a flat vector circle.

    Crescent position: rotated 45° CCW from the 'right edge' default and flipped
    horizontally → ends up in the upper-left (waxing-gibbous illumination)."""
    def initWithFrame_(self, frame):  # type: ignore
        self = objc.super(MoonOrb, self).initWithFrame_(frame)
        if self is None:
            return None
        self.setWantsLayer_(True)
        layer = self.layer()
        if layer is not None:
            layer.setMasksToBounds_(False)
            warm = NSColor.colorWithCalibratedRed_green_blue_alpha_(*WARM, 1.0)
            layer.setShadowColor_(warm.CGColor())
            layer.setShadowRadius_(14.0)        # bigger soft halo
            layer.setShadowOpacity_(1.0)        # full glow
            layer.setShadowOffset_((0.0, 0.0))
        return self

    def isOpaque(self):  # noqa: N802
        return False

    def drawRect_(self, dirty):  # noqa: N802, ARG002
        import math
        rect = self.bounds()
        cx = rect.size.width / 2
        cy = rect.size.height / 2
        r = min(cx, cy) - 2

        ball_rect = NSMakeRect(cx - r, cy - r, r * 2, r * 2)
        ball_path = NSBezierPath.bezierPathWithOvalInRect_(ball_rect)

        # ── Layer 1: warm soft outer glow tint inside the ball (large amber radial) ──
        amber_hot = NSColor.colorWithCalibratedRed_green_blue_alpha_(*WARM, 0.85)
        amber_edge = NSColor.colorWithCalibratedRed_green_blue_alpha_(*WARM, 0.25)
        g1 = NSGradient.alloc().initWithStartingColor_endingColor_(amber_hot, amber_edge)
        # save graphics state for clipping
        NSBezierPath.bezierPathWithOvalInRect_(ball_rect).addClip()
        g1.drawInBezierPath_relativeCenterPosition_(ball_path, (0.0, 0.0))

        # ── Layer 2: gold core, biased toward lower-right (illuminated side) ──
        gold_hot = NSColor.colorWithCalibratedRed_green_blue_alpha_(*GOLD, 0.95)
        gold_edge = NSColor.colorWithCalibratedRed_green_blue_alpha_(*GOLD, 0.10)
        g2 = NSGradient.alloc().initWithStartingColor_endingColor_(gold_hot, gold_edge)
        # bias center toward lower-right (positive X, negative Y in non-flipped coords)
        g2.drawInBezierPath_relativeCenterPosition_(ball_path, (0.35, -0.30))

        # ── Layer 3: soft horizontal gradient shadow ───────────────────────────
        # Linear gradient from dark (left) to fully transparent (right), filling the
        # clipped ball area. Result: left half feels in shadow, right half is the
        # full warm sun-lit side, transition is smooth and luminous — not a vector cut.
        shadow_color = NSColor.colorWithCalibratedRed_green_blue_alpha_(*SIDEBAR_BG, 0.65)
        clear_color = NSColor.colorWithCalibratedRed_green_blue_alpha_(*SIDEBAR_BG, 0.0)
        shadow_grad = NSGradient.alloc().initWithStartingColor_endingColor_(
            shadow_color, clear_color
        )
        # angle 0° = left-to-right (dark on left → transparent on right)
        shadow_grad.drawInBezierPath_angle_(ball_path, 0.0)

        # ── Layer 4: cream specular highlight (tiny, lower-right inside ball) ──
        hl_x = cx + r * 0.30
        hl_y = cy - r * 0.30
        hl_r = r * 0.42
        hl_rect = NSMakeRect(hl_x - hl_r, hl_y - hl_r, hl_r * 2, hl_r * 2)
        hl_hot = NSColor.colorWithCalibratedRed_green_blue_alpha_(0xFF/255, 0xF4/255, 0xD8/255, 0.55)
        hl_edge = NSColor.colorWithCalibratedRed_green_blue_alpha_(0xFF/255, 0xF4/255, 0xD8/255, 0.0)
        ghl = NSGradient.alloc().initWithStartingColor_endingColor_(hl_hot, hl_edge)
        ghl.drawInBezierPath_relativeCenterPosition_(
            NSBezierPath.bezierPathWithOvalInRect_(hl_rect), (0.0, 0.0)
        )


# ── Filled rounded panel (sidebar bg, card bg, etc.) ───────────────────
class FilledView(NSView):
    def initWithFrame_color_radius_(self, frame, color, radius):  # type: ignore
        self = objc.super(FilledView, self).initWithFrame_(frame)
        if self is None:
            return None
        self._fill = color
        self._radius = radius
        self._border = None
        self._border_width = 0.0
        return self

    def setBorder_width_(self, color, width):  # noqa: N802
        self._border = color
        self._border_width = width
        self.setNeedsDisplay_(True)

    def setFill_(self, color):  # noqa: N802
        self._fill = color
        self.setNeedsDisplay_(True)

    def drawRect_(self, dirty):  # noqa: N802
        rect = self.bounds()
        path = NSBezierPath.bezierPathWithRoundedRect_xRadius_yRadius_(
            rect, self._radius, self._radius
        )
        _ns_color(self._fill).setFill()
        path.fill()
        if self._border is not None and self._border_width > 0:
            _ns_color(self._border).setStroke()
            border_path = NSBezierPath.bezierPathWithRoundedRect_xRadius_yRadius_(
                NSMakeRect(0.5, 0.5, rect.size.width - 1, rect.size.height - 1),
                self._radius, self._radius,
            )
            border_path.setLineWidth_(self._border_width)
            border_path.stroke()


class SidebarItem(NSView):
    """Sidebar nav item per Lirelle Meetings design spec:
      • SF Symbol icon (gold when active, INK otherwise) left of label
      • Gold 3pt × 18pt vertical bar at left edge for active state
      • Active state: CARD_BG rounded background fill
      • Optional count badge on the right (mono 11px in pill)
      • "soon" label for disabled items
    """

    def initWithFrame_id_label_enabled_active_icon_count_onClick_(  # type: ignore
        self, frame, item_id, label, enabled, active, icon_name, count, on_click,
    ):
        from AppKit import NSImage, NSImageView
        self = objc.super(SidebarItem, self).initWithFrame_(frame)
        if self is None:
            return None
        self._id = item_id
        self._label = label
        self._enabled = enabled
        self._active = active
        self._hovered = False
        self._icon_name = icon_name
        self._count = count
        self._on_click = on_click

        # SF Symbol icon as an NSImageView subview — contentTintColor
        # gives us native colorisation we can swap on state change.
        self._icon_view = None
        if icon_name:
            try:
                img = NSImage.imageWithSystemSymbolName_accessibilityDescription_(
                    icon_name, label,
                )
            except Exception:
                img = None
            if img is not None:
                img.setTemplate_(True)
                icon_size = 18
                icon_y = (frame.size.height - icon_size) / 2
                self._icon_view = NSImageView.alloc().initWithFrame_(
                    NSMakeRect(20, icon_y, icon_size, icon_size)
                )
                self._icon_view.setImage_(img)
                self._icon_view.setImageScaling_(0)  # NSImageScaleProportionallyUpOrDown
                self._icon_view.setContentTintColor_(_ns_color(
                    GOLD if active else INK,
                    1.0 if enabled else 0.45,
                ))
                self.addSubview_(self._icon_view)

        tracking = NSTrackingArea.alloc().initWithRect_options_owner_userInfo_(
            self.bounds(),
            NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways,
            self, None,
        )
        self.addTrackingArea_(tracking)
        return self

    def setActive_(self, active):  # noqa: N802
        self._active = active
        self._update_icon_tint()
        self.setNeedsDisplay_(True)

    def setCount_(self, count):  # noqa: N802
        self._count = count
        self.setNeedsDisplay_(True)

    def _update_icon_tint(self) -> None:
        if self._icon_view is None:
            return
        color = GOLD if self._active else (TEXT if self._hovered else INK)
        alpha = 1.0 if self._enabled else 0.45
        try:
            self._icon_view.setContentTintColor_(_ns_color(color, alpha))
        except Exception:
            pass

    def drawRect_(self, dirty):  # noqa: N802, ARG002
        rect = self.bounds()

        text_color = TEXT if (self._active or self._hovered) else INK
        text_alpha = 1.0 if self._enabled else 0.45

        # Active background: CARD_BG rounded fill (inset from edges).
        if self._active:
            bg_rect = NSMakeRect(8, 2, rect.size.width - 16,
                                 rect.size.height - 4)
            _ns_color(CARD_BG).setFill()
            NSBezierPath.bezierPathWithRoundedRect_xRadius_yRadius_(
                bg_rect, 8.0, 8.0,
            ).fill()
            # Gold left bar — 3pt × 18pt, hugging the very left edge.
            bar_y = (rect.size.height - 18) / 2
            _ns_color(GOLD).setFill()
            NSBezierPath.bezierPathWithRoundedRect_xRadius_yRadius_(
                NSMakeRect(0, bar_y, 3, 18), 1.5, 1.5,
            ).fill()

        # Label (icon is an NSImageView subview, set up in init).
        label_x = 20 + 18 + 12   # icon_x + icon_size + 12pt gap
        s = NSMutableAttributedString.alloc().init()
        s.appendAttributedString_(
            _attr_str(self._label, size=14, color=text_color,
                      weight=NSFontWeightMedium, alpha=text_alpha)
        )
        if not self._enabled:
            s.appendAttributedString_(
                _attr_str("   soon", size=10, color=INK_DIM, mono=True,
                          letter_spacing=0.5)
            )
        s.drawAtPoint_((label_x, rect.size.height / 2 - 9))

        # Count badge (right edge).
        if self._count is not None and self._count > 0:
            badge_text = str(self._count)
            badge_attr = _attr_str(
                badge_text, size=11, color=INK_DIM, mono=True,
            )
            tw = badge_attr.size().width
            badge_w = tw + 14
            badge_h = 18
            badge_x = rect.size.width - badge_w - 14
            badge_y = (rect.size.height - badge_h) / 2
            _ns_color(CARD_BG).setFill()
            NSBezierPath.bezierPathWithRoundedRect_xRadius_yRadius_(
                NSMakeRect(badge_x, badge_y, badge_w, badge_h),
                9, 9,
            ).fill()
            badge_attr.drawAtPoint_(
                (badge_x + (badge_w - tw) / 2, badge_y + 2)
            )

    def mouseEntered_(self, event):  # noqa: N802, ARG002
        if self._enabled:
            self._hovered = True
            self.setNeedsDisplay_(True)

    def mouseExited_(self, event):  # noqa: N802, ARG002
        self._hovered = False
        self.setNeedsDisplay_(True)

    def mouseDown_(self, event):  # noqa: N802, ARG002
        if self._enabled and self._on_click:
            try:
                self._on_click(self._id)
            except Exception as e:
                print(f"[bank] sidebar click error: {e}")


# ── Bank window controller ──────────────────────────────────────────────
class BankWindow:
    def __init__(self, store, app=None):
        self.store = store
        self.app = app                    # reference to LirelleApp for shared state (cleanup level)
        self.window = None
        self._content_host = None         # right-side container that holds the active pane
        self._panes: dict[str, NSView] = {}  # cached pane views by section id
        self._search = None
        self._sidebar_items: dict[str, SidebarItem] = {}
        self._native_sidebar = None  # set in _build_window — hidden when WebView is mounted
        self._web_shell = None       # lazy-init in _activate_web_shell
        self._current_section = "transcripts"
        # CR #15 — legacy AppKit pane state was removed. The bank window is
        # a WebShell host now; all pane data lives on the JS side, queried
        # via the lumo bridge handlers in web_handlers.py.

    # ── public ──
    def show(self) -> None:
        AppHelper.callAfter(self._show_main)

    def _show_main(self) -> None:
        try:
            if self.window is None:
                print("[bank] building window for the first time")
                self._build_window()
                self.window.center()
            # Bring the window forward normally — makeKeyAndOrderFront is
            # the polite door knock. We deliberately do NOT call
            # `orderFrontRegardless()` here — that's the nuclear option
            # that puts the window above EVERY other app system-wide, and
            # if anything triggers _show_main on a wrong cadence (e.g. an
            # NSApplicationDidBecomeActive listener) the user ends up with
            # an app they can't click away from. Same logic for the
            # `activateIgnoringOtherApps_` — moved into the explicit
            # user-action call sites (the global hotkey + dock reopen)
            # instead of every show.
            self.window.makeKeyAndOrderFront_(None)
            print(f"[bank] window shown (frame={self.window.frame()})")
        except Exception as e:
            import traceback
            print(f"[bank] _show_main error: {e}")
            traceback.print_exc()

    def show_from_user_action(self) -> None:
        """Called when the user EXPLICITLY asks for the window — global
        hotkey or dock-icon reopen. Activates the app so the window comes
        forward even if Lirelle was in the background. Do NOT use from any
        notification observer or timer."""
        def _do():
            NSApplication.sharedApplication().activateIgnoringOtherApps_(True)
            self._show_main()
        AppHelper.callAfter(_do)

    # ── build ──
    def _build_window(self) -> None:
        rect = NSMakeRect(0, 0, WIN_W, WIN_H)
        style = (
            NSWindowStyleMaskTitled
            | NSWindowStyleMaskClosable
            | NSWindowStyleMaskMiniaturizable
            | NSWindowStyleMaskResizable
            | NSWindowStyleMaskFullSizeContentView
        )
        win = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
            rect, style, NSBackingStoreBuffered, False
        )
        win.setTitle_("Lirelle")
        win.setTitlebarAppearsTransparent_(True)
        win.setMovableByWindowBackground_(True)
        win.setBackgroundColor_(_ns_color(BG))
        win.setMinSize_(NSMakeSize(MIN_WIN_W, MIN_WIN_H))

        root = FilledView.alloc().initWithFrame_color_radius_(
            NSMakeRect(0, 0, WIN_W, WIN_H), BG, 0.0
        )

        # sidebar
        sidebar = FilledView.alloc().initWithFrame_color_radius_(
            NSMakeRect(0, 0, SIDEBAR_W, WIN_H), SIDEBAR_BG, 0.0
        )
        # Stash reference so we can hide it when the design's web sidebar
        # is in play (WebShell mode).
        self._native_sidebar = sidebar
        # glowing moon orb (logo accent) — shifted down for breathing room from titlebar
        orb_size = 24
        orb_y = WIN_H - 78
        orb = MoonOrb.alloc().initWithFrame_(
            NSMakeRect(20, orb_y, orb_size, orb_size)
        )
        sidebar.addSubview_(orb)

        # wordmark — slightly bigger (16pt), aligned with orb center, breathing room from chrome
        wm = NSTextField.alloc().initWithFrame_(NSMakeRect(48, orb_y - 1, 110, 22))
        wm.setAttributedStringValue_(
            _attr_str("LIRELLE", size=16, color=TEXT, weight=NSFontWeightMedium, letter_spacing=1.8)
        )
        wm.setBezeled_(False)
        wm.setEditable_(False)
        wm.setDrawsBackground_(False)
        wm.setSelectable_(False)
        sidebar.addSubview_(wm)

        # section items
        y = WIN_H - 110
        # Track which group we last emitted so we can insert the "ACCOUNT"
        # section label between the main nav and the account/settings group.
        last_group = None
        # Pre-count meetings for the badge.
        try:
            from .meeting_recorder import meetings_root
            meetings_count = sum(
                1 for c in meetings_root().iterdir() if c.is_dir()
            )
        except Exception:
            meetings_count = None
        for sid, label, enabled, icon_name, group in SECTIONS:
            if last_group is not None and group != last_group:
                # Group separator label ("ACCOUNT" between Adapt and Account).
                label_h = 28
                section_label = NSTextField.alloc().initWithFrame_(
                    NSMakeRect(28, y - label_h, SIDEBAR_W - 56, 18)
                )
                section_label.setAttributedStringValue_(_attr_str(
                    group.upper(), size=11, color=INK_DIM,
                    weight=NSFontWeightSemibold, letter_spacing=1.0,
                ))
                section_label.setBezeled_(False); section_label.setEditable_(False)
                section_label.setDrawsBackground_(False); section_label.setSelectable_(False)
                sidebar.addSubview_(section_label)
                y -= label_h
            last_group = group
            count = meetings_count if sid == "meetings" else None
            item = SidebarItem.alloc().initWithFrame_id_label_enabled_active_icon_count_onClick_(
                NSMakeRect(0, y - 38, SIDEBAR_W, 38),
                sid, label, enabled, sid == self._current_section,
                icon_name, count,
                self._show_section,   # CR #15 — route through unified handler
            )
            sidebar.addSubview_(item)
            self._sidebar_items[sid] = item
            y -= 42

        # footer hint
        hint = NSTextField.alloc().initWithFrame_(NSMakeRect(20, 16, SIDEBAR_W - 40, 28))
        hint.setAttributedStringValue_(
            _attr_str("Hold ⌥ to dictate\n⌘⌥V repaste last", size=10, color=INK, mono=True, alpha=0.65)
        )
        hint.setBezeled_(False)
        hint.setEditable_(False)
        hint.setDrawsBackground_(False)
        hint.setSelectable_(False)
        hint.cell().setLineBreakMode_(NSLineBreakByWordWrapping)
        hint.cell().setWraps_(True)
        sidebar.addSubview_(hint)

        root.addSubview_(sidebar)

        # content host — swaps active pane (transcripts / settings)
        host = FilledView.alloc().initWithFrame_color_radius_(
            NSMakeRect(SIDEBAR_W, 0, WIN_W - SIDEBAR_W, WIN_H), BG, 0.0
        )
        root.addSubview_(host)
        self._content_host = host

        win.setContentView_(root)
        win.setDelegate_(self._make_delegate())
        self.window = win

        # show the initial section
        self._show_section(self._current_section)

    # ── window resize ──
    def _relayout_for_resize(self) -> None:
        """Recompute frames after a window resize. Sidebar stays fixed width,
        spans full height. Host fills the remaining area. Sidebar items are
        repositioned because their Y depends on the new height. The active
        pane is rebuilt because every pane reads host.bounds() at build time."""
        if self.window is None or self._content_host is None:
            return
        root = self.window.contentView()
        if root is None:
            return
        size = root.bounds().size
        new_w, new_h = size.width, size.height

        # resize root (covers full window content)
        root.setFrame_(NSMakeRect(0, 0, new_w, new_h))

        # resize sidebar (fixed width, full height) and rebuild its inner items
        # so they re-anchor to the new top
        for sub in list(root.subviews()):
            # sidebar is the FilledView at x=0 with width = SIDEBAR_W
            fr = sub.frame()
            if abs(fr.origin.x) < 0.5 and abs(fr.size.width - SIDEBAR_W) < 0.5 and sub is not self._content_host:
                sub.setFrame_(NSMakeRect(0, 0, SIDEBAR_W, new_h))
                self._rebuild_sidebar_items(sub, new_h)

        # resize host and clear its subviews — rebuild active pane against new size
        self._content_host.setFrame_(NSMakeRect(SIDEBAR_W, 0, new_w - SIDEBAR_W, new_h))
        self._show_section(self._current_section)

    def _rebuild_sidebar_items(self, sidebar, new_h: float) -> None:
        """Re-place the orb, wordmark, section items, and footer hint inside the
        sidebar after a height change. Their Y coords were computed against the
        original WIN_H so they'd float up off-screen otherwise."""
        # remove all current sidebar children, rebuild from scratch
        for sub in list(sidebar.subviews()):
            sub.removeFromSuperview()
        self._sidebar_items = {}

        orb_size = 24
        orb_y = new_h - 78
        orb = MoonOrb.alloc().initWithFrame_(NSMakeRect(20, orb_y, orb_size, orb_size))
        sidebar.addSubview_(orb)

        wm = NSTextField.alloc().initWithFrame_(NSMakeRect(48, orb_y - 1, 110, 22))
        wm.setAttributedStringValue_(
            _attr_str("LIRELLE", size=16, color=TEXT, weight=NSFontWeightMedium, letter_spacing=1.8)
        )
        wm.setBezeled_(False); wm.setEditable_(False)
        wm.setDrawsBackground_(False); wm.setSelectable_(False)
        sidebar.addSubview_(wm)

        y = new_h - 110
        last_group = None
        try:
            from .meeting_recorder import meetings_root
            meetings_count = sum(
                1 for c in meetings_root().iterdir() if c.is_dir()
            )
        except Exception:
            meetings_count = None
        for sid, label, enabled, icon_name, group in SECTIONS:
            if last_group is not None and group != last_group:
                label_h = 28
                section_label = NSTextField.alloc().initWithFrame_(
                    NSMakeRect(28, y - label_h, SIDEBAR_W - 56, 18)
                )
                section_label.setAttributedStringValue_(_attr_str(
                    group.upper(), size=11, color=INK_DIM,
                    weight=NSFontWeightSemibold, letter_spacing=1.0,
                ))
                section_label.setBezeled_(False); section_label.setEditable_(False)
                section_label.setDrawsBackground_(False); section_label.setSelectable_(False)
                sidebar.addSubview_(section_label)
                y -= label_h
            last_group = group
            count = meetings_count if sid == "meetings" else None
            item = SidebarItem.alloc().initWithFrame_id_label_enabled_active_icon_count_onClick_(
                NSMakeRect(0, y - 38, SIDEBAR_W, 38),
                sid, label, enabled, sid == self._current_section,
                icon_name, count,
                self._show_section,   # CR #15 — route through unified handler
            )
            sidebar.addSubview_(item)
            self._sidebar_items[sid] = item
            y -= 42

        hint = NSTextField.alloc().initWithFrame_(NSMakeRect(20, 16, SIDEBAR_W - 40, 28))
        hint.setAttributedStringValue_(
            _attr_str("Hold ⌥ to dictate\n⌘⌥V repaste last", size=10, color=INK, mono=True, alpha=0.65)
        )
        hint.setBezeled_(False); hint.setEditable_(False)
        hint.setDrawsBackground_(False); hint.setSelectable_(False)
        hint.cell().setLineBreakMode_(NSLineBreakByWordWrapping)
        hint.cell().setWraps_(True)
        sidebar.addSubview_(hint)

    # ── section switching ──
    def _show_section(self, sid: str) -> None:
        """Route every section through the WebShell. The four real
        sections (meetings, transcripts, account, settings) each have a
        designer HTML file; anything else is rejected so we can't end up
        rendering an undefined pane. CR #15 — the legacy AppKit fallback
        branches that used to live here are gone."""
        host = self._content_host
        if host is None:
            return
        for sub in list(host.subviews()):
            sub.removeFromSuperview()

        if sid not in ("meetings", "transcripts", "account", "settings"):
            print(f"[bank] _show_section ignored unknown id: {sid!r}",
                  flush=True)
            return
        self._activate_web_shell(sid)
        self._current_section = sid

    def refresh_settings_view(self) -> None:
        """Called by LirelleApp.set_cleanup_level so the bank reload-section
        pulls the fresh value. The Settings UI lives in the WebShell now;
        re-loading the section re-runs settings-bind.js which queries
        the current cleanup level via the bridge."""
        if self._current_section == "settings":
            shell = getattr(self, "_web_shell", None)
            if shell is not None:
                shell.load_section("settings")

    def _activate_web_shell(self, sid: str) -> None:
        """Switch the bank window to WebView mode for `sid`.
          • lazy-creates WebShell on first call (registers bridge handlers)
          • hides the native AppKit sidebar (design has its own inside HTML)
          • expands content_host to fill the entire window
          • loads ui/{Section}.html into the WebView
        """
        # Lazy-create WebShell.
        shell = getattr(self, "_web_shell", None)
        if shell is None:
            from .web_shell import WebShell
            shell = WebShell()
            shell.build(NSMakeRect(0, 0, WIN_W, WIN_H))
            # Wire the design sidebar nav clicks → Python section switch.
            shell.bridge.set_handler(
                "switchSection",
                lambda args: self._on_web_switch_section(args),
            )
            # Data handlers (Phase B) — meetings list + single meeting.
            from . import web_handlers
            shell.bridge.set_handler("requestMeetings", web_handlers.list_meetings)
            shell.bridge.set_handler("requestMeeting", web_handlers.get_meeting)
            # Transcripts pane — dictation history + clipboard copy.
            # Deletion is intentionally NOT wired; the trash icon in the
            # design is a no-op until we have a real confirm flow (per
            # GLOBAL RULE #1 in OPS/CLAUDE.md — no deletion without
            # explicit per-item confirmation).
            shell.bridge.set_handler("requestTranscripts",
                                     web_handlers.list_transcripts)
            shell.bridge.set_handler("copyTranscript",
                                     web_handlers.copy_transcript_to_clipboard)
            shell.bridge.set_handler("retryMeetingSummary",
                                     web_handlers.retry_meeting_summary)
            shell.bridge.set_handler("retrySummariesForAwaiting",
                                     web_handlers.retry_summaries_for_awaiting)
            shell.bridge.set_handler("retryMeetingTranscribe",
                                     web_handlers.retry_meeting_transcribe)
            shell.bridge.set_handler("updateMeetingTitle",
                                     web_handlers.update_meeting_title)
            # Bridge LirelleApp.transcriber to retry_meeting_transcribe so
            # it can reach the warmed-up MLX without re-importing src.app.
            # CR (2026-06-30) — point the Settings probe at the ACTIVE
            # dictation transcriber (CloudDictator when cloud_mode=true,
            # MLX/whisper-cpp when cloud_mode=false). Was wired to
            # `self.app.transcriber` which is always the LOCAL fallback
            # — that made the OFFLINE/ONLINE pill in Settings claim
            # "Local — MLX Whisper" even on a Mac running Cloud Mode
            # with no Whisper model on disk. Now reads
            # `dictation_transcriber`; get_transcriber_status still
            # falls back to a fresh local probe to report local_ready.
            web_handlers.set_live_transcriber_provider(
                lambda: getattr(self.app, "dictation_transcriber", None)
                        or getattr(self.app, "transcriber", None))
            shell.bridge.set_handler("requestUserCleanupLevel",
                                     web_handlers.get_user_cleanup_level)
            # Account + Settings panes.
            shell.bridge.set_handler("requestAccountInfo",
                                     web_handlers.get_account_info)
            shell.bridge.set_handler("signOut",
                                     web_handlers.sign_out)
            shell.bridge.set_handler("signOutEverywhere",
                                     web_handlers.sign_out_everywhere)
            shell.bridge.set_handler("removeDevice",
                                     web_handlers.remove_device)
            shell.bridge.set_handler("requestSettings",
                                     web_handlers.get_settings)
            # CR (2026-06-23) — Settings pane "Offline Model" surface.
            shell.bridge.set_handler("requestTranscriberStatus",
                                     web_handlers.get_transcriber_status)
            shell.bridge.set_handler("downloadOfflineModel",
                                     web_handlers.download_offline_model)
            # CR (2026-06-27) — used by Account → Manage subscription /
            # Upgrade to send the user to https://lirelle.us/cabinet
            # in their default browser.
            shell.bridge.set_handler("openExternalUrl",
                                     web_handlers.open_external_url)
            # CR #4 — wrap update_setting so cleanup.level changes apply
            # live. Without this, the Settings UI silently lied: writes
            # landed in config.json but every dictation kept using the
            # old level until restart, because the dictation pipeline
            # reads self.app._cleanup_level (loaded once at startup, kept
            # in sync only by set_cleanup_level — which the JS handler
            # never called).
            def _live_update_setting(args):
                res = web_handlers.update_setting(args)
                try:
                    if res.get("ok") and args.get("key") == "cleanup.level":
                        AppHelper.callAfter(self.app.set_cleanup_level,
                                            args.get("value"))
                except Exception as e:
                    print(f"[bank] live cleanup.level apply failed: {e}",
                          flush=True)
                return res
            shell.bridge.set_handler("updateSetting", _live_update_setting)
            # Audit #5 — off-device summary consent.
            shell.bridge.set_handler("requestSummaryConsent",
                                     web_handlers.get_summary_consent)
            shell.bridge.set_handler("setSummaryConsent",
                                     web_handlers.set_summary_consent)
            # Actions (Phase C) — start/stop/reveal + live recording state.
            shell.bridge.set_handler("startMeeting",
                                     lambda args: self._on_web_start_meeting(args))
            shell.bridge.set_handler("stopMeeting",
                                     lambda args: self._on_web_stop_meeting(args))
            shell.bridge.set_handler("revealMeeting",
                                     lambda args: self._on_web_reveal_meeting(args))
            shell.bridge.set_handler("requestRecordingState",
                                     lambda args: self._on_web_request_recording_state(args))
            # Archive (non-destructive — flips meta.archived) + favorite.
            # Real deletion is intentionally NOT exposed — see
            # GLOBAL RULE #1 in OPS/CLAUDE.md. archiveMeeting requires
            # args.confirmed == True so a click can't fire it by accident.
            shell.bridge.set_handler("archiveMeeting",
                                     lambda args: self._on_web_archive_meeting(args))
            shell.bridge.set_handler("toggleFavoriteMeeting",
                                     lambda args: self._on_web_toggle_favorite_meeting(args))
            self._web_shell = shell

        # CR (2026-06-23) — wire MeetingRecorder lifecycle callbacks on
        # EVERY load_section (not just first-time shell create). The
        # recorder is a lazy singleton, and on first launch the property
        # access in the hook block might create a fresh instance whose
        # on_stopped is None. If the user then started a meeting from
        # the capsule before opening the bank, the bank's wired-once
        # closures could be referencing a stale shell — pushes never
        # reach the JS, and the in-app Stop button is forever stuck on
        # "Stopping…". Re-wiring per section navigation guarantees the
        # closures always point at the currently-visible WebShell.
        try:
            rec = self.app.meeting_recorder
            if rec is not None:
                def _on_meeting_stopped(saved_dir):
                    s = getattr(self, "_web_shell", None)
                    if s is None:
                        return
                    try:
                        s.bridge.push("recording-stopped", {
                            "id": saved_dir.name if saved_dir else "",
                        })
                    except Exception as e:  # noqa: BLE001
                        print(f"[bank] on_stopped push failed: {e}",
                              flush=True)

                def _on_meeting_transcribed(saved_dir):
                    s = getattr(self, "_web_shell", None)
                    if s is None:
                        return
                    try:
                        s.bridge.push("meeting-transcribed", {
                            "id": saved_dir.name if saved_dir else "",
                        })
                    except Exception as e:  # noqa: BLE001
                        print(f"[bank] on_transcribed push failed: {e}",
                              flush=True)

                rec.on_stopped = _on_meeting_stopped
                rec.on_transcribed = _on_meeting_transcribed
        except Exception as e:
            print(f"[bank] meeting_recorder hook setup failed: {e}",
                  flush=True)

        # Hide native sidebar (the design ships its own inside the HTML).
        if self._native_sidebar is not None:
            self._native_sidebar.setHidden_(True)
        # Expand content_host to cover the entire window.
        host = self._content_host
        win = self.window
        win_size = win.contentView().bounds().size if win else NSMakeSize(WIN_W, WIN_H)
        host.setFrame_(NSMakeRect(0, 0, win_size.width, win_size.height))

        # Mount the WebView + load the requested page.
        wv = shell.view()
        wv.setFrame_(host.bounds())
        host.addSubview_(wv)
        shell.load_section(sid)

    def _on_web_start_meeting(self, _args: dict) -> dict:
        """JS-side click on "Start meeting". Spawns a MeetingRecording on
        a bg thread, returns {ok, error?}. The web UI gets a live
        recording-started push + per-second recording-tick pushes via
        the bridge so it can transform to recording mode + run the
        timer."""
        try:
            # CR #6 — single recorder owned by LirelleApp. Previously this
            # path created a SECOND MeetingRecorder instance (bank-local)
            # that the menubar didn't know about; a menubar-started
            # recording would render the Meetings page as idle and
            # clicking Start here would launch a duplicate.
            rec = self.app.meeting_recorder
            recording = rec.start()
            self._start_recording_ticker()
            shell = getattr(self, "_web_shell", None)
            if shell is not None:
                try:
                    started_at_iso = recording.started_at.isoformat(timespec="seconds")
                except Exception:
                    started_at_iso = ""
                shell.bridge.push("recording-started", {
                    "id": getattr(recording, "meeting_dir", None) and recording.meeting_dir.name or "",
                    "started_at": started_at_iso,
                })
            return {"ok": True}
        except Exception as e:
            print(f"[bank] web startMeeting failed: {e}", flush=True)
            return {"ok": False, "error": str(e)}

    def _on_web_stop_meeting(self, _args: dict) -> dict:
        """JS-side click on "Stop and save". Returns ok=True even if no
        active recording (idempotent). After stop completes (on the bg
        thread), a recording-stopped push fires so the web UI can revert
        and refresh the meeting list."""
        try:
            rec = self.app.meeting_recorder  # CR #6
            if rec is not None:
                import threading as _th
                def _stop_in_bg():
                    try:
                        rec.stop()
                    except Exception as e:
                        print(f"[bank] rec.stop() failed: {e}", flush=True)
                    # CR (2026-06-23) — safety-net push. on_stopped is
                    # wired in load_section and SHOULD have fired during
                    # rec.stop(); the duplicate is intentional belt-and-
                    # suspenders for cases where the closure references
                    # a stale shell, the recorder was recreated, or stop
                    # raised before reaching the callback. exitRecordingUI
                    # in the JS is idempotent — extra push is harmless,
                    # missing push leaves the user stuck at "Stopping…".
                    s = getattr(self, "_web_shell", None)
                    if s is not None:
                        try:
                            s.bridge.push("recording-stopped", {"id": ""})
                        except Exception as e:  # noqa: BLE001
                            print(f"[bank] stop safety-push failed: {e}",
                                  flush=True)
                _th.Thread(target=_stop_in_bg, daemon=True).start()
            return {"ok": True}
        except Exception as e:
            print(f"[bank] web stopMeeting failed: {e}", flush=True)
            return {"ok": False, "error": str(e)}

    def _on_web_request_recording_state(self, _args: dict) -> dict:
        """Snapshot of current recording state. Used by the web UI on
        first load (e.g. user reopened the bank window while a recording
        was already in progress) — without this, the page would render
        as if idle and the timer would jump straight to 00:00."""
        rec = self.app.meeting_recorder   # CR #6
        if rec is None or not rec.is_recording():
            return {"recording": False}
        # rec.current is the live MeetingRecording — see meeting_recorder.
        current = getattr(rec, "_current", None) or getattr(rec, "current", None)
        meeting_id = ""
        started_at_iso = ""
        if current is not None:
            try:
                meeting_id = current.meeting_dir.name
                started_at_iso = current.started_at.isoformat(timespec="seconds")
            except Exception:
                pass
        try:
            seconds = int(rec.current_duration())
        except Exception:
            seconds = 0
        return {
            "recording": True,
            "id": meeting_id,
            "started_at": started_at_iso,
            "seconds": seconds,
        }

    def _start_recording_ticker(self) -> None:
        """Push 'recording-tick' to JS every second while the underlying
        recording is alive. Tick stops when the recorder reports
        is_recording() == False. Idempotent — a previous ticker thread
        will exit naturally on its next loop because rec.is_recording()
        flips False before a new one can start (start() is serialised
        through the singleton MeetingRecorder)."""
        import threading as _th
        rec = self.app.meeting_recorder   # CR #6
        if rec is None:
            return
        def _tick_loop():
            from time import sleep as _sleep
            last_pushed = -1
            while rec.is_recording():
                try:
                    seconds = int(rec.current_duration())
                except Exception:
                    seconds = 0
                if seconds != last_pushed:
                    shell = getattr(self, "_web_shell", None)
                    if shell is not None:
                        shell.bridge.push("recording-tick", {"seconds": seconds})
                    last_pushed = seconds
                _sleep(0.5)
            # Bug-fix 2026-06-12: when recording ends EXTERNALLY (user
            # pressed Stop on the floating capsule or menubar item, not
            # in the bank window), the bank UI used to stay stuck in
            # "Recording" until the user clicked the now-stale Stop
            # button. The ticker is the canonical observer of recorder
            # state — when its while-loop exits, recording is over. Fire
            # the same `recording-stopped` push that the in-bank Stop
            # path emits so the bank UI reliably reverts no matter how
            # the stop was triggered.
            shell = getattr(self, "_web_shell", None)
            if shell is not None:
                try:
                    shell.bridge.push("recording-stopped", {"id": ""})
                except Exception as e:
                    print(f"[bank] ticker stop-push failed: {e}", flush=True)
        _th.Thread(target=_tick_loop, daemon=True).start()

    def _on_web_archive_meeting(self, args: dict) -> dict:
        """Mark a meeting as archived. NON-DESTRUCTIVE: the meeting dir
        + audio + transcript + summary all stay on disk untouched; only
        meta.json gets `archived: true` so list_meetings filters it out.
        Per OPS/CLAUDE.md GLOBAL RULE #1, deletion is forbidden — this
        handler refuses unless args.confirmed is True so a click can't
        archive by accident."""
        try:
            if not (args or {}).get("confirmed"):
                return {"ok": False, "error": "missing confirmed=true"}
            import json as _json
            from . import web_handlers
            mdir = web_handlers._safe_meeting_dir((args or {}).get("id"))
            if mdir is None:
                return {"ok": False, "error": "invalid id"}
            meta_path = mdir / "meta.json"
            try:
                meta = _json.loads(meta_path.read_text(encoding="utf-8"))
            except Exception:
                meta = {}
            meta["archived"] = True
            from datetime import datetime as _dt
            meta["archived_at"] = _dt.now().isoformat(timespec="seconds")
            meta_path.write_text(_json.dumps(meta, ensure_ascii=False, indent=2),
                                 encoding="utf-8")
            return {"ok": True}
        except Exception as e:
            return {"ok": False, "error": f"{type(e).__name__}: {e}"}

    def _on_web_toggle_favorite_meeting(self, args: dict) -> dict:
        """Flip meta.favorite for a meeting. Returns {ok, favorite}."""
        try:
            import json as _json
            from . import web_handlers
            mdir = web_handlers._safe_meeting_dir((args or {}).get("id"))
            if mdir is None:
                return {"ok": False, "error": "invalid id"}
            meta_path = mdir / "meta.json"
            try:
                meta = _json.loads(meta_path.read_text(encoding="utf-8"))
            except Exception:
                meta = {}
            new_state = not bool(meta.get("favorite"))
            meta["favorite"] = new_state
            meta_path.write_text(_json.dumps(meta, ensure_ascii=False, indent=2),
                                 encoding="utf-8")
            return {"ok": True, "favorite": new_state}
        except Exception as e:
            return {"ok": False, "error": f"{type(e).__name__}: {e}"}

    def _on_web_reveal_meeting(self, args: dict) -> dict:
        """JS-side click on Open in Finder for a meeting row."""
        try:
            from . import web_handlers
            target = web_handlers._safe_meeting_dir((args or {}).get("id"))
            if target is None:
                return {"ok": False, "error": "invalid id"}
            import subprocess as _sub
            _sub.Popen(["open", str(target)])
            return {"ok": True}
        except Exception as e:
            return {"ok": False, "error": str(e)}

    def _on_web_switch_section(self, args: dict) -> bool:
        """JS-side `lumo.call('switchSection', {id})` handler. Args id is
        lowercase label text ('meetings' / 'transcripts' / 'account' /
        'settings'). Returns True for the JS Promise."""
        sid = (args or {}).get("id")
        if sid in ("meetings", "transcripts", "account", "settings"):
            AppHelper.callAfter(self._show_section, sid)
        return True

    def _build_meetings_pane_view(self) -> NSView:
        """Meetings pane — hosts the design HTML via WKWebView.

        The bank window's content host gets a WKWebView that loads the
        designer's actual `ui/Meetings.html` so the UI is pixel-perfect
        with the design tool. Data flows through the lumo bridge in
        web_shell.py (lumo.call('requestMeetings') etc.).

        Native bits that stay AppKit (and are NOT inside this WebView):
          • Window titlebar + traffic lights
          • Floating meeting capsule (NSPanel)
          • Floating dictation capsule (NSPanel)
          • Hotkeys, menubar, notifications
        Everything inside the content host (sidebar nav + meetings UI) is
        the web page — including the LIRELLE orb + sidebar nav, because the
        design puts them inside the page."""
        host_w = self._content_host.bounds().size.width
        host_h = self._content_host.bounds().size.height
        frame = NSMakeRect(0, 0, host_w, host_h)

        # Lazy-init a single WebShell per BankWindow so the WKWebView
        # process stays alive across section switches.
        shell = getattr(self, "_web_shell", None)
        if shell is None:
            from .web_shell import WebShell
            shell = WebShell()
            shell.build(frame)
            self._web_shell = shell
            # TODO Phase B: register data handlers — bridge.register(
            #   'requestMeetings', self._on_request_meetings) etc.

        # Load (or reload) the Meetings page.
        shell.load_section("meetings")
        wv = shell.view()
        # Make sure the WebView frame matches whatever host size we have now.
        wv.setFrame_(frame)
        return wv

    def _make_delegate(self):
        d = _BankWindowDelegate.alloc().initWithBank_(self)
        self._delegate = d  # retain
        return d
