#!/usr/bin/env python3
"""
Dragon Vault — Menu Bar App
Local-only encrypted version control. Lives in the macOS menu bar.
Mirrors the DragonSource (dragonkey) console pattern.

© 2026 DigiFX LLC · Dragon Vault
"""

import rumps
import subprocess
import shutil
import os
import sys
import json
from datetime import datetime

from AppKit import (
    NSWindow, NSWindowStyleMaskTitled, NSWindowStyleMaskClosable,
    NSWindowStyleMaskFullSizeContentView, NSBackingStoreBuffered,
    NSImage, NSImageView, NSTextField, NSFont, NSColor,
    NSTextAlignmentCenter, NSTextAlignmentLeft, NSTextAlignmentRight, NSApp,
    NSOpenPanel, NSView,
)
from Foundation import NSMakeRect, NSBundle


# ─── Configuration ──────────────────────────────────────────────────────────

VERSION = "0.1.0"
APP_NAME = "DragonVault"
KEYCHAIN_SERVICE = "DragonVault"
KEYCHAIN_ACCOUNT = "default-passphrase"
POLL_INTERVAL_SECS = 10
SUBPROCESS_PATH = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
VAULT_BIN_CANDIDATES = [
    "/opt/homebrew/bin/vault",
    "/usr/local/bin/vault",
]
NODE_BIN_CANDIDATES = [
    "/opt/homebrew/bin/node",
    "/usr/local/bin/node",
    "/usr/bin/node",
]


def _find_node_bin():
    """Find Node.js binary — prefers bundled runtime inside .app, then system."""
    # 1. Bundled Node inside .app/Contents/Resources/bundled/node/bin/node
    bundled = get_resource_path(os.path.join("bundled", "node", "bin", "node"))
    if bundled and os.path.isfile(bundled) and os.access(bundled, os.X_OK):
        return bundled
    # 2. Flat layout fallback (legacy)
    bundled_flat = get_resource_path(os.path.join("node", "bin", "node"))
    if bundled_flat and os.path.isfile(bundled_flat) and os.access(bundled_flat, os.X_OK):
        return bundled_flat
    # 3. System Node
    for c in NODE_BIN_CANDIDATES:
        if os.path.isfile(c) and os.access(c, os.X_OK):
            return c
    return shutil.which("node")


def _find_bundled_vault():
    """Find the bundled vault.js inside the app's Resources/ or project root."""
    candidates = [
        get_resource_path(os.path.join("bundled", "vault.js")),  # .app with dir structure
        get_resource_path("vault.js"),                           # .app flat (legacy)
    ]
    # Also check project-level bundled dir (standalone install)
    dragon_config = os.path.expanduser("~/.dragon/config.json")
    if os.path.isfile(dragon_config):
        try:
            with open(dragon_config) as f:
                cfg = json.load(f)
            vault_root = cfg.get("platforms", {}).get("VAULT_ROOT", "")
            if vault_root:
                vault_root = os.path.expanduser(vault_root)
                candidates.append(os.path.join(vault_root, "bundled", "vault.js"))
        except Exception:
            pass
    candidates.append(os.path.expanduser("~/Library/Application Support/DragonVault/bundled/vault.js"))
    for c in candidates:
        if c and os.path.isfile(c):
            return c
    return None


def find_vault_bin():
    """Find the vault CLI — prefers bundled, falls back to system binary."""
    for c in VAULT_BIN_CANDIDATES:
        if os.path.isfile(c) and os.access(c, os.X_OK):
            return c
    return shutil.which("vault")


def get_resource_path(filename):
    """Find a resource file. Bundled .app → Resources/. Dev → script dir."""
    bundle = NSBundle.mainBundle()
    if bundle:
        resources_dir = bundle.resourcePath()
        if resources_dir:
            p = os.path.join(resources_dir, filename)
            if os.path.exists(p):
                return p
    dev_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), filename)
    if os.path.exists(dev_path):
        return dev_path
    return None


# Resolved on launch — vault data can live in multiple locations.
# Priority: ~/.dragon/config.json → Application Support → dev source tree (fallback).
def _resolve_vault_folders():
    """Build ordered list of candidate paths for the encrypted-vault directory."""
    candidates = []
    dragon_config = os.path.expanduser("~/.dragon/config.json")
    if os.path.isfile(dragon_config):
        try:
            with open(dragon_config) as f:
                cfg = json.load(f)
            vault_root = cfg.get("platforms", {}).get("VAULT_ROOT", "")
            if vault_root:
                vault_root = os.path.expanduser(vault_root)
                candidates.append(os.path.join(vault_root, "encrypted-vault"))
        except Exception:
            pass
    candidates.append(os.path.expanduser("~/Library/Application Support/DragonVault/encrypted-vault"))
    candidates.append(os.path.expanduser("~/1. Platforms/VAULT/encrypted-vault"))
    return candidates

VAULT_FOLDER_CANDIDATES = _resolve_vault_folders()


def find_vault_folder():
    for c in VAULT_FOLDER_CANDIDATES:
        if os.path.isdir(c):
            return c
    return None


NODE_BIN = _find_node_bin()
BUNDLED_VAULT = _find_bundled_vault()
VAULT_BIN = find_vault_bin()
ICON_PATH = get_resource_path("menubar_icon.png")
ABOUT_LOGO = get_resource_path("about_logo.png")

if not NODE_BIN and not VAULT_BIN:
    import AppKit
    alert = AppKit.NSAlert.alloc().init()
    alert.setMessageText_("Dragon Vault — Node.js Not Found")
    alert.setInformativeText_(
        "Vault requires Node.js to run the CLI.\n\n"
        "The bundled runtime was not found. Please reinstall Vault.app "
        "or install Node.js from https://nodejs.org"
    )
    alert.setAlertStyle_(AppKit.NSAlertStyleCritical)
    alert.runModal()


# ─── Keychain helpers ───────────────────────────────────────────────────────

def keychain_get(account=KEYCHAIN_ACCOUNT, service=KEYCHAIN_SERVICE):
    r = subprocess.run(
        ["security", "find-generic-password", "-s", service, "-a", account, "-w"],
        capture_output=True, encoding='utf-8', errors='replace'
    )
    if r.returncode == 0:
        return r.stdout.rstrip("\n")
    return None


