"""obol admin monitor - 24/7 admin dashboard for Obol operations.

Live dashboard showing:
- VPS health (CPU, memory, disk, DB connections)
- API status (uptime, response time, errors)
- Cross-org metrics (orgs, agents, events, anomalies)
- Open alerts
- News ticker (HN + tech RSS)
- Recent events stream
"""
import concurrent.futures
import json
import os
import select
import shutil
import signal
import sys
import termios
import threading
import time
import tty
import urllib.request
import urllib.error
from datetime import datetime, timezone

from obol.cli._base import (
    CHAR_FILLED,
    CHAR_EMPTY,
    CHAR_WARNING,
    CHAR_CHECK,
    CHAR_CROSS,
    CHAR_HLINE,
    CHAR_DLINE,
    UNICODE,
)

# ── ANSI codes ──────────────────────────────────────────────────────────────

_RESET = "\033[0m"
_BOLD = "\033[1m"
_DIM = "\033[2m"
_RED = "\033[31m"
_GREEN = "\033[32m"
_YELLOW = "\033[33m"
_BLUE = "\033[34m"
_CYAN = "\033[36m"
_WHITE = "\033[37m"
_RED_BOLD = "\033[1;31m"
_GREEN_BOLD = "\033[1;32m"
_YELLOW_BOLD = "\033[1;33m"

_CLEAR = "\033[H\033[J"
_HOME = "\033[H"
_HIDE_CURSOR = "\033[?25l"
_SHOW_CURSOR = "\033[?25h"
_SAVE_CURSOR = "\033[s"
_RESTORE_CURSOR = "\033[u"

_USE_COLOR = sys.stdout.isatty()

# ── Animation & UX constants ────────────────────────────────────────────────

SPINNER_FRAMES = ["◐", "◓", "◑", "◒"] if UNICODE else ["|", "/", "-", "\\"]
TREND_UP = "↑" if UNICODE else "^"
TREND_DOWN = "↓" if UNICODE else "v"
TREND_SAME = "─" if UNICODE else "-"

# Global state for animations and trends
_spinner_idx = 0
_last_metrics = {}  # For trend tracking

# Connection state tracking (QOL #1, #2)
_connection_state = {
    "last_error": None,           # Last error message
    "consecutive_failures": 0,    # For reconnect feedback
    "last_success_time": None,    # When we last had a successful connection
    "notified_alerts": set(),     # Alert IDs we've already notified about (pruned at 5000)
    "rate_limited_until": 0,      # Timestamp when rate limit expires
    "backoff_seconds": 0,         # Current backoff duration
}

# Cache for org list (avoids repeated fetches)
_org_cache = {"data": None, "fetched_at": 0}
_ORG_CACHE_TTL = 60  # seconds


def _setup_terminal():
    """Set terminal to raw mode for keypress detection. Returns old settings."""
    if not sys.stdin.isatty():
        return None
    try:
        old_settings = termios.tcgetattr(sys.stdin)
        tty.setcbreak(sys.stdin.fileno())
        return old_settings
    except Exception:
        return None


def _restore_terminal(old_settings):
    """Restore terminal settings."""
    if old_settings is not None:
        try:
            termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
        except Exception:
            pass


def _check_keypress():
    """Check for keypress without blocking. Returns key or None."""
    if not sys.stdin.isatty():
        return None
    try:
        if select.select([sys.stdin], [], [], 0)[0]:
            return sys.stdin.read(1)
    except Exception:
        pass
    return None


def _get_key(timeout=None):
    """Block and wait for a single keypress. Returns key name.

    Handles special keys:
    - 'UP', 'DOWN', 'LEFT', 'RIGHT' for arrow keys
    - 'ESC' for escape
    - 'ENTER' for enter/return
    - 'BACKSPACE' for backspace
    - Regular characters as-is

    Args:
        timeout: Max seconds to wait (None = forever)

    Returns: Key name string, or None on timeout
    """
    if not sys.stdin.isatty():
        return None

    start = time.time()
    while True:
        # Check for timeout
        if timeout is not None:
            remaining = timeout - (time.time() - start)
            if remaining <= 0:
                return None
            wait_time = min(0.05, remaining)
        else:
            wait_time = 0.05

        try:
            if select.select([sys.stdin], [], [], wait_time)[0]:
                ch = sys.stdin.read(1)

                # Handle escape sequences
                if ch == '\x1b':
                    # Check if more characters follow (arrow keys, etc)
                    if select.select([sys.stdin], [], [], 0.05)[0]:
                        seq = sys.stdin.read(1)
                        if seq == '[':
                            if select.select([sys.stdin], [], [], 0.02)[0]:
                                code = sys.stdin.read(1)
                                if code == 'A':
                                    return 'UP'
                                elif code == 'B':
                                    return 'DOWN'
                                elif code == 'C':
                                    return 'RIGHT'
                                elif code == 'D':
                                    return 'LEFT'
                                elif code == '3':
                                    # Delete key: ESC [ 3 ~
                                    if select.select([sys.stdin], [], [], 0.02)[0]:
                                        sys.stdin.read(1)  # consume ~
                                    return 'DELETE'
                    # Plain escape
                    return 'ESC'

                # Other special keys
                if ch in ('\r', '\n'):
                    return 'ENTER'
                if ch in ('\x7f', '\x08'):
                    return 'BACKSPACE'
                if ch == '\t':
                    return 'TAB'

                return ch
        except Exception:
            pass

    return None

# Box-drawing
_TL = "\u250c" if UNICODE else "+"   # ┌
_TR = "\u2510" if UNICODE else "+"   # ┐
_BL = "\u2514" if UNICODE else "+"   # └
_BR = "\u2518" if UNICODE else "+"   # ┘
_H = "\u2500" if UNICODE else "-"    # ─
_V = "\u2502" if UNICODE else "|"    # │
_DOT = "\u25c9" if UNICODE else "*"  # ◉


def _c(code, text):
    if _USE_COLOR:
        return f"{code}{text}{_RESET}"
    return str(text)


def _strip_ansi_len(s):
    import re
    return len(re.sub(r"\033\[[0-9;]*m", "", s))


def _pad(s, width):
    visible = _strip_ansi_len(s)
    if visible >= width:
        return s
    return s + " " * (width - visible)


def _truncate(s, max_len):
    if len(s) <= max_len:
        return s
    if max_len < 4:
        return s[:max_len]
    return s[: max_len - 3] + "..."


def _progress_bar(pct, width=10):
    """ASCII progress bar."""
    filled = int(pct / 100 * width)
    filled = min(filled, width)
    bar = CHAR_FILLED * filled + CHAR_EMPTY * (width - filled)
    if pct >= 90:
        return _c(_RED, bar)
    if pct >= 70:
        return _c(_YELLOW, bar)
    return _c(_GREEN, bar)


# ── UX helpers ──────────────────────────────────────────────────────────────

def _send_desktop_notification(title: str, message: str, urgency: str = "normal"):
    """Send desktop notification (Linux). QOL #4.

    Args:
        title: Notification title
        message: Notification body
        urgency: low, normal, critical
    """
    import subprocess
    try:
        subprocess.run(
            ["notify-send", "-u", urgency, "-a", "Obol Monitor", title, message],
            capture_output=True,
            timeout=2,
        )
    except Exception:
        pass  # Notifications are best-effort


def _notify_critical_alerts(alerts: list, enabled: bool = True):
    """Send desktop notifications for new critical/high alerts. QOL #4."""
    if not enabled or not alerts:
        return

    global _connection_state
    notified = _connection_state.get("notified_alerts", set())

    for alert in alerts:
        alert_id = alert.get("id")
        severity = alert.get("severity", "").lower()

        if alert_id in notified:
            continue

        if severity in ("critical", "high"):
            urgency = "critical" if severity == "critical" else "normal"
            title = f"Obol Alert: {severity.upper()}"
            msg = f"{alert.get('alert_type', 'Unknown')}\n{alert.get('title', '')[:100]}"
            _send_desktop_notification(title, msg, urgency)
            notified.add(alert_id)

    # Prune if too large (prevent unbounded memory growth in long-running sessions)
    if len(notified) > 5000:
        # Keep only last 1000 entries (arbitrary slice from set — older alerts drop)
        notified = set(list(notified)[-1000:])
    _connection_state["notified_alerts"] = notified


def _show_help_overlay(old_settings):
    """Show full help overlay with all hotkeys. QOL #5."""
    width = min(shutil.get_terminal_size((120, 40))[0], 100)

    sys.stdout.write(_CLEAR + _HOME)
    if _USE_COLOR:
        sys.stdout.write(_SHOW_CURSOR)

    print(_c(_BOLD, " OBOL ADMIN MONITOR - HELP"))
    print(_c(_DIM, " " + "═" * (width - 2)))
    print()

    sections = [
        ("Navigation", [
            ("q", "Quit monitor"),
            ("r", "Force refresh all panels"),
            ("1", "Switch to Ops view (VPS, services, events)"),
            ("2", "Switch to Biz view (revenue, signups)"),
            ("h", "Show this help"),
        ]),
        ("Operations", [
            ("a", "Alert management - resolve or mark false positive"),
            ("s", "Service management - restart VPS services"),
            ("l", "Log viewer - view recent journalctl logs"),
        ]),
        ("Organization Tools", [
            ("o", "Org lookup - view org details, agents, events"),
            ("e", "Event viewer - browse org events with filters"),
            ("b", "Billing - refunds, credits, cancel subscriptions"),
        ]),
        ("In Menus", [
            ("↑/↓", "Navigate options"),
            ("Enter", "Select option"),
            ("ESC", "Go back / cancel"),
            ("/", "Switch to different org (in detail views)"),
            ("1-9", "Quick select numbered option"),
        ]),
    ]

    for section_name, keys in sections:
        print(f" {_c(_CYAN, section_name)}")
        print(f" {_c(_DIM, '─' * 50)}")
        for key, desc in keys:
            print(f"   {_c(_BOLD, f'{key:<8}')} {desc}")
        print()

    # Capacity thresholds section
    print(f" {_c(_CYAN, 'Capacity Thresholds')}")
    print(f" {_c(_DIM, '─' * 50)}")
    print(f"   CPU:    {_c(_YELLOW, '80%')} warning, {_c(_RED, '90%')} critical")
    print(f"   Memory: {_c(_YELLOW, '75%')} warning, {_c(_RED, '90%')} critical")
    print(f"   Disk:   {_c(_YELLOW, '70%')} warning, {_c(_RED, '85%')} critical")
    print(f"   DB:     {_c(_YELLOW, '80%')} warning, {_c(_RED, '95%')} critical")
    print()

    print(_c(_DIM, " " + "─" * (width - 2)))
    print(f" {_c(_DIM, 'Press any key to return...')}")
    sys.stdout.flush()

    # Wait for keypress
    _restore_terminal(old_settings)
    old_settings = _setup_terminal()
    _get_key()
    return old_settings


def _get_spinner():
    """Get current spinner frame and advance."""
    global _spinner_idx
    frame = SPINNER_FRAMES[_spinner_idx % len(SPINNER_FRAMES)]
    _spinner_idx += 1
    return frame


def _get_trend(key, current_value):
    """Get trend indicator comparing to last known value."""
    global _last_metrics
    last = _last_metrics.get(key)
    _last_metrics[key] = current_value
    if last is None:
        return ""
    if current_value > last:
        return _c(_RED if key in ("cpu", "mem", "disk", "alerts", "anomalies") else _GREEN, TREND_UP)
    elif current_value < last:
        return _c(_GREEN if key in ("cpu", "mem", "disk", "alerts", "anomalies") else _RED, TREND_DOWN)
    return _c(_DIM, TREND_SAME)


def _render_hotkey_bar(context="main", width=120):
    """Render context-sensitive hotkey bar. Always visible."""
    if context == "main":
        keys = [
            ("q", "quit"), ("r", "refresh"), ("1", "ops"), ("2", "biz"),
            ("a", "alerts"), ("s", "svc"), ("o", "org"), ("e", "events"),
            ("b", "billing"), ("h", "help"),
        ]
    elif context == "menu":
        keys = [("ESC", "back"), ("↑↓", "navigate"), ("Enter", "select"), ("q", "quit")]
    elif context == "detail":
        keys = [("ESC", "back"), ("e", "events"), ("b", "billing"), ("a", "alerts"), ("q", "quit")]
    elif context == "confirm":
        keys = [("y", "yes"), ("n", "no"), ("ESC", "cancel")]
    else:
        keys = [("ESC", "back"), ("q", "quit")]

    parts = []
    for key, label in keys:
        parts.append(f"{_c(_BOLD, f'[{key}]')} {label}")
    bar = "  ".join(parts)

    # Pad to width
    visible_len = _strip_ansi_len(bar)
    if visible_len < width:
        bar = bar + " " * (width - visible_len)

    return _c(_DIM, "─" * width) + "\n" + bar


def _render_header(title, width=120, spinner=True, last_update=None):
    """Render consistent header with title, spinner, and timestamp."""
    now = datetime.now(timezone.utc)
    time_str = now.strftime("%H:%M:%S UTC")

    # Spinner shows system is alive
    spin = _get_spinner() if spinner else " "

    # Last update indicator
    if last_update:
        age = (now - last_update).total_seconds()
        if age < 5:
            age_str = _c(_GREEN, "just now")
        elif age < 60:
            age_str = _c(_GREEN, f"{int(age)}s ago")
        else:
            age_str = _c(_YELLOW, f"{int(age/60)}m ago")
        update_str = f"Updated: {age_str}"
    else:
        update_str = ""

    # Build header line
    left = f" {_c(_CYAN, spin)} {_c(_BOLD, title)}"
    right = f"{update_str}  {_c(_DIM, time_str)} "

    left_len = _strip_ansi_len(left)
    right_len = _strip_ansi_len(right)
    padding = width - left_len - right_len
    if padding < 0:
        padding = 0

    header = left + " " * padding + right
    separator = _c(_DIM, "═" * width)

    return header + "\n" + separator


def _wait_for_key(valid_keys=None, timeout=None):
    """Wait for a keypress. Returns the key or None on timeout/interrupt.

    Args:
        valid_keys: List of valid keys (None = any key)
        timeout: Max seconds to wait (None = forever)
    """
    start = time.time()
    while True:
        try:
            key = _check_keypress()
            if key:
                # Handle ESC key
                if key == '\x1b':
                    return 'ESC'
                if valid_keys is None or key.lower() in [k.lower() for k in valid_keys]:
                    return key
            time.sleep(0.05)
            if timeout and (time.time() - start) > timeout:
                return None
        except (KeyboardInterrupt, SystemExit):
            return None


def _interactive_menu(title, options, context_keys=None, width=80):
    """Display an interactive menu with arrow key navigation.

    Args:
        title: Menu title
        options: List of (key, label, action_func) or just (label, action_func)
        context_keys: Additional context-specific keys to handle
        width: Display width

    Returns: Selected option index or -1 if cancelled
    """
    selected = 0

    while True:
        # Clear and render
        sys.stdout.write(_CLEAR + _HOME)
        print(_render_header(title, width, spinner=False))
        print()

        for i, opt in enumerate(options):
            label = opt[1] if len(opt) > 2 else opt[0]
            if i == selected:
                print(f"  {_c(_CYAN, '▶')} {_c(_BOLD, label)}")
            else:
                print(f"    {label}")

        print()
        print(_render_hotkey_bar("menu", width))
        sys.stdout.flush()

        # Wait for input
        key = _wait_for_key()
        if key is None or key == 'ESC' or key.lower() == 'q':
            return -1
        elif key in ('\x1b[A', 'k'):  # Up arrow or k
            selected = (selected - 1) % len(options)
        elif key in ('\x1b[B', 'j'):  # Down arrow or j
            selected = (selected + 1) % len(options)
        elif key in ('\r', '\n'):  # Enter
            return selected
        elif key.isdigit() and int(key) <= len(options):
            return int(key) - 1


# ── Data sources ────────────────────────────────────────────────────────────

def _get_admin_creds():
    """Get API key and admin secret from env/config."""
    api_key = os.environ.get("OBOL_API_KEY", "")
    admin_secret = os.environ.get("OBOL_ADMIN_SECRET", "")
    base_url = os.environ.get("OBOL_API_URL", "https://obol.grayowlresearch.com/v1")

    config_path = os.path.expanduser("~/.obol/config.json")
    if os.path.exists(config_path):
        with open(config_path) as f:
            config = json.load(f)
            api_key = api_key or config.get("api_key", "")
            admin_secret = admin_secret or config.get("admin_secret", "")
            base_url = config.get("api_url", base_url)

    return api_key, admin_secret, base_url


def _check_rate_limited():
    """Check if we're currently rate limited. Returns (is_limited, seconds_remaining)."""
    now = time.time()
    until = _connection_state.get("rate_limited_until", 0)
    if now < until:
        return True, int(until - now)
    return False, 0


def _handle_rate_limit_error(error_str):
    """Detect rate limiting and set backoff. Returns True if rate limited."""
    if "403" in error_str or "429" in error_str or "Access denied" in error_str:
        # Exponential backoff: 30s, 60s, 120s, max 300s
        current_backoff = _connection_state.get("backoff_seconds", 0)
        new_backoff = min(300, max(30, current_backoff * 2) if current_backoff else 30)
        _connection_state["backoff_seconds"] = new_backoff
        _connection_state["rate_limited_until"] = time.time() + new_backoff
        return True
    return False


def _clear_rate_limit():
    """Clear rate limit state on successful request."""
    _connection_state["rate_limited_until"] = 0
    _connection_state["backoff_seconds"] = 0


def _fetch_admin_stats(api_key, admin_secret, base_url):
    """Fetch admin stats from API."""
    # Check rate limit before making request
    is_limited, remaining = _check_rate_limited()
    if is_limited:
        return None, 0, f"Rate limited - retry in {remaining}s"

    try:
        req = urllib.request.Request(
            f"{base_url.rstrip('/')}/admin/stats/summary",
            headers={
                "Authorization": f"Bearer {api_key}",
                "X-Admin-Secret": admin_secret,
                "User-Agent": "obol-sdk/0.1.0",
            },
        )
        start = time.monotonic()
        with urllib.request.urlopen(req, timeout=10) as resp:
            data = json.loads(resp.read().decode())
        latency_ms = int((time.monotonic() - start) * 1000)
        _clear_rate_limit()  # Success - clear any backoff
        return data, latency_ms, None
    except urllib.error.HTTPError as e:
        err_str = f"HTTP {e.code}"
        _handle_rate_limit_error(err_str)
        return None, 0, err_str
    except Exception as e:
        return None, 0, str(e)


def _fetch_recent_events(api_key, admin_secret, base_url, limit=15):
    """Fetch recent events across all orgs."""
    is_limited, remaining = _check_rate_limited()
    if is_limited:
        return None, f"Rate limited - retry in {remaining}s"

    try:
        req = urllib.request.Request(
            f"{base_url.rstrip('/')}/admin/stats/events/recent?limit={limit}",
            headers={
                "Authorization": f"Bearer {api_key}",
                "X-Admin-Secret": admin_secret,
                "User-Agent": "obol-sdk/0.1.0",
            },
        )
        with urllib.request.urlopen(req, timeout=10) as resp:
            _clear_rate_limit()
            return json.loads(resp.read().decode()), None
    except urllib.error.HTTPError as e:
        _handle_rate_limit_error(f"HTTP {e.code}")
        return None, f"HTTP {e.code}"
    except Exception as e:
        return None, str(e)


def _fetch_recent_alerts(api_key, admin_secret, base_url, limit=5):
    """Fetch recent open alerts."""
    is_limited, remaining = _check_rate_limited()
    if is_limited:
        return None, f"Rate limited - retry in {remaining}s"

    try:
        req = urllib.request.Request(
            f"{base_url.rstrip('/')}/admin/stats/alerts/recent?limit={limit}",
            headers={
                "Authorization": f"Bearer {api_key}",
                "X-Admin-Secret": admin_secret,
                "User-Agent": "obol-sdk/0.1.0",
            },
        )
        with urllib.request.urlopen(req, timeout=10) as resp:
            _clear_rate_limit()
            return json.loads(resp.read().decode()), None
    except urllib.error.HTTPError as e:
        _handle_rate_limit_error(f"HTTP {e.code}")
        return None, f"HTTP {e.code}"
    except Exception as e:
        return None, str(e)


def _check_api_health(base_url):
    """Quick health check with response time."""
    try:
        # Use the public health endpoint (no auth required)
        url = base_url.rstrip("/v1").rstrip("/") + "/health"
        req = urllib.request.Request(url, headers={"User-Agent": "obol-sdk/0.1.0"})
        start = time.monotonic()
        with urllib.request.urlopen(req, timeout=5) as resp:
            data = json.loads(resp.read().decode())
        latency_ms = int((time.monotonic() - start) * 1000)
        return True, latency_ms, data.get("version", "?")
    except Exception:
        return False, 0, "?"


# ── VPS metrics via SSH ─────────────────────────────────────────────────────

def _fetch_vps_metrics():
    """Fetch VPS metrics via SSH. Returns dict or None on error."""
    from obol.cli.admin_vps import fetch_vps_stats
    return fetch_vps_stats()


def _fetch_vps_all():
    """Fetch VPS metrics AND service status in a single SSH call.

    Returns (vps_stats, services) tuple. Either can be None on error.
    """
    from obol.cli.admin_vps import _run_ssh_command

    # Combined command: all stats + services in one SSH connection
    cmd = """
echo "LOAD:$(cat /proc/loadavg | awk '{print $1}')"
echo "CPU:$(top -bn1 | grep 'Cpu(s)' | awk '{print $2}' | tr -d '%us,')"
echo "MEM:$(free -b | awk '/Mem:/ {printf "%.2f %.2f", $3/1073741824, $2/1073741824}')"
echo "DISK:$(df -BG / | awk 'NR==2 {gsub(/G/,""); printf "%s %s", $3, $2}')"
echo "DBCONN:$(sudo -u postgres psql -t -c "SELECT count(*) FROM pg_stat_activity" 2>/dev/null | tr -d ' ' || echo 0)"
echo "DBMAX:$(sudo -u postgres psql -t -c "SHOW max_connections" 2>/dev/null | tr -d ' ' || echo 100)"
echo "SVC:$(systemctl is-active obol-green nginx postgresql cloudflared 2>/dev/null | paste -sd' ')"
""".strip()

    output = _run_ssh_command(cmd)
    if not output:
        return None, None

    # Parse labeled output
    data = {}
    for line in output.strip().split("\n"):
        if ":" in line:
            key, val = line.split(":", 1)
            data[key.strip()] = val.strip()

    # Parse VPS stats
    vps_stats = None
    try:
        load_1m = float(data.get("LOAD", "0"))
        cpu_str = data.get("CPU", "0")
        cpu_pct = float(cpu_str.replace(",", ".").split()[0]) if cpu_str else 0

        mem_parts = data.get("MEM", "0 1").split()
        mem_used_gb = float(mem_parts[0])
        mem_total_gb = float(mem_parts[1]) if len(mem_parts) > 1 else 1
        mem_pct = (mem_used_gb / mem_total_gb * 100) if mem_total_gb > 0 else 0

        disk_parts = data.get("DISK", "0 1").split()
        disk_used_gb = float(disk_parts[0])
        disk_total_gb = float(disk_parts[1]) if len(disk_parts) > 1 else 1
        disk_pct = (disk_used_gb / disk_total_gb * 100) if disk_total_gb > 0 else 0

        db_connections = int(data.get("DBCONN", "0") or "0")
        db_max_connections = int(data.get("DBMAX", "100") or "100")

        vps_stats = {
            "cpu_pct": cpu_pct,
            "load_1m": load_1m,
            "mem_pct": mem_pct,
            "mem_used_gb": mem_used_gb,
            "mem_total_gb": mem_total_gb,
            "disk_pct": disk_pct,
            "disk_used_gb": disk_used_gb,
            "disk_total_gb": disk_total_gb,
            "db_connections": db_connections,
            "db_max_connections": db_max_connections,
        }
    except (ValueError, IndexError, KeyError):
        pass

    # Parse service status
    services = None
    try:
        svc_output = data.get("SVC", "")
        if svc_output:
            statuses = svc_output.strip().split()
            svc_names = ["obol-green", "nginx", "postgresql", "cloudflared"]
            services = {svc: (st == "active") for svc, st in zip(svc_names, statuses)}
    except Exception:
        pass

    return vps_stats, services


def _fetch_business_metrics(api_key, admin_secret, base_url):
    """Fetch business metrics (MRR, signups, etc.)."""
    try:
        req = urllib.request.Request(
            f"{base_url.rstrip('/')}/admin/business/metrics",
            headers={
                "Authorization": f"Bearer {api_key}",
                "X-Admin-Secret": admin_secret,
                "User-Agent": "obol-sdk/0.1.0",
            },
        )
        with urllib.request.urlopen(req, timeout=10) as resp:
            return json.loads(resp.read().decode()), None
    except Exception as e:
        return None, str(e)


def _fetch_service_status():
    """Fetch service status via SSH."""
    from obol.cli.admin_vps import _run_ssh_command
    try:
        output = _run_ssh_command(
            "systemctl is-active obol-green nginx postgresql cloudflared 2>/dev/null | paste -sd' '"
        )
        if not output:
            return None
        statuses = output.strip().split()
        services = ["obol-green", "nginx", "postgresql", "cloudflared"]
        return {svc: (st == "active") for svc, st in zip(services, statuses)}
    except Exception:
        return None


# ── Interactive actions ──────────────────────────────────────────────────────

def _resolve_alert(api_key, admin_secret, base_url, alert_id, resolution="resolved"):
    """Resolve an alert via API."""
    try:
        data = json.dumps({"resolution": resolution}).encode()
        req = urllib.request.Request(
            f"{base_url.rstrip('/')}/alerts/{alert_id}",
            data=data,
            method="PATCH",
            headers={
                "Authorization": f"Bearer {api_key}",
                "X-Admin-Secret": admin_secret,
                "Content-Type": "application/json",
                "User-Agent": "obol-sdk/0.1.0",
            },
        )
        with urllib.request.urlopen(req, timeout=10) as resp:
            return True, None
    except Exception as e:
        return False, str(e)


def _restart_service(service_name):
    """Restart a service via SSH."""
    from obol.cli.admin_vps import _run_ssh_command, VALID_SERVICES
    if service_name not in VALID_SERVICES:
        return False, "Invalid service name"
    try:
        output = _run_ssh_command(f"sudo systemctl restart {service_name}")
        return True, output
    except Exception as e:
        return False, str(e)


def _get_service_logs(service_name, lines=20):
    """Get recent logs for a service via SSH."""
    from obol.cli.admin_vps import get_recent_logs
    return get_recent_logs(service_name, lines)