def keychain_set(passphrase, account=KEYCHAIN_ACCOUNT, service=KEYCHAIN_SERVICE):
    subprocess.run(
        ["security", "delete-generic-password", "-s", service, "-a", account],
        capture_output=True
    )
    subprocess.run(
        ["security", "add-generic-password", "-s", service, "-a", account,
         "-w", passphrase, "-T", "/usr/bin/security"],
        check=True
    )


def keychain_delete(account=KEYCHAIN_ACCOUNT, service=KEYCHAIN_SERVICE):
    subprocess.run(
        ["security", "delete-generic-password", "-s", service, "-a", account],
        capture_output=True
    )


# ─── Subprocess helpers ─────────────────────────────────────────────────────

def call_vault(args, passphrase=None, timeout=15):
    """Invoke the vault CLI. Prefers bundled vault.js (via node), falls back to
    system-installed vault binary. Returns CompletedProcess."""
    # Determine how to invoke the vault CLI
    if BUNDLED_VAULT and NODE_BIN:
        cmd = [NODE_BIN, BUNDLED_VAULT] + args
    elif VAULT_BIN:
        cmd = [VAULT_BIN] + args
    else:
        return subprocess.CompletedProcess(args, 127, "", "vault CLI not found (no bundle, no system binary)")
    env = os.environ.copy()
    env["PATH"] = SUBPROCESS_PATH + ":" + env.get("PATH", "")
    # Set NODE_PATH so the bundled vault.js can find better-sqlite3 next to it
    if BUNDLED_VAULT:
        bundled_dir = os.path.dirname(BUNDLED_VAULT)
        env["NODE_PATH"] = os.path.join(bundled_dir, "node_modules") + ":" + env.get("NODE_PATH", "")
    if passphrase:
        env["VAULT_PASSPHRASE"] = passphrase
    try:
        return subprocess.run(
            cmd,
            env=env, capture_output=True, encoding='utf-8', errors='replace', timeout=timeout,
        )
    except subprocess.TimeoutExpired:
        return subprocess.CompletedProcess(args, 124, "", f"vault {args[0]} timed out after {timeout}s")


def fetch_status(passphrase):
    """Returns (data, error_kind) — error_kind is one of None, 'no-vault', 'wrong-pass', 'other'."""
    r = call_vault(["status", "--json"], passphrase=passphrase, timeout=10)
    if r.returncode != 0:
        msg = (r.stderr or "") + (r.stdout or "")
        if "No vault found" in msg:
            return None, 'no-vault'
        if "Wrong passphrase" in msg:
            return None, 'wrong-pass'
        return None, 'other'
    try:
        return json.loads(r.stdout), None
    except json.JSONDecodeError:
        return None, 'other'


def pick_directory(title="Select a directory to track"):
    """Native macOS folder picker. Returns absolute path string or None."""
    panel = NSOpenPanel.openPanel()
    panel.setTitle_(title)
    panel.setCanChooseFiles_(False)
    panel.setCanChooseDirectories_(True)
    panel.setAllowsMultipleSelection_(False)
    panel.setPrompt_("Add to Vault")
    NSApp.activateIgnoringOtherApps_(True)
    if panel.runModal() != 1:  # 1 == NSModalResponseOK
        return None
    urls = panel.URLs()
    if not urls:
        return None
    return str(urls[0].path())


def fetch_log(passphrase, limit=10, platform_name=None):
    """Fetch snapshot log, optionally filtered to one platform."""
    args = ["log", str(limit), "--json"]
    if platform_name:
        args += ["--platform", platform_name]
    r = call_vault(args, passphrase=passphrase, timeout=10)
    if r.returncode != 0:
        return []
    try:
        return json.loads(r.stdout)
    except json.JSONDecodeError:
        return []


# ─── Console window ─────────────────────────────────────────────────────────