def _show_alert_menu(alerts, api_key, admin_secret, base_url, old_settings):
    """Interactive alert management overlay."""
    width = min(shutil.get_terminal_size((120, 40))[0], 120)

    if not alerts:
        sys.stdout.write(_CLEAR + _HOME)
        print(_render_header("ALERTS", width, spinner=False))
        print(f"\n  {_c(_GREEN, '✓')} No open alerts")
        print(f"\n  {_c(_DIM, 'Press any key...')}")
        sys.stdout.flush()
        _get_key(timeout=2)
        _flash_back()
        return

    selected_alert = None

    while True:
        sys.stdout.write(_CLEAR + _HOME)
        print(_render_header("ALERT MANAGEMENT", width, spinner=False))
        print()

        # Show alerts with number keys (1-9, 0 for 10)
        display_alerts = alerts[:10]
        for i, alert in enumerate(display_alerts):
            key_num = (i + 1) % 10  # 1-9, then 0 for 10th
            alert_id = alert.get("id", "?")
            severity = alert.get("severity", "?")[:4].upper()
            alert_type = alert.get("alert_type", "?")
            sev_color = _RED_BOLD if severity == "CRIT" else _YELLOW if severity == "HIGH" else _DIM
            print(f"  {_c(_CYAN, f'[{key_num}]')} {_c(sev_color, severity):4}  ID:{alert_id:<5} {_truncate(alert_type, 30)}")

        print(f"\n  {_c(_DIM, '─' * 50)}")
        print(f"  {_c(_BOLD, '[0-9]')} select alert  {_c(_BOLD, '[q]')} back")
        sys.stdout.flush()

        key = _get_key()
        if key is None:
            continue
        if key == 'q' or key == 'ESC':
            _flash_back()
            return

        if key.isdigit():
            idx = int(key) - 1 if key != '0' else 9
            if 0 <= idx < len(display_alerts):
                selected_alert = display_alerts[idx]
                alert_id = selected_alert.get("id")

                # Show resolution options
                print(f"\n  Alert {_c(_BOLD, alert_id)}: {selected_alert.get('alert_type', '?')}")
                print(f"  {_c(_CYAN, '[r]')} resolve  {_c(_CYAN, '[f]')} false positive  {_c(_DIM, '[ESC]')} cancel")
                sys.stdout.flush()

                action = _get_key()
                if action == 'r':
                    _show_loading(f"Resolving alert {alert_id}...")
                    ok, err = _resolve_alert(api_key, admin_secret, base_url, alert_id, "resolved")
                    if ok:
                        print(_c(_GREEN, f"\n  ✓ Alert {alert_id} resolved"))
                        # Remove from list
                        alerts.remove(selected_alert)
                    else:
                        print(_c(_RED, f"\n  ✗ Error: {err}"))
                    print(f"\n  {_c(_DIM, 'Press any key...')}")
                    _get_key(timeout=2)
                elif action == 'f':
                    _show_loading(f"Marking alert {alert_id} as false positive...")
                    ok, err = _resolve_alert(api_key, admin_secret, base_url, alert_id, "false_positive")
                    if ok:
                        print(_c(_GREEN, f"\n  ✓ Alert {alert_id} marked false positive"))
                        alerts.remove(selected_alert)
                    else:
                        print(_c(_RED, f"\n  ✗ Error: {err}"))
                    print(f"\n  {_c(_DIM, 'Press any key...')}")
                    _get_key(timeout=2)

                if not alerts:
                    return


def _show_service_menu(services, old_settings):
    """Interactive service management overlay."""
    width = min(shutil.get_terminal_size((120, 40))[0], 120)
    svc_list = ["obol-green", "nginx", "postgresql", "cloudflared"]

    while True:
        sys.stdout.write(_CLEAR + _HOME)
        print(_render_header("SERVICE MANAGEMENT", width, spinner=False))
        print()

        for i, svc in enumerate(svc_list, 1):
            is_up = services.get(svc, False) if services else None
            if is_up is None:
                status = _c(_DIM, "?")
            elif is_up:
                status = _c(_GREEN, "UP")
            else:
                status = _c(_RED, "DOWN")
            print(f"  {_c(_CYAN, f'[{i}]')} {svc:<15} {status}")

        print(f"\n  {_c(_DIM, '─' * 40)}")
        print(f"  {_c(_BOLD, '[1-4]')} restart service  {_c(_BOLD, '[q]')} back")
        sys.stdout.flush()

        key = _get_key()
        if key is None:
            continue
        if key == 'q' or key == 'ESC':
            _flash_back()
            return

        if key.isdigit() and 1 <= int(key) <= len(svc_list):
            svc = svc_list[int(key) - 1]
            print(f"\n  Restart {_c(_BOLD, svc)}? {_c(_CYAN, '[y]')} yes  {_c(_DIM, '[n]')} no")
            sys.stdout.flush()

            confirm = _get_key()
            if confirm == 'y':
                _show_loading(f"Restarting {svc}...")
                ok, err = _restart_service(svc)
                if ok:
                    print(_c(_GREEN, f"\n  ✓ {svc} restarted"))
                else:
                    print(_c(_RED, f"\n  ✗ Error: {err}"))
                print(f"\n  {_c(_DIM, 'Press any key...')}")
                _get_key(timeout=3)


def _show_logs_menu(old_settings):
    """Interactive log viewer overlay."""
    width = min(shutil.get_terminal_size((120, 40))[0], 120)
    svc_list = ["obol-green", "nginx", "postgresql", "cloudflared"]

    while True:
        sys.stdout.write(_CLEAR + _HOME)
        print(_render_header("LOG VIEWER", width, spinner=False))
        print()

        for i, svc in enumerate(svc_list, 1):
            print(f"  {_c(_CYAN, f'[{i}]')} {svc}")

        print(f"\n  {_c(_DIM, '─' * 40)}")
        print(f"  {_c(_BOLD, '[1-4]')} view logs  {_c(_BOLD, '[q]')} back")
        sys.stdout.flush()

        key = _get_key()
        if key is None:
            continue
        if key == 'q' or key == 'ESC':
            _flash_back()
            return

        if key.isdigit() and 1 <= int(key) <= len(svc_list):
            svc = svc_list[int(key) - 1]
            _show_loading(f"Fetching logs for {svc}...")
            logs = _get_service_logs(svc, 30)

            sys.stdout.write(_CLEAR + _HOME)
            print(_render_header(f"LOGS: {svc}", width, spinner=False))
            print()

            if logs:
                for line in logs[:25]:  # Limit display
                    # Truncate long lines
                    if len(line) > width - 4:
                        line = line[:width - 7] + "..."
                    print(f"  {line}")
                if len(logs) > 25:
                    print(f"\n  {_c(_DIM, f'... and {len(logs) - 25} more lines')}")
            else:
                print(_c(_DIM, "  No logs available or SSH failed."))

            print(f"\n  {_c(_DIM, '─' * 40)}")
            print(f"  {_c(_DIM, 'Press any key to continue...')}")
            sys.stdout.flush()
            _get_key()


def _fetch_org_details(api_key, admin_secret, base_url, org_id):
    """Fetch details about a specific org."""
    try:
        req = urllib.request.Request(
            f"{base_url.rstrip('/')}/admin/orgs/{org_id}",
            headers={
                "Authorization": f"Bearer {api_key}",
                "X-Admin-Secret": admin_secret,
                "User-Agent": "obol-sdk/0.1.0",
            },
        )
        with urllib.request.urlopen(req, timeout=10) as resp:
            return json.loads(resp.read().decode()), None
    except Exception as e:
        return None, str(e)


def _show_org_lookup(api_key, admin_secret, base_url, old_settings):
    """Interactive org lookup with searchable org picker."""
    # Animated loading with cache
    orgs, err = _fetch_with_spinner(
        lambda: _get_orgs_cached(api_key, admin_secret, base_url),
        "Loading organizations..."
    )
    if err:
        print(_c(_RED, f"\n  Error: {err}"))
        print(f"\n  {_c(_DIM, 'Press any key...')}")
        _get_key(timeout=5)
        return
    if not orgs:
        print(_c(_YELLOW, "\n  No organizations found"))
        print(f"\n  {_c(_DIM, 'Press any key...')}")
        _get_key(timeout=5)
        return

    while True:
        # Show org picker
        selected_org = _org_picker(orgs, old_settings, title="ORG LOOKUP: Select Organization")
        if selected_org is None:
            return

        org_id = selected_org['id']

        # Animated loading while fetching details
        org, err = _fetch_with_spinner(
            lambda oid=org_id: _fetch_org_details(api_key, admin_secret, base_url, oid),
            f"Loading {selected_org.get('name', f'org {org_id}')}..."
        )

        if err:
            print(_c(_RED, f"\n  Error: {err}"))
            print(f"\n  {_c(_DIM, 'Press any key...')}")
            _get_key(timeout=5)
            continue

        width = min(shutil.get_terminal_size((120, 40))[0], 120)

        # Display org details - clear and render
        sys.stdout.write(_CLEAR + _HOME)
        print(_render_header(f"ORG: {org.get('name', '?')}", width, spinner=False))

        print(f"\n  {_c(_BOLD, 'Organization Details')}")
        print(f"  {_c(_DIM, '─' * 50)}")
        print(f"  ID:         {org.get('id')}")
        print(f"  Name:       {org.get('name')}")

        tier = org.get('tier', '?')
        tier_colors = {'free': _DIM, 'solo': _CYAN, 'team': _GREEN, 'enterprise': _YELLOW, 'admin': _RED}
        print(f"  Tier:       {_c(tier_colors.get(tier, _DIM), tier.upper())}")

        print(f"  Created:    {org.get('created_at', '')[:10]}")
        print(f"  Agents:     {org.get('agent_count', '?')}")
        print(f"  Events:     {org.get('event_count', '?')}")
        print(f"  Alerts:     {org.get('alert_count', '?')}")

        if org.get('email'):
            print(f"  Email:      {org.get('email')}")
        if org.get('stripe_status'):
            status = org.get('stripe_status')
            status_c = _GREEN if status == 'active' else _YELLOW if status == 'past_due' else _RED
            print(f"  Stripe:     {_c(status_c, status)}")
        if org.get('stripe_customer_id'):
            print(f"  Customer:   {org.get('stripe_customer_id')}")

        print(f"\n  {_c(_DIM, '─' * 50)}")
        print(f"  {_c(_CYAN, '[e]')} events  {_c(_CYAN, '[b]')} billing  {_c(_CYAN, '[/]')} different org  {_c(_CYAN, '[q]')} back")
        sys.stdout.flush()

        # Instant key detection
        key = _get_key()
        if key is None:
            continue
        choice = key.lower() if isinstance(key, str) and len(key) == 1 else key

        if choice == 'q' or choice == 'ESC':
            _flash_back()
            return
        elif choice == '/':
            continue  # Go back to org picker
        elif choice == 'e':
            _show_event_viewer_for_org(api_key, admin_secret, base_url, org_id, old_settings)
        elif choice == 'b':
            # Jump to billing for this org
            billing, err = _fetch_with_spinner(
                lambda oid=org_id: _fetch_org_billing(api_key, admin_secret, base_url, oid),
                f"Loading billing for {org.get('name', f'org {org_id}')}..."
            )
            if err:
                print(_c(_RED, f"\n  Error: {err}"))
                print(f"\n  {_c(_DIM, 'Press any key...')}")
                _get_key(timeout=5)
                continue

            # Show billing detail inline
            _show_billing_detail(api_key, admin_secret, base_url, org_id, billing, orgs, old_settings)
            return


def _show_billing_detail(api_key, admin_secret, base_url, org_id, billing, orgs, old_settings):
    """Show billing detail for an org (shared by billing menu and org lookup)."""
    width = min(shutil.get_terminal_size((120, 40))[0], 120)

    while True:
        _restore_terminal(old_settings)
        if _USE_COLOR:
            sys.stdout.write(_SHOW_CURSOR)
        sys.stdout.write(_CLEAR + _HOME)
        sys.stdout.flush()

        print(_render_header(f"BILLING: {billing['org_name']}", width, spinner=False))

        # Org summary
        print(f"\n  {_c(_BOLD, 'Organization')}")
        print(f"  {_c(_DIM, '─' * 45)}")
        print(f"  ID: {billing['org_id']}  │  Tier: {_c(_GREEN, billing['tier'].upper())}")
        status = billing.get('stripe_status') or 'No subscription'
        status_color = _GREEN if status == 'active' else _YELLOW if status in ('past_due', 'canceling') else _DIM
        print(f"  Stripe: {_c(status_color, status)}")

        if billing.get('customer_balance') is not None:
            bal = billing['customer_balance']
            if bal < 0:
                print(f"  Credit: {_c(_GREEN, f'${abs(bal)/100:.2f}')}")
            elif bal > 0:
                print(f"  Owes: {_c(_RED, f'${bal/100:.2f}')}")

        if billing.get('subscription'):
            sub = billing['subscription']
            end_ts = sub.get('current_period_end', 0)
            end_date = datetime.fromtimestamp(end_ts).strftime('%Y-%m-%d') if end_ts else "?"
            amt = sub.get('plan_amount', 0) / 100 if sub.get('plan_amount') else 0
            print(f"  Plan: ${amt:.0f}/{sub.get('plan_interval', 'mo')}  │  Renews: {end_date}")
            if sub.get('cancel_at_period_end'):
                print(f"  {_c(_YELLOW, '⚠ CANCELS AT PERIOD END')}")

        # Recent invoices
        invoices = billing.get('recent_invoices', [])
        charges = {}
        if invoices:
            print(f"\n  {_c(_BOLD, 'Recent Invoices')}")
            print(f"  {_c(_DIM, '─' * 45)}")
            for i, inv in enumerate(invoices[:5], 1):
                status = inv.get('status', '?')
                amount = inv.get('amount_paid', 0) / 100
                charge_id = inv.get('charge_id') or ''
                inv_date = datetime.fromtimestamp(inv.get('created', 0)).strftime('%Y-%m-%d') if inv.get('created') else '?'
                status_color = _GREEN if status == 'paid' else _YELLOW if status == 'open' else _RED
                refundable = _c(_DIM, ' [refundable]') if charge_id.startswith('ch_') else ''
                print(f"  {_c(_CYAN, f'[{i}]')} {inv_date}  ${amount:>7.2f}  {_c(status_color, status):<8}{refundable}")
                charges[str(i)] = (charge_id, amount, inv.get('number'))

        # Actions
        print(f"\n  {_c(_DIM, '─' * 45)}")
        print(f"  {_c(_CYAN, '[1-5]')} Refund  {_c(_CYAN, '[c]')} Credit  {_c(_CYAN, '[x]')} Cancel  {_c(_CYAN, '[Esc]')} Back")
        sys.stdout.flush()

        old_term = _setup_terminal()
        try:
            key = _get_key()
        finally:
            _restore_terminal(old_term)

        if key is None or key == 'ESC' or key == 'q':
            _flash_back()
            return

        choice = key.lower() if isinstance(key, str) and len(key) == 1 else ''

        # Quick refund
        if choice.isdigit() and choice in charges:
            charge_id, amount, inv_num = charges[choice]
            if not charge_id.startswith('ch_'):
                print(_c(_YELLOW, "\n  Not refundable"))
                time.sleep(1)
                continue

            print(f"\n  {_c(_BOLD, 'REFUND')}: {inv_num} - ${amount:.2f}")
            print(f"  {_c(_DIM, 'y=full refund, n=cancel')}")
            sys.stdout.flush()

            old_term = _setup_terminal()
            try:
                confirm = _get_key()
            finally:
                _restore_terminal(old_term)

            if confirm == 'y':
                print(f"\n  {_c(_DIM, 'Processing...')} ", end="", flush=True)
                result, err = _do_refund(api_key, admin_secret, base_url, org_id, charge_id, None)
                if err:
                    print(_c(_RED, f"FAILED: {err}"))
                else:
                    print(_c(_GREEN, f"Refunded ${result.get('amount_cents', 0)/100:.2f}"))
                time.sleep(1.5)
                billing, _ = _fetch_org_billing(api_key, admin_secret, base_url, org_id)

        elif choice == 'c':
            # Credit - need text input for amount
            print(f"\n  Credit amount: $", end="", flush=True)
            amt_str = input().strip()
            if amt_str:
                try:
                    cents = int(float(amt_str) * 100)
                    if cents > 0 and cents <= 100000:
                        result, err = _do_credit(api_key, admin_secret, base_url, org_id, cents, "Admin credit")
                        if err:
                            print(_c(_RED, f"  Failed: {err}"))
                        else:
                            print(_c(_GREEN, f"  Applied ${cents/100:.2f} credit"))
                        time.sleep(1.5)
                        billing, _ = _fetch_org_billing(api_key, admin_secret, base_url, org_id)
                except ValueError:
                    pass

        elif choice == 'x':
            if not billing.get('stripe_subscription_id'):
                print(_c(_YELLOW, "\n  No subscription"))
                time.sleep(1)
                continue

            print(f"\n  {_c(_BOLD, 'Cancel subscription?')} {_c(_DIM, 'y/n')}")
            sys.stdout.flush()
            old_term = _setup_terminal()
            try:
                confirm = _get_key()
            finally:
                _restore_terminal(old_term)

            if confirm == 'y':
                result, err = _do_cancel_subscription(api_key, admin_secret, base_url, org_id, immediate=False)
                if err:
                    print(_c(_RED, f"  Failed: {err}"))
                else:
                    print(_c(_GREEN, "  Cancelled at period end"))
                time.sleep(1.5)
                billing, _ = _fetch_org_billing(api_key, admin_secret, base_url, org_id)


def _fetch_org_events(api_key, admin_secret, base_url, org_id, limit=30, anomalies_only=False, since=None):
    """Fetch events for a specific org."""
    try:
        params = [f"limit={limit}"]
        if anomalies_only:
            params.append("anomalies_only=true")
        if since:
            params.append(f"since={since}")
        query = "&".join(params)

        req = urllib.request.Request(
            f"{base_url.rstrip('/')}/admin/orgs/{org_id}/events?{query}",
            headers={
                "Authorization": f"Bearer {api_key}",
                "X-Admin-Secret": admin_secret,
                "User-Agent": "obol-sdk/0.1.0",
            },
        )
        with urllib.request.urlopen(req, timeout=8) as resp:
            return json.loads(resp.read().decode()), None
    except Exception as e:
        return None, str(e)


def _show_event_viewer(api_key, admin_secret, base_url, old_settings):
    """Interactive event viewer with org picker."""
    width = min(shutil.get_terminal_size((120, 40))[0], 120)

    # Load orgs with animated spinner and cache
    orgs, err = _fetch_with_spinner(
        lambda: _get_orgs_cached(api_key, admin_secret, base_url),
        "Loading organizations..."
    )
    if err:
        print(_c(_RED, f"\n  Error: {err}"))
        print(f"\n  {_c(_DIM, 'Press any key...')}")
        _get_key(timeout=5)
        return
    if not orgs:
        print(_c(_YELLOW, "\n  No organizations found"))
        print(f"\n  {_c(_DIM, 'Press any key...')}")
        _get_key(timeout=5)
        return

    while True:
        # Show org picker
        selected_org = _org_picker(orgs, old_settings, title="EVENTS: Select Organization")
        if selected_org is None:
            return

        org_id = selected_org['id']

        # Show filter options
        sys.stdout.write(_CLEAR + _HOME)
        print(_render_header(f"EVENTS: {selected_org.get('name', '?')}", width, spinner=False))
        print()
        print(f"  {_c(_CYAN, '[1]')} All events (last 50)")
        print(f"  {_c(_CYAN, '[2]')} Anomalies only")
        print(f"  {_c(_CYAN, '[3]')} Last hour")
        print(f"  {_c(_CYAN, '[4]')} Last 24 hours")
        print(f"\n  {_c(_DIM, '─' * 40)}")
        print(f"  {_c(_BOLD, '[1-4]')} select filter  {_c(_BOLD, '[q]')} back")
        sys.stdout.flush()

        choice = _get_key()
        if choice == 'q' or choice == 'ESC':
            continue  # Back to org picker

        anomalies_only = choice == '2'
        since = None
        if choice == '3':
            since = "1h"
        elif choice == '4':
            since = "24h"

        events, err = _fetch_with_spinner(
            lambda: _fetch_org_events(api_key, admin_secret, base_url, org_id,
                                      limit=50, anomalies_only=anomalies_only, since=since),
            f"Fetching events for org {org_id}..."
        )

        # Display results
        sys.stdout.write(_CLEAR + _HOME)
        print(_render_header(f"EVENTS: {selected_org.get('name', '?')}", width, spinner=False))
        print()

        if err:
            print(_c(_RED, f"  Error: {err}"))
        elif not events:
            print(_c(_DIM, "  No events found."))
        else:
            print(f"  {_c(_GREEN, f'{len(events)} events')}")
            print(f"  {_c(_DIM, '─' * 70)}")

            # Display events in a table
            for e in events[:25]:  # Limit display
                ts = e.get("created_at", "")[:19].replace("T", " ")
                agent = e.get("agent_name") or f"agent:{e.get('agent_id')}"
                action = e.get("action", "?")[:12]
                target = (e.get("target") or "")[:30]
                result = e.get("result", "")

                # Result indicator
                if result in ("success", "allowed"):
                    res_sym = _c(_GREEN, CHAR_CHECK)
                elif result in ("error", "failure", "denied", "blocked"):
                    res_sym = _c(_RED, CHAR_CROSS)
                else:
                    res_sym = _c(_YELLOW, "~")

                # Anomaly indicator
                flags = e.get("anomaly_flags", [])
                if flags:
                    anomaly_str = _c(_RED, f" [{flags[0][:10]}]")
                else:
                    anomaly_str = ""

                print(f"  {ts[5:]}  {agent:<10} {action:<12} {res_sym} {_truncate(target, 28)}{anomaly_str}")

            if len(events) > 25:
                print(f"\n  {_c(_DIM, f'... and {len(events) - 25} more events')}")

        print(f"\n  {_c(_DIM, '─' * 40)}")
        print(f"  {_c(_BOLD, '[/]')} different org  {_c(_BOLD, '[q]')} back")
        sys.stdout.flush()

        key = _get_key()
        if key == 'q' or key == 'ESC':
            _flash_back()
            return
        # '/' or anything else goes back to org picker


def _fetch_org_billing(api_key, admin_secret, base_url, org_id):
    """Fetch billing details for an org."""
    try:
        req = urllib.request.Request(
            f"{base_url.rstrip('/')}/admin/orgs/{org_id}/billing",
            headers={
                "Authorization": f"Bearer {api_key}",
                "X-Admin-Secret": admin_secret,
                "User-Agent": "obol-sdk/0.1.0",
            },
        )
        with urllib.request.urlopen(req, timeout=8) as resp:
            return json.loads(resp.read().decode()), None
    except urllib.error.HTTPError as e:
        return None, f"HTTP {e.code}"
    except Exception as e:
        return None, str(e)


def _do_refund(api_key, admin_secret, base_url, org_id, charge_id, amount_cents=None):
    """Issue a refund for an org."""
    try:
        payload = {"charge_id": charge_id, "reason": "requested_by_customer"}
        if amount_cents:
            payload["amount_cents"] = amount_cents
        data = json.dumps(payload).encode()
        req = urllib.request.Request(
            f"{base_url.rstrip('/')}/admin/orgs/{org_id}/refund",
            data=data,
            headers={
                "Authorization": f"Bearer {api_key}",
                "X-Admin-Secret": admin_secret,
                "User-Agent": "obol-sdk/0.1.0",
                "Content-Type": "application/json",
            },
            method="POST",
        )
        with urllib.request.urlopen(req, timeout=8) as resp:
            return json.loads(resp.read().decode()), None
    except urllib.error.HTTPError as e:
        body = e.read().decode() if e.fp else ""
        return None, f"HTTP {e.code}: {body}"
    except Exception as e:
        return None, str(e)


def _do_credit(api_key, admin_secret, base_url, org_id, amount_cents, description=None):
    """Apply account credit for an org."""
    try:
        payload = {"amount_cents": amount_cents}
        if description:
            payload["description"] = description
        data = json.dumps(payload).encode()
        req = urllib.request.Request(
            f"{base_url.rstrip('/')}/admin/orgs/{org_id}/credit",
            data=data,
            headers={
                "Authorization": f"Bearer {api_key}",
                "X-Admin-Secret": admin_secret,
                "User-Agent": "obol-sdk/0.1.0",
                "Content-Type": "application/json",
            },
            method="POST",
        )
        with urllib.request.urlopen(req, timeout=8) as resp:
            return json.loads(resp.read().decode()), None
    except urllib.error.HTTPError as e:
        body = e.read().decode() if e.fp else ""
        return None, f"HTTP {e.code}: {body}"
    except Exception as e:
        return None, str(e)


def _do_cancel_subscription(api_key, admin_secret, base_url, org_id, immediate=False, reason=None):
    """Cancel subscription for an org."""
    try:
        payload = {"immediate": immediate}
        if reason:
            payload["reason"] = reason
        data = json.dumps(payload).encode()
        req = urllib.request.Request(
            f"{base_url.rstrip('/')}/admin/orgs/{org_id}/cancel-subscription",
            data=data,
            headers={
                "Authorization": f"Bearer {api_key}",
                "X-Admin-Secret": admin_secret,
                "User-Agent": "obol-sdk/0.1.0",
                "Content-Type": "application/json",
            },
            method="POST",
        )
        with urllib.request.urlopen(req, timeout=8) as resp:
            return json.loads(resp.read().decode()), None
    except urllib.error.HTTPError as e:
        body = e.read().decode() if e.fp else ""
        return None, f"HTTP {e.code}: {body}"
    except Exception as e:
        return None, str(e)


def _fetch_all_orgs(api_key, admin_secret, base_url):
    """Fetch all orgs for the org picker."""
    try:
        req = urllib.request.Request(
            f"{base_url.rstrip('/')}/admin/orgs",
            headers={
                "Authorization": f"Bearer {api_key}",
                "X-Admin-Secret": admin_secret,
                "User-Agent": "obol-sdk/0.1.0",
            },
        )
        with urllib.request.urlopen(req, timeout=8) as resp:
            return json.loads(resp.read().decode()), None
    except urllib.error.HTTPError as e:
        return None, f"HTTP {e.code}"
    except Exception as e:
        return None, str(e)


def _show_loading(message="Loading..."):
    """Show instant loading feedback (static frame)."""
    sys.stdout.write(_CLEAR + _HOME)
    width = min(shutil.get_terminal_size((120, 40))[0], 120)
    print(_render_header("LOADING", width, spinner=True))
    print()
    print(f"  {_c(_CYAN, _get_spinner())} {message}")
    sys.stdout.flush()


def _flash_back():
    """Show instant feedback when returning to main screen."""
    sys.stdout.write(_CLEAR + _HOME)
    width = min(shutil.get_terminal_size((120, 40))[0], 120)
    print(_render_header("OBOL ADMIN MONITOR", width, spinner=True))
    print()
    print(f"  {_c(_DIM, '← returning...')}")
    sys.stdout.flush()


def _fetch_with_spinner(fetch_func, message="Loading..."):
    """Execute a fetch function while showing animated spinner.

    Args:
        fetch_func: Callable that returns (data, error)
        message: Loading message to display

    Returns: (data, error) from fetch_func
    """
    width = min(shutil.get_terminal_size((120, 40))[0], 120)
    result = [None, None]  # [data, error]
    done = threading.Event()

    def do_fetch():
        try:
            result[0], result[1] = fetch_func()
        except Exception as e:
            result[1] = str(e)
        finally:
            done.set()

    # Start fetch in background
    thread = threading.Thread(target=do_fetch, daemon=True)
    thread.start()

    # Animate spinner while waiting
    global _spinner_idx
    while not done.is_set():
        frame = SPINNER_FRAMES[_spinner_idx % len(SPINNER_FRAMES)]
        _spinner_idx += 1

        sys.stdout.write(_CLEAR + _HOME)
        print(_render_header("LOADING", width, spinner=False))
        print()
        print(f"  {_c(_CYAN, frame)} {message}")
        sys.stdout.flush()

        done.wait(timeout=0.1)  # Update spinner 10x/sec

    return result[0], result[1]


def _get_orgs_cached(api_key, admin_secret, base_url, force_refresh=False):
    """Get org list with caching. Returns (orgs, error)."""
    global _org_cache

    now = time.time()
    if not force_refresh and _org_cache["data"] and (now - _org_cache["fetched_at"]) < _ORG_CACHE_TTL:
        return _org_cache["data"], None

    # Fetch fresh
    orgs, err = _fetch_all_orgs(api_key, admin_secret, base_url)
    if not err and orgs:
        _org_cache["data"] = orgs
        _org_cache["fetched_at"] = now

    return orgs, err