class ConsoleWindow:
    """Live status console — mirrors DragonSource's console aesthetic."""

    def __init__(self):
        self.window = None
        self.views = {}
        self._create_window()

    def _create_window(self):
        w, h = 520, 460
        self.window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
            NSMakeRect(0, 0, w, h),
            NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskFullSizeContentView,
            NSBackingStoreBuffered, False
        )
        self.window.setTitle_("")
        self.window.center()
        self.window.setTitlebarAppearsTransparent_(True)
        self.window.setBackgroundColor_(NSColor.blackColor())
        self.window.setReleasedWhenClosed_(False)
        content = self.window.contentView()

        # Logo
        if ABOUT_LOGO and os.path.exists(ABOUT_LOGO):
            img = NSImage.alloc().initWithContentsOfFile_(ABOUT_LOGO)
            if img:
                iv = NSImageView.alloc().initWithFrame_(NSMakeRect((w - 100) / 2, h - 130, 100, 100))
                iv.setImageScaling_(3)
                iv.setImage_(img)
                content.addSubview_(iv)

        content.addSubview_(self._label("Dragon Vault", 0, h - 170, w, 28, size=22, bold=True))
        content.addSubview_(self._label(f"v{VERSION}", 0, h - 195, w, 16, size=11, color=NSColor.darkGrayColor()))

        y = h - 240
        content.addSubview_(self._label("STATUS", 40, y, 100, 14, size=10, color=NSColor.grayColor(), align=NSTextAlignmentLeft))
        self.views['status'] = self._label("—", 140, y, w - 180, 14, size=12, bold=True, align=NSTextAlignmentLeft)
        content.addSubview_(self.views['status'])

        y -= 32
        content.addSubview_(self._label("LATEST", 40, y, 100, 14, size=10, color=NSColor.grayColor(), align=NSTextAlignmentLeft))
        self.views['latest'] = self._label("—", 140, y, w - 180, 14, size=11, color=NSColor.lightGrayColor(), align=NSTextAlignmentLeft)
        content.addSubview_(self.views['latest'])

        y -= 32
        content.addSubview_(self._label("CHANGES", 40, y, 100, 14, size=10, color=NSColor.grayColor(), align=NSTextAlignmentLeft))
        self.views['changes'] = self._label("—", 140, y, w - 180, 14, size=11, color=NSColor.lightGrayColor(), align=NSTextAlignmentLeft)
        content.addSubview_(self.views['changes'])

        y -= 50
        content.addSubview_(self._label("TRACKED DIRECTORIES", 40, y, 250, 14, size=10, color=NSColor.grayColor(), align=NSTextAlignmentLeft))
        self.views['dirs'] = self._label("—", 40, y - 90, w - 80, 90, size=11, mono=True, color=NSColor.lightGrayColor(), align=NSTextAlignmentLeft)
        content.addSubview_(self.views['dirs'])

        # Footer
        content.addSubview_(self._label("© 2026 DigiFX LLC · Dragon Vault — local-only, AES-256-GCM",
                                        0, 16, w, 14, size=10, color=NSColor.darkGrayColor()))

    def _label(self, text, x, y, width, height, size=13, bold=False, mono=False,
               color=None, align=NSTextAlignmentCenter):
        label = NSTextField.alloc().initWithFrame_(NSMakeRect(x, y, width, height))
        label.setStringValue_(text)
        if bold:
            label.setFont_(NSFont.boldSystemFontOfSize_(size))
        elif mono:
            label.setFont_(NSFont.monospacedSystemFontOfSize_weight_(size, 0.0))
        else:
            label.setFont_(NSFont.systemFontOfSize_(size))
        label.setTextColor_(color or NSColor.whiteColor())
        label.setAlignment_(align)
        label.setBezeled_(False)
        label.setDrawsBackground_(False)
        label.setEditable_(False)
        label.setSelectable_(True)
        return label

    def update(self, state):
        if not self.window:
            return
        if state.get('locked'):
            self.views['status'].setStringValue_("✗  Locked — passphrase required")
            self.views['status'].setTextColor_(NSColor.colorWithCalibratedRed_green_blue_alpha_(0.95, 0.55, 0.20, 1.0))
            self.views['latest'].setStringValue_("Click 'Set Passphrase…' in the menu bar")
            self.views['changes'].setStringValue_("—")
            self.views['dirs'].setStringValue_("—")
            return
        if state.get('no_vault'):
            self.views['status'].setStringValue_("○  No vault initialized")
            self.views['status'].setTextColor_(NSColor.lightGrayColor())
            self.views['latest'].setStringValue_("Click 'Add Platform…' in the menu to create the vault")
            self.views['changes'].setStringValue_("—")
            self.views['dirs'].setStringValue_("(no vault yet)")
            return
        if state.get('error'):
            self.views['status'].setStringValue_(f"⚠  {state['error']}")
            self.views['status'].setTextColor_(NSColor.colorWithCalibratedRed_green_blue_alpha_(0.95, 0.30, 0.30, 1.0))
            return

        s = state.get('data') or {}
        platforms = s.get('platforms', []) or []

        # Aggregate across platforms for the headline.
        total_changes = 0
        latest_overall = None
        for p in platforms:
            c = p.get('changes', {})
            total_changes += len(c.get('new', [])) + len(c.get('modified', [])) + len(c.get('deleted', []))
            ls = p.get('latestSnapshot') or None
            if ls and (latest_overall is None or ls['timestamp'] > latest_overall['timestamp']):
                latest_overall = {**ls, 'platformName': p['platformName']}

        if not platforms:
            self.views['status'].setStringValue_("○  No platforms tracked")
            self.views['status'].setTextColor_(NSColor.lightGrayColor())
        elif total_changes == 0 and latest_overall:
            self.views['status'].setStringValue_(f"●  Clean ({len(platforms)} platform{'s' if len(platforms) != 1 else ''})")
            self.views['status'].setTextColor_(NSColor.colorWithCalibratedRed_green_blue_alpha_(0.30, 0.90, 0.40, 1.0))
        elif latest_overall is None:
            self.views['status'].setStringValue_(f"○  No snapshots yet ({len(platforms)} platform{'s' if len(platforms) != 1 else ''})")
            self.views['status'].setTextColor_(NSColor.lightGrayColor())
        else:
            self.views['status'].setStringValue_(f"⚠  {total_changes} change{'s' if total_changes != 1 else ''} pending")
            self.views['status'].setTextColor_(NSColor.colorWithCalibratedRed_green_blue_alpha_(0.95, 0.75, 0.20, 1.0))

        if latest_overall:
            ts = datetime.fromtimestamp(latest_overall['timestamp'] / 1000)
            self.views['latest'].setStringValue_(
                f"[{latest_overall['platformName']}]  {latest_overall['id'][:8]}  ·  {ts.strftime('%Y-%m-%d %H:%M')}  ·  {latest_overall['message']}"
            )
        else:
            self.views['latest'].setStringValue_("(no snapshots yet — pick a platform → Commit Now…)")

        # Render per-platform breakdown in the changes line.
        if platforms:
            parts = []
            for p in platforms:
                c = p.get('changes', {})
                n = len(c.get('new', [])) + len(c.get('modified', [])) + len(c.get('deleted', []))
                marker = "●" if n == 0 and p.get('latestSnapshot') else ("⚠" if n else "○")
                parts.append(f"{marker} {p['platformName']} ({n})")
            self.views['changes'].setStringValue_("   ·   ".join(parts))
        else:
            self.views['changes'].setStringValue_("—")

        dirs = s.get('trackedDirectories', [])
        if dirs:
            self.views['dirs'].setStringValue_("\n".join(d['path'] for d in dirs))
        else:
            self.views['dirs'].setStringValue_("(none — click Add Platform… in the menu)")

    def show(self):
        if self.window:
            self.window.makeKeyAndOrderFront_(None)
            NSApp.activateIgnoringOtherApps_(True)


# ─── Menu bar application ───────────────────────────────────────────────────

class DragonVaultApp(rumps.App):

    def __init__(self):
        icon = ICON_PATH if ICON_PATH and os.path.exists(ICON_PATH) else None
        # template=False keeps the dragon's color in the menu bar.
        # Set to True for a monochrome silhouette that auto-tints to the menu bar theme.
        super().__init__(name=APP_NAME, title=None, icon=icon, template=False, quit_button=None)

        self._state = {'data': None, 'locked': True, 'error': None, 'no_vault': False}
        self._passphrase = keychain_get()
        if self._passphrase:
            self._state['locked'] = False

        self.console = ConsoleWindow()
        self._init_menu()

        # Ensure state file exists in Application Support so Glyph can detect Vault
        self._ensure_state_file()

        # Initial poll, then schedule
        self._poll(None)
        self.timer = rumps.Timer(self._poll, POLL_INTERVAL_SECS)
        self.timer.start()

    def _ensure_state_file(self):
        """Create .vault-state/auto-snapshot.json in Application Support if missing."""
        state_dir = os.path.expanduser("~/Library/Application Support/DragonVault/.vault-state")
        state_file = os.path.join(state_dir, "auto-snapshot.json")
        if not os.path.isfile(state_file):
            os.makedirs(state_dir, exist_ok=True)
            import json as _json
            _json.dump({
                "bypass": False,
                "lastToggled": "",
                "toggledBy": "vault-menubar",
                "platforms": 0,
                "pendingChanges": 0,
            }, open(state_file, "w"), indent=2)

    # --- menu construction ---

    def _init_menu(self):
        """Build the initial menu skeleton with hero status card."""
        self.hero_item = rumps.MenuItem("")
        self._platforms_signature: tuple = ()
        self._platform_menu_items: dict = {}
        self._build_menu([])
        self._setup_hero_view()

    def _setup_hero_view(self):
        """Build the custom hero status card (NSView in the top menu item).
        Mirrors Crypt's hero card pattern."""
        w, h = 280, 72
        view = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, w, h))

        # ── Mode label (large, bold) ── "CLEAN", "823 PENDING", "LOCKED"
        self._hero_mode = NSTextField.alloc().initWithFrame_(NSMakeRect(16, h - 26, 160, 20))
        self._hero_mode.setStringValue_("INITIALIZING")
        self._hero_mode.setBezeled_(False)
        self._hero_mode.setDrawsBackground_(False)
        self._hero_mode.setEditable_(False)
        self._hero_mode.setSelectable_(False)
        self._hero_mode.setFont_(NSFont.boldSystemFontOfSize_(14))
        self._hero_mode.setTextColor_(NSColor.labelColor())
        view.addSubview_(self._hero_mode)

        # ── Badge (right-aligned) ── "4 platforms"
        self._hero_badge = NSTextField.alloc().initWithFrame_(NSMakeRect(w - 110, h - 26, 94, 20))
        self._hero_badge.setStringValue_("")
        self._hero_badge.setBezeled_(False)
        self._hero_badge.setDrawsBackground_(False)
        self._hero_badge.setEditable_(False)
        self._hero_badge.setSelectable_(False)
        self._hero_badge.setFont_(NSFont.monospacedSystemFontOfSize_weight_(10, 0.0))
        self._hero_badge.setTextColor_(NSColor.secondaryLabelColor())
        self._hero_badge.setAlignment_(NSTextAlignmentRight)
        view.addSubview_(self._hero_badge)

        # ── Subtitle ── "All platforms up to date"
        self._hero_subtitle = NSTextField.alloc().initWithFrame_(NSMakeRect(16, h - 44, 250, 14))
        self._hero_subtitle.setStringValue_("Starting up…")
        self._hero_subtitle.setBezeled_(False)
        self._hero_subtitle.setDrawsBackground_(False)
        self._hero_subtitle.setEditable_(False)
        self._hero_subtitle.setSelectable_(False)
        self._hero_subtitle.setFont_(NSFont.systemFontOfSize_(11))
        self._hero_subtitle.setTextColor_(NSColor.secondaryLabelColor())
        view.addSubview_(self._hero_subtitle)

        # ── Detail line (latest snapshot) ─────────────────────────────────
        self._hero_detail = NSTextField.alloc().initWithFrame_(NSMakeRect(16, h - 60, 250, 13))
        self._hero_detail.setStringValue_("")
        self._hero_detail.setBezeled_(False)
        self._hero_detail.setDrawsBackground_(False)
        self._hero_detail.setEditable_(False)
        self._hero_detail.setSelectable_(False)
        self._hero_detail.setFont_(NSFont.monospacedSystemFontOfSize_weight_(9.5, 0.0))
        self._hero_detail.setTextColor_(
            NSColor.colorWithCalibratedRed_green_blue_alpha_(0.55, 0.55, 0.55, 1.0)
        )
        view.addSubview_(self._hero_detail)

        # Attach custom view to the underlying NSMenuItem
        self.hero_item._menuitem.setView_(view)

    def _update_hero(self):
        """Update the hero card labels and colors based on current state."""
        if self._state.get('locked'):
            self._hero_mode.setStringValue_("LOCKED")
            self._hero_mode.setTextColor_(
                NSColor.colorWithCalibratedRed_green_blue_alpha_(0.95, 0.55, 0.20, 1.0)
            )
            self._hero_subtitle.setStringValue_("Set passphrase to unlock")
            self._hero_subtitle.setTextColor_(NSColor.secondaryLabelColor())
            self._hero_detail.setStringValue_("")
            self._hero_badge.setStringValue_("")
            return

        if self._state.get('no_vault'):
            self._hero_mode.setStringValue_("NO VAULT")
            self._hero_mode.setTextColor_(NSColor.secondaryLabelColor())
            self._hero_subtitle.setStringValue_("Add a platform to get started")
            self._hero_subtitle.setTextColor_(NSColor.secondaryLabelColor())
            self._hero_detail.setStringValue_("")
            self._hero_badge.setStringValue_("")
            return

        if self._state.get('error'):
            self._hero_mode.setStringValue_("ERROR")
            self._hero_mode.setTextColor_(
                NSColor.colorWithCalibratedRed_green_blue_alpha_(0.90, 0.30, 0.30, 1.0)
            )
            self._hero_subtitle.setStringValue_(self._state['error'][:40])
            self._hero_subtitle.setTextColor_(NSColor.secondaryLabelColor())
            self._hero_detail.setStringValue_("")
            self._hero_badge.setStringValue_("")
            return

        platforms = (self._state.get('data') or {}).get('platforms', []) or []
        total_changes = 0
        latest_overall = None
        for p in platforms:
            c = p.get('changes', {})
            total_changes += len(c.get('new', [])) + len(c.get('modified', [])) + len(c.get('deleted', []))
            ls = p.get('latestSnapshot') or None
            if ls and (latest_overall is None or ls['timestamp'] > latest_overall['timestamp']):
                latest_overall = {**ls, 'platformName': p['platformName']}

        plat_count = len(platforms)
        badge = f"{plat_count} platform{'s' if plat_count != 1 else ''}" if plat_count else ""
        self._hero_badge.setStringValue_(badge)

        if not platforms:
            self._hero_mode.setStringValue_("EMPTY")
            self._hero_mode.setTextColor_(NSColor.secondaryLabelColor())
            self._hero_subtitle.setStringValue_("No platforms tracked yet")
            self._hero_subtitle.setTextColor_(NSColor.secondaryLabelColor())
        elif total_changes == 0 and latest_overall:
            green = NSColor.colorWithCalibratedRed_green_blue_alpha_(0.20, 0.88, 0.35, 1.0)
            self._hero_mode.setStringValue_("CLEAN")
            self._hero_mode.setTextColor_(green)
            self._hero_subtitle.setStringValue_("All platforms up to date")
            self._hero_subtitle.setTextColor_(green.colorWithAlphaComponent_(0.7))
        elif latest_overall is None:
            self._hero_mode.setStringValue_("READY")
            self._hero_mode.setTextColor_(NSColor.secondaryLabelColor())
            self._hero_subtitle.setStringValue_("No snapshots yet — commit to start")
            self._hero_subtitle.setTextColor_(NSColor.secondaryLabelColor())
        else:
            amber = NSColor.colorWithCalibratedRed_green_blue_alpha_(0.95, 0.75, 0.20, 1.0)
            self._hero_mode.setStringValue_(f"{total_changes} PENDING")
            self._hero_mode.setTextColor_(amber)
            pending_plats = sum(
                1 for p in platforms
                if sum(len(p.get('changes', {}).get(k, [])) for k in ('new', 'modified', 'deleted')) > 0
            )
            self._hero_subtitle.setStringValue_(
                f"Changes across {pending_plats} platform{'s' if pending_plats != 1 else ''}"
            )
            self._hero_subtitle.setTextColor_(amber.colorWithAlphaComponent_(0.7))

        # Detail line: latest snapshot info
        if latest_overall:
            ts = datetime.fromtimestamp(latest_overall['timestamp'] / 1000).strftime('%m-%d %H:%M')
            msg = latest_overall['message'][:28]
            self._hero_detail.setStringValue_(
                f"[{latest_overall['platformName']}] {latest_overall['id'][:8]} · {ts} · {msg}"
            )
        else:
            self._hero_detail.setStringValue_("")

    # --- Launch at Login ---

    LAUNCHD_LABEL = "com.digifx.vault.menubar"
    LAUNCHD_PLIST_PATH = os.path.expanduser(
        f"~/Library/LaunchAgents/{LAUNCHD_LABEL}.plist"
    )
    LAUNCHD_PLIST_CONTENT = f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>{LAUNCHD_LABEL}</string>

    <key>ProgramArguments</key>
    <array>
        <string>/usr/bin/open</string>
        <string>/Applications/DragonSource/Vault.app</string>
    </array>

    <key>RunAtLoad</key>
    <true/>

    <key>KeepAlive</key>
    <false/>