def _render_page_bar(current_page, total_pages, width=20):
    """Render a visual page indicator bar."""
    if total_pages <= 1:
        return ""

    # Use dots for small page counts, bar for larger
    if total_pages <= 10:
        dots = []
        for i in range(total_pages):
            if i == current_page:
                dots.append(_c(_CYAN, "●"))
            else:
                dots.append(_c(_DIM, "○"))
        return " ".join(dots) + f"  {_c(_DIM, f'{current_page + 1}/{total_pages}')}"
    else:
        # Progress bar style for many pages
        pct = current_page / (total_pages - 1) if total_pages > 1 else 0
        filled = int(pct * width)
        bar = "█" * filled + "░" * (width - filled)
        return f"{_c(_CYAN, bar)} {_c(_DIM, f'{current_page + 1}/{total_pages}')}"


def _org_picker(orgs, old_settings, title="SELECT ORGANIZATION"):
    """Interactive org picker with arrow key navigation.

    Navigation:
    - ↑/↓: Move selection
    - ←/→ or n/p: Change page
    - Enter: Select highlighted org
    - 1-9: Quick select from current page
    - /: Enter search mode
    - q/ESC: Cancel

    Returns selected org dict or None if cancelled.
    """
    if not orgs:
        return None

    width = min(shutil.get_terminal_size((120, 40))[0], 120)
    search_term = ""
    selected_idx = 0  # Index within filtered list
    page_size = 9

    while True:
        # Filter orgs by search
        if search_term:
            filtered = [o for o in orgs if
                        search_term.lower() in o.get('name', '').lower() or
                        search_term.lower() in str(o.get('id', '')) or
                        search_term.lower() in (o.get('email') or '').lower()]
        else:
            filtered = orgs

        # Clamp selection
        if filtered:
            selected_idx = max(0, min(selected_idx, len(filtered) - 1))
        else:
            selected_idx = 0

        # Calculate current page based on selection
        page = selected_idx // page_size
        total_pages = max(1, (len(filtered) + page_size - 1) // page_size)
        start_idx = page * page_size
        end_idx = min(start_idx + page_size, len(filtered))
        page_orgs = filtered[start_idx:end_idx]

        # Render
        sys.stdout.write(_CLEAR + _HOME)
        print(_render_header(title, width, spinner=False))
        print()

        # Search status
        if search_term:
            print(f"  {_c(_CYAN, '/')} {_c(_BOLD, search_term)} {_c(_DIM, f'({len(filtered)} matches)')}")
        else:
            print(f"  {_c(_DIM, f'{len(orgs)} orgs')}  {_c(_DIM, '[/] search')}")
        print(f"  {_c(_DIM, '─' * 55)}")

        # Org list with arrow selection
        if not filtered:
            print(f"  {_c(_YELLOW, 'No matching orgs')}")
        else:
            for i, org in enumerate(page_orgs):
                global_idx = start_idx + i
                tier = org.get('tier', '?')
                tier_colors = {'free': _DIM, 'solo': _CYAN, 'team': _GREEN, 'enterprise': _YELLOW, 'admin': _RED}
                tier_c = tier_colors.get(tier, _DIM)

                status = org.get('stripe_status') or ''
                status_indicator = ''
                if status == 'active':
                    status_indicator = _c(_GREEN, ' $')
                elif status == 'past_due':
                    status_indicator = _c(_RED, ' !')

                name = _truncate(org.get('name', '?'), 28)
                org_id = org['id']
                num_key = i + 1

                if global_idx == selected_idx:
                    # Highlighted row
                    print(f"  {_c(_CYAN, '▶')} {_c(_BOLD, f'{org_id:>4}')}  {_c(_BOLD, f'{name:<30}')} {_c(tier_c, tier):<8}{status_indicator}")
                else:
                    print(f"  {_c(_DIM, f'{num_key}')} {org_id:>4}  {name:<30} {_c(tier_c, tier):<8}{status_indicator}")

        # Page bar
        print()
        if total_pages > 1:
            print(f"  {_render_page_bar(page, total_pages)}")
        print(f"  {_c(_BOLD, '[↑↓]')} move  {_c(_BOLD, '[Enter]')} select  {_c(_BOLD, '[/]')} search  {_c(_BOLD, '[q]')} back")
        sys.stdout.flush()

        # Get input
        key = _get_key()
        if key is None:
            continue

        if key == 'ESC' or key == 'q':
            _flash_back()
            return None
        elif key == 'UP':
            selected_idx = max(0, selected_idx - 1)
        elif key == 'DOWN':
            selected_idx = min(len(filtered) - 1, selected_idx + 1) if filtered else 0
        elif key == 'LEFT' or key == 'p':
            # Previous page
            if page > 0:
                selected_idx = (page - 1) * page_size
        elif key == 'RIGHT' or key == 'n':
            # Next page
            if page < total_pages - 1:
                selected_idx = (page + 1) * page_size
        elif key == 'ENTER':
            if filtered and 0 <= selected_idx < len(filtered):
                return filtered[selected_idx]
        elif key == '/':
            # Enter search mode - show cursor, get input
            _restore_terminal(old_settings)
            if _USE_COLOR:
                sys.stdout.write(_SHOW_CURSOR)
            sys.stdout.write(f"\r  {_c(_CYAN, '/')} ")
            sys.stdout.flush()
            try:
                search_term = input().strip()
            except (KeyboardInterrupt, EOFError):
                search_term = ""
            selected_idx = 0
            old_settings = _setup_terminal()
            if _USE_COLOR:
                sys.stdout.write(_HIDE_CURSOR)
        elif key == 'BACKSPACE' and search_term:
            search_term = ""
            selected_idx = 0
        elif key.isdigit() and 1 <= int(key) <= len(page_orgs):
            # Quick select by number
            return page_orgs[int(key) - 1]


def _show_billing_menu(api_key, admin_secret, base_url, old_settings):
    """Interactive billing/subscription management with org picker."""
    width = min(shutil.get_terminal_size((120, 40))[0], 120)

    # Animated loading with cache
    orgs, err = _fetch_with_spinner(
        lambda: _get_orgs_cached(api_key, admin_secret, base_url),
        "Loading organizations..."
    )
    if err:
        print(_c(_RED, f"\n  Error: {err}"))
        print(f"\n  {_c(_DIM, 'Press any key...')}")
        _get_key(timeout=5)
        return
    if not orgs:
        print(_c(_YELLOW, "\n  No organizations found"))
        print(f"\n  {_c(_DIM, 'Press any key...')}")
        _get_key(timeout=5)
        return

    # Show org picker (stays in raw mode)
    selected_org = _org_picker(orgs, old_settings, title="BILLING: Select Organization")
    if selected_org is None:
        return

    org_id = selected_org['id']

    try:
        # Animated loading while fetching billing
        billing, err = _fetch_with_spinner(
            lambda oid=org_id: _fetch_org_billing(api_key, admin_secret, base_url, oid),
            f"Loading billing for {selected_org.get('name', f'org {org_id}')}..."
        )
        if err:
            print(_c(_RED, f"\n  Error: {err}"))
            print(f"\n  {_c(_DIM, 'Press any key...')}")
            _get_key(timeout=5)
            return

        while True:  # Allow multiple actions without returning to main menu
            sys.stdout.write(_CLEAR + _HOME)
            print(_render_header(f"BILLING: {billing['org_name']}", width, spinner=False))

            # Org summary box
            print(f"\n  {_c(_BOLD, 'Organization')}")
            print(f"  {_c(_DIM, '─' * 45)}")
            print(f"  ID: {billing['org_id']}  │  Tier: {_c(_GREEN, billing['tier'].upper())}")
            status = billing.get('stripe_status') or 'No subscription'
            status_color = _GREEN if status == 'active' else _YELLOW if status in ('past_due', 'canceling') else _RED
            print(f"  Stripe: {_c(status_color, status)}")

            if billing.get('customer_balance') is not None:
                bal = billing['customer_balance']
                if bal < 0:
                    print(f"  Credit: {_c(_GREEN, f'${abs(bal)/100:.2f}')}")
                elif bal > 0:
                    print(f"  Owes: {_c(_RED, f'${bal/100:.2f}')}")

            if billing.get('subscription'):
                sub = billing['subscription']
                end_ts = sub.get('current_period_end', 0)
                end_date = datetime.fromtimestamp(end_ts).strftime('%Y-%m-%d') if end_ts else "?"
                amt = sub.get('plan_amount', 0) / 100 if sub.get('plan_amount') else 0
                print(f"  Plan: ${amt:.0f}/{sub.get('plan_interval', 'mo')}  │  Renews: {end_date}")
                if sub.get('cancel_at_period_end'):
                    print(f"  {_c(_YELLOW, '⚠ CANCELS AT PERIOD END')}")

            # Recent invoices with selection numbers
            invoices = billing.get('recent_invoices', [])
            if invoices:
                print(f"\n  {_c(_BOLD, 'Recent Invoices')} {_c(_DIM, '(enter # to refund)')}")
                print(f"  {_c(_DIM, '─' * 45)}")
                for i, inv in enumerate(invoices[:5], 1):
                    status = inv.get('status', '?')
                    amount = inv.get('amount_paid', 0) / 100
                    charge_id = inv.get('charge_id') or 'no-charge'
                    inv_date = datetime.fromtimestamp(inv.get('created', 0)).strftime('%Y-%m-%d') if inv.get('created') else '?'
                    status_color = _GREEN if status == 'paid' else _YELLOW if status == 'open' else _RED
                    refundable = _c(_DIM, ' [refundable]') if charge_id and charge_id.startswith('ch_') else ''
                    print(f"  {_c(_CYAN, f'[{i}]')} {inv_date}  ${amount:>7.2f}  {_c(status_color, status):<8}{refundable}")
                    # Store charge_id for quick refund
                    if not hasattr(billing, '_charges'):
                        billing['_charges'] = {}
                    billing['_charges'][str(i)] = (charge_id, amount, inv.get('number'))

            # Actions
            print(f"\n  {_c(_BOLD, 'Actions')}")
            print(f"  {_c(_DIM, '─' * 45)}")
            print(f"  {_c(_CYAN, '[1-5]')} Refund invoice  {_c(_CYAN, '[c]')} Apply credit")
            print(f"  {_c(_CYAN, '[x]')} Cancel sub      {_c(_CYAN, '[e]')} View events")
            print(f"  {_c(_CYAN, '[/]')} Different org   {_c(_CYAN, '[Esc/q]')} Back")
            sys.stdout.flush()

            # Use instant key detection
            old_term = _setup_terminal()
            try:
                key = _get_key()
            finally:
                _restore_terminal(old_term)

            if key is None:
                continue
            choice = key.lower() if isinstance(key, str) and len(key) == 1 else key

            if choice == 'ESC' or choice == 'q':
                _flash_back()
                return
            if choice == '/':
                # Go back to org picker
                selected_org = _org_picker(orgs, old_settings, title="BILLING: Select Organization")
                if selected_org is None:
                    return
                org_id = selected_org['id']
                billing, _ = _fetch_with_spinner(
                    lambda oid=org_id: _fetch_org_billing(api_key, admin_secret, base_url, oid),
                    f"Loading billing for {selected_org.get('name', f'org {org_id}')}..."
                )
                continue

            # Quick refund by invoice number
            if choice.isdigit() and choice in billing.get('_charges', {}):
                charge_id, amount, inv_num = billing['_charges'][choice]
                if not charge_id or not charge_id.startswith('ch_'):
                    print(_c(_YELLOW, "\nThis invoice has no refundable charge."))
                    input("Press Enter to continue...")
                    continue

                print(f"\n{_c(_BOLD, 'REFUND CONFIRMATION')}")
                print(f"  Invoice: {inv_num}")
                print(f"  Charge:  {charge_id}")
                print(f"  Amount:  ${amount:.2f}")
                print()
                amt_str = input(f"  Refund amount (Enter for full ${amount:.2f}): $").strip()
                amount_cents = None
                if amt_str:
                    try:
                        amount_cents = int(float(amt_str) * 100)
                    except ValueError:
                        print(_c(_RED, "Invalid amount"))
                        input("Press Enter to continue...")
                        continue

                confirm = input(f"\n  {_c(_YELLOW, 'Confirm refund?')} (y/n): ").strip().lower()
                if confirm != 'y':
                    print(_c(_DIM, "Cancelled."))
                    input("Press Enter to continue...")
                    continue

                print(f"\n  {_c(_DIM, 'Processing...')} ", end="", flush=True)
                result, err = _do_refund(api_key, admin_secret, base_url, org_id, charge_id, amount_cents)
                if err:
                    print(_c(_RED, f"FAILED: {err}"))
                else:
                    refund_amt = result.get('amount_cents', 0) / 100
                    print(_c(_GREEN, f"OK - Refunded ${refund_amt:.2f}"))
                    print(f"  Refund ID: {result.get('refund_id')}")
                input("\nPress Enter to continue...")
                # Refresh billing data
                billing, _ = _fetch_org_billing(api_key, admin_secret, base_url, org_id)
                continue

            elif choice == 'c':
                # Apply credit
                print(f"\n{_c(_BOLD, 'APPLY CREDIT')}")
                amt_str = input("  Credit amount: $").strip()
                if not amt_str:
                    continue

                try:
                    amount_cents = int(float(amt_str) * 100)
                except ValueError:
                    print(_c(_RED, "Invalid amount"))
                    input("Press Enter to continue...")
                    continue

                if amount_cents > 100000:
                    print(_c(_RED, "Max credit is $1000"))
                    input("Press Enter to continue...")
                    continue

                description = input("  Reason (optional): ").strip()
                confirm = input(f"\n  Apply ${amount_cents/100:.2f} credit? (y/n): ").strip().lower()
                if confirm != 'y':
                    continue

                print(f"\n  {_c(_DIM, 'Processing...')} ", end="", flush=True)
                result, err = _do_credit(api_key, admin_secret, base_url, org_id, amount_cents, description)
                if err:
                    print(_c(_RED, f"FAILED: {err}"))
                else:
                    print(_c(_GREEN, f"OK - Credit applied"))
                    new_bal = result.get('new_balance_cents', 0)
                    if new_bal < 0:
                        print(f"  New balance: ${abs(new_bal)/100:.2f} credit")
                input("\nPress Enter to continue...")
                billing, _ = _fetch_org_billing(api_key, admin_secret, base_url, org_id)
                continue

            elif choice == 'x':
                # Cancel subscription
                if not billing.get('stripe_subscription_id'):
                    print(_c(_YELLOW, "\nNo active subscription to cancel."))
                    input("Press Enter to continue...")
                    continue

                print(f"\n{_c(_BOLD, 'CANCEL SUBSCRIPTION')}")
                print(f"  {_c(_CYAN, '[1]')} At period end (customer keeps access)")
                print(f"  {_c(_RED, '[2]')} Immediate (access ends now)")
                cancel_type = input("\n  Choice [1]: ").strip() or "1"
                immediate = cancel_type == "2"

                reason = input("  Reason: ").strip()

                warn = _c(_RED, "IMMEDIATELY") if immediate else "at period end"
                confirm = input(f"\n  Cancel {warn}? (y/n): ").strip().lower()
                if confirm != 'y':
                    continue

                print(f"\n  {_c(_DIM, 'Processing...')} ", end="", flush=True)
                result, err = _do_cancel_subscription(api_key, admin_secret, base_url, org_id, immediate, reason)
                if err:
                    print(_c(_RED, f"FAILED: {err}"))
                else:
                    print(_c(_GREEN, "OK - Subscription cancelled"))
                    if result.get('current_period_end'):
                        end_date = datetime.fromtimestamp(result['current_period_end']).strftime('%Y-%m-%d')
                        print(f"  Access until: {end_date}")
                input("\nPress Enter to continue...")
                billing, _ = _fetch_org_billing(api_key, admin_secret, base_url, org_id)
                continue

            elif choice == 'e':
                # Jump to events for this org
                _show_event_viewer_for_org(api_key, admin_secret, base_url, org_id, old_settings)
                continue

            elif choice == 'a':
                # Could add alert viewer for specific org here
                print(_c(_DIM, "\nAlert viewer for specific org coming soon."))
                input("Press Enter to continue...")
                continue

    except ValueError:
        print(_c(_RED, "\nInvalid org ID"))
        time.sleep(1)
    except KeyboardInterrupt:
        pass


def _show_event_viewer_for_org(api_key, admin_secret, base_url, org_id, old_settings):
    """Show events for a specific org (called from billing/org detail)."""
    _restore_terminal(old_settings)
    if _USE_COLOR:
        sys.stdout.write(_SHOW_CURSOR)
    sys.stdout.write(_CLEAR + _HOME)
    sys.stdout.flush()

    width = min(shutil.get_terminal_size((120, 40))[0], 120)
    print(_render_header(f"EVENTS: Org {org_id}", width, spinner=False))

    print(f"\n{_c(_DIM, 'Fetching events...')} ", end="", flush=True)
    events, err = _fetch_org_events(api_key, admin_secret, base_url, org_id, limit=30)

    if err:
        print(_c(_RED, f"Error: {err}"))
        input("\nPress Enter to return...")
        return

    print(_c(_GREEN, f"OK ({len(events)} events)"))
    print()

    if not events:
        print(_c(_DIM, "  No events found."))
    else:
        for e in events[:20]:
            ts = e.get("created_at", "")[:19].replace("T", " ")
            agent = e.get("agent_name") or f"agent:{e.get('agent_id')}"
            action = (e.get("action") or "?")[:12]
            target = (e.get("target") or "")[:35]
            result = e.get("result", "")

            if result in ("success", "allowed"):
                res_sym = _c(_GREEN, CHAR_CHECK)
            elif result in ("error", "failure", "denied", "blocked"):
                res_sym = _c(_RED, CHAR_CROSS)
            else:
                res_sym = _c(_DIM, "~")

            flags = e.get("anomaly_flags", [])
            anomaly_str = _c(_RED, f" [{','.join(flags[:2])}]") if flags else ""

            print(f"  {_c(_DIM, ts)}  {agent:<10} {action:<12} {res_sym} {target}{anomaly_str}")

    print(f"\n{_render_hotkey_bar('simple', width)}")
    input("\nPress Enter to return...")


# Note: _show_help_overlay is defined earlier in the file (line ~285)


# ── News ticker ─────────────────────────────────────────────────────────────

def _fetch_news():
    """Fetch tech news headlines."""
    from obol.cli.admin_news import fetch_news_headlines
    return fetch_news_headlines()


# ── Rendering helpers ───────────────────────────────────────────────────────

def _severity_color(sev):
    sev = (sev or "").lower()
    if sev == "critical":
        return _RED_BOLD
    if sev == "high":
        return _RED
    if sev in ("medium", "med"):
        return _YELLOW
    return _DIM


def _severity_label(sev):
    return {
        "critical": "CRIT",
        "high": "HIGH",
        "medium": "MED",
        "low": "LOW",
    }.get((sev or "").lower(), sev or "?")


def _time_ago(iso_ts):
    try:
        dt = datetime.fromisoformat(iso_ts.replace("Z", "+00:00"))
        delta = datetime.now(timezone.utc) - dt
        secs = int(delta.total_seconds())
        if secs < 0:
            secs = 0
        if secs < 60:
            return f"{secs}s"
        mins = secs // 60
        if mins < 60:
            return f"{mins}m"
        hrs = mins // 60
        if hrs < 24:
            return f"{hrs}h"
        days = hrs // 24
        return f"{days}d"
    except Exception:
        return ""


# ── Capacity thresholds ──────────────────────────────────────────────────────

# Thresholds: (warning, critical)
THRESHOLDS = {
    "cpu": (80, 90),
    "mem": (75, 90),
    "disk": (70, 85),
    "db_conn_pct": (80, 95),  # As percentage of max
}


def _check_thresholds(vps):
    """Check VPS metrics against thresholds.

    Returns dict with status for each metric:
    - "ok": below warning
    - "warn": at or above warning, below critical
    - "crit": at or above critical

    Also returns list of alert messages.
    """
    if not vps:
        return {}, []

    results = {}
    alerts = []

    # CPU
    cpu_pct = vps.get("cpu_pct", 0)
    warn, crit = THRESHOLDS["cpu"]
    if cpu_pct >= crit:
        results["cpu"] = "crit"
        alerts.append(f"CPU CRITICAL: {cpu_pct:.0f}% (>={crit}%)")
    elif cpu_pct >= warn:
        results["cpu"] = "warn"
        alerts.append(f"CPU WARNING: {cpu_pct:.0f}% (>={warn}%)")
    else:
        results["cpu"] = "ok"

    # Memory
    mem_pct = vps.get("mem_pct", 0)
    warn, crit = THRESHOLDS["mem"]
    if mem_pct >= crit:
        results["mem"] = "crit"
        alerts.append(f"MEMORY CRITICAL: {mem_pct:.0f}% (>={crit}%)")
    elif mem_pct >= warn:
        results["mem"] = "warn"
        alerts.append(f"MEMORY WARNING: {mem_pct:.0f}% (>={warn}%)")
    else:
        results["mem"] = "ok"

    # Disk
    disk_pct = vps.get("disk_pct", 0)
    warn, crit = THRESHOLDS["disk"]
    if disk_pct >= crit:
        results["disk"] = "crit"
        alerts.append(f"DISK CRITICAL: {disk_pct:.0f}% (>={crit}%)")
    elif disk_pct >= warn:
        results["disk"] = "warn"
        alerts.append(f"DISK WARNING: {disk_pct:.0f}% (>={warn}%)")
    else:
        results["disk"] = "ok"

    # DB connections
    db_conn = vps.get("db_connections", 0)
    db_max = vps.get("db_max_connections", 100)
    db_pct = (db_conn / db_max * 100) if db_max > 0 else 0
    warn, crit = THRESHOLDS["db_conn_pct"]
    if db_pct >= crit:
        results["db"] = "crit"
        alerts.append(f"DB CONNECTIONS CRITICAL: {db_conn}/{db_max} (>={crit}%)")
    elif db_pct >= warn:
        results["db"] = "warn"
        alerts.append(f"DB CONNECTIONS WARNING: {db_conn}/{db_max} (>={warn}%)")
    else:
        results["db"] = "ok"

    return results, alerts


def _threshold_color(status):
    """Get color code for threshold status."""
    if status == "crit":
        return _RED_BOLD
    elif status == "warn":
        return _YELLOW
    return _GREEN


def _progress_bar_colored(pct, width, status):
    """Progress bar with color based on threshold status."""
    filled = int(pct / 100 * width)
    empty = width - filled
    color = _threshold_color(status)
    return _c(color, CHAR_FILLED * filled) + _c(_DIM, CHAR_EMPTY * empty)


def _local_time(iso_ts):
    try:
        dt = datetime.fromisoformat(iso_ts.replace("Z", "+00:00"))
        return dt.astimezone().strftime("%H:%M:%S")
    except Exception:
        return str(iso_ts)[:8] if iso_ts else "??:??:??"


# ── Panel renderers ─────────────────────────────────────────────────────────

def _render_top_bar(width, api_ok, version, last_update=None):
    """Render the top status bar with spinner animation."""
    now = datetime.now(timezone.utc)
    now_str = now.strftime("%H:%M:%S UTC")

    # Spinner shows the system is alive
    spinner = _get_spinner()

    # Status indicator
    dot = _c(_GREEN_BOLD, _DOT) if api_ok else _c(_RED_BOLD, _DOT)
    title = f" {_c(_CYAN, spinner)} {dot} {_c(_BOLD, 'OBOL ADMIN MONITOR')}"

    # Last update indicator
    if last_update:
        age = (now - last_update).total_seconds()
        if age < 10:
            age_str = _c(_GREEN, "live")
        elif age < 60:
            age_str = _c(_GREEN, f"{int(age)}s")
        else:
            age_str = _c(_YELLOW, f"{int(age/60)}m")
        update_part = f"[{age_str}]"
    else:
        update_part = ""

    right = f"{update_part} v{version} {_V} {now_str}"
    title_vis = _strip_ansi_len(title)
    right_vis = _strip_ansi_len(right)
    gap = width - title_vis - right_vis
    if gap < 1:
        gap = 1

    lines = []
    lines.append(title + " " * gap + right)
    lines.append(" " + CHAR_DLINE * (width - 2))
    return lines


def _render_vps_panel(vps, panel_width, threshold_status=None):
    """Render VPS HEALTH box with color-coded thresholds."""
    lines = []

    # Header with warning indicator if thresholds exceeded
    if threshold_status and any(s != "ok" for s in threshold_status.values()):
        has_crit = any(s == "crit" for s in threshold_status.values())
        if has_crit:
            header = _c(_RED_BOLD, " VPS HEALTH ") + _c(_RED_BOLD, "[!] CRITICAL")
        else:
            header = _c(_YELLOW, " VPS HEALTH ") + _c(_YELLOW, "[!] WARNING")
    else:
        header = _c(_BOLD, " VPS HEALTH")
    lines.append(header)

    inner_w = panel_width - 4
    lines.append(" " + _TL + _H * (panel_width - 4) + _TR)

    if vps is None:
        err = _c(_RED, "SSH connection failed")
        lines.append(" " + _V + " " + _pad(err, inner_w - 1) + _V)
    else:
        ts = threshold_status or {}

        # CPU
        cpu_pct = vps.get("cpu_pct", 0)
        cpu_bar = _progress_bar_colored(cpu_pct, 10, ts.get("cpu", "ok"))
        load = vps.get("load_1m", 0)
        cpu_label = _c(_threshold_color(ts.get("cpu", "ok")), f"{cpu_pct:3.0f}%")
        cpu_line = f" CPU:  {cpu_bar}  {cpu_label}   Load: {load:.2f}"
        lines.append(" " + _V + _pad(cpu_line, inner_w) + _V)

        # Memory
        mem_pct = vps.get("mem_pct", 0)
        mem_bar = _progress_bar_colored(mem_pct, 10, ts.get("mem", "ok"))
        mem_used = vps.get("mem_used_gb", 0)
        mem_total = vps.get("mem_total_gb", 0)
        mem_label = _c(_threshold_color(ts.get("mem", "ok")), f"{mem_pct:3.0f}%")
        mem_line = f" MEM:  {mem_bar}  {mem_label}   {mem_used:.1f}/{mem_total:.1f} GB"
        lines.append(" " + _V + _pad(mem_line, inner_w) + _V)

        # Disk
        disk_pct = vps.get("disk_pct", 0)
        disk_bar = _progress_bar_colored(disk_pct, 10, ts.get("disk", "ok"))
        disk_used = vps.get("disk_used_gb", 0)
        disk_total = vps.get("disk_total_gb", 0)
        disk_label = _c(_threshold_color(ts.get("disk", "ok")), f"{disk_pct:3.0f}%")
        disk_line = f" DISK: {disk_bar}  {disk_label}   {disk_used:.0f}/{disk_total:.0f} GB"
        lines.append(" " + _V + _pad(disk_line, inner_w) + _V)

        # DB connections
        db_conn = vps.get("db_connections", 0)
        db_max = vps.get("db_max_connections", 100)
        db_pct = (db_conn / db_max * 100) if db_max > 0 else 0
        db_bar = _progress_bar_colored(db_pct, 10, ts.get("db", "ok"))
        db_label = _c(_threshold_color(ts.get("db", "ok")), f"{db_conn}/{db_max}")
        db_line = f" DB:   {db_bar}  {db_label}"
        lines.append(" " + _V + _pad(db_line, inner_w) + _V)

    lines.append(" " + _BL + _H * (panel_width - 4) + _BR)
    return lines


def _render_api_panel(api_ok, latency_ms, version, stats, panel_width):
    """Render API STATUS box."""
    lines = []
    header = _c(_BOLD, "API STATUS")
    lines.append(header)

    inner_w = panel_width - 4
    lines.append(_TL + _H * (panel_width - 4) + _TR)

    # Status
    if api_ok:
        status_str = _c(_GREEN, f"{CHAR_CHECK} UP")
    else:
        status_str = _c(_RED, f"{CHAR_CROSS} DOWN")
    latency_str = f"{latency_ms}ms" if latency_ms else "--"
    status_line = f" Status:  {status_str}   Response: {latency_str}"
    lines.append(_V + _pad(status_line, inner_w) + _V)

    # Version
    version_line = f" Version: {version}"
    lines.append(_V + _pad(version_line, inner_w) + _V)

    # Events rate
    if stats:
        rate = stats.get("events_rate_per_min", 0)
        rate_line = f" Events:  {rate:.1f}/min"
        lines.append(_V + _pad(rate_line, inner_w) + _V)

        # Anomaly rate
        anom_pct = stats.get("anomaly_rate_pct", 0)
        if anom_pct > 1:
            anom_str = _c(_YELLOW, f"{anom_pct:.1f}%")
        else:
            anom_str = _c(_GREEN, f"{anom_pct:.1f}%")
        anom_line = f" Anomaly: {anom_str}"
        lines.append(_V + _pad(anom_line, inner_w) + _V)

    lines.append(_BL + _H * (panel_width - 4) + _BR)
    return lines


def _render_metrics_panel(stats, panel_width, last_error=None, failures=0):
    """Render METRICS TODAY box with trend indicators and error context. QOL #1, #2."""
    lines = []
    header = _c(_BOLD, " METRICS TODAY")
    lines.append(header)

    inner_w = panel_width - 4
    lines.append(" " + _TL + _H * (panel_width - 4) + _TR)

    if stats is None:
        # QOL #1: Show error context
        is_limited, remaining = _check_rate_limited()
        if is_limited:
            # Rate limited - show countdown
            err = _c(_YELLOW, f"Rate limited - retry in {remaining}s")
            lines.append(" " + _V + " " + _pad(err, inner_w - 1) + _V)
            backoff = _connection_state.get("backoff_seconds", 30)
            hint = _c(_DIM, f" (backoff: {backoff}s)")
            lines.append(" " + _V + _pad(hint, inner_w) + _V)
        elif last_error:
            # Truncate and clean up error message
            err_msg = str(last_error)[:40]
            if "403" in err_msg:
                err_msg = "403 Access denied"
            elif "401" in err_msg:
                err_msg = "401 Unauthorized"
            elif "timeout" in err_msg.lower():
                err_msg = "Request timeout"
            elif "connection" in err_msg.lower():
                err_msg = "Connection refused"
            elif "Rate limited" in err_msg:
                err_msg = err_msg  # Keep as-is
            err = _c(_RED, f"Connection lost ({err_msg})")
            lines.append(" " + _V + " " + _pad(err, inner_w - 1) + _V)

            # QOL #2: Show reconnect feedback
            if failures > 0:
                retry_msg = _c(_YELLOW, f" Reconnecting... (attempt {failures})")
                lines.append(" " + _V + _pad(retry_msg, inner_w) + _V)
        else:
            err = _c(_RED, "Connection lost")
            lines.append(" " + _V + " " + _pad(err, inner_w - 1) + _V)
    else:
        # Orgs (trend: more is good)
        orgs_total = stats.get("orgs_total", 0)
        orgs_today = stats.get("orgs_today", 0)
        orgs_trend = _get_trend("orgs", orgs_total)
        orgs_line = f" Orgs:     {orgs_total:,} (+{orgs_today} today) {orgs_trend}"
        lines.append(" " + _V + _pad(orgs_line, inner_w) + _V)

        # Agents (trend: more active is good)
        agents_total = stats.get("agents_total", 0)
        agents_active = stats.get("agents_active", 0)
        agents_trend = _get_trend("agents_active", agents_active)
        agents_line = f" Agents:   {agents_total} total, {agents_active} active {agents_trend}"
        lines.append(" " + _V + _pad(agents_line, inner_w) + _V)

        # Events (trend: more is good = more usage)
        events_today = stats.get("events_today", 0)
        rate = stats.get("events_rate_per_min", 0)
        events_trend = _get_trend("events", events_today)
        events_line = f" Events:   {events_today:,} ({rate:.1f}/min) {events_trend}"
        lines.append(" " + _V + _pad(events_line, inner_w) + _V)

        # Anomalies (trend: fewer is good)
        anomalies = stats.get("anomalies_today", 0)
        anom_pct = stats.get("anomaly_rate_pct", 0)
        anom_trend = _get_trend("anomalies", anomalies)
        anom_line = f" Anomalies: {anomalies} ({anom_pct:.2f}%) {anom_trend}"
        lines.append(" " + _V + _pad(anom_line, inner_w) + _V)

    lines.append(" " + _BL + _H * (panel_width - 4) + _BR)
    return lines


def _render_alerts_panel(stats, alerts, panel_width):
    """Render ALERTS box."""
    lines = []
    header = _c(_BOLD, "ALERTS")
    lines.append(header)

    inner_w = panel_width - 4
    lines.append(_TL + _H * (panel_width - 4) + _TR)

    if stats is None:
        err = _c(_RED, "Connection lost")
        lines.append(_V + " " + _pad(err, inner_w - 1) + _V)
        lines.append(_BL + _H * (panel_width - 4) + _BR)
        return lines

    open_count = stats.get("alerts_open", 0)
    crit_count = stats.get("alerts_critical", 0)
    high_count = stats.get("alerts_high", 0)

    if open_count == 0:
        msg = _c(_GREEN, f"{CHAR_CHECK} No open alerts")
        lines.append(_V + " " + _pad(msg, inner_w - 1) + _V)
    else:
        # Summary
        count_str = f"{CHAR_WARNING} {open_count} open alert{'s' if open_count != 1 else ''}"
        if crit_count > 0:
            count_str = _c(_RED_BOLD, count_str)
        elif high_count > 0:
            count_str = _c(_YELLOW_BOLD, count_str)
        else:
            count_str = _c(_YELLOW, count_str)
        lines.append(_V + " " + _pad(count_str, inner_w - 1) + _V)

        # Recent alerts
        if alerts:
            for alert in alerts[:3]:
                sev = _severity_label(alert.get("severity"))
                sev_colored = _c(_severity_color(alert.get("severity")), f"{sev:<5}")
                atype = _truncate(alert.get("alert_type", "?"), inner_w - 20)
                ago = _time_ago(alert.get("created_at", ""))
                entry = f"  {sev_colored} {atype}"
                entry_vis = _strip_ansi_len(entry)
                ago_len = len(ago)
                gap = inner_w - entry_vis - ago_len - 1
                if gap < 1:
                    gap = 1
                full = entry + " " * gap + _c(_DIM, ago)
                lines.append(_V + _pad(full, inner_w) + _V)

    lines.append(_BL + _H * (panel_width - 4) + _BR)
    return lines


def _render_business_panel(biz, panel_width):
    """Render BUSINESS / REVENUE box."""
    lines = []
    header = _c(_BOLD, " BUSINESS")
    lines.append(header)

    inner_w = panel_width - 4
    lines.append(" " + _TL + _H * (panel_width - 4) + _TR)

    if biz is None:
        err = _c(_DIM, "Loading...")
        lines.append(" " + _V + " " + _pad(err, inner_w - 1) + _V)
    else:
        rev = biz.get("revenue", {})
        cust = biz.get("customers", {})

        # MRR
        mrr = rev.get("mrr", 0)
        mrr_color = _GREEN if mrr > 0 else _DIM
        mrr_line = f" MRR:     {_c(mrr_color, f'${mrr:,}')}"
        lines.append(" " + _V + _pad(mrr_line, inner_w) + _V)

        # Paying customers
        paying = rev.get("paying_customers", 0)
        paying_line = f" Paying:  {paying} customers"
        lines.append(" " + _V + _pad(paying_line, inner_w) + _V)

        # Total orgs
        total = cust.get("total", 0)
        total_line = f" Total:   {total} orgs"
        lines.append(" " + _V + _pad(total_line, inner_w) + _V)

        # Signups
        today = cust.get("signups_today", 0)
        week = cust.get("signups_this_week", 0)
        signup_color = _GREEN if today > 0 else _DIM
        signup_line = f" Signups: {_c(signup_color, str(today))} today, {week} week"
        lines.append(" " + _V + _pad(signup_line, inner_w) + _V)

    lines.append(" " + _BL + _H * (panel_width - 4) + _BR)
    return lines


def _render_services_panel(services, panel_width):
    """Render SERVICES status box."""
    lines = []
    header = _c(_BOLD, "SERVICES")
    lines.append(header)

    inner_w = panel_width - 4
    lines.append(_TL + _H * (panel_width - 4) + _TR)

    if services is None:
        err = _c(_DIM, "Loading...")
        lines.append(_V + " " + _pad(err, inner_w - 1) + _V)
    else:
        for svc, is_up in services.items():
            if is_up:
                status = _c(_GREEN, f"{CHAR_CHECK} UP")
            else:
                status = _c(_RED, f"{CHAR_CROSS} DOWN")
            svc_line = f" {svc:<12} {status}"
            lines.append(_V + _pad(svc_line, inner_w) + _V)

    lines.append(_BL + _H * (panel_width - 4) + _BR)
    return lines


def _render_signups_panel(biz, panel_width):
    """Render RECENT SIGNUPS box."""
    lines = []
    header = _c(_BOLD, " RECENT SIGNUPS")
    lines.append(header)

    inner_w = panel_width - 4
    lines.append(" " + _TL + _H * (panel_width - 4) + _TR)

    if biz is None:
        err = _c(_DIM, "Loading...")
        lines.append(" " + _V + " " + _pad(err, inner_w - 1) + _V)
    else:
        signups = biz.get("recent_signups", [])
        if not signups:
            lines.append(" " + _V + " " + _pad(_c(_DIM, "No recent signups"), inner_w - 1) + _V)
        else:
            for s in signups[:5]:
                name = _truncate(s.get("name", "?"), 15)
                tier = s.get("tier", "free")
                agents = s.get("agents", 0)
                events = s.get("events", 0)
                # Color based on activation
                if events > 0:
                    status = _c(_GREEN, f"{agents}a/{events}e")
                elif agents > 0:
                    status = _c(_YELLOW, f"{agents}a/0e")
                else:
                    status = _c(_DIM, "inactive")
                line = f" {name:<15} {tier:<5} {status}"
                lines.append(" " + _V + _pad(line, inner_w) + _V)

    lines.append(" " + _BL + _H * (panel_width - 4) + _BR)
    return lines


def _render_news_panel(news, width):
    """Render NEWS ticker with source labels."""
    lines = []
    lines.append(" " + _c(_BOLD, "NEWS") + " " + CHAR_HLINE * (width - 10))

    if not news:
        lines.append(" " + _V + " " + _c(_DIM, "Loading news...") + " " * (width - 25) + _V)
    else:
        # News is now [(source, title), ...] - show up to 5 headlines
        for source, title in news[:5]:
            # Format: [Source] Title...
            src_label = _c(_CYAN, f"[{source[:12]}]")  # Max 12 char source
            available = width - 20  # Account for borders and source
            if len(title) > available:
                title = title[:available - 3] + "..."
            line = f" {_V} {src_label} {title}"
            # Pad to width
            pad = max(0, width - _strip_ansi_len(line) - 2)
            lines.append(line + " " * pad + _V)

    lines.append(" " + CHAR_HLINE * (width - 2))
    return lines


def _render_events_panel(events, width, max_rows, last_error=None, failures=0):
    """Render RECENT EVENTS section with enhanced anomaly display. QOL #5."""
    lines = []

    header = f" {_c(_BOLD, 'RECENT EVENTS')} (all orgs)"
    lines.append(header)
    lines.append(" " + CHAR_HLINE * (width - 2))

    if events is None:
        # QOL #1, #2: Show error context and reconnect feedback
        if last_error:
            err_msg = str(last_error)[:50]
            lines.append(" " + _c(_RED, f"  Connection lost: {err_msg}"))
        else:
            lines.append(" " + _c(_RED, "  Connection lost - waiting for reconnect..."))
        if failures > 0:
            lines.append(" " + _c(_YELLOW, f"  Reconnecting... (attempt {failures})"))
        return lines

    if not events:
        lines.append(" " + _c(_DIM, "  No events yet"))
        return lines

    for event in events[:max_rows]:
        ts = _local_time(event.get("timestamp", ""))
        org_id = event.get("org_id", "?")
        agent_id = event.get("agent_id", "?")
        action = event.get("action", "?")
        result = event.get("result", "?")
        target = event.get("target", "") or ""
        has_anom = event.get("has_anomalies", False)
        anomaly_flags = event.get("anomaly_flags", [])

        time_part = _c(_DIM, ts)
        org_part = _c(_CYAN, f"org:{org_id:<3}")
        agent_part = _c(_BLUE, f"agent:{agent_id:<4}")
        action_part = f"{action:<8}"

        # QOL #5: Better anomaly visibility
        if has_anom or anomaly_flags:
            # Color the entire action red for anomalous events
            action_part = _c(_RED_BOLD, f"{action:<8}")
            result_part = _c(_RED, _result_symbol(result, raw=True))
        else:
            result_part = _result_symbol(result)

        # Calculate available space for target
        base_len = 55  # Approximate fixed part length
        max_target = width - base_len

        # QOL #5: Show anomaly flag inline if present
        if anomaly_flags:
            # Show first flag (most important)
            flag_str = anomaly_flags[0][:15]
            if len(anomaly_flags) > 1:
                flag_str += f"+{len(anomaly_flags)-1}"
            anomaly_indicator = _c(_RED, f" [{flag_str}]")
            max_target -= len(flag_str) + 4
        else:
            anomaly_indicator = ""

        if max_target < 10:
            max_target = 10
        target_part = _truncate(target, max_target) if target else _c(_DIM, "-")

        lines.append(f" {time_part}  {org_part}  {agent_part}  {action_part} {result_part}  {target_part}{anomaly_indicator}")

    return lines


def _result_symbol(result, raw=False):
    """Get result symbol, optionally raw (uncolored)."""
    if result in ("success", "allowed"):
        sym = CHAR_CHECK
        return sym if raw else _c(_GREEN, sym)
    elif result in ("error", "failure", "denied", "blocked"):
        sym = CHAR_CROSS
        return sym if raw else _c(_RED, sym)
    else:
        sym = "~"
        return sym if raw else _c(_DIM, sym)


def _render_status_bar(width, view):
    """Render the bottom status/help bar."""
    keys = [
        ("[q]", "quit"),
        ("[r]", "refresh"),
        ("[1]", "ops"),
        ("[2]", "biz"),
        ("[a]", "alerts"),
        ("[s]", "svc"),
        ("[l]", "logs"),
        ("[o]", "org"),
        ("[e]", "events"),
        ("[b]", "billing"),
        ("[h]", "help"),
    ]
    parts = []
    for key, label in keys:
        if (view == "ops" and key == "[1]") or (view == "biz" and key == "[2]"):
            parts.append(f"{_c(_YELLOW_BOLD, key)} {_c(_YELLOW, label)}")
        else:
            parts.append(f"{_c(_BOLD, key)} {label}")
    return " " + "  ".join(parts)


# ── Main render ─────────────────────────────────────────────────────────────

def _render_screen(vps, stats, alerts, events, news, biz, services, api_ok, latency_ms, version, view="ops", last_update=None, last_error=None, failures=0):
    """Compose and print the full dashboard. QOL #1, #2: Added error context."""
    cols, rows = shutil.get_terminal_size((120, 40))
    width = min(cols, 120)

    output_lines = []

    # Check VPS thresholds
    threshold_status, threshold_alerts = _check_thresholds(vps)

    # Top bar with spinner animation
    output_lines.extend(_render_top_bar(width, api_ok, version, last_update))

    # Capacity warning banner (if thresholds exceeded)
    if threshold_alerts:
        has_crit = any("CRITICAL" in a for a in threshold_alerts)
        if has_crit:
            banner = _c(_RED_BOLD, " " + CHAR_WARNING + " CAPACITY ALERT: " + " | ".join(threshold_alerts) + " ")
        else:
            banner = _c(_YELLOW, " " + CHAR_WARNING + " CAPACITY WARNING: " + " | ".join(threshold_alerts) + " ")
        # Truncate if too long
        if len(banner) > width * 2:  # Account for ANSI codes
            banner = banner[:width * 2 - 3] + "..."
        output_lines.append(banner)
    output_lines.append("")

    if view == "ops":
        # OPS VIEW: VPS + API | METRICS + ALERTS
        left_width = 40
        right_width = width - left_width - 3

        # Left column: VPS + Services
        vps_lines = _render_vps_panel(vps, left_width, threshold_status)
        svc_lines = _render_services_panel(services, left_width)
        left_lines = vps_lines + [""] + svc_lines

        # Right column: METRICS + ALERTS - QOL #1, #2: Pass error context
        metrics_lines = _render_metrics_panel(stats, right_width, last_error, failures)
        alerts_lines = _render_alerts_panel(stats, alerts, right_width)
        right_lines = metrics_lines + [""] + alerts_lines

    else:
        # BIZ VIEW: BUSINESS + SIGNUPS | API + ALERTS
        left_width = 40
        right_width = width - left_width - 3

        # Left column: Business + Signups
        biz_lines = _render_business_panel(biz, left_width)
        signup_lines = _render_signups_panel(biz, left_width)
        left_lines = biz_lines + [""] + signup_lines

        # Right column: API + ALERTS
        api_lines = _render_api_panel(api_ok, latency_ms, version, stats, right_width)
        alerts_lines = _render_alerts_panel(stats, alerts, right_width)
        right_lines = api_lines + [""] + alerts_lines

    # Merge side by side
    max_panel_lines = max(len(left_lines), len(right_lines))
    for i in range(max_panel_lines):
        left = left_lines[i] if i < len(left_lines) else ""
        right = right_lines[i] if i < len(right_lines) else ""

        left_vis = _strip_ansi_len(left)
        padding = left_width - left_vis + 3
        if padding < 1:
            padding = 1
        output_lines.append(left + " " * padding + right)

    output_lines.append("")

    # News ticker (only in ops view)
    if view == "ops" and news:
        output_lines.extend(_render_news_panel(news, width))
        output_lines.append("")

    # Events panel - QOL #1, #2, #5: Pass error context
    used_lines = len(output_lines) + 4
    available = rows - used_lines
    max_event_rows = max(3, min(available, 15))
    output_lines.extend(_render_events_panel(events, width, max_event_rows, last_error, failures))
    output_lines.append("")

    # Status bar
    output_lines.append(_render_status_bar(width, view))

    # Print everything
    sys.stdout.write(_CLEAR)
    sys.stdout.write("\n".join(output_lines))
    sys.stdout.write("\n")
    sys.stdout.flush()


# ── Entry point ─────────────────────────────────────────────────────────────

def cmd_admin_monitor(args):
    """Main admin monitor loop."""
    interval = args.interval
    skip_vps = args.no_vps
    skip_news = args.no_news
    skip_yubikey = getattr(args, 'skip_yubikey', False)
    enable_notify = getattr(args, 'notify', False)  # QOL #4

    # YubiKey authentication (if configured)
    if not skip_yubikey:
        from obol.cli.yubikey import require_yubikey, prompt_and_verify_yubikey

        if require_yubikey():
            if not prompt_and_verify_yubikey():
                sys.exit(1)

    # Get credentials
    api_key, admin_secret, base_url = _get_admin_creds()
    if not api_key or not admin_secret:
        print("Error: Admin credentials required.")
        print("Set OBOL_API_KEY and OBOL_ADMIN_SECRET, or run:")
        print("  obol config set admin_secret <secret>")
        sys.exit(1)

    # Set up clean exit
    interrupted = False

    def _on_sigint(sig, frame):
        nonlocal interrupted
        interrupted = True

    signal.signal(signal.SIGINT, _on_sigint)

    # Hide cursor and set up terminal for keypress detection
    old_term_settings = None
    if _USE_COLOR:
        sys.stdout.write(_HIDE_CURSOR)
        sys.stdout.flush()
        old_term_settings = _setup_terminal()

    try:
        # Cached data
        last_stats = None
        last_alerts = None
        last_events = None
        last_vps = None
        last_news = None
        last_biz = None
        last_services = None
        api_ok = False
        latency_ms = 0
        version = "?"

        # View state: "ops" or "biz"
        current_view = "ops"

        # Counters for staggered fetches
        tick = 0

        # Last successful data update time (for "live" indicator)
        last_update_time = None

        while not interrupted:
            # Differential polling rates (base interval = 2s):
            # - Stats/events: every 2s (every tick) - fast, cheap queries
            # - Alerts: every 6s - moderate importance
            # - VPS SSH: every 30s - expensive, SSH connection
            # - News RSS: every 5 min - external feeds
            # - Business: every 30s - aggregations

            # Parallel API fetching - all requests run concurrently
            import concurrent.futures
            with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
                # Always fetch stats and events
                future_stats = executor.submit(_fetch_admin_stats, api_key, admin_secret, base_url)
                future_events = executor.submit(_fetch_recent_events, api_key, admin_secret, base_url, 15)

                # Conditional fetches based on tick
                future_alerts = None
                future_health = None
                future_biz = None
                future_vps = None
                future_news = None

                if tick % 3 == 0:  # Every 6s
                    future_alerts = executor.submit(_fetch_recent_alerts, api_key, admin_secret, base_url, 5)

                if tick % 30 == 0:  # Every 60s
                    future_health = executor.submit(_check_api_health, base_url)

                if tick % 15 == 0:  # Every 30s
                    future_biz = executor.submit(_fetch_business_metrics, api_key, admin_secret, base_url)
                    if not skip_vps:
                        future_vps = executor.submit(_fetch_vps_all)

                if not skip_news and tick % 150 == 0:  # Every 5 min
                    future_news = executor.submit(_fetch_news)

                # Collect results - QOL #1, #2: Track errors
                stats, latency_ms, err = future_stats.result()
                api_ok = stats is not None
                if stats:
                    last_stats = stats
                    last_update_time = datetime.now(timezone.utc)
                    _connection_state["last_error"] = None
                    _connection_state["consecutive_failures"] = 0
                    _connection_state["last_success_time"] = datetime.now(timezone.utc)
                else:
                    _connection_state["last_error"] = err
                    _connection_state["consecutive_failures"] += 1

                events, events_err = future_events.result()
                if events is not None:
                    last_events = events
                elif events_err and not _connection_state.get("last_error"):
                    _connection_state["last_error"] = events_err

                if future_alerts:
                    alerts, _ = future_alerts.result()
                    if alerts is not None:
                        last_alerts = alerts
                        # QOL #4: Send desktop notifications for critical alerts
                        if enable_notify:
                            _notify_critical_alerts(alerts, enabled=True)

                if future_health:
                    health_ok, _, ver = future_health.result()
                    if health_ok:
                        version = ver

                if future_biz:
                    biz, _ = future_biz.result()
                    if biz is not None:
                        last_biz = biz

                if future_vps:
                    vps, svcs = future_vps.result()
                    if vps:
                        last_vps = vps
                    if svcs:
                        last_services = svcs

                if future_news:
                    last_news = future_news.result()

            # Render - QOL #1, #2: Pass error context
            _render_screen(
                last_vps if not skip_vps else None,
                last_stats,
                last_alerts,
                last_events,
                last_news if not skip_news else None,
                last_biz,
                last_services if not skip_vps else None,
                api_ok,
                latency_ms,
                version,
                current_view,
                last_update_time,
                last_error=_connection_state.get("last_error"),
                failures=_connection_state.get("consecutive_failures", 0),
            )

            tick += 1

            # Sleep in small increments, checking for keypresses
            slept = 0.0
            force_refresh = False
            while slept < interval and not interrupted and not force_refresh:
                try:
                    time.sleep(0.1)
                    # Check for keypress
                    key = _check_keypress()
                    if key:
                        if key.lower() == 'q':
                            interrupted = True
                            break
                        elif key.lower() == 'r':
                            force_refresh = True
                            tick = 0  # Reset tick to refresh everything
                            break
                        elif key == '1':
                            current_view = "ops"
                            force_refresh = True
                            break
                        elif key == '2':
                            current_view = "biz"
                            force_refresh = True
                            break
                        elif key.lower() == 'a':
                            # Alert management
                            _show_alert_menu(last_alerts or [], api_key, admin_secret, base_url, old_term_settings)
                            old_term_settings = _setup_terminal()
                            if _USE_COLOR:
                                sys.stdout.write(_HIDE_CURSOR)
                            force_refresh = True
                            tick = 0
                            break
                        elif key.lower() == 's':
                            # Service management
                            _show_service_menu(last_services, old_term_settings)
                            old_term_settings = _setup_terminal()
                            if _USE_COLOR:
                                sys.stdout.write(_HIDE_CURSOR)
                            force_refresh = True
                            tick = 0
                            break
                        elif key.lower() == 'l':
                            # Log viewer
                            _show_logs_menu(old_term_settings)
                            old_term_settings = _setup_terminal()
                            if _USE_COLOR:
                                sys.stdout.write(_HIDE_CURSOR)
                            force_refresh = True
                            break
                        elif key.lower() == 'o':
                            # Org lookup
                            _show_org_lookup(api_key, admin_secret, base_url, old_term_settings)
                            old_term_settings = _setup_terminal()
                            if _USE_COLOR:
                                sys.stdout.write(_HIDE_CURSOR)
                            force_refresh = True
                            break
                        elif key.lower() == 'e':
                            # Event viewer
                            _show_event_viewer(api_key, admin_secret, base_url, old_term_settings)
                            old_term_settings = _setup_terminal()
                            if _USE_COLOR:
                                sys.stdout.write(_HIDE_CURSOR)
                            force_refresh = True
                            break
                        elif key.lower() == 'b':
                            # Billing management
                            _show_billing_menu(api_key, admin_secret, base_url, old_term_settings)
                            old_term_settings = _setup_terminal()
                            if _USE_COLOR:
                                sys.stdout.write(_HIDE_CURSOR)
                            force_refresh = True
                            break
                        elif key.lower() == 'h':
                            # Help
                            _show_help_overlay(old_term_settings)
                            old_term_settings = _setup_terminal()
                            if _USE_COLOR:
                                sys.stdout.write(_HIDE_CURSOR)
                            force_refresh = True
                            break
                except (KeyboardInterrupt, SystemExit):
                    interrupted = True
                    break
                slept += 0.1

    finally:
        _restore_terminal(old_term_settings)
        if _USE_COLOR:
            sys.stdout.write(_SHOW_CURSOR)
        sys.stdout.write(_CLEAR)
        sys.stdout.flush()
        print("Obol admin monitor ended.")


def setup_parser(subparsers):
    """Add monitor subparser."""
    parser = subparsers.add_parser(
        "monitor",
        help="24/7 admin dashboard: VPS health, API metrics, alerts, events",
    )
    parser.set_defaults(func=cmd_admin_monitor)
    parser.add_argument(
        "--interval", type=float, default=2.0,
        help="Refresh interval in seconds (default: 2)",
    )
    parser.add_argument(
        "--skip-yubikey", action="store_true",
        help="Skip YubiKey authentication (dev testing only)",
    )
    parser.add_argument(
        "--no-vps", action="store_true",
        help="Skip VPS metrics (faster startup, no SSH)",
    )
    parser.add_argument(
        "--no-news", action="store_true",
        help="Skip news ticker",
    )
    parser.add_argument(
        "--notify", action="store_true",
        help="Send desktop notifications for critical/high alerts (QOL #4)",
    )