</dict>
</plist>
"""

    def _is_launch_at_login(self):
        return os.path.isfile(self.LAUNCHD_PLIST_PATH)

    def _toggle_launch_at_login(self, sender):
        if self._is_launch_at_login():
            subprocess.run(
                ["launchctl", "unload", self.LAUNCHD_PLIST_PATH],
                capture_output=True
            )
            try:
                os.remove(self.LAUNCHD_PLIST_PATH)
            except OSError:
                pass
        else:
            os.makedirs(os.path.dirname(self.LAUNCHD_PLIST_PATH), exist_ok=True)
            with open(self.LAUNCHD_PLIST_PATH, "w") as f:
                f.write(self.LAUNCHD_PLIST_CONTENT)
            subprocess.run(
                ["launchctl", "load", self.LAUNCHD_PLIST_PATH],
                capture_output=True
            )
        self._build_menu(self._last_platforms_data or [])

    def _build_menu(self, platforms_data):
        """(Re)build the entire menu. Called on init and when the platform
        list changes between polls."""
        self.menu.clear()
        self._platform_menu_items = {}

        items = [
            self.hero_item,
            rumps.separator,
            rumps.MenuItem("Open Console", callback=self.open_console),
            rumps.separator,
        ]

        if platforms_data:
            for p in platforms_data:
                pname = p['platformName']
                parent = self._build_platform_submenu(pname, p)
                self._platform_menu_items[pname] = parent
                items.append(parent)
            items.append(rumps.separator)

        launch_label = "✓ Launch at Login" if self._is_launch_at_login() else "  Launch at Login"
        items.extend([
            rumps.MenuItem("Add Platform…", callback=self.add_platform),
            rumps.separator,
            rumps.MenuItem("Open Vault Folder", callback=self.open_vault_folder),
            rumps.MenuItem("Set Passphrase…", callback=self.set_passphrase),
            rumps.MenuItem("Forget Passphrase", callback=self.forget_passphrase),
            rumps.separator,
            rumps.MenuItem(launch_label, callback=self._toggle_launch_at_login),
            rumps.separator,
            rumps.MenuItem("About", callback=self.show_about),
            rumps.MenuItem("Quit", callback=rumps.quit_application),
        ])

        self.menu = items
        self._platforms_signature = tuple(p['platformName'] for p in platforms_data)
        self._last_platforms_data = platforms_data

    def _build_platform_submenu(self, platform_name, platform_data):
        """A platform's submenu: status, Commit, Rollback to…, View Snapshots,
        Edit .vaultignore."""
        latest = platform_data.get('latestSnapshot')
        changes = platform_data.get('changes', {})
        new_n = len(changes.get('new', []))
        mod_n = len(changes.get('modified', []))
        del_n = len(changes.get('deleted', []))
        total = new_n + mod_n + del_n

        if not latest:
            status_text = f"○  {platform_name} — no snapshots yet"
        elif total == 0:
            status_text = f"●  {platform_name} — clean"
        else:
            status_text = f"⚠  {platform_name} — {total} change{'s' if total != 1 else ''}"

        parent = rumps.MenuItem(status_text)

        # Latest snapshot summary line (clickable: copies the id to clipboard)
        if latest:
            ts = datetime.fromtimestamp(latest['timestamp'] / 1000).strftime('%Y-%m-%d %H:%M')
            latest_label = f"    {latest['id'][:8]} · {ts} · {latest['message'][:32]}"
        else:
            latest_label = "    (run Commit Now… to make the first snapshot)"

        children = [
            rumps.MenuItem(latest_label),
            rumps.separator,
            rumps.MenuItem("Commit Now…", callback=self._make_commit_cb(platform_name)),
            self._build_rollback_submenu(platform_name),
            rumps.MenuItem("View Snapshots", callback=self._make_log_cb(platform_name)),
            rumps.separator,
            rumps.MenuItem("Edit .vaultignore…", callback=self._make_ignore_cb(platform_name)),
            rumps.MenuItem("Reveal in Finder", callback=self._make_reveal_cb(platform_name)),
            rumps.separator,
            rumps.MenuItem("Remove Platform…", callback=self._make_remove_cb(platform_name)),
        ]
        parent.update(children)
        return parent

    def _build_rollback_submenu(self, platform_name):
        """List up to 10 recent snapshots; clicking one starts the rollback flow."""
        parent = rumps.MenuItem("Rollback to…")
        snapshots = fetch_log(self._passphrase, limit=10, platform_name=platform_name) if self._passphrase else []
        if not snapshots:
            parent.update([rumps.MenuItem("(no snapshots yet)")])
            return parent
        items = []
        for s in snapshots:
            ts = datetime.fromtimestamp(s['timestamp'] / 1000).strftime('%m-%d %H:%M')
            label = f"{s['id'][:8]}  {ts}  {s['message'][:36]}"
            items.append(rumps.MenuItem(label, callback=self._make_rollback_cb(platform_name, s)))
        parent.update(items)
        return parent

    # --- polling ---

    def _poll(self, _):
        try:
            if not self._passphrase:
                self._state = {'data': None, 'locked': True, 'error': None, 'no_vault': False}
            elif not VAULT_BIN and not (BUNDLED_VAULT and NODE_BIN):
                self._state = {'data': None, 'locked': False, 'error': "vault CLI not found on PATH", 'no_vault': False}
            else:
                data, err = fetch_status(self._passphrase)
                if err == 'no-vault':
                    self._state = {'data': None, 'locked': False, 'error': None, 'no_vault': True}
                elif err == 'wrong-pass':
                    self._state = {'data': None, 'locked': False, 'error': "Wrong passphrase — click 'Set Passphrase…' to retry", 'no_vault': False}
                elif err is not None:
                    self._state = {'data': None, 'locked': False, 'error': "Cannot read vault status", 'no_vault': False}
                else:
                    self._state = {'data': data, 'locked': False, 'error': None, 'no_vault': False}
        except Exception as exc:
            self._state = {'data': None, 'locked': False, 'error': f"Internal: {type(exc).__name__}", 'no_vault': False}
            sys.stderr.write(f"[DragonVault] poll error: {exc!r}\n")
        self._update_menu()
        self.console.update(self._state)

    def _update_menu(self):
        """Update the hero card and sync platform submenus."""
        self._update_hero()

        if self._state.get('locked') or self._state.get('no_vault') or self._state.get('error'):
            self._sync_platforms_in_menu([])
            return

        platforms = (self._state.get('data') or {}).get('platforms', []) or []
        self._sync_platforms_in_menu(platforms)

    def _sync_platforms_in_menu(self, platforms_data):
        """Rebuild platform submenus only when the platform list changes.
        When unchanged, update titles in-place to avoid expensive rebuilds."""
        new_sig = tuple(p['platformName'] for p in platforms_data)
        if new_sig == self._platforms_signature:
            self._update_platform_titles(platforms_data)
            return
        self._build_menu(platforms_data)

    def _update_platform_titles(self, platforms_data):
        """Update platform submenu titles in-place without rebuilding."""
        for p in platforms_data:
            pname = p['platformName']
            item = self._platform_menu_items.get(pname)
            if not item:
                continue
            changes = p.get('changes', {})
            total = (len(changes.get('new', []))
                     + len(changes.get('modified', []))
                     + len(changes.get('deleted', [])))
            latest = p.get('latestSnapshot')

            if not latest:
                item.title = f"○  {pname} — no snapshots yet"
            elif total == 0:
                item.title = f"●  {pname} — clean"
            else:
                item.title = f"⚠  {pname} — {total} change{'s' if total != 1 else ''}"

    # ─── helpers ───────────────────────────────────────────────────────────

    def _gate_passphrase(self) -> bool:
        if self._state.get('locked'):
            rumps.alert(title="Locked",
                        message="Click 'Set Passphrase…' first to unlock the vault.",
                        ok="OK")
            return False
        return True

    def _platform_path(self, platform_name):
        """Look up the on-disk directory for a platform name."""
        for d in (self._state.get('data') or {}).get('trackedDirectories', []):
            base = os.path.basename(d['path'].rstrip('/'))
            if base == platform_name:
                return d['path']
        return None

    # ─── per-platform action factories ─────────────────────────────────────

    def _make_commit_cb(self, platform_name):
        def cb(_):
            self._do_commit(platform_name)
        return cb

    def _make_log_cb(self, platform_name):
        def cb(_):
            self._do_show_log(platform_name)
        return cb

    def _make_ignore_cb(self, platform_name):
        def cb(_):
            self._do_edit_ignore(platform_name)
        return cb

    def _make_reveal_cb(self, platform_name):
        def cb(_):
            path = self._platform_path(platform_name)
            if path and os.path.exists(path):
                subprocess.run(["open", path])
            else:
                rumps.alert(title="Not Found",
                            message=f"Platform directory missing on disk:\n{path or '(unknown)'}",
                            ok="OK")
        return cb

    def _make_rollback_cb(self, platform_name, snapshot_payload):
        def cb(_):
            self._do_rollback(platform_name, snapshot_payload)
        return cb

    def _make_remove_cb(self, platform_name):
        def cb(_):
            self._do_remove_platform(platform_name)
        return cb

    # ─── action implementations ────────────────────────────────────────────

    def open_console(self, _):
        self.console.show()

    def add_platform(self, _):
        """Pick a directory on disk and register it as a new tracked platform.
        If the vault doesn't exist yet, initialize it first using the saved
        passphrase. Refuses duplicates."""
        if not self._gate_passphrase():
            return

        if self._state.get('no_vault'):
            confirm = rumps.alert(
                title="Initialize Vault?",
                message="No vault exists yet. Initialize one with the saved passphrase?\n\n"
                        "The vault is local and encrypted with AES-256-GCM. "
                        "Only the passphrase in your Keychain can decrypt it.",
                ok="Initialize", cancel="Cancel",
            )
            if not confirm:
                return
            r = call_vault(["init"], passphrase=self._passphrase, timeout=60)
            if r.returncode != 0:
                rumps.alert(title="Initialization Failed",
                            message=(r.stderr or r.stdout or "Unknown error")[:600],
                            ok="OK")
                return
            self._poll(None)
            if self._state.get('no_vault') or self._state.get('error'):
                return

        path = pick_directory("Select a directory to track as a platform")
        if not path:
            return

        existing_paths = {
            d['path'] for d in (self._state.get('data') or {}).get('trackedDirectories', [])
        }
        if path in existing_paths:
            rumps.alert(title="Already Tracking",
                        message=f"This directory is already a platform:\n\n{path}",
                        ok="OK")
            return

        # Two platforms can't share a basename — would collide in the menu.
        new_basename = os.path.basename(path.rstrip('/'))
        existing_basenames = {os.path.basename(p.rstrip('/')) for p in existing_paths}
        if new_basename in existing_basenames:
            rumps.alert(
                title="Platform Name Conflict",
                message=(
                    f"Another tracked directory already has the basename \"{new_basename}\".\n\n"
                    "Each platform's directory needs a unique name. Either rename the "
                    "directory before adding, or remove the existing one first."
                ),
                ok="OK",
            )
            return

        r = call_vault(["add", path], passphrase=self._passphrase, timeout=60)
        if r.returncode != 0:
            rumps.alert(title="Add Failed",
                        message=(r.stderr or r.stdout or "Unknown error")[:600],
                        ok="OK")
            return
        if "Already tracking" in (r.stdout or "") + (r.stderr or ""):
            rumps.alert(title="Already Tracking",
                        message=f"This directory is already a platform:\n\n{path}",
                        ok="OK")
            return

        self._poll(None)
        rumps.alert(
            title="Platform Added",
            message=(
                f"Now tracking platform: {new_basename}\n"
                f"Path: {path}\n\n"
                "Open the menu — you'll see it as a top-level item.\n"
                "Use its 'Commit Now…' to snapshot the current state."
            ),
            ok="OK",
        )

    def _do_commit(self, platform_name):
        if not self._gate_passphrase():
            return
        win = rumps.Window(
            message=f"Commit message for {platform_name}:",
            title=f"Dragon Vault — Commit {platform_name}",
            default_text="snapshot",
            ok="Commit",
            cancel="Cancel",
            dimensions=(360, 80),
        )
        resp = win.run()
        if not resp.clicked or not resp.text.strip():
            return

        msg = resp.text.strip()
        r = call_vault(
            ["commit", "--platform", platform_name, "-m", msg, "--json"],
            passphrase=self._passphrase, timeout=600,
        )
        if r.returncode != 0:
            rumps.alert(title="Commit Failed",
                        message=(r.stderr or r.stdout or "Unknown error")[:600],
                        ok="OK")
            return

        snap_id = ""
        file_count = 0
        skipped = []
        try:
            payload = json.loads(r.stdout)
            snap_id = payload.get('id', '')[:8]
            file_count = payload.get('fileCount', 0)
            skipped = payload.get('skipped', []) or []
        except (json.JSONDecodeError, AttributeError):
            pass

        self._poll(None)

        if skipped:
            lines = [
                f"[{platform_name}] Snapshot {snap_id} created with {file_count} file(s).",
                "",
                f"⚠ {len(skipped)} file(s) skipped:",
                "",
            ]
            for s in skipped[:8]:
                lines.append(f"  • {s.get('path', '?')}")
                lines.append(f"      ({s.get('reason', '?')})")
            if len(skipped) > 8:
                lines.append(f"  … and {len(skipped) - 8} more")
            lines.append("")
            lines.append("Use 'Edit .vaultignore…' under this platform to silence them.")
            rumps.alert(title="Commit Succeeded — Files Skipped",
                        message="\n".join(lines), ok="OK")
        else:
            rumps.notification(
                title=f"Dragon Vault — {platform_name}",
                subtitle=f"Snapshot {snap_id} created",
                message=f"{file_count} file(s) — {msg}",
                sound=False,
            )

    def _do_show_log(self, platform_name):
        if not self._gate_passphrase():
            return
        snapshots = fetch_log(self._passphrase, limit=20, platform_name=platform_name)
        if not snapshots:
            rumps.alert(title=f"{platform_name} — Snapshots",
                        message="No snapshots yet for this platform.", ok="OK")
            return
        lines = []
        for s in snapshots:
            ts = datetime.fromtimestamp(s['timestamp'] / 1000).strftime('%Y-%m-%d %H:%M')
            lines.append(f"{s['id'][:8]}  {ts}  {s['message']}  ({s['fileCount']} files)")
        rumps.alert(title=f"{platform_name} — Snapshots",
                    message="\n".join(lines), ok="Close")

    def _do_rollback(self, platform_name, snapshot_payload):
        if not self._gate_passphrase():
            return
        snap_id = snapshot_payload['id']
        ts = datetime.fromtimestamp(snapshot_payload['timestamp'] / 1000).strftime('%Y-%m-%d %H:%M')
        confirm = rumps.alert(
            title=f"Rollback {platform_name} to {snap_id[:8]}?",
            message=(
                f"You are about to restore {platform_name} to:\n\n"
                f"   {snap_id[:8]}\n"
                f"   {ts}\n"
                f"   \"{snapshot_payload['message']}\"\n"
                f"   {snapshot_payload['fileCount']} file(s)\n\n"
                "Files NOT in this snapshot are NOT deleted (additive restore).\n"
                "A safety snapshot of the current state will be created first, "
                "so you can undo this rollback."
            ),
            ok="Rollback",
            cancel="Cancel",
        )
        if not confirm:
            return

        # Use rollback CLI; it prompts y/N (promptConfirm), but our CLI calls
        # promptConfirm via readline which reads from stdin. We provide "y\n"
        # via subprocess input.
        env = os.environ.copy()
        env["PATH"] = SUBPROCESS_PATH + ":" + env.get("PATH", "")
        env["VAULT_PASSPHRASE"] = self._passphrase
        try:
            r = subprocess.run(
                [VAULT_BIN, "rollback", "--platform", platform_name, snap_id],
                env=env, capture_output=True, encoding='utf-8', errors='replace',
                input="y\n", timeout=600,
            )
        except subprocess.TimeoutExpired:
            rumps.alert(title="Rollback Timed Out",
                        message="The rollback took longer than 10 minutes — aborted.",
                        ok="OK")
            return

        if r.returncode != 0:
            rumps.alert(title="Rollback Failed",
                        message=(r.stderr or r.stdout or "Unknown error")[:800],
                        ok="OK")
            return

        # Extract the safety snapshot ID from the CLI output for the success message.
        safety_id = ""
        for line in (r.stdout or "").splitlines():
            if "Safety snapshot:" in line:
                safety_id = line.split(":", 1)[1].strip().split()[0]
                break

        self._poll(None)
        rumps.alert(
            title="Rollback Complete",
            message=(
                f"{platform_name} restored to {snap_id[:8]}.\n\n"
                + (f"Safety snapshot of pre-rollback state: {safety_id[:8]}\n"
                   "Click that snapshot under Rollback to… to undo." if safety_id else "")
            ),
            ok="OK",
        )

    def _do_remove_platform(self, platform_name):
        if not self._gate_passphrase():
            return

        # Find this platform's snapshot count for the confirmation copy.
        platforms = (self._state.get('data') or {}).get('platforms', []) or []
        plat = next((p for p in platforms if p['platformName'] == platform_name), None)
        latest = (plat or {}).get('latestSnapshot') or {}
        snap_count = "?"
        snapshots = fetch_log(self._passphrase, limit=10000, platform_name=platform_name)
        if snapshots is not None:
            snap_count = str(len(snapshots))
        target_path = self._platform_path(platform_name) or "(unknown)"

        # Three-button NSAlert via rumps.alert: ok / cancel / other.
        # rumps.alert returns 1 = ok, 0 = cancel, 2 = other.
        choice = rumps.alert(
            title=f"Remove platform “{platform_name}”?",
            message=(
                f"Path: {target_path}\n"
                f"Snapshot history: {snap_count} snapshot(s)\n\n"
                "Files on disk will NOT be modified.\n\n"
                "• Keep History — stop tracking, but keep snapshots so re-adding "
                "the same directory later auto-relinks to its history.\n"
                "• Delete History — also purge all snapshot metadata for this "
                "platform. Encrypted blobs stay on disk for now (a future GC "
                "command will reclaim those no longer referenced)."
            ),
            ok="Keep History",
            cancel="Cancel",
            other="Delete History",
        )

        if choice == 0:  # Cancel
            return

        purge = (choice == 2)
        cli_args = ["remove", "--platform", platform_name, "--yes"]
        if purge:
            cli_args.append("--purge-history")

        r = call_vault(cli_args, passphrase=self._passphrase, timeout=60)
        if r.returncode != 0:
            rumps.alert(title="Remove Failed",
                        message=(r.stderr or r.stdout or "Unknown error")[:600],
                        ok="OK")
            return

        self._poll(None)
        rumps.alert(
            title="Platform Removed",
            message=(
                f"Stopped tracking “{platform_name}”.\n"
                + ("Snapshot history purged.\n" if purge
                   else "Snapshot history kept — re-add the directory to relink.\n")
                + "Files on disk are unchanged."
            ),
            ok="OK",
        )

    def _do_edit_ignore(self, platform_name):
        if not self._gate_passphrase():
            return
        target_dir = self._platform_path(platform_name)
        if not target_dir or not os.path.isdir(target_dir):
            rumps.alert(title="Platform Missing",
                        message=f"Directory for {platform_name} not found.",
                        ok="OK")
            return

        ignore_path = os.path.join(target_dir, '.vaultignore')
        if not os.path.exists(ignore_path):
            try:
                with open(ignore_path, 'w', encoding='utf-8') as f:
                    f.write(
                        "# Dragon Vault — files and directories to skip\n"
                        "# Syntax: gitignore-like. Lines starting with # are comments.\n"
                        "#   *.iso       → matches files ending in .iso\n"
                        "#   build/      → matches the build directory and contents\n"
                        "#   /dist       → only at this directory's root\n"
                        "#   **/cache/   → matches cache/ at any depth\n"
                        "\n"
                        "# Big binary artifacts (skipped because Vault loads files into memory)\n"
                        "*.iso\n"
                        "*.dmg\n"
                        "*.tar.gz\n"
                        "*.tar.bz2\n"
                        "*.tar.xz\n"
                        "*.zip\n"
                        "*.7z\n"
                        "\n"
                        "# Common build outputs\n"
                        "build/\n"
                        "dist/\n"
                        "target/\n"
                        "out/\n"
                        "\n"
                        "# Python\n"
                        "**/__pycache__/\n"
                        "*.pyc\n"
                        "\n"
                        "# Logs and macOS noise\n"
                        "*.log\n"
                    )
            except OSError as e:
                rumps.alert(title="Can't Create .vaultignore",
                            message=str(e), ok="OK")
                return

        # Open in default text editor (TextEdit by default on macOS)
        subprocess.run(["open", "-t", ignore_path])
        rumps.notification(
            title="Dragon Vault",
            subtitle="Opened .vaultignore",
            message="Save the file, then click 'Commit Now…' again.",
            sound=False,
        )

    def open_vault_folder(self, _):
        folder = find_vault_folder()
        if folder:
            subprocess.run(["open", folder])
        else:
            rumps.alert(
                title="No Vault Yet",
                message="The vault hasn't been created yet.\n\n"
                        "Click 'Add Platform…' to set it up — it will initialize the vault "
                        "with your saved passphrase and start tracking the directory you pick.",
                ok="OK",
            )

    def set_passphrase(self, _):
        win = rumps.Window(
            message="Passphrase (will be saved to macOS Keychain):",
            title="Dragon Vault — Set Passphrase",
            default_text="",
            ok="Save",
            cancel="Cancel",
            dimensions=(360, 24),
            secure=True,
        )
        resp = win.run()
        if not resp.clicked or not resp.text:
            return
        try:
            keychain_set(resp.text)
            self._passphrase = resp.text
            self._poll(None)
            rumps.notification(title="Dragon Vault", subtitle="Passphrase saved",
                               message="", sound=False)
        except subprocess.CalledProcessError as e:
            rumps.alert(title="Keychain Error", message=str(e), ok="OK")

    def forget_passphrase(self, _):
        keychain_delete()
        self._passphrase = None
        self._poll(None)
        rumps.notification(title="Dragon Vault", subtitle="Passphrase removed from Keychain",
                           message="", sound=False)

    def show_about(self, _):
        rumps.alert(
            title="Dragon Vault",
            message=(
                f"Version {VERSION}\n\n"
                "Local-only encrypted version control.\n"
                "AES-256-GCM · scrypt · zero network calls.\n\n"
                "© 2026 DigiFX LLC · Dragon Vault"
            ),
            ok="Close",
        )


# ─── Main ───────────────────────────────────────────────────────────────────

if __name__ == "__main__":
    DragonVaultApp().run()
