#!/usr/bin/env python3
"""Codex Launcher GUI — manage endpoints, launch Desktop or CLI with any provider."""

import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk, GLib
import subprocess, os, signal, sys, threading, time, json, urllib.request, tempfile, shutil
import hashlib, socket, contextlib
from pathlib import Path

HOME = Path.home()
START_SH = Path("/opt/codex-desktop/start.sh")
CONFIG = HOME / ".codex/config.toml"
CONFIG_BAK = HOME / ".codex/config.toml.launcher-bak"
CLEANUP = HOME / ".local/bin/cleanup-codex-stale.sh"
PROXY = HOME / ".local/bin/translate-proxy.py"
ENDPOINTS_FILE = HOME / ".codex/endpoints.json"
BGP_POOLS_FILE = HOME / ".codex/bgp-pools.json"
LOG_DIR = HOME / ".cache/codex-desktop"
LAUNCH_LOG = LOG_DIR / "launcher.log"
PROXY_CONFIG_DIR = HOME / ".cache/codex-proxy"
DEFAULT_CONFIG = """model = ""
model_provider = ""
model_catalog_json = ""
"""

CHANGELOG = [
    ("2.6.1", "2026-05-20", [
        "Google OAuth rebuilt to emulate Gemini CLI — no client_secret.json needed",
        "Uses Google's public OAuth client_id (same as gemini-cli)",
        "PKCE + CSRF state protection for secure auth",
        "Just click OAuth Login → browser opens → authorize → done",
        "Includes cloud-platform scope for Gemini Code Assist compatibility",
    ]),
    ("2.6.0", "2026-05-20", [
        "Usage Dashboard — per-provider request/token/latency tracking",
        "Visual cards with success rate bars, model breakdown, error tracking",
        "Google OAuth: browse for client_secret.json instead of fixed path",
    ]),
    ("2.5.1", "2026-05-20", [
        "Adaptive retry for 429/502/503 errors with exponential backoff",
        "BGP routes also retry transient errors before failing over",
        "Proxy socket reuse — no more 'Address already in use' crashes",
        "BGP route count shown at proxy startup",
    ]),
    ("2.5.0", "2026-05-20", [
        "AI BGP — multi-provider routing with automatic failover",
        "Create BGP pools with ordered routes from any configured endpoint",
        "Each route uses its own endpoint URL, API key, and model",
        "Failover strategy: tries primary, falls back on error/timeout",
        "BGP pools appear in endpoint dropdown with shuffle icon",
        "Up/down reordering for route priority in pool editor",
        "Fixed TOML config breakage from multi-line paste in fields",
    ]),
    ("2.4.0", "2026-05-20", [
        "Added OpenAdapter provider preset (api.openadapter.in)",
        "One API key access to 40+ models — GLM, DeepSeek, Kimi, Qwen, Claude, GPT, Gemini",
        "Fixed Add/Edit dialog crash (missing _on_reasoning_toggled method)",
        "Redesigned Google OAuth flow with live status dialog",
    ]),
    ("2.3.2", "2026-05-20", [
        "Added Google Gemini provider with OAuth support",
        "Two presets: 'Google Gemini (API Key)' and 'Google Gemini (OAuth)'",
        "OAuth Login button in endpoint editor — full Google OAuth2 flow with auto-refresh",
        "Auto-refreshes OAuth access tokens when expired (no manual re-login needed)",
        "Supports gemini-2.5-flash, gemini-2.5-pro, gemini-2.0-flash, and more",
        "Uses Gemini's OpenAI-compatible endpoint — works with existing proxy",
    ]),
    ("2.3.0", "2026-05-20", [
        "Adaptive Crof self-healing system — auto-adjusts to Crof model limits",
        "Tracks per-model success/failure history, learns item count limits dynamically",
        "Proactively compacts input when above learned limit before sending to Crof",
        "Auto-retries on finish_reason=length — aggressively compacts and resends",
        "Prevents 'stream disconnected' and 'incomplete' errors on long conversations",
    ]),
    ("2.2.1", "2026-05-20", [
        "Fixed compaction orphaning function_call_output items — root cause of Crof incomplete responses",
        "Compaction now respects function_call/function_call_output pairs — no more dangling tool results",
        "Fixed reasoning control: reasoning_effort=none now always sends enable_thinking=false too",
    ]),
    ("2.2.0", "2026-05-20", [
        "Added per-provider Reasoning On/Off toggle in endpoint editor",
        "Added Reasoning Effort level per provider: None, Minimal, Low, Medium, High, Max",
        "When reasoning is OFF: sends enable_thinking=false + reasoning_effort=none to upstream API",
        "When reasoning is ON: sends user-selected effort level (default: Medium)",
        "Fixes Crof mimo-v2.5-pro and similar reasoning models exhausting output tokens",
        "Strip reasoning_content from proxy output — Codex doesn't use it",
        "Force max_tokens=64000 minimum for openai-compat providers",
    ]),
    ("2.1.3", "2026-05-19", [
        "Fixed Crof mimo-v2.5-pro stopping: reasoning_content exhausted all output tokens",
        "Strip reasoning_content from proxy output — Codex doesn't use it, avoids token waste",
        "Force max_tokens=64000 minimum for openai-compat providers — gives models room for both reasoning and content",
    ]),
    ("2.1.2", "2026-05-19", [
        "Fixed Crof.ai and providers stopping after first tool call (root cause: None tool IDs)",
        "Codex sends function_call items with id=None — proxy now matches tool results to calls by position",
        "Fixed orphan message output item when response has only tool calls (no text)",
        "Auto-trims long conversations (>30 items) to prevent context overflow on providers like Crof",
        "Added request/response logging to ~/.cache/codex-proxy/requests.log",
    ]),
    ("2.1.1", "2026-05-19", [
        "Fixed proxy: map 'developer' role to 'system' for Chat Completions providers",
        "Fixed proxy: map 'developer' role to 'user' for Anthropic providers",
        "Forward 'instructions' field from Responses API as system message/param",
        "Fixes DeepSeek and other providers rejecting unknown 'developer' role",
    ]),
    ("2.1.0", "2026-05-19", [
        "Added Codex auth status detection (codex login status)",
        "Added Re-login button to re-authenticate via codex login",
        "Auto-checks auth before launching Codex Default mode",
        "Warns if OAuth token expired or missing before launch",
    ]),
    ("2.0.1", "2026-05-19", [
        "Added Codex CLI/Desktop installation verifier to main page",
        "Disables Desktop/CLI launch buttons when corresponding tool is missing",
        "Shows install instructions in status area on startup",
    ]),
    ("2.0.0", "2026-05-19", [
        "Initial release: multi-provider Codex Launcher",
        "Translation proxy: Responses API to Chat Completions + Anthropic Messages",
        "GTK endpoint manager with 10+ provider presets",
        "Codex Default mode (built-in OAuth, zero config)",
        "Browser UA injection for Cloudflare-protected providers (OpenCode)",
        "Streaming SSE, tool calls, reasoning content support",
        "Profile backup/import, model auto-fetch, bulk import",
        "Refresh Models in background thread",
        "URL normalization to prevent double-path bugs",
        "Config backup/restore around sessions",
        ".deb installer package",
    ]),
]

PROVIDER_PRESETS = {
    "Custom": {
        "backend_type": "openai-compat",
        "base_url": "",
        "models": [],
    },
    "OpenAI": {
        "backend_type": "native",
        "base_url": "https://api.openai.com/v1",
        "models": ["gpt-4o", "gpt-4o-mini"],
    },
    "Anthropic": {
        "backend_type": "anthropic",
        "base_url": "https://api.anthropic.com/v1",
        "models": ["claude-sonnet-4-5", "claude-3-5-haiku-latest"],
    },
    "OpenCode Zen (OpenAI-compatible)": {
        "backend_type": "openai-compat",
        "base_url": "https://opencode.ai/zen/v1",
        "models": [
            "glm-5.1", "glm-5", "kimi-k2.5", "kimi-k2.6",
            "minimax-m2.7", "minimax-m2.5", "minimax-m2.5-free",
            "deepseek-v4-flash-free", "nemotron-3-super-free",
            "qwen3.6-plus", "qwen3.5-plus", "big-pickle",
        ],
    },
    "OpenCode Zen (Anthropic)": {
        "backend_type": "anthropic",
        "base_url": "https://opencode.ai/zen/v1",
        "models": [
            "claude-opus-4-7", "claude-opus-4-6", "claude-opus-4-5",
            "claude-opus-4-1", "claude-sonnet-4-6", "claude-sonnet-4-5",
            "claude-sonnet-4", "claude-haiku-4-5", "claude-3-5-haiku",
        ],
    },
    "OpenCode Go (OpenAI-compatible)": {
        "backend_type": "openai-compat",
        "base_url": "https://opencode.ai/zen/go/v1",
        "models": [
            "glm-5.1", "glm-5", "kimi-k2.5", "kimi-k2.6",
            "mimo-v2.5", "mimo-v2.5-pro", "minimax-m2.7", "minimax-m2.5",
            "qwen3.6-plus", "qwen3.5-plus", "deepseek-v4-pro", "deepseek-v4-flash",
        ],
    },
    "OpenCode Go (Anthropic)": {
        "backend_type": "anthropic",
        "base_url": "https://opencode.ai/zen/go/v1",
        "models": ["minimax-m2.7", "minimax-m2.5"],
    },
    "Crof.ai": {
        "backend_type": "openai-compat",
        "base_url": "https://crof.ai/v1",
        "models": [],
    },
    "NVIDIA NIM": {
        "backend_type": "openai-compat",
        "base_url": "https://integrate.api.nvidia.com/v1",
        "models": [],
    },
    "Kilo.ai Gateway": {
        "backend_type": "openai-compat",
        "base_url": "https://api.kilo.ai/api/gateway",
        "models": [],
    },
    "Command Code": {
        "backend_type": "command-code",
        "base_url": "https://api.commandcode.ai",
        "cc_version": "0.26.8",
        "models": [
            "deepseek/deepseek-v4-flash", "deepseek/deepseek-v4-pro",
            "anthropic:claude-sonnet-4-6", "anthropic:claude-haiku-4-5-20251001",
            "anthropic:claude-opus-4-7", "anthropic:claude-opus-4-6",
            "openai:gpt-5.5", "openai:gpt-5.4", "openai:gpt-5.4-mini", "openai:gpt-5.3-codex",
            "moonshotai/Kimi-K2.6", "moonshotai/Kimi-K2.5",
            "zai-org/GLM-5.1", "zai-org/GLM-5",
            "MiniMaxAI/MiniMax-M2.7", "MiniMaxAI/MiniMax-M2.5",
            "Qwen/Qwen3.6-Max-Preview", "Qwen/Qwen3.6-Plus",
            "stepfun/Step-3.5-Flash", "google/gemini-3.1-flash-lite",
        ],
    },
    "OpenRouter": {
        "backend_type": "openai-compat",
        "base_url": "https://openrouter.ai/api/v1",
        "models": [],
    },
    "Google Gemini (API Key)": {
        "backend_type": "openai-compat",
        "base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
        "models": [
            "gemini-2.5-flash", "gemini-2.5-pro",
            "gemini-2.0-flash", "gemini-2.0-flash-lite",
            "gemini-2.5-flash-preview-native-audio-dialog",
        ],
    },
    "Google Gemini (OAuth)": {
        "backend_type": "openai-compat",
        "base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
        "oauth_provider": "google",
        "models": [
            "gemini-2.5-flash", "gemini-2.5-pro",
            "gemini-2.0-flash", "gemini-2.0-flash-lite",
            "gemini-2.5-flash-preview-native-audio-dialog",
        ],
    },
    "OpenAdapter": {
        "backend_type": "openai-compat",
        "base_url": "https://api.openadapter.in/v1",
        "models": [
            "0G-DeepSeek-V3",
            "0G-DeepSeek-v4-Pro",
            "0G-GLM-5",
            "0G-GLM-5.1",
            "0G-Qwen3.6",
            "0G-Qwen-VL",
        ],
    },
}

def safe_name(name):
    base = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in name).strip("._-") or "endpoint"
    digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8]
    return f"{base}-{digest}"

def label_for_backend(backend_type):
    return {
        "openai-compat": "OpenAI-compatible",
        "anthropic": "Anthropic",
        "command-code": "Command Code",
        "native": "Native",
    }.get(backend_type, backend_type)

def normalize_model_id(text):
    value = text.strip().lower()
    if not value:
        return ""
    value = value.replace("/", "-")
    value = value.replace("+", "plus")
    value = "".join(ch if ch.isalnum() or ch in ".-" else "-" for ch in value)
    while "--" in value:
        value = value.replace("--", "-")
    return value.strip("-.")

def normalize_base_url(url):
    base = (url or "").strip().rstrip("/")
    for suffix in ("/chat/completions", "/responses", "/messages"):
        if base.endswith(suffix):
            base = base[: -len(suffix)]
            break
    return base.rstrip("/")

def parse_model_list(text):
    out = []
    seen = set()
    for raw in text.replace(",", "\n").splitlines():
        mid = normalize_model_id(raw)
        if mid and mid not in seen:
            seen.add(mid)
            out.append(mid)
    return out

def apply_provider_preset(endpoint, preset_name):
    preset = PROVIDER_PRESETS.get(preset_name)
    if not preset:
        return endpoint
    updated = dict(endpoint)
    updated["provider_preset"] = preset_name
    updated["backend_type"] = preset["backend_type"]
    updated["base_url"] = normalize_base_url(preset["base_url"])
    if preset.get("cc_version") and not updated.get("cc_version"):
        updated["cc_version"] = preset["cc_version"]
    if not updated.get("models"):
        updated["models"] = list(preset.get("models", []))
    if not updated.get("default_model") and updated.get("models"):
        updated["default_model"] = updated["models"][0]
    return updated

def endpoint_models_url(endpoint):
    base = normalize_base_url(endpoint.get("base_url") or "")
    if not base:
        return ""
    return f"{base}/models"

def endpoint_model_headers(endpoint):
    key = (endpoint.get("api_key") or "").strip()
    backend = endpoint.get("backend_type", "openai-compat")
    headers = {}
    if backend == "anthropic":
        if key:
            headers["x-api-key"] = key
        headers["anthropic-version"] = "2023-06-01"
    elif key:
        headers["Authorization"] = f"Bearer {key}"
    return headers

def fetch_models_for_endpoint(endpoint, timeout=10):
    url = endpoint_models_url(endpoint)
    if not url:
        return None, "Base URL is empty"
    try:
        req = urllib.request.Request(url, headers=endpoint_model_headers(endpoint))
        raw = urllib.request.urlopen(req, timeout=timeout).read()
        payload = json.loads(raw)
        items = payload.get("data") or payload.get("models") or []
        ids = []
        seen = set()
        for item in items:
            mid = item.get("id") if isinstance(item, dict) else None
            if mid and mid not in seen:
                seen.add(mid)
                ids.append(mid)
        if not ids:
            return None, "No models returned"
        return ids, None
    except Exception as e:
        return None, str(e)

def refresh_endpoint_models(endpoint):
    ids, err = fetch_models_for_endpoint(endpoint)
    if not ids:
        return None, err
    updated = dict(endpoint)
    updated["models"] = ids
    if updated.get("default_model") not in ids:
        updated["default_model"] = ids[0]
    return updated, None

# ═══════════════════════════════════════════════════════════════════
# Endpoint storage
# ═══════════════════════════════════════════════════════════════════

def load_endpoints():
    if ENDPOINTS_FILE.exists():
        try:
            return json.loads(ENDPOINTS_FILE.read_text())
        except Exception:
            pass
    return {"default": None, "endpoints": []}

def save_endpoints(data):
    ENDPOINTS_FILE.parent.mkdir(parents=True, exist_ok=True)
    ENDPOINTS_FILE.write_text(json.dumps(data, indent=2))

def load_bgp_pools():
    if BGP_POOLS_FILE.exists():
        try:
            return json.loads(BGP_POOLS_FILE.read_text())
        except Exception:
            pass
    return {"pools": []}

def save_bgp_pools(data):
    BGP_POOLS_FILE.parent.mkdir(parents=True, exist_ok=True)
    BGP_POOLS_FILE.write_text(json.dumps(data, indent=2))

def get_endpoint(name):
    for e in load_endpoints()["endpoints"]:
        if e["name"] == name:
            return e
    return None

def now_utc_iso():
    return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())

def build_profile_bundle():
    return {
        "version": 1,
        "exported_at": now_utc_iso(),
        "endpoints": load_endpoints(),
        "codex_config_toml": CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else "",
    }

def save_profile_bundle(path):
    bundle = build_profile_bundle()
    Path(path).write_text(json.dumps(bundle, indent=2), encoding="utf-8")

def import_profile_bundle(path):
    data = json.loads(Path(path).read_text(encoding="utf-8"))
    if not isinstance(data, dict):
        raise ValueError("Invalid profile bundle")

    endpoints = data.get("endpoints")
    if not isinstance(endpoints, dict) or "endpoints" not in endpoints:
        raise ValueError("Profile bundle missing endpoints")

    # Keep a local rollback point before overwriting the current profile.
    if CONFIG.exists():
        shutil.copy2(str(CONFIG), str(CONFIG_BAK))
    if ENDPOINTS_FILE.exists():
        shutil.copy2(str(ENDPOINTS_FILE), str(ENDPOINTS_FILE.with_suffix(".json.import-bak")))

    save_endpoints(endpoints)

    cfg = data.get("codex_config_toml", "")
    if isinstance(cfg, str) and cfg.strip():
        CONFIG.parent.mkdir(parents=True, exist_ok=True)
        CONFIG.write_text(cfg, encoding="utf-8")
    return endpoints

# ═══════════════════════════════════════════════════════════════════
# Config management
# ═══════════════════════════════════════════════════════════════════

def backup_config():
    if CONFIG.exists():
        tmp = CONFIG_BAK.with_suffix(".tmp")
        shutil.copy2(str(CONFIG), str(tmp))
        os.replace(str(tmp), str(CONFIG_BAK))

def restore_config():
    if CONFIG_BAK.exists():
        tmp = CONFIG.with_suffix(".tmp")
        shutil.copy2(str(CONFIG_BAK), str(tmp))
        os.replace(str(tmp), str(CONFIG))

def write_secure_text(path, text):
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(path.suffix + ".tmp")
    tmp.write_text(text, encoding="utf-8")
    os.chmod(str(tmp), 0o600)
    os.replace(str(tmp), str(path))

CONFIG_TXN = HOME / ".codex/config.toml.launcher-txn.json"

def begin_config_transaction(reason):
    txn = {"started_at": time.time(), "reason": reason,
           "config_existed": CONFIG.exists(), "backup_path": str(CONFIG_BAK)}
    if CONFIG.exists():
        backup_config()
    CONFIG_TXN.parent.mkdir(parents=True, exist_ok=True)
    CONFIG_TXN.write_text(json.dumps(txn, indent=2))

def end_config_transaction():
    CONFIG_TXN.unlink(missing_ok=True)

def recover_config_if_needed(logfn=None):
    if not CONFIG_TXN.exists():
        return
    try:
        txn = json.loads(CONFIG_TXN.read_text())
        if txn.get("config_existed") and CONFIG_BAK.exists():
            restore_config()
            if logfn:
                logfn("Recovered Codex config from interrupted session.")
        elif CONFIG.exists():
            CONFIG.unlink()
            if logfn:
                logfn("Removed generated config from interrupted session.")
    finally:
        CONFIG_TXN.unlink(missing_ok=True)

def write_config_for_native(endpoint, selected_model):
    """Write config for native OpenAI (no proxy needed)."""
    backup_config()
    model_catalog = _gen_model_catalog(endpoint, selected_model)
    mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
    mc_path.parent.mkdir(parents=True, exist_ok=True)
    mc_path.write_text(json.dumps(model_catalog, indent=2))

    lines = [
        f'model = "{_toml_safe(selected_model)}"\n',
        f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
        f'model_catalog_json = "{mc_path}"\n',
        f'\n[model_providers."{endpoint["name"]}"]\n',
        f'name = "{_toml_safe(endpoint["name"])}"\n',
        f'base_url = "{_toml_safe(endpoint["base_url"])}"\n',
        f'experimental_bearer_token = "{_toml_safe(endpoint["api_key"])}"\n',
        f'\n[profiles."{endpoint["name"]}"]\n',
        f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
        f'model = "{_toml_safe(selected_model)}"\n',
        f'model_catalog_json = "{mc_path}"\n',
        f'service_tier = "default"\n',
        f'approvals_reviewer = "user"\n',
    ]
    CONFIG.write_text("".join(lines))

def _toml_safe(val):
    val = str(val).replace('"', '\\"')
    return val.split('\n', 1)[0].strip()

def write_config_for_translated(endpoint, selected_model, proxy_port=8080):
    backup_config()
    model_catalog = _gen_model_catalog(endpoint, selected_model)
    mc_path = PROXY_CONFIG_DIR / f"models-{safe_name(endpoint['name'])}.json"
    mc_path.parent.mkdir(parents=True, exist_ok=True)
    mc_path.write_text(json.dumps(model_catalog, indent=2))

    lines = [
        f'model = "{_toml_safe(selected_model)}"\n',
        f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
        f'model_catalog_json = "{mc_path}"\n',
        f'\n[model_providers."{endpoint["name"]}"]\n',
        f'name = "{_toml_safe(endpoint["name"])}"\n',
        f'base_url = "http://127.0.0.1:{proxy_port}"\n',
        f'experimental_bearer_token = "codex-launcher-local"\n',
        f'\n[profiles."{endpoint["name"]}"]\n',
        f'model_provider = "{_toml_safe(endpoint["name"])}"\n',
        f'model = "{_toml_safe(selected_model)}"\n',
        f'model_catalog_json = "{mc_path}"\n',
        f'service_tier = "fast"\n',
        f'approvals_reviewer = "user"\n',
    ]
    write_secure_text(CONFIG, "".join(lines))

def _gen_model_catalog(endpoint, selected_model=None):
    default_model = selected_model or endpoint.get("default_model")
    models = []
    for mid in endpoint.get("models", []):
        models.append({
            "slug": mid, "model": mid, "display_name": mid,
            "description": f"{endpoint['name']} {mid}",
            "hidden": False, "isDefault": mid == default_model,
            "shell_type": "shell_command", "visibility": "list",
            "default_reasoning_level": "medium",
            "supported_reasoning_levels": [
                {"effort": "low", "description": "Fast"},
                {"effort": "medium", "description": "Balanced"},
                {"effort": "high", "description": "Deep"},
                {"effort": "xhigh", "description": "Extra deep"},
            ],
            "supportedReasoningEfforts": [
                {"reasoningEffort": "low", "description": "Fast"},
                {"reasoningEffort": "medium", "description": "Balanced"},
                {"reasoningEffort": "high", "description": "Deep"},
                {"reasoningEffort": "xhigh", "description": "Extra deep"},
            ],
            "priority": 30, "context_size": 128000,
            "additional_speed_tiers": [], "service_tiers": [],
            "supports_reasoning_summaries": True, "support_verbosity": True,
            "reasoning": True, "tool_call": True,
            "supports_parallel_tool_calls": True,
            "experimental_supported_tools": [], "supported_in_api": True,
            "truncation_policy": {"mode": "tokens", "limit": 128000},
            "base_instructions": "You are Codex, a coding agent.",
        })
    return {"models": models}

# ═══════════════════════════════════════════════════════════════════
# Proxy management
# ═══════════════════════════════════════════════════════════════════

_proxy_proc = None
_proxy_port = None

PID_REGISTRY = HOME / ".cache" / "codex-launcher" / "pids.json"

def _pick_free_port():
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.bind(("127.0.0.1", 0))
        return s.getsockname()[1]

def _load_pid_registry():
    if PID_REGISTRY.exists():
        try:
            return json.loads(PID_REGISTRY.read_text())
        except Exception:
            pass
    return {}

def _save_pid_registry(data):
    PID_REGISTRY.parent.mkdir(parents=True, exist_ok=True)
    tmp = PID_REGISTRY.with_suffix(".tmp")
    tmp.write_text(json.dumps(data, indent=2))
    os.replace(str(tmp), str(PID_REGISTRY))

def _register_pgid(kind, pid):
    data = _load_pid_registry()
    try:
        pgid = os.getpgid(pid)
    except ProcessLookupError:
        return
    data[kind] = {"pid": pid, "pgid": pgid, "ts": time.time()}
    _save_pid_registry(data)

def safe_cleanup_owned(logfn=None):
    data = _load_pid_registry()
    changed = False
    for kind, meta in list(data.items()):
        pgid = meta.get("pgid")
        if not pgid:
            continue
        try:
            os.killpg(pgid, signal.SIGTERM)
            if logfn:
                logfn(f"Stopped {kind} (pgid {pgid})")
            changed = True
        except ProcessLookupError:
            changed = True
        except Exception as e:
            if logfn:
                logfn(f"Could not stop {kind}: {e}")
    if changed:
        _save_pid_registry({})

def _start_proxy_for(endpoint, logfn):
    global _proxy_proc, _proxy_port
    _stop_proxy()
    port = _pick_free_port()
    _proxy_port = port

    pcfg = {
        "port": port,
        "backend_type": endpoint["backend_type"],
        "target_url": normalize_base_url(endpoint["base_url"]),
        "api_key": endpoint["api_key"],
        "cc_version": endpoint.get("cc_version", ""),
        "oauth_provider": endpoint.get("oauth_provider", ""),
        "reasoning_enabled": endpoint.get("reasoning_enabled", True),
        "reasoning_effort": endpoint.get("reasoning_effort", "medium"),
        "models": [{"id": m, "object": "model", "created": 1700000000, "owned_by": endpoint["name"]}
                   for m in endpoint.get("models", [])],
    }
    pcfg_path = PROXY_CONFIG_DIR / f"proxy-{safe_name(endpoint['name'])}-{port}.json"
    pcfg_path.parent.mkdir(parents=True, exist_ok=True)
    pcfg_path.write_text(json.dumps(pcfg, indent=2))
    _start_proxy_with_config(pcfg_path, port, logfn)
    return port

def _start_proxy_with_config(pcfg_path, port, logfn):
    global _proxy_proc
    _proxy_proc = subprocess.Popen(
        ["python3", str(PROXY), "--config", str(pcfg_path)],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.PIPE,
        preexec_fn=os.setsid,
        text=True,
    )
    _register_pgid("proxy", _proxy_proc.pid)

    def _pipe_stderr():
        if not _proxy_proc.stderr:
            return
        for line in _proxy_proc.stderr:
            GLib.idle_add(logfn, f"[proxy] {line.rstrip()}")
    threading.Thread(target=_pipe_stderr, daemon=True).start()

    deadline = time.time() + 15
    last_err = None
    while time.time() < deadline:
        if _proxy_proc.poll() is not None:
            raise RuntimeError(f"Proxy exited early with code {_proxy_proc.returncode}")
        try:
            urllib.request.urlopen(f"http://127.0.0.1:{port}/v1/models", timeout=2)
            logfn(f"Proxy ready on port {port}")
            return
        except Exception as e:
            last_err = e
            time.sleep(0.3)
    try:
        os.killpg(os.getpgid(_proxy_proc.pid), signal.SIGTERM)
        _proxy_proc.wait(timeout=3)
    except Exception:
        with contextlib.suppress(Exception):
            os.killpg(os.getpgid(_proxy_proc.pid), signal.SIGKILL)
    raise RuntimeError(f"Proxy failed health check on port {port}: {last_err}")

def _stop_proxy():
    global _proxy_proc
    if _proxy_proc and _proxy_proc.poll() is None:
        try:
            os.killpg(os.getpgid(_proxy_proc.pid), signal.SIGTERM)
            time.sleep(0.5)
            if _proxy_proc.poll() is None:
                os.killpg(os.getpgid(_proxy_proc.pid), signal.SIGKILL)
        except (ProcessLookupError, PermissionError):
            pass
        _proxy_proc = None

def _run_cleanup(logfn=None):
    safe_cleanup_owned(logfn)

def _last_log_lines(n=15):
    try:
        t = LAUNCH_LOG.read_text()
        return "\n".join(t.splitlines()[-n:])
    except Exception:
        return "(no log file)"

def _detect_codex_cli():
    try:
        path = shutil.which("codex")
        if not path:
            return None
        out = subprocess.run(["codex", "--version"], capture_output=True, text=True, timeout=5)
        ver = (out.stdout or "").strip() or (out.stderr or "").strip() or "unknown"
        return (path, ver)
    except Exception:
        return None

def _detect_codex_desktop():
    if START_SH.exists():
        return str(START_SH)
    return None

def _check_codex_auth():
    try:
        out = subprocess.run(
            ["codex", "login", "status"],
            capture_output=True, text=True, timeout=10,
        )
        text = (out.stdout or "").strip()
        if not text:
            text = (out.stderr or "").strip()
        if out.returncode == 0 and text:
            return ("logged_in", text)
        if text:
            return ("error", text)
        return ("unknown", "No output from codex login status")
    except FileNotFoundError:
        return ("not_installed", "codex not found")
    except Exception as e:
        return ("error", str(e))

# ═══════════════════════════════════════════════════════════════════
# Main window
# ═══════════════════════════════════════════════════════════════════

class LauncherWin(Gtk.Window):
    def __init__(self):
        super().__init__(title="Codex Launcher")
        self.set_default_size(560, 460)
        self.set_border_width(12)
        self.set_position(Gtk.WindowPosition.CENTER)
        self._proc = None
        self._endpoints_data = load_endpoints()
        recover_config_if_needed()

        vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        self.add(vbox)

        # header row
        hdr = Gtk.Box(spacing=8)
        vbox.pack_start(hdr, False, False, 0)
        lbl = Gtk.Label(label="<b>Codex Launcher v3.0.0</b>")
        lbl.set_use_markup(True)
        hdr.pack_start(lbl, False, False, 0)
        changelog_btn = Gtk.Button(label="Changelog")
        changelog_btn.connect("clicked", lambda b: self._show_changelog())
        hdr.pack_end(changelog_btn, False, False, 0)
        usage_btn = Gtk.Button(label="Usage")
        usage_btn.connect("clicked", lambda b: self._open_usage())
        hdr.pack_end(usage_btn, False, False, 0)
        bgp_btn = Gtk.Button(label="AI BGP")
        bgp_btn.connect("clicked", lambda b: self._open_bgp())
        hdr.pack_end(bgp_btn, False, False, 0)
        mgr_btn = Gtk.Button(label="Manage Endpoints")
        mgr_btn.connect("clicked", lambda b: self._open_mgr())
        hdr.pack_end(mgr_btn, False, False, 0)

        # verification status bar
        self._cli_info = _detect_codex_cli()
        self._desktop_info = _detect_codex_desktop()
        ver_box = Gtk.Box(spacing=12)
        vbox.pack_start(ver_box, False, False, 0)

        if self._cli_info:
            cli_path, cli_ver = self._cli_info
            cli_lbl = Gtk.Label()
            cli_lbl.set_markup(f"<span foreground='#2ea043'>✔ Codex CLI</span>  <small>{cli_ver} ({cli_path})</small>")
            cli_lbl.set_use_markup(True)
            ver_box.pack_start(cli_lbl, False, False, 0)
        else:
            cli_lbl = Gtk.Label()
            cli_lbl.set_markup("<span foreground='#d29922'>✘ Codex CLI — not found</span>")
            cli_lbl.set_use_markup(True)
            ver_box.pack_start(cli_lbl, False, False, 0)
            cli_install_btn = Gtk.Button(label="Install")
            cli_install_btn.connect("clicked", lambda b: self._show_install_guide("cli"))
            ver_box.pack_start(cli_install_btn, False, False, 0)

        ver_box.pack_start(Gtk.Label(label="  "), False, False, 0)

        if self._desktop_info:
            desk_lbl = Gtk.Label()
            desk_lbl.set_markup(f"<span foreground='#2ea043'>✔ Codex Desktop</span>  <small>({self._desktop_info})</small>")
            desk_lbl.set_use_markup(True)
            ver_box.pack_start(desk_lbl, False, False, 0)
        else:
            desk_lbl = Gtk.Label()
            desk_lbl.set_markup("<span foreground='#d29922'>✘ Codex Desktop — not found</span>")
            desk_lbl.set_use_markup(True)
            ver_box.pack_start(desk_lbl, False, False, 0)
            desk_install_btn = Gtk.Button(label="Install")
            desk_install_btn.connect("clicked", lambda b: self._show_install_guide("desktop"))
            ver_box.pack_start(desk_install_btn, False, False, 0)

        self._missing = []
        if not self._cli_info:
            self._missing.append("cli")
        if not self._desktop_info:
            self._missing.append("desktop")

        auth_box = Gtk.Box(spacing=12)
        vbox.pack_start(auth_box, False, False, 0)
        self._auth_label = Gtk.Label()
        self._auth_label.set_markup("<span foreground='#888'>Checking auth…</span>")
        self._auth_label.set_use_markup(True)
        self._auth_label.set_ellipsize(3)
        auth_box.pack_start(self._auth_label, False, False, 0)
        self._relogin_btn = Gtk.Button(label="Re-login")
        self._relogin_btn.set_sensitive(False)
        self._relogin_btn.connect("clicked", lambda b: self._codex_relogin())
        auth_box.pack_end(self._relogin_btn, False, False, 0)
        threading.Thread(target=self._check_auth_async, daemon=True).start()

        ops_box = Gtk.Box(spacing=8)
        vbox.pack_start(ops_box, False, False, 0)
        self._refresh_all_btn = Gtk.Button(label="Refresh Models")
        self._refresh_all_btn.connect("clicked", lambda b: self._refresh_all_models())
        ops_box.pack_start(self._refresh_all_btn, False, False, 0)
        self._backup_btn = Gtk.Button(label="Backup Profile")
        self._backup_btn.connect("clicked", lambda b: self._backup_profile())
        ops_box.pack_start(self._backup_btn, False, False, 0)
        self._import_btn = Gtk.Button(label="Import Profile")
        self._import_btn.connect("clicked", lambda b: self._import_profile())
        ops_box.pack_start(self._import_btn, False, False, 0)

        # endpoint selector
        sel_box = Gtk.Box(spacing=6)
        vbox.pack_start(sel_box, False, False, 4)
        sel_box.pack_start(Gtk.Label(label="Endpoint:"), False, False, 0)
        self._combo = Gtk.ComboBoxText()
        self._combo.connect("changed", lambda c: self._on_endpoint_changed())
        sel_box.pack_start(self._combo, True, True, 0)

        # model selector
        sel_box.pack_start(Gtk.Label(label="Model:"), False, False, 0)
        self._model_combo = Gtk.ComboBoxText()
        sel_box.pack_start(self._model_combo, True, True, 0)

        # launch buttons
        btn_box = Gtk.Box(spacing=8, homogeneous=True)
        vbox.pack_start(btn_box, False, False, 8)
        self._btn_desktop = Gtk.Button(label="Launch Desktop")
        self._btn_desktop.connect("clicked", lambda b: self._launch("desktop"))
        if "desktop" in self._missing:
            self._btn_desktop.set_tooltip_text("Codex Desktop is not installed")
            self._btn_desktop.set_sensitive(False)
        btn_box.pack_start(self._btn_desktop, True, True, 0)
        self._btn_cli = Gtk.Button(label="Launch CLI")
        self._btn_cli.connect("clicked", lambda b: self._launch("cli"))
        if "cli" in self._missing:
            self._btn_cli.set_tooltip_text("Codex CLI is not installed")
            self._btn_cli.set_sensitive(False)
        btn_box.pack_start(self._btn_cli, True, True, 0)

        btn_box2 = Gtk.Box(spacing=8, homogeneous=True)
        vbox.pack_start(btn_box2, False, False, 0)
        self._btn_codex_desktop = Gtk.Button(label="Codex Default (Desktop)")
        self._btn_codex_desktop.connect("clicked", lambda b: self._launch_codex_default("desktop"))
        if "desktop" in self._missing:
            self._btn_codex_desktop.set_tooltip_text("Codex Desktop is not installed")
            self._btn_codex_desktop.set_sensitive(False)
        btn_box2.pack_start(self._btn_codex_desktop, True, True, 0)
        self._btn_codex_cli = Gtk.Button(label="Codex Default (CLI)")
        self._btn_codex_cli.connect("clicked", lambda b: self._launch_codex_default("cli"))
        if "cli" in self._missing:
            self._btn_codex_cli.set_tooltip_text("Codex CLI is not installed")
            self._btn_codex_cli.set_sensitive(False)
        btn_box2.pack_start(self._btn_codex_cli, True, True, 0)

        # status
        sw = Gtk.ScrolledWindow()
        sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
        vbox.pack_start(sw, True, True, 0)
        self._buf = Gtk.TextBuffer()
        self._tv = Gtk.TextView(buffer=self._buf)
        self._tv.set_editable(False)
        self._tv.set_cursor_visible(False)
        self._tv.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
        sw.add(self._tv)

        # bottom bar
        bb = Gtk.Box(spacing=8)
        vbox.pack_start(bb, False, False, 0)
        self._kill_btn = Gtk.Button(label="Kill && Cleanup")
        self._kill_btn.connect("clicked", lambda b: self._kill())
        self._kill_btn.set_sensitive(False)
        bb.pack_start(self._kill_btn, True, True, 0)
        self._view_log_btn = Gtk.Button(label="View Log")
        self._view_log_btn.connect("clicked", lambda b: subprocess.Popen(["xdg-open", str(LAUNCH_LOG)]))
        bb.pack_start(self._view_log_btn, False, False, 0)
        self._close_btn = Gtk.Button(label="Close")
        self._close_btn.connect("clicked", lambda b: self._do_close())
        bb.pack_start(self._close_btn, False, False, 0)

        self.show_all()
        self._rebuild_combo()
        self._log_dependency_status()

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

    def log(self, msg):
        GLib.idle_add(self._append_log, msg)

    def _append_log(self, msg):
        e = self._buf.get_end_iter()
        self._buf.insert(e, msg + "\n")
        m = self._buf.create_mark(None, e, False)
        self._tv.scroll_to_mark(m, 0.0, True, 0.0, 0.5)
        self._buf.delete_mark(m)

    def _log_dependency_status(self):
        if self._cli_info:
            _, ver = self._cli_info
            self.log(f"✔ Codex CLI detected ({ver})")
        else:
            self.log("✘ Codex CLI NOT found — CLI launch disabled. Click 'Install' above.")
        if self._desktop_info:
            self.log(f"✔ Codex Desktop detected ({self._desktop_info})")
        else:
            self.log("✘ Codex Desktop NOT found — Desktop launch disabled. Click 'Install' above.")
        if self._missing:
            self.log("⚠  Install missing tools before using the launcher.")
        else:
            self.log("All dependencies OK.")

    def _check_auth_async(self):
        status, msg = _check_codex_auth()
        GLib.idle_add(self._update_auth_status, status, msg)

    def _update_auth_status(self, status, msg):
        if status == "logged_in":
            self._auth_label.set_markup(f"<span foreground='#2ea043'>✔ Auth: {msg}</span>")
            self._relogin_btn.set_sensitive("cli" not in self._missing)
        elif status == "not_installed":
            self._auth_label.set_markup("<span foreground='#888'>Auth: N/A (CLI not installed)</span>")
        else:
            self._auth_label.set_markup(f"<span foreground='#d29922'>⚠ Auth: {msg}</span>")
            self._relogin_btn.set_sensitive("cli" not in self._missing)
        return False

    def _codex_relogin(self):
        self.log("Opening codex login in terminal…")
        terms = [
            ("x-terminal-emulator", ["-e"]),
            ("kgx", ["--"]),
            ("gnome-terminal", ["--"]),
            ("konsole", ["-e"]),
            ("xterm", ["-e"]),
        ]
        term = None
        term_args = None
        for t in terms:
            if shutil.which(t[0]):
                term = t[0]
                term_args = t[1]
                break
        if not term:
            self.log("ERROR: no terminal emulator found for re-login")
            return
        cmd_parts = [term] + term_args + ["codex", "login"]
        subprocess.Popen(cmd_parts, preexec_fn=os.setsid)
        self.log("Login flow started in terminal. Re-checking auth in 30s…")
        self._auth_label.set_markup("<span foreground='#888'>Auth: waiting for login…</span>")
        threading.Thread(target=self._delayed_auth_check, daemon=True).start()

    def _delayed_auth_check(self):
        time.sleep(30)
        self._check_auth_async()

    def _set_busy(self, busy):
        def _update():
            has_cli = "cli" not in self._missing
            has_desk = "desktop" not in self._missing
            self._btn_desktop.set_sensitive(not busy and has_desk)
            self._btn_cli.set_sensitive(not busy and has_cli)
            self._btn_codex_desktop.set_sensitive(not busy and has_desk)
            self._btn_codex_cli.set_sensitive(not busy and has_cli)
            self._kill_btn.set_sensitive(busy)
        GLib.idle_add(_update)

    def _rebuild_combo(self):
        self._endpoints_data = load_endpoints()
        self._combo.remove_all()
        names = [e["name"] for e in self._endpoints_data["endpoints"]]
        for n in names:
            self._combo.append_text(n)
        bgp_names = [p["name"] for p in load_bgp_pools().get("pools", [])]
        for n in bgp_names:
            self._combo.append_text(f"🔀 {n}")
        if names or bgp_names:
            default = self._endpoints_data.get("default")
            if default and default in names:
                self._combo.set_active(names.index(default))
            else:
                self._combo.set_active(0)
        self._on_endpoint_changed()

    def _on_endpoint_changed(self):
        name = self._combo.get_active_text()
        is_bgp = name and name.startswith("🔀 ")
        bgp_name = name[2:] if is_bgp else None
        ep = get_endpoint(name) if name and not is_bgp else None
        self._model_combo.remove_all()
        if is_bgp:
            pool = None
            for p in load_bgp_pools().get("pools", []):
                if p["name"] == bgp_name:
                    pool = p
                    break
            if pool:
                seen = set()
                for r in pool.get("routes", []):
                    m = r.get("model", "")
                    if m and m not in seen:
                        self._model_combo.append_text(m)
                        seen.add(m)
                if seen:
                    self._model_combo.set_active(0)
        elif ep:
            for m in ep.get("models", []):
                self._model_combo.append_text(m)
            GLib.idle_add(self._select_default_model, ep)

    def _select_default_model(self, ep):
        dm = ep.get("default_model", "")
        models = ep.get("models", [])
        if dm in models:
            self._model_combo.set_active(models.index(dm))
        elif models:
            self._model_combo.set_active(0)

    # ── endpoint mgr ─────────────────────────────────────────────

    def _open_mgr(self):
        try:
            self._mgr_window = EndpointMgr(self)
            self._mgr_window.connect("destroy", lambda *_: setattr(self, "_mgr_window", None))
        except Exception as e:
            import traceback; traceback.print_exc()
            d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}")
            d.run(); d.destroy()

    def _open_bgp(self):
        try:
            self._bgp_window = BGPPoolMgr(self)
            self._bgp_window.connect("destroy", lambda *_: setattr(self, "_bgp_window", None))
        except Exception as e:
            import traceback; traceback.print_exc()
            d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}")
            d.run(); d.destroy()

    def _open_usage(self):
        try:
            self._usage_window = UsageWindow(self)
            self._usage_window.connect("destroy", lambda *_: setattr(self, "_usage_window", None))
        except Exception as e:
            import traceback; traceback.print_exc()
            d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}")
            d.run(); d.destroy()

    def _backup_profile(self):
        chooser = Gtk.FileChooserDialog(
            title="Backup Codex Profile",
            parent=self,
            action=Gtk.FileChooserAction.SAVE,
        )
        chooser.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
                            Gtk.STOCK_SAVE, Gtk.ResponseType.OK)
        chooser.set_do_overwrite_confirmation(True)
        chooser.set_current_name(f"codex-profile-{time.strftime('%Y%m%d-%H%M%S')}.json")
        resp = chooser.run()
        filename = chooser.get_filename() if resp == Gtk.ResponseType.OK else None
        chooser.destroy()
        if not filename:
            return
        try:
            save_profile_bundle(filename)
            self.log(f"Profile backed up to {filename}")
        except Exception as e:
            self._show_message(Gtk.MessageType.ERROR, f"Backup failed:\n{e}")

    def _refresh_all_models(self):
        if getattr(self, "_refresh_running", False):
            return
        self._refresh_running = True
        self._refresh_all_btn.set_sensitive(False)
        self.log("Refreshing models for all providers...")
        threading.Thread(target=self._refresh_all_models_worker, daemon=True).start()

    def _refresh_all_models_worker(self):
        try:
            data = load_endpoints()
            updated = 0
            failed = []

            for idx, ep in enumerate(list(data["endpoints"])):
                refreshed, err = refresh_endpoint_models(ep)
                if refreshed:
                    data["endpoints"][idx] = refreshed
                    updated += 1
                else:
                    failed.append(f"{ep['name']}: {err}")

            if updated:
                save_endpoints(data)

            GLib.idle_add(self._finish_refresh_all_models, updated, failed)
        except Exception as e:
            GLib.idle_add(self._finish_refresh_all_models_error, str(e))

    def _finish_refresh_all_models(self, updated, failed):
        try:
            if updated:
                self._rebuild_combo()
                if getattr(self, "_mgr_window", None):
                    try:
                        self._mgr_window._rebuild()
                    except Exception:
                        pass
                self.log(f"Refreshed models for {updated} provider(s)")

            if failed:
                self._show_message(
                    Gtk.MessageType.WARNING,
                    "Some providers could not auto-fetch models.\n\n"
                    + "\n".join(failed)
                    + "\n\nThose providers were left unchanged so you can manage them manually."
                )
            elif updated:
                self._show_message(Gtk.MessageType.INFO, f"Refreshed models for {updated} provider(s).")
            else:
                self._show_message(Gtk.MessageType.INFO, "No providers were refreshed.")
        finally:
            self._refresh_running = False
            self._refresh_all_btn.set_sensitive(True)
        return False

    def _finish_refresh_all_models_error(self, err):
        try:
            self._show_message(Gtk.MessageType.ERROR, f"Refresh failed:\n{err}")
        finally:
            self._refresh_running = False
            self._refresh_all_btn.set_sensitive(True)
        return False

    def _import_profile(self):
        if self._proc and self._proc.poll() is None:
            self._show_message(Gtk.MessageType.WARNING, "Stop Codex before importing a profile.")
            return

        chooser = Gtk.FileChooserDialog(
            title="Import Codex Profile",
            parent=self,
            action=Gtk.FileChooserAction.OPEN,
        )
        chooser.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
                            Gtk.STOCK_OPEN, Gtk.ResponseType.OK)
        resp = chooser.run()
        filename = chooser.get_filename() if resp == Gtk.ResponseType.OK else None
        chooser.destroy()
        if not filename:
            return

        confirm = Gtk.MessageDialog(
            self, 0, Gtk.MessageType.QUESTION, Gtk.ButtonsType.YES_NO,
            "Importing will replace the current endpoints and Codex config. Continue?"
        )
        ok = confirm.run() == Gtk.ResponseType.YES
        confirm.destroy()
        if not ok:
            return

        try:
            import_profile_bundle(filename)
            self._rebuild_combo()
            self.log(f"Profile imported from {filename}")
            self._show_message(Gtk.MessageType.INFO, "Profile imported successfully.")
        except Exception as e:
            self._show_message(Gtk.MessageType.ERROR, f"Import failed:\n{e}")

    def _on_endpoints_updated(self):
        self._rebuild_combo()

    def _show_message(self, msg_type, text):
        d = Gtk.MessageDialog(self, 0, msg_type, Gtk.ButtonsType.OK, text)
        d.run()
        d.destroy()

    def _show_changelog(self):
        d = Gtk.Dialog(title="Changelog", transient_for=self, modal=True)
        d.set_default_size(520, 480)
        d.add_button("Close", Gtk.ResponseType.CLOSE)
        area = d.get_content_area()
        area.set_margin_start(12)
        area.set_margin_end(12)
        area.set_margin_top(12)
        area.set_margin_bottom(12)
        sw = Gtk.ScrolledWindow()
        sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
        area.pack_start(sw, True, True, 0)
        buf = Gtk.TextBuffer()
        tv = Gtk.TextView(buffer=buf)
        tv.set_editable(False)
        tv.set_cursor_visible(False)
        tv.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
        sw.add(tv)
        lines = []
        for ver, date, items in CHANGELOG:
            lines.append(f"<b>v{ver}</b>  ({date})")
            for item in items:
                lines.append(f"  \u2022 {item}")
            lines.append("")
        txt = "\n".join(lines).strip()
        buf.insert(buf.get_end_iter(), txt)
        d.show_all()
        d.run()
        d.destroy()

    def _show_install_guide(self, which):
        if which == "cli":
            title = "Install Codex CLI"
            guide = (
                "Codex CLI is required to use CLI launch features.\n\n"
                "Install with npm:\n"
                "  npm install -g @openai/codex\n\n"
                "Or download from:\n"
                "  https://github.com/openai/codex\n\n"
                "After installing, restart the launcher."
            )
        else:
            title = "Install Codex Desktop"
            guide = (
                "Codex Desktop is required to use Desktop launch features.\n\n"
                "Expected location: /opt/codex-desktop/start.sh\n\n"
                "Download from:\n"
                "  https://codex.desktop.openai.com\n\n"
                "After installing, restart the launcher."
            )
        d = Gtk.MessageDialog(self, 0, Gtk.MessageType.INFO, Gtk.ButtonsType.OK, guide)
        d.set_title(title)
        d.run()
        d.destroy()

    # ── launch ───────────────────────────────────────────────────

    def _launch(self, target):
        name = self._combo.get_active_text()
        if not name:
            self.log("ERROR: no endpoint selected")
            return
        model = self._model_combo.get_active_text()
        if not model:
            self.log("ERROR: no model selected")
            return

        is_bgp = name.startswith("🔀 ")
        if is_bgp:
            pool_name = name[2:]
            pool = None
            for p in load_bgp_pools().get("pools", []):
                if p["name"] == pool_name:
                    pool = p
                    break
            if not pool:
                self.log(f"ERROR: BGP pool '{pool_name}' not found")
                return
            self._set_busy(True)
            self.log(f"=== 🔀 BGP: {pool_name} / {model} → {'Desktop' if target == 'desktop' else 'CLI'} ===")
            threading.Thread(target=self._run_bgp, args=(pool, model, target), daemon=True).start()
            return

        ep = get_endpoint(name)
        if not ep:
            self.log("ERROR: endpoint not found")
            return
        self._set_busy(True)
        self.log(f"=== {ep['name']} / {model} → {'Desktop' if target == 'desktop' else 'CLI'} ===")
        threading.Thread(target=self._run, args=(ep, model, target), daemon=True).start()

    def _launch_codex_default(self, target):
        if "cli" not in self._missing:
            status, msg = _check_codex_auth()
            if status != "logged_in":
                d = Gtk.MessageDialog(
                    self, 0, Gtk.MessageType.WARNING, Gtk.ButtonsType.YES_NO,
                    f"Codex auth check: {msg}\n\n"
                    "Launch may fail without valid authentication.\n"
                    "Continue anyway?"
                )
                r = d.run()
                d.destroy()
                if r != Gtk.ResponseType.YES:
                    self._set_busy(False)
                    return
        self._set_busy(True)
        self.log(f"=== Codex Default (OAuth) → {'Desktop' if target == 'desktop' else 'CLI'} ===")
        threading.Thread(target=self._run_codex_default, args=(target,), daemon=True).start()

    def _run(self, ep, model, target):
        try:
            self.log("Cleaning up stale processes…")
            _run_cleanup(self.log)
            recover_config_if_needed(self.log)

            needs_proxy = ep["backend_type"] != "native"

            if needs_proxy:
                self.log("Starting translation proxy…")
                try:
                    proxy_port = _start_proxy_for(ep, self.log)
                except RuntimeError as e:
                    GLib.idle_add(self._show_error_dialog, "Proxy startup failed", str(e))
                    return
                self.log(f"Configuring Codex for {ep['name']} (proxied on :{proxy_port})…")
                begin_config_transaction(f"launch:{ep['name']}")
                write_config_for_translated(ep, model, proxy_port)
            else:
                self.log(f"Configuring Codex for {ep['name']} (native)…")
                begin_config_transaction(f"launch:{ep['name']}")
                write_config_for_native(ep, model)

            if target == "desktop":
                self._launch_desktop(ep, model)
            else:
                self._launch_cli(ep, model)

        except Exception as e:
            self.log(f"ERROR: {e}")
        finally:
            _stop_proxy()
            restore_config()
            end_config_transaction()
            self._set_busy(False)
            self.log("Ready.")

    def _run_bgp(self, pool, model, target):
        try:
            self.log("Cleaning up stale processes…")
            _run_cleanup(self.log)
            recover_config_if_needed(self.log)

            port = _pick_free_port()
            self.log(f"Starting BGP proxy with {len(pool.get('routes', []))} routes on :{port}…")
            bgp_ep = {
                "name": pool["name"],
                "backend_type": "openai-compat",
                "base_url": "http://bgp.placeholder",
                "api_key": "",
                "default_model": model,
                "models": list(dict.fromkeys(r.get("model", model) for r in pool.get("routes", []))),
            }
            pcfg = {
                "port": port,
                "backend_type": "openai-compat",
                "target_url": "http://bgp.placeholder",
                "api_key": "",
                "bgp_routes": pool.get("routes", []),
                "models": [{"id": m, "object": "model", "created": 1700000000, "owned_by": "bgp"} for m in bgp_ep["models"]],
            }
            pcfg_path = PROXY_CONFIG_DIR / f"proxy-{safe_name(pool['name'])}-{port}.json"
            pcfg_path.parent.mkdir(parents=True, exist_ok=True)
            pcfg_path.write_text(json.dumps(pcfg, indent=2))
            try:
                _start_proxy_with_config(pcfg_path, port, self.log)
            except RuntimeError as e:
                GLib.idle_add(self._show_error_dialog, "BGP proxy startup failed", str(e))
                return

            begin_config_transaction(f"launch:bgp:{pool['name']}")
            write_config_for_translated(bgp_ep, model, port)

            if target == "desktop":
                self._launch_desktop(bgp_ep, model)
            else:
                self._launch_cli(bgp_ep, model)

        except Exception as e:
            self.log(f"ERROR: {e}")
        finally:
            _stop_proxy()
            restore_config()
            end_config_transaction()
            self._set_busy(False)
            self.log("Ready.")

    def _run_codex_default(self, target):
        try:
            self.log("Cleaning up stale processes…")
            _run_cleanup(self.log)
            _stop_proxy()
            recover_config_if_needed(self.log)

            self.log("Resetting config to Codex defaults (OAuth)…")
            begin_config_transaction("launch:default")
            if CONFIG.exists():
                CONFIG.unlink()

            if target == "desktop":
                self._launch_desktop_direct()
            else:
                self._launch_cli_default()
        except Exception as e:
            self.log(f"ERROR: {e}")
        finally:
            restore_config()
            end_config_transaction()
            self._set_busy(False)
            self.log("Ready.")

    def _show_error_dialog(self, title, message):
        dialog = Gtk.MessageDialog(
            transient_for=self, flags=0,
            message_type=Gtk.MessageType.ERROR,
            buttons=Gtk.ButtonsType.CLOSE, text=str(title))
        dialog.format_secondary_text(str(message))
        dialog.run()
        dialog.destroy()

    def _launch_desktop(self, ep, model):
        args = [str(START_SH)]
        if ep["backend_type"] != "native":
            args += ["--", "--ozone-platform=wayland"]

        self._proc = subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, preexec_fn=os.setsid)
        pid = self._proc.pid
        self.log(f"Desktop started (PID {pid})")
        self.log(f"Log: {LAUNCH_LOG}")

        t0 = time.time()
        stall_warned = False
        while self._proc and self._proc.poll() is None:
            time.sleep(1.5)
            el = time.time() - t0
            if el > 20 and not stall_warned:
                self.log("⚠  Still starting after 20 s — possible stall. Click Kill if window doesn't appear.")
                self.log(f"--- last log lines ---\n{_last_log_lines()}")
                stall_warned = True

        if self._proc:
            rc = self._proc.poll()
            el = time.time() - t0
            self.log(f"Desktop exited (code {rc}) after {el:.0f}s")
            if el < 12:
                self.log("TIP: Quick exit — may be warm-start handoff (normal) or crash. Kill && retry if needed.")
                self.log(f"--- last log lines ---\n{_last_log_lines()}")
            self._proc = None

    def _launch_cli(self, ep, model):
        """Launch codex CLI in a terminal with the selected endpoint."""
        self.log(f"Launching Codex CLI with {ep['name']}…")

        # Find a terminal emulator
        terms = [
            ("x-terminal-emulator", ["-e"]),
            ("kgx", ["--"]),
            ("gnome-terminal", ["--"]),
            ("konsole", ["-e"]),
            ("xterm", ["-e"]),
        ]
        term = None
        term_args = None
        for t in terms:
            if shutil.which(t[0]):
                term = t[0]
                term_args = t[1]
                break

        if not term:
            self.log("ERROR: no terminal emulator found (tried x-terminal-emulator, kgx, gnome-terminal, konsole, xterm)")
            return

        # For proxied endpoints, the proxy is already running (from _run)
        # For native, no proxy needed
        cmd_parts = [term] + term_args

        if ep["backend_type"] == "native":
            # Just run codex directly — config.toml is already set up
            cmd_parts.extend(["codex", "-c", f"model={model}"])
        else:
            # Proxy is running, run codex with the profile
            cmd_parts.extend(["codex", "--profile", ep["name"], "-c", f"model={model}"])

        self.log(f"Running: {' '.join(cmd_parts)}")
        self._proc = subprocess.Popen(cmd_parts, preexec_fn=os.setsid)
        pid = self._proc.pid
        self.log(f"CLI started in terminal (PID {pid})")

        # Wait for terminal process
        while self._proc and self._proc.poll() is None:
            time.sleep(1.5)

        if self._proc:
            rc = self._proc.poll()
            self.log(f"CLI exited (code {rc})")
            self._proc = None

    def _launch_desktop_direct(self):
        self.log("Launching Codex Desktop (default OAuth)…")
        self._proc = subprocess.Popen(
            [str(START_SH), "--", "--ozone-platform=wayland"],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, preexec_fn=os.setsid,
        )
        pid = self._proc.pid
        self.log(f"Desktop started (PID {pid})")
        self.log(f"Log: {LAUNCH_LOG}")

        t0 = time.time()
        stall_warned = False
        while self._proc and self._proc.poll() is None:
            time.sleep(1.5)
            el = time.time() - t0
            if el > 20 and not stall_warned:
                self.log("Still starting after 20s — possible stall. Click Kill if window doesn't appear.")
                self.log(f"--- last log lines ---\n{_last_log_lines()}")
                stall_warned = True

        if self._proc:
            rc = self._proc.poll()
            el = time.time() - t0
            self.log(f"Desktop exited (code {rc}) after {el:.0f}s")
            if el < 12:
                self.log("TIP: Quick exit — may be warm-start handoff (normal) or crash.")
                self.log(f"--- last log lines ---\n{_last_log_lines()}")
            self._proc = None

    def _launch_cli_default(self):
        self.log("Launching Codex CLI (default OAuth)…")
        terms = [
            ("x-terminal-emulator", ["-e"]),
            ("kgx", ["--"]),
            ("gnome-terminal", ["--"]),
            ("konsole", ["-e"]),
            ("xterm", ["-e"]),
        ]
        term = None
        term_args = None
        for t in terms:
            if shutil.which(t[0]):
                term = t[0]
                term_args = t[1]
                break

        if not term:
            self.log("ERROR: no terminal emulator found")
            return

        cmd_parts = [term] + term_args + ["codex"]
        self.log(f"Running: {' '.join(cmd_parts)}")
        self._proc = subprocess.Popen(cmd_parts, preexec_fn=os.setsid)
        pid = self._proc.pid
        self.log(f"CLI started in terminal (PID {pid})")

        while self._proc and self._proc.poll() is None:
            time.sleep(1.5)

        if self._proc:
            rc = self._proc.poll()
            self.log(f"CLI exited (code {rc})")
            self._proc = None

    # ── kill ─────────────────────────────────────────────────────

    def _kill(self):
        self.log("=== Killing ===")
        if self._proc and self._proc.poll() is None:
            try:
                pgid = os.getpgid(self._proc.pid)
                os.killpg(pgid, signal.SIGTERM)
                time.sleep(1)
                if self._proc.poll() is None:
                    os.killpg(pgid, signal.SIGKILL)
            except (ProcessLookupError, PermissionError):
                pass
            self._proc = None
        _stop_proxy()
        _run_cleanup(self.log)
        restore_config()
        end_config_transaction()
        LOG_DIR.mkdir(parents=True, exist_ok=True)
        LAUNCH_LOG.unlink(missing_ok=True)
        self.log("Cleanup complete")
        self._set_busy(False)
        self.log("Ready.")

    def _do_close(self):
        if self._proc and self._proc.poll() is None:
            d = Gtk.MessageDialog(self, 0, Gtk.MessageType.QUESTION, Gtk.ButtonsType.YES_NO,
                                  "Codex is still running. Kill it?")
            r = d.run()
            d.destroy()
            if r != Gtk.ResponseType.YES:
                return
            self._kill()
        _stop_proxy()
        Gtk.main_quit()

# ═══════════════════════════════════════════════════════════════════
# Endpoint manager dialog
# ═══════════════════════════════════════════════════════════════════

class EndpointMgr(Gtk.Window):
    def __init__(self, parent):
        super().__init__(title="Manage Endpoints")
        self.set_transient_for(parent)
        self.set_modal(True)
        self._parent = parent
        self.set_default_size(500, 350)
        self.set_border_width(12)
        self.set_position(Gtk.WindowPosition.CENTER_ON_PARENT)

        vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        self.add(vbox)

        title_lbl = Gtk.Label(label="<b>Endpoints</b>")
        title_lbl.set_use_markup(True)
        vbox.pack_start(title_lbl, False, False, 0)

        sw = Gtk.ScrolledWindow()
        vbox.pack_start(sw, True, True, 0)
        self._store = Gtk.ListStore(str, str, str, str)  # name, provider, backend, default_model
        self._tree = Gtk.TreeView(model=self._store)
        for i, title in enumerate(["Name", "Provider", "Type", "Default Model"]):
            col = Gtk.TreeViewColumn(title, Gtk.CellRendererText(), text=i)
            col.set_resizable(True)
            self._tree.append_column(col)
        sw.add(self._tree)

        btn_bar = Gtk.Box(spacing=8)
        vbox.pack_start(btn_bar, False, False, 0)
        self._add_btn = Gtk.Button(label="Add")
        self._add_btn.connect("clicked", lambda b: self._add())
        btn_bar.pack_start(self._add_btn, False, False, 0)
        self._edit_btn = Gtk.Button(label="Edit")
        self._edit_btn.connect("clicked", lambda b: self._edit())
        btn_bar.pack_start(self._edit_btn, False, False, 0)
        self._delete_btn = Gtk.Button(label="Delete")
        self._delete_btn.connect("clicked", lambda b: self._delete())
        btn_bar.pack_start(self._delete_btn, False, False, 0)
        self._default_btn = Gtk.Button(label="Set Default")
        self._default_btn.connect("clicked", lambda b: self._set_default())
        btn_bar.pack_start(self._default_btn, False, False, 0)
        self._mgr_close_btn = Gtk.Button(label="Close")
        self._mgr_close_btn.connect("clicked", lambda b: self.destroy())
        btn_bar.pack_end(self._mgr_close_btn, False, False, 0)

        self._rebuild()
        self.show_all()

    def _rebuild(self):
        data = load_endpoints()
        self._store.clear()
        for ep in data["endpoints"]:
            provider = ep.get("provider_preset", "Custom")
            bt = label_for_backend(ep["backend_type"])
            self._store.append([ep["name"], provider, bt, ep.get("default_model", "")])

    def _selected(self):
        sel = self._tree.get_selection()
        m, i = sel.get_selected()
        if i is None:
            return None
        return self._store[i][0]

    def _add(self):
        try:
            self._dialog = EditEndpointDialog(self, None)
            self._dialog.connect("destroy", lambda *_: setattr(self, "_dialog", None))
        except Exception as e:
            import traceback; traceback.print_exc()
            d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}")
            d.run(); d.destroy()

    def _edit(self):
        name = self._selected()
        if name:
            try:
                self._dialog = EditEndpointDialog(self, name)
                self._dialog.connect("destroy", lambda *_: setattr(self, "_dialog", None))
            except Exception as e:
                import traceback; traceback.print_exc()
                d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, f"Error: {e}")
                d.run(); d.destroy()

    def _delete(self):
        name = self._selected()
        if not name:
            return
        d = Gtk.MessageDialog(self, 0, Gtk.MessageType.QUESTION, Gtk.ButtonsType.YES_NO,
                              f'Delete endpoint "{name}"?')
        r = d.run()
        d.destroy()
        if r != Gtk.ResponseType.YES:
            return
        data = load_endpoints()
        data["endpoints"] = [e for e in data["endpoints"] if e["name"] != name]
        if data.get("default") == name:
            data["default"] = data["endpoints"][0]["name"] if data["endpoints"] else None
        save_endpoints(data)
        self._rebuild()
        self._parent._on_endpoints_updated()

    def _set_default(self):
        name = self._selected()
        if not name:
            return
        data = load_endpoints()
        data["default"] = name
        save_endpoints(data)
        self._rebuild()
        self._parent._on_endpoints_updated()

# ═══════════════════════════════════════════════════════════════════
# Edit endpoint dialog
# ═══════════════════════════════════════════════════════════════════

class EditEndpointDialog(Gtk.Dialog):
    def __init__(self, parent, existing_name):
        title = "Edit Endpoint" if existing_name else "Add Endpoint"
        Gtk.Dialog.__init__(self, title=title)
        self.set_transient_for(parent)
        self.set_modal(True)
        self._parent_mgr = parent
        self._existing_name = existing_name
        self._data = get_endpoint(existing_name) if existing_name else {
            "name": "", "backend_type": "openai-compat",
            "base_url": "", "api_key": "", "default_model": "", "models": [],
            "provider_preset": "Custom",
        }
        self.set_default_size(480, 520)

        area = self.get_content_area()
        area.set_spacing(6)
        area.set_margin_start(12)
        area.set_margin_end(12)
        area.set_margin_top(12)
        area.set_margin_bottom(12)

        grid = Gtk.Grid(column_spacing=8, row_spacing=6)
        area.pack_start(grid, False, False, 0)

        def add_row(row, label, widget):
            grid.attach(Gtk.Label(label=label, xalign=1), 0, row, 1, 1)
            grid.attach(widget, 1, row, 1, 1)

        self._entry_name = Gtk.Entry(text=self._data.get("name", ""))
        add_row(0, "Name:", self._entry_name)

        self._combo_preset = Gtk.ComboBoxText()
        self._preset_names = list(PROVIDER_PRESETS.keys())
        for preset_name in self._preset_names:
            self._combo_preset.append_text(preset_name)
        self._combo_preset.set_active(self._preset_names.index(self._data.get("provider_preset", "Custom")) if self._data.get("provider_preset", "Custom") in self._preset_names else 0)
        self._combo_preset.connect("changed", lambda c: self._apply_selected_preset())
        add_row(1, "Preset:", self._combo_preset)

        self._combo_type = Gtk.ComboBoxText()
        for val, lab in [("openai-compat", "OpenAI-compatible (needs proxy)"),
                          ("anthropic", "Anthropic (needs proxy)"),
                          ("command-code", "Command Code (needs proxy)"),
                          ("native", "Native OpenAI (no proxy)")]:
            self._combo_type.append(val, lab)
        bt = self._data.get("backend_type", "openai-compat")
        self._combo_type.set_active_id(bt)
        add_row(2, "Type:", self._combo_type)

        self._entry_url = Gtk.Entry(text=self._data.get("base_url", ""))
        add_row(3, "Base URL:", self._entry_url)

        self._entry_key = Gtk.Entry(text=self._data.get("api_key", ""))
        self._entry_key.set_visibility(False)
        key_box = Gtk.Box(spacing=6)
        key_box.pack_start(self._entry_key, True, True, 0)
        self._oauth_btn = Gtk.Button(label="OAuth Login")
        self._oauth_btn.connect("clicked", lambda b: self._do_oauth_login())
        key_box.pack_start(self._oauth_btn, False, False, 0)
        add_row(4, "API Key:", key_box)
        self._oauth_btn.set_visible(False)

        self._entry_cc_ver = Gtk.Entry(text=self._data.get("cc_version", ""))
        self._entry_cc_ver.set_placeholder_text("e.g. 0.26.8 (Command Code only)")
        add_row(5, "CC Version:", self._entry_cc_ver)

        reasoning_css = b"""
        switch.reasoning-toggle {
            min-width: 56px; min-height: 28px;
            border-radius: 14px;
            background: #e67e22;
            border: 2px solid #cf6d17;
        }
        switch.reasoning-toggle:checked {
            background: #2ecc71;
            border: 2px solid #27ae60;
        }
        switch.reasoning-toggle slider {
            min-width: 24px; min-height: 24px;
            border-radius: 12px;
            background: white;
            border: 1px solid #bbb;
        }
        """
        reasoning_box = Gtk.Box(spacing=10)
        self._switch_reasoning = Gtk.Switch()
        self._switch_reasoning.set_name("reasoning-toggle")
        ctx = self._switch_reasoning.get_style_context()
        ctx.add_class("reasoning-toggle")
        try:
            css_prov = Gtk.CssProvider()
            css_prov.load_from_data(reasoning_css)
            ctx.add_provider(css_prov, Gtk.STYLE_PROVIDER_PRIORITY_USER)
        except Exception:
            pass
        self._switch_reasoning.set_active(self._data.get("reasoning_enabled", True))
        self._switch_reasoning.connect("notify::active", lambda *a: self._on_reasoning_toggled())
        reasoning_box.pack_start(self._switch_reasoning, False, False, 0)
        self._lbl_reasoning = Gtk.Label()
        reasoning_box.pack_start(self._lbl_reasoning, False, False, 0)
        add_row(6, "Reasoning:", reasoning_box)

        self._combo_effort = Gtk.ComboBoxText()
        for ev, el in [("none", "None"), ("minimal", "Minimal"), ("low", "Low"),
                       ("medium", "Medium"), ("high", "High"), ("max", "Max")]:
            self._combo_effort.append(ev, el)
        saved_effort = self._data.get("reasoning_effort", "medium")
        self._combo_effort.set_active_id(saved_effort if saved_effort in ("none","minimal","low","medium","high","max") else "medium")
        add_row(7, "Effort:", self._combo_effort)
        self._on_reasoning_toggled()

        # Models
        mlbl = Gtk.Label(label="Models:", xalign=0)
        area.pack_start(mlbl, False, False, 4)

        mbox = Gtk.Box(spacing=6)
        area.pack_start(mbox, False, False, 0)
        self._entry_model = Gtk.Entry()
        mbox.pack_start(self._entry_model, True, True, 0)
        self._add_model_btn = Gtk.Button(label="Add")
        self._add_model_btn.connect("clicked", lambda b: self._add_model())
        mbox.pack_start(self._add_model_btn, False, False, 0)
        self._add_list_btn = Gtk.Button(label="Add List")
        self._add_list_btn.connect("clicked", lambda b: self._add_models_from_text())
        mbox.pack_start(self._add_list_btn, False, False, 0)
        self._fetch_models_btn = Gtk.Button(label="Fetch from API")
        self._fetch_models_btn.connect("clicked", lambda b: self._fetch_models())
        mbox.pack_start(self._fetch_models_btn, False, False, 0)

        bulk_lbl = Gtk.Label(label="Bulk add models (one per line or comma-separated):", xalign=0)
        area.pack_start(bulk_lbl, False, False, 2)
        bulk_sw = Gtk.ScrolledWindow()
        bulk_sw.set_min_content_height(72)
        area.pack_start(bulk_sw, False, False, 0)
        self._bulk_buf = Gtk.TextBuffer()
        self._bulk_text = Gtk.TextView(buffer=self._bulk_buf)
        self._bulk_text.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
        bulk_sw.add(self._bulk_text)

        sw = Gtk.ScrolledWindow()
        sw.set_min_content_height(120)
        area.pack_start(sw, True, True, 0)
        self._model_store = Gtk.ListStore(str)
        self._model_tree = Gtk.TreeView(model=self._model_store)
        self._model_tree.append_column(Gtk.TreeViewColumn("Model ID", Gtk.CellRendererText(), text=0))
        self._model_tree.set_rules_hint(True)
        sw.add(self._model_tree)
        self._model_tree.connect("row-activated", lambda t, p, c: self._remove_model(p))

        for m in self._data.get("models", []):
            self._model_store.append([m])

        # Default model combo
        dbox = Gtk.Box(spacing=6)
        area.pack_start(dbox, False, False, 0)
        dbox.pack_start(Gtk.Label(label="Default Model:"), False, False, 0)
        self._combo_default = Gtk.ComboBoxText()
        self._refresh_default_combo()
        dbox.pack_start(self._combo_default, True, True, 0)
        dm = self._data.get("default_model", "")
        if dm:
            self._combo_default.set_active_id(dm)

        self._apply_selected_preset(initial=True)

        # Buttons
        self.add_button("Cancel", Gtk.ResponseType.CANCEL)
        self.add_button("Save", Gtk.ResponseType.OK)
        self.connect("response", self._on_response)
        self.show_all()

    def _add_model(self):
        m = normalize_model_id(self._entry_model.get_text())
        if m:
            current = self._combo_default.get_active_text()
            self._model_store.append([m])
            self._refresh_default_combo(current or m)
            self._entry_model.set_text("")

    def _add_models_from_text(self):
        buf = self._bulk_buf.get_text(self._bulk_buf.get_start_iter(), self._bulk_buf.get_end_iter(), True)
        models = parse_model_list(buf)
        if not models:
            return
        current = self._combo_default.get_active_text()
        existing = {self._model_store[i][0] for i in range(len(self._model_store))}
        added = False
        for mid in models:
            if mid not in existing:
                self._model_store.append([mid])
                existing.add(mid)
                added = True
        if added:
            self._refresh_default_combo(current or models[0])
        self._bulk_buf.set_text("")

    def _apply_selected_preset(self, initial=False):
        preset_name = self._combo_preset.get_active_text() or "Custom"
        preset = PROVIDER_PRESETS.get(preset_name, PROVIDER_PRESETS["Custom"])
        is_oauth = bool(preset.get("oauth_provider"))
        self._oauth_btn.set_visible(is_oauth)
        if is_oauth:
            self._entry_key.set_placeholder_text("Auto-filled by OAuth")
        else:
            self._entry_key.set_placeholder_text("")
        if not initial or self._existing_name is None:
            self._combo_type.set_active_id(preset.get("backend_type", "openai-compat"))
            self._entry_url.set_text(preset.get("base_url", ""))
            if not self._entry_key.get_text().strip():
                self._entry_key.set_text("")
            cc_ver = preset.get("cc_version", "")
            if cc_ver and not self._entry_cc_ver.get_text().strip():
                self._entry_cc_ver.set_text(cc_ver)
            if preset.get("models") and len(self._model_store) == 0:
                for mid in preset["models"]:
                    self._model_store.append([mid])
                self._refresh_default_combo(preset["models"][0])
        if initial and self._data.get("models"):
            self._refresh_default_combo(self._data.get("default_model", ""))

    def _on_reasoning_toggled(self, *_):
        active = self._switch_reasoning.get_active()
        self._combo_effort.set_sensitive(active)
        if active:
            self._lbl_reasoning.set_markup('<span foreground="#27ae60" weight="bold">ON</span>')
        else:
            self._lbl_reasoning.set_markup('<span foreground="#e67e22" weight="bold">OFF</span>')

    def _do_oauth_login(self):
        preset_name = self._combo_preset.get_active_text() or "Custom"
        preset = PROVIDER_PRESETS.get(preset_name, {})
        provider = preset.get("oauth_provider", "")
        if provider == "google":
            self._google_oauth_flow()

    def _google_oauth_flow(self):
        token_path = os.path.expanduser("~/.cache/codex-proxy/google-oauth-token.json")

        CLIENT_ID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
        CLIENT_SECRET = "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w"
        SCOPES = [
            "https://www.googleapis.com/auth/cloud-platform",
            "https://www.googleapis.com/auth/generative-language.retriever",
            "https://www.googleapis.com/auth/userinfo.email",
            "https://www.googleapis.com/auth/userinfo.profile",
        ]
        import http.server, hashlib, secrets, socket

        port = 8085
        state = secrets.token_hex(32)
        verifier = secrets.token_urlsafe(32)
        challenge = hashlib.sha256(verifier.encode()).digest()
        challenge_b64 = urllib.parse.quote_plus(__import__('base64').urlsafe_b64encode(challenge).rstrip(b'=').decode())

        redirect_uri = f"http://127.0.0.1:{port}/oauth2callback"
        scope_str = " ".join(SCOPES)
        auth_url = (
            f"https://accounts.google.com/o/oauth2/v2/auth?"
            f"client_id={CLIENT_ID}"
            f"&redirect_uri={urllib.parse.quote(redirect_uri)}"
            f"&response_type=code"
            f"&scope={urllib.parse.quote(scope_str)}"
            f"&access_type=offline"
            f"&prompt=consent"
            f"&state={state}"
            f"&code_challenge={challenge_b64}"
            f"&code_challenge_method=S256"
        )

        dlg = Gtk.Dialog(title="Google OAuth (Gemini Mode)", parent=self, modal=True)
        dlg.add_button("Cancel", Gtk.ResponseType.CANCEL)
        dlg.set_default_size(520, 280)
        area = dlg.get_content_area()
        area.set_margin_start(16)
        area.set_margin_end(16)
        area.set_margin_top(12)
        area.set_margin_bottom(12)
        area.set_spacing(8)

        area.pack_start(Gtk.Label(label="<b>Sign in with Google</b>", use_markup=True, xalign=0), False, False, 0)
        area.pack_start(Gtk.Label(label="Emulating Gemini CLI OAuth — no client_secret.json needed.", xalign=0), False, False, 0)

        link_lbl = Gtk.Label()
        link_lbl.set_markup(f'<a href="{auth_url}">Click here to open Google authorization</a>')
        link_lbl.set_line_wrap(True)
        area.pack_start(link_lbl, False, False, 4)

        self._oauth_status = Gtk.Label(label="Opening browser…", xalign=0)
        area.pack_start(self._oauth_status, False, False, 4)

        spinner = Gtk.Spinner()
        spinner.start()
        area.pack_start(spinner, False, False, 8)

        area.show_all()

        code_holder = [None]
        error_holder = [None]
        received_state = [None]

        class OAuthHandler(http.server.BaseHTTPRequestHandler):
            def do_GET(self2):
                qs = urllib.parse.urlparse(self2.path).query
                params = urllib.parse.parse_qs(qs)
                received_state[0] = params.get("state", [None])[0]
                if "code" in params:
                    if received_state[0] != state:
                        self2.send_response(400)
                        self2.send_header("Content-Type", "text/html")
                        self2.end_headers()
                        self2.wfile.write(b"<html><body style='font-family:sans-serif;text-align:center;padding-top:80px'>"
                                         b"<h2 style='color:#e74c3c'>CSRF state mismatch.</h2></body></html>")
                        error_holder[0] = "CSRF state mismatch"
                        return
                    code_holder[0] = params["code"][0]
                    self2.send_response(302)
                    self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_success_gemini")
                    self2.end_headers()
                else:
                    error_holder[0] = params.get("error", ["unknown"])[0]
                    self2.send_response(302)
                    self2.send_header("Location", "https://developers.google.com/gemini-code-assist/auth_failure_gemini")
                    self2.end_headers()
            def log_message(self2, *a): pass

        try:
            server = http.server.HTTPServer(("127.0.0.1", port), OAuthHandler)
        except OSError:
            self._oauth_status.set_text(f"Port {port} already in use — close other apps and retry.")
            spinner.stop()
            dlg.run(); dlg.destroy()
            return

        def wait_for_code():
            server.handle_request()
            server.server_close()
            GLib.idle_add(self._google_oauth_complete_gemini, dlg, code_holder, error_holder,
                          CLIENT_ID, CLIENT_SECRET, redirect_uri, token_path, spinner, verifier)

        threading.Thread(target=wait_for_code, daemon=True).start()
        subprocess.Popen(["xdg-open", auth_url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        dlg.run()
        dlg.destroy()

    def _google_oauth_complete_gemini(self, dlg, code_holder, error_holder,
                                       client_id, client_secret, redirect_uri, token_path, spinner, verifier):
        spinner.stop()
        if error_holder[0]:
            self._oauth_status.set_markup(f'<span foreground="#e74c3c">Error: {error_holder[0]}</span>')
            return
        if not code_holder[0]:
            self._oauth_status.set_text("No authorization code received.")
            return

        self._oauth_status.set_text("Exchanging code for token…")
        try:
            token_data = urllib.parse.urlencode({
                "code": code_holder[0],
                "client_id": client_id,
                "client_secret": client_secret,
                "redirect_uri": redirect_uri,
                "grant_type": "authorization_code",
                "code_verifier": verifier,
            }).encode()
            req = urllib.request.Request("https://oauth2.googleapis.com/token", data=token_data,
                                         headers={"Content-Type": "application/x-www-form-urlencoded"})
            resp = urllib.request.urlopen(req, timeout=30)
            tokens = json.loads(resp.read())
            tokens["client_id"] = client_id
            tokens["client_secret"] = client_secret
            tokens["expires_at"] = time.time() + tokens.get("expires_in", 3600)
            os.makedirs(os.path.dirname(token_path), exist_ok=True)
            with open(token_path, "w") as f:
                json.dump(tokens, f, indent=2)
            os.chmod(token_path, 0o600)
            self._entry_key.set_text(tokens.get("access_token", ""))
            self._oauth_status.set_markup('<span foreground="#27ae60" weight="bold">Authorization successful! Token saved.</span>')
            dlg.set_title("Google OAuth — Success")
        except Exception as e:
            self._oauth_status.set_markup(f'<span foreground="#e74c3c">Token exchange failed: {e}</span>')

    def _remove_model(self, path):
        current = self._combo_default.get_active_text()
        self._model_store.remove(self._model_store.get_iter(path))
        self._refresh_default_combo(current)

    def _refresh_default_combo(self, active=None):
        if active is None:
            active = self._combo_default.get_active_text()
        self._combo_default.remove_all()
        for row in self._model_store:
            self._combo_default.append(row[0], row[0])
        if active and any(row[0] == active for row in self._model_store):
            self._combo_default.set_active_id(active)
        elif len(self._model_store) > 0:
            self._combo_default.set_active(0)

    def _fetch_models(self):
        ok, err = self._try_fetch_models()
        if not ok:
            d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK,
                                  f"Failed to fetch models:\n{err}")
            d.run()
            d.destroy()

    def _try_fetch_models(self):
        endpoint = {
            "base_url": self._entry_url.get_text().strip(),
            "api_key": self._entry_key.get_text().strip(),
            "backend_type": self._combo_type.get_active_id() or "openai-compat",
        }
        ids, err = fetch_models_for_endpoint(endpoint)
        if ids:
            current = self._combo_default.get_active_text()
            added = 0
            for mid in ids:
                # check dupes
                found = any(self._model_store[i][0] == mid for i in range(len(self._model_store)))
                if not found:
                    self._model_store.append([mid])
                    added += 1
            self._refresh_default_combo(current)
            return True, None
        return False, err or "No models returned by endpoint"

    def _on_response(self, dialog, response):
        if response != Gtk.ResponseType.OK:
            self.destroy()
            return

        name = self._entry_name.get_text().strip()
        if not name:
            self._show_error("Name is required")
            return
        bt = self._combo_type.get_active_id()
        url = self._entry_url.get_text().strip()
        key = self._entry_key.get_text().strip()
        models = [self._model_store[i][0] for i in range(len(self._model_store))]
        if not models:
            ok, err = self._try_fetch_models()
            if ok:
                models = [self._model_store[i][0] for i in range(len(self._model_store))]
            else:
                d = Gtk.MessageDialog(
                    self, 0, Gtk.MessageType.QUESTION, Gtk.ButtonsType.YES_NO,
                    f"Auto-fetch failed ({err}).\n\nAdd models manually now?"
                )
                r = d.run()
                d.destroy()
                if r == Gtk.ResponseType.YES:
                    self._entry_model.grab_focus()
                    return
                self.destroy()
                return

        if not models:
            self._show_error("At least one model is required")
            self._entry_model.grab_focus()
            return
        default = self._combo_default.get_active_text() or models[0]

        data = load_endpoints()

        # If renaming, remove old entry
        if self._existing_name and self._existing_name != name:
            data["endpoints"] = [e for e in data["endpoints"] if e["name"] != self._existing_name]

        # Check for duplicate name
        existing = [e for e in data["endpoints"] if e["name"] == name and e != self._data]
        if existing:
            self._show_error(f'Endpoint "{name}" already exists')
            return

        new_ep = {"name": name, "backend_type": bt, "base_url": url,
                  "api_key": key, "default_model": default, "models": models,
                  "provider_preset": self._combo_preset.get_active_text() or "Custom"}
        cc_ver = self._entry_cc_ver.get_text().strip()
        if cc_ver:
            new_ep["cc_version"] = cc_ver
        new_ep["reasoning_enabled"] = self._switch_reasoning.get_active()
        new_ep["reasoning_effort"] = self._combo_effort.get_active_id() or "medium"
        preset_name = self._combo_preset.get_active_text() or "Custom"
        preset = PROVIDER_PRESETS.get(preset_name, {})
        if preset.get("oauth_provider"):
            new_ep["oauth_provider"] = preset["oauth_provider"]
        new_ep["base_url"] = normalize_base_url(new_ep["base_url"])

        # Update or append
        found = False
        for i, e in enumerate(data["endpoints"]):
            if e["name"] == name:
                data["endpoints"][i] = new_ep
                found = True
                break
        if not found:
            data["endpoints"].append(new_ep)
            if data.get("default") is None:
                data["default"] = name

        save_endpoints(data)
        self._parent_mgr._rebuild()
        self._parent_mgr._parent._on_endpoints_updated()
        self.destroy()

    def _show_error(self, msg):
        d = Gtk.MessageDialog(self, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.OK, msg)
        d.run(); d.destroy()

# ═══════════════════════════════════════════════════════════════════
# Entry point
# ═══════════════════════════════════════════════════════════════════

# ═══════════════════════════════════════════════════════════════════
# BGP Pool Manager
# ═══════════════════════════════════════════════════════════════════

class BGPPoolMgr(Gtk.Window):
    def __init__(self, parent):
        super().__init__(title="AI BGP — Pool Manager")
        self.set_transient_for(parent)
        self.set_default_size(620, 440)
        self._parent = parent

        vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
        vbox.set_margin_start(12)
        vbox.set_margin_end(12)
        vbox.set_margin_top(12)
        vbox.set_margin_bottom(12)
        self.add(vbox)

        hdr = Gtk.Box(spacing=8)
        vbox.pack_start(hdr, False, False, 0)
        hdr.pack_start(Gtk.Label(label="<b>AI BGP Pools</b>  —  multi-provider routing with automatic failover", use_markup=True), False, False, 0)

        self._store = Gtk.ListStore(str, str, str)
        self._tree = Gtk.TreeView(model=self._store)
        for i, (title, w) in enumerate([("Pool Name", 200), ("Routes", 250), ("Strategy", 100)]):
            r = Gtk.CellRendererText()
            c = Gtk.TreeViewColumn(title, r, text=i)
            c.set_min_width(w)
            self._tree.append_column(c)
        self._tree.set_headers_visible(True)
        sw = Gtk.ScrolledWindow()
        sw.add(self._tree)
        vbox.pack_start(sw, True, True, 0)

        sel = self._tree.get_selection()
        sel.connect("changed", lambda *_: self._on_select())

        bbox = Gtk.Box(spacing=8)
        vbox.pack_start(bbox, False, False, 0)
        self._add_btn = Gtk.Button(label="Create Pool")
        self._add_btn.connect("clicked", lambda b: self._add_pool())
        bbox.pack_start(self._add_btn, True, True, 0)
        self._edit_btn = Gtk.Button(label="Edit Pool")
        self._edit_btn.connect("clicked", lambda b: self._edit_pool())
        self._edit_btn.set_sensitive(False)
        bbox.pack_start(self._edit_btn, True, True, 0)
        self._del_btn = Gtk.Button(label="Delete Pool")
        self._del_btn.connect("clicked", lambda b: self._del_pool())
        self._del_btn.set_sensitive(False)
        bbox.pack_start(self._del_btn, True, True, 0)
        close_btn = Gtk.Button(label="Close")
        close_btn.connect("clicked", lambda b: self.destroy())
        bbox.pack_start(close_btn, True, True, 0)

        self._rebuild()
        self.show_all()

    def _rebuild(self):
        self._store.clear()
        for pool in load_bgp_pools().get("pools", []):
            routes_str = " → ".join(f'{r.get("name","?")}/{r.get("model","?")}' for r in pool.get("routes", []))
            self._store.append([pool["name"], routes_str, pool.get("strategy", "failover")])

    def _selected_name(self):
        sel = self._tree.get_selection()
        m, i = sel.get_selected()
        return self._store[i][0] if i else None

    def _on_select(self):
        name = self._selected_name()
        self._edit_btn.set_sensitive(bool(name))
        self._del_btn.set_sensitive(bool(name))

    def _add_pool(self):
        d = BGPPoolEditDialog(self, None)
        d.connect("response", lambda *_: self._rebuild())

    def _edit_pool(self):
        name = self._selected_name()
        if name:
            d = BGPPoolEditDialog(self, name)
            d.connect("response", lambda *_: self._rebuild())

    def _del_pool(self):
        name = self._selected_name()
        if not name:
            return
        d = Gtk.MessageDialog(self, 0, Gtk.MessageType.QUESTION, Gtk.ButtonsType.YES_NO,
                              f'Delete BGP pool "{name}"?')
        r = d.run(); d.destroy()
        if r != Gtk.ResponseType.YES:
            return
        data = load_bgp_pools()
        data["pools"] = [p for p in data["pools"] if p["name"] != name]
        save_bgp_pools(data)
        self._rebuild()
        self._parent._on_endpoints_updated()


class BGPPoolEditDialog(Gtk.Dialog):
    def __init__(self, parent, existing_name):
        title = "Edit BGP Pool" if existing_name else "Create BGP Pool"
        Gtk.Dialog.__init__(self, title=title, parent=parent, modal=True)
        self.add_button("Cancel", Gtk.ResponseType.CANCEL)
        self.add_button("Save", Gtk.ResponseType.OK)
        self.set_default_size(580, 480)

        self._existing_name = existing_name
        self._parent_mgr = parent

        data = load_bgp_pools()
        pool = None
        if existing_name:
            for p in data.get("pools", []):
                if p["name"] == existing_name:
                    pool = p
                    break
        if not pool:
            pool = {"name": "", "strategy": "failover", "routes": []}

        area = self.get_content_area()
        area.set_margin_start(12)
        area.set_margin_end(12)
        area.set_margin_top(12)
        area.set_margin_bottom(12)
        area.set_spacing(8)

        grid = Gtk.Grid(column_spacing=8, row_spacing=6)
        area.pack_start(grid, False, False, 0)

        grid.attach(Gtk.Label(label="Pool Name:", xalign=1), 0, 0, 1, 1)
        self._entry_name = Gtk.Entry(text=pool["name"])
        grid.attach(self._entry_name, 1, 0, 1, 1)

        grid.attach(Gtk.Label(label="Strategy:", xalign=1), 0, 1, 1, 1)
        self._combo_strategy = Gtk.ComboBoxText()
        self._combo_strategy.append("failover", "Failover (try primary, fall back on error)")
        self._combo_strategy.append("race", "Race (send to all, return fastest)")
        self._combo_strategy.set_active_id(pool.get("strategy", "failover"))
        grid.attach(self._combo_strategy, 1, 1, 1, 1)

        area.pack_start(Gtk.Label(label="<b>Routes</b>  (drag to reorder priority)", use_markup=True, xalign=0), False, False, 8)

        self._route_store = Gtk.ListStore(str, str, str, str, str, str)
        for r in pool.get("routes", []):
            self._route_store.append([
                r.get("name", ""), r.get("endpoint_name", ""),
                r.get("target_url", ""), r.get("api_key", ""),
                r.get("model", ""), str(r.get("priority", 99))
            ])

        self._route_tree = Gtk.TreeView(model=self._route_store)
        for i, (title, w) in enumerate([
            ("Route Name", 120), ("Endpoint", 120), ("URL", 150),
            ("API Key", 80), ("Model", 120), ("Priority", 60)
        ]):
            renderer = Gtk.CellRendererText()
            renderer.set_property("editable", False)
            col = Gtk.TreeViewColumn(title, renderer, text=i)
            col.set_min_width(w)
            col.set_resizable(True)
            self._route_tree.append_column(col)
        self._route_tree.set_headers_visible(True)
        sw = Gtk.ScrolledWindow()
        sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
        sw.add(self._route_tree)
        sw.set_min_content_height(200)
        area.pack_start(sw, True, True, 0)

        bbox = Gtk.Box(spacing=6)
        area.pack_start(bbox, False, False, 0)
        add_r = Gtk.Button(label="Add Route")
        add_r.connect("clicked", lambda b: self._add_route())
        bbox.pack_start(add_r, True, True, 0)
        edit_r = Gtk.Button(label="Edit Route")
        edit_r.connect("clicked", lambda b: self._edit_route())
        bbox.pack_start(edit_r, True, True, 0)
        rm_r = Gtk.Button(label="Remove Route")
        rm_r.connect("clicked", lambda b: self._remove_route())
        bbox.pack_start(rm_r, True, True, 0)
        up_r = Gtk.Button(label="↑ Up")
        up_r.connect("clicked", lambda b: self._move_route(-1))
        bbox.pack_start(up_r, True, True, 0)
        down_r = Gtk.Button(label="↓ Down")
        down_r.connect("clicked", lambda b: self._move_route(1))
        bbox.pack_start(down_r, True, True, 0)

        self.show_all()

        if self.run() == Gtk.ResponseType.OK:
            self._save()

        self.destroy()

    def _save(self):
        name = self._entry_name.get_text().strip()
        if not name:
            return
        strategy = self._combo_strategy.get_active_id() or "failover"
        routes = []
        for i, row in enumerate(self._route_store):
            if not row[2]:
                continue
            routes.append({
                "name": row[0] or f"Route {i+1}",
                "endpoint_name": row[1],
                "target_url": row[2],
                "api_key": row[3],
                "model": row[4],
                "priority": i + 1,
                "reasoning_enabled": True,
                "reasoning_effort": "medium",
            })
        data = load_bgp_pools()
        if self._existing_name:
            data["pools"] = [p for p in data["pools"] if p["name"] != self._existing_name]
        data["pools"].append({"name": name, "strategy": strategy, "routes": routes})
        save_bgp_pools(data)
        self._parent_mgr._parent._on_endpoints_updated()

    def _add_route(self):
        endpoints = load_endpoints().get("endpoints", [])
        if not endpoints:
            d = Gtk.MessageDialog(self, 0, Gtk.MessageType.INFO, Gtk.ButtonsType.OK,
                                  "No endpoints configured. Add endpoints in Manage Endpoints first.")
            d.run(); d.destroy()
            return
        d = BGPRouteDialog(self, endpoints, None)
        if d.result:
            r = d.result
            self._route_store.append([
                r.get("name", ""), r.get("endpoint_name", ""),
                r.get("target_url", ""), r.get("api_key", ""),
                r.get("model", ""), str(r.get("priority", 99))
            ])

    def _edit_route(self):
        sel = self._route_tree.get_selection()
        m, i = sel.get_selected()
        if not i:
            return
        endpoints = load_endpoints().get("endpoints", [])
        existing = {
            "name": m[i][0], "endpoint_name": m[i][1],
            "target_url": m[i][2], "api_key": m[i][3],
            "model": m[i][4], "priority": int(m[i][5]) if m[i][5] else 99,
        }
        d = BGPRouteDialog(self, endpoints, existing)
        if d.result:
            r = d.result
            m[i][0] = r.get("name", "")
            m[i][1] = r.get("endpoint_name", "")
            m[i][2] = r.get("target_url", "")
            m[i][3] = r.get("api_key", "")
            m[i][4] = r.get("model", "")
            m[i][5] = str(r.get("priority", 99))

    def _remove_route(self):
        sel = self._route_tree.get_selection()
        m, i = sel.get_selected()
        if i:
            self._route_store.remove(i)

    def _move_route(self, direction):
        sel = self._route_tree.get_selection()
        m, i = sel.get_selected()
        if not i:
            return
        path = m.get_path(i)
        idx = path.get_indices()[0]
        new_idx = idx + direction
        if new_idx < 0 or new_idx >= len(self._route_store):
            return
        row_data = [m[idx][c] for c in range(6)]
        self._route_store.remove(m.get_iter(Gtk.TreePath(idx)))
        new_iter = self._route_store.insert(new_idx)
        for c, v in enumerate(row_data):
            self._route_store.set_value(new_iter, c, v)


class BGPRouteDialog(Gtk.Dialog):
    def __init__(self, parent, endpoints, existing):
        Gtk.Dialog.__init__(self, title="BGP Route", parent=parent, modal=True)
        self.add_button("Cancel", Gtk.ResponseType.CANCEL)
        self.add_button("OK", Gtk.ResponseType.OK)
        self.set_default_size(440, 300)
        self.result = None

        area = self.get_content_area()
        area.set_margin_start(12)
        area.set_margin_end(12)
        area.set_margin_top(12)
        area.set_margin_bottom(12)
        area.set_spacing(6)

        grid = Gtk.Grid(column_spacing=8, row_spacing=6)
        area.pack_start(grid, False, False, 0)

        def add_row(row, label, widget):
            grid.attach(Gtk.Label(label=label, xalign=1), 0, row, 1, 1)
            grid.attach(widget, 1, row, 1, 1)

        self._entry_name = Gtk.Entry(text=existing.get("name", "") if existing else "")
        add_row(0, "Route Name:", self._entry_name)

        self._combo_ep = Gtk.ComboBoxText()
        ep_names = [e["name"] for e in endpoints]
        for en in ep_names:
            self._combo_ep.append(en, en)
        if existing and existing.get("endpoint_name") in ep_names:
            self._combo_ep.set_active_id(existing["endpoint_name"])
        elif ep_names:
            self._combo_ep.set_active(0)
        self._combo_ep.connect("changed", lambda b: self._on_ep_changed(endpoints))
        add_row(1, "Endpoint:", self._combo_ep)

        self._entry_url = Gtk.Entry()
        add_row(2, "URL:", self._entry_url)

        self._entry_key = Gtk.Entry()
        self._entry_key.set_visibility(False)
        add_row(3, "API Key:", self._entry_key)

        self._combo_model = Gtk.ComboBoxText()
        add_row(4, "Model:", self._combo_model)

        if existing:
            self._entry_url.set_text(existing.get("target_url", ""))
            self._entry_key.set_text(existing.get("api_key", ""))
        self._on_ep_changed(endpoints)
        if existing and existing.get("model"):
            self._combo_model.set_active_id(existing["model"])

        self.show_all()
        if self.run() == Gtk.ResponseType.OK:
            ep_name = self._combo_ep.get_active_text() or ""
            ep = None
            for e in endpoints:
                if e["name"] == ep_name:
                    ep = e
                    break
            self.result = {
                "name": self._entry_name.get_text().strip() or ep_name,
                "endpoint_name": ep_name,
                "target_url": self._entry_url.get_text().strip(),
                "api_key": self._entry_key.get_text().strip(),
                "model": self._combo_model.get_active_text() or "",
                "priority": 99,
            }
            if ep:
                self.result["reasoning_enabled"] = ep.get("reasoning_enabled", True)
                self.result["reasoning_effort"] = ep.get("reasoning_effort", "medium")
                self.result["oauth_provider"] = ep.get("oauth_provider", "")
        self.destroy()

    def _on_ep_changed(self, endpoints):
        ep_name = self._combo_ep.get_active_text()
        ep = None
        for e in endpoints:
            if e["name"] == ep_name:
                ep = e
                break
        if ep:
            self._entry_url.set_text(normalize_base_url(ep.get("base_url", "")))
            self._entry_key.set_text(ep.get("api_key", ""))
            self._combo_model.remove_all()
            for m in ep.get("models", []):
                mid = normalize_model_id(m) if m else ""
                self._combo_model.append(mid, m)
            if ep.get("default_model"):
                self._combo_model.set_active_id(normalize_model_id(ep["default_model"]))
            elif len(ep.get("models", [])) > 0:
                self._combo_model.set_active(0)


_U = {
    "base": "#0C0E16", "surface0": "#161928", "surface1": "#1E2235",
    "surface2": "#2A2F47", "text": "#E4E6F0", "subtext": "#B0B4C8",
    "dim": "#5C6180", "accent": "#7EB8F7", "blue": "#5DA4E8",
    "sapphire": "#4EC5C1", "green": "#59D4A0", "yellow": "#F0C75E",
    "red": "#F06A77", "peach": "#F09860", "teal": "#4EC5C1",
    "lavender": "#A899F0", "sky": "#70C8E8", "maroon": "#C44B5C",
    "flamingo": "#E878B0", "rosewater": "#F0D0C0",
    "model_palette": ["#F09860", "#4EC5C1", "#5DA4E8", "#59D4A0",
                      "#F0C75E", "#A899F0", "#70C8E8", "#E878B0",
                      "#C44B5C", "#F0D0C0", "#7EB8F7", "#F06A77"],
}

_USAGE_STATS_FILE = HOME / ".cache/codex-proxy/usage-stats.json"

def _load_usage_stats():
    try:
        if _USAGE_STATS_FILE.exists():
            return json.loads(_USAGE_STATS_FILE.read_text())
    except Exception:
        pass
    return {"providers": {}, "updated": None}

def _fmt_tok(n):
    if n >= 1_000_000:
        return f"{n/1_000_000:.1f}M"
    if n >= 1_000:
        return f"{n/1_000:.1f}K"
    return str(n)

def _fmt_dur(s):
    if s >= 3600:
        return f"{s/3600:.1f}h"
    if s >= 60:
        return f"{s/60:.1f}m"
    return f"{s:.1f}s"

def _status_pill(success_rate, fail_pct):
    if fail_pct > 0.15:
        return ("ERR", _U["red"])
    if fail_pct > 0.05:
        return ("WARN", _U["yellow"])
    return ("OK", _U["green"])

def _make_css_widget(css_str):
    p = Gtk.CssProvider()
    p.load_from_data(css_str.encode())
    return p

def _apply_css(widget, css_str):
    ctx = widget.get_style_context()
    ctx.add_provider(_make_css_widget(css_str), Gtk.STYLE_PROVIDER_PRIORITY_USER)


class UsageWindow(Gtk.Window):
    def __init__(self, parent):
        super().__init__(title="Usage Dashboard")
        self.set_transient_for(parent)
        self.set_default_size(720, 640)
        self.set_position(Gtk.WindowPosition.CENTER)
        self._parent = parent

        _apply_css(self, f"""
            window {{ background-color: {_U["base"]}; }}
            separator {{ background-color: {_U["surface1"]}; }}
        """)

        vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
        self.add(vbox)

        self._build_header(vbox)
        self._build_summary_strip(vbox)
        sep = Gtk.Separator()
        vbox.pack_start(sep, False, False, 0)

        self._cards_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
        self._cards_box.set_margin_top(8)
        sw = Gtk.ScrolledWindow()
        sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        sw.add(self._cards_box)
        vbox.pack_start(sw, True, True, 0)

        self._refresh()
        self.show_all()

    def _build_header(self, parent):
        hdr = Gtk.Box(spacing=8)
        hdr.set_margin_start(16)
        hdr.set_margin_end(16)
        hdr.set_margin_top(12)
        hdr.set_margin_bottom(6)
        parent.pack_start(hdr, False, False, 0)

        bolt = Gtk.Label()
        bolt.set_markup(f'<span foreground="{_U["accent"]}" font="14">\u26A1</span>')
        hdr.pack_start(bolt, False, False, 0)

        title = Gtk.Label()
        title.set_markup(f'<span font="14" weight="bold" foreground="{_U["text"]}">Usage Dashboard</span>')
        hdr.pack_start(title, False, False, 0)

        self._status_dots = Gtk.Label()
        hdr.pack_start(self._status_dots, False, False, 8)

        self._updated_lbl = Gtk.Label()
        self._updated_lbl.set_markup(f'<span foreground="{_U["dim"]}" size="small">Never</span>')
        hdr.pack_end(self._updated_lbl, False, False, 4)

        refresh_btn = Gtk.Button(label="Refresh")
        _apply_css(refresh_btn, f"""
            button {{ color: {_U["text"]}; background-color: {_U["surface0"]};
                     border: 1px solid {_U["surface1"]}; border-radius: 6px; padding: 4px 12px; }}
            button:hover {{ background-color: {_U["surface1"]}; }}
        """)
        refresh_btn.connect("clicked", lambda b: self._refresh())
        hdr.pack_end(refresh_btn, False, False, 0)

    def _build_summary_strip(self, parent):
        strip = Gtk.Box(spacing=0)
        strip.set_margin_start(16)
        strip.set_margin_end(16)
        strip.set_margin_bottom(6)
        _apply_css(strip, f"box {{ background-color: {_U["surface0"]}; border-radius: 8px; padding: 8px 12px; }}")
        parent.pack_start(strip, False, False, 0)

        self._kpi_boxes = {}
        for key, label, icon in [
            ("providers", "Providers", "\U0001F4CA"),
            ("requests", "Requests", "\u26A1"),
            ("tokens", "Tokens", "\U0001F9E0"),
            ("latency", "Avg Latency", "\u23F1"),
        ]:
            box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1)
            lbl = Gtk.Label()
            lbl.set_markup(f'<span foreground="{_U["dim"]}" size="x-small">{icon} {label}</span>')
            lbl.set_xalign(0)
            box.pack_start(lbl, False, False, 0)
            val = Gtk.Label()
            val.set_markup(f'<span weight="bold" foreground="{_U["text"]}" size="small">-</span>')
            val.set_xalign(0)
            box.pack_start(val, False, False, 0)
            box.set_margin_end(20)
            strip.pack_start(box, False, False, 0)
            self._kpi_boxes[key] = val

    def _refresh(self):
        for c in self._cards_box.get_children():
            self._cards_box.remove(c)
        stats = _load_usage_stats()
        updated = stats.get("updated")
        if updated:
            self._updated_lbl.set_markup(f'<span foreground="{_U["dim"]}" size="small">{updated}</span>')
        providers = stats.get("providers", {})
        if not providers:
            empty = Gtk.Label()
            empty.set_markup(f'<span foreground="{_U["dim"]}" size="large">No usage data yet.\nLaunch a session to start tracking.</span>')
            empty.set_margin_top(60)
            self._cards_box.pack_start(empty, False, False, 0)
            self._cards_box.show_all()
            return

        total_req = 0
        total_tok_in = 0
        total_tok_out = 0
        total_dur = 0.0
        n_ok = 0
        n_warn = 0
        n_err = 0

        sorted_providers = sorted(providers.items(), key=lambda x: x[1].get("total_requests", 0), reverse=True)
        for prov_name, prov_data in sorted_providers:
            t = prov_data.get("total_requests", 0)
            total_req += t
            total_tok_in += prov_data.get("total_tokens_in", 0)
            total_tok_out += prov_data.get("total_tokens_out", 0)
            total_dur += prov_data.get("total_duration_s", 0.0)
            fail = prov_data.get("failures", 0)
            fail_pct = fail / t if t > 0 else 0
            _, sc = _status_pill(0, fail_pct)
            if fail_pct > 0.15:
                n_err += 1
            elif fail_pct > 0.05:
                n_warn += 1
            else:
                n_ok += 1

        self._kpi_boxes["providers"].set_markup(
            f'<span weight="bold" foreground="{_U["text"]}" size="small">{len(providers)}</span>')
        self._kpi_boxes["requests"].set_markup(
            f'<span weight="bold" foreground="{_U["text"]}" size="small">{total_req:,}</span>')
        tok_sum = total_tok_in + total_tok_out
        tok_str = f"{_fmt_tok(tok_sum)} in:{_fmt_tok(total_tok_in)} out:{_fmt_tok(total_tok_out)}" if tok_sum else "N/A"
        self._kpi_boxes["tokens"].set_markup(
            f'<span weight="bold" foreground="{_U["text"]}" size="small">{tok_str}</span>')
        avg_lat = total_dur / total_req if total_req > 0 else 0
        self._kpi_boxes["latency"].set_markup(
            f'<span weight="bold" foreground="{_U["text"]}" size="small">{_fmt_dur(avg_lat)}</span>')

        dots_parts = []
        if n_ok:
            dots_parts.append(f'<span foreground="{_U["green"]}">\u25CF{n_ok}</span>')
        if n_warn:
            dots_parts.append(f'<span foreground="{_U["yellow"]}">\u25D0{n_warn}</span>')
        if n_err:
            dots_parts.append(f'<span foreground="{_U["red"]}">\u2717{n_err}</span>')
        if dots_parts:
            self._status_dots.set_markup(" ".join(dots_parts))

        for prov_name, prov_data in sorted_providers:
            card = self._build_card(prov_name, prov_data)
            self._cards_box.pack_start(card, False, False, 0)
        self._cards_box.show_all()

    def _build_card(self, name, data):
        card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
        card.set_margin_start(12)
        card.set_margin_end(12)
        _apply_css(card, f"""
            box {{ background-color: {_U["surface0"]}; border-radius: 10px;
                  border: 1px solid {_U["surface1"]}; }}
        """)

        total = data.get("total_requests", 0)
        ok = data.get("successes", 0)
        fail = data.get("failures", 0)
        success_rate = ok / total if total > 0 else 1.0
        fail_pct = fail / total if total > 0 else 0
        status_text, status_color = _status_pill(success_rate, fail_pct)

        border_color = status_color
        _apply_css(card, f"""
            box {{ background-color: {_U["surface0"]}; border-radius: 10px;
                  border: 1px solid {border_color}; }}
        """)

        inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3)
        inner.set_margin_start(14)
        inner.set_margin_end(14)
        inner.set_margin_top(10)
        inner.set_margin_bottom(10)
        card.pack_start(inner, False, False, 0)

        top = Gtk.Box(spacing=6)
        inner.pack_start(top, False, False, 0)

        dot = Gtk.Label()
        dot.set_markup(f'<span foreground="{status_color}" font="10">\u25CF</span>')
        top.pack_start(dot, False, False, 0)

        name_lbl = Gtk.Label()
        short = name.replace("https://", "").replace("http://", "").split("/")[0]
        name_lbl.set_markup(f'<span weight="bold" foreground="{_U["text"]}" size="medium">{short}</span>')
        top.pack_start(name_lbl, False, False, 0)

        pill = Gtk.Label()
        pill.set_markup(f'<span foreground="{_U["base"]}" bgalpha="80%" background="{status_color}" weight="bold" size="x-small"> {status_text} </span>')
        top.pack_start(pill, False, False, 4)

        req_lbl = Gtk.Label()
        req_lbl.set_markup(f'<span foreground="{_U["subtext"]}" size="small">{total} req</span>')
        top.pack_start(req_lbl, False, False, 6)

        last_used = data.get("last_used", "")
        if last_used:
            lu_lbl = Gtk.Label()
            lu_lbl.set_markup(f'<span foreground="{_U["dim"]}" size="x-small">{last_used}</span>')
            top.pack_end(lu_lbl, False, False, 0)

        sep1 = Gtk.Separator()
        _apply_css(sep1, f"separator {{ background-color: {status_color}; margin-top: 4px; }}")
        inner.pack_start(sep1, False, False, 0)

        gauge_box = Gtk.Box(spacing=4)
        gauge_box.set_margin_top(4)
        inner.pack_start(gauge_box, False, False, 0)

        gauge_label = Gtk.Label()
        gauge_label.set_markup(f'<span foreground="{_U["yellow"]}" size="x-small">\u26A1</span>')
        gauge_box.pack_start(gauge_label, False, False, 0)

        bar = Gtk.ProgressBar()
        bar.set_fraction(success_rate)
        bar_pct = int(success_rate * 100)
        bar.set_text(f"{bar_pct}%")
        bar.set_show_text(True)
        bar_css = f"""
            progress {{ background-color: {status_color}; border-radius: 6px; }}
            trough {{ background-color: {_U["surface1"]}; border-radius: 6px; min-height: 12px; }}
        """
        _apply_css(bar, bar_css)
        bar.set_hexpand(True)
        gauge_box.pack_start(bar, True, True, 0)

        if fail > 0:
            fail_lbl = Gtk.Label()
            fail_lbl.set_markup(f'<span foreground="{_U["red"]}" size="x-small">{fail} fail</span>')
            gauge_box.pack_end(fail_lbl, False, False, 0)

        metrics_box = Gtk.Box(spacing=0)
        metrics_box.set_margin_top(4)
        inner.pack_start(metrics_box, False, False, 0)

        t_in = data.get("total_tokens_in", 0)
        t_out = data.get("total_tokens_out", 0)
        dur = data.get("total_duration_s", 0.0)
        avg_dur = dur / total if total > 0 else 0

        for label, value, color in [
            ("Tokens In", f"{_fmt_tok(t_in)}", _U["sapphire"]),
            ("Tokens Out", f"{_fmt_tok(t_out)}", _U["peach"]),
            ("Avg Latency", _fmt_dur(avg_dur), _U["sky"]),
            ("Duration", _fmt_dur(dur), _U["lavender"]),
        ]:
            box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
            l = Gtk.Label()
            l.set_markup(f'<span foreground="{_U["dim"]}" size="x-small">{label}</span>')
            l.set_xalign(0)
            box.pack_start(l, False, False, 0)
            v = Gtk.Label()
            v.set_markup(f'<span weight="bold" foreground="{color}" size="small">{value}</span>')
            v.set_xalign(0)
            box.pack_start(v, False, False, 0)
            box.set_margin_end(16)
            metrics_box.pack_start(box, False, False, 0)

        models = data.get("models", {})
        if models:
            self._build_models_section(inner, models, total)

        last_err = data.get("last_error")
        if last_err:
            err_box = Gtk.Box(spacing=4)
            err_box.set_margin_top(4)
            inner.pack_start(err_box, False, False, 0)
            icon = Gtk.Label()
            icon.set_markup(f'<span foreground="{_U["red"]}" size="x-small">\u26A0</span>')
            err_box.pack_start(icon, False, False, 0)
            err_lbl = Gtk.Label()
            err_lbl.set_markup(f'<span foreground="{_U["red"]}" size="x-small">{last_err}</span>')
            err_lbl.set_xalign(0)
            err_lbl.set_line_wrap(True)
            err_box.pack_start(err_lbl, False, False, 0)

        return card

    def _build_models_section(self, parent, models, total_req):
        sep_m = Gtk.Separator()
        _apply_css(sep_m, f"separator {{ background-color: {_U["lavender"]}; margin-top: 4px; margin-bottom: 2px; }}")
        parent.pack_start(sep_m, False, False, 0)

        header = Gtk.Box(spacing=4)
        header.set_margin_top(2)
        parent.pack_start(header, False, False, 0)
        icon = Gtk.Label()
        icon.set_markup(f'<span foreground="{_U["lavender"]}" size="x-small">\U0001F916</span>')
        header.pack_start(icon, False, False, 0)
        lbl = Gtk.Label()
        lbl.set_markup(f'<span foreground="{_U["lavender"]}" weight="bold" size="x-small">Models</span>')
        header.pack_start(lbl, False, False, 0)

        sorted_models = sorted(models.items(), key=lambda x: x[1].get("requests", 0), reverse=True)

        if total_req > 0:
            comp_bar = Gtk.Box(spacing=0)
            _apply_css(comp_bar, f"box {{ background-color: {_U["surface1"]}; border-radius: 4px; min-height: 8px; margin-top: 2px; }}")
            parent.pack_start(comp_bar, False, False, 0)
            for i, (mname, mdata) in enumerate(sorted_models):
                m_req = mdata.get("requests", 0)
                pct = m_req / total_req
                if pct < 0.01:
                    continue
                seg = Gtk.Box()
                color = _U["model_palette"][i % len(_U["model_palette"])]
                _apply_css(seg, f"box {{ background-color: {color}; min-height: 8px; }}")
                seg.set_size_request(max(int(pct * 400), 4), 8)
                comp_bar.pack_start(seg, False, False, 0)

        models_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1)
        models_box.set_margin_top(2)
        parent.pack_start(models_box, False, False, 0)

        for i, (mname, mdata) in enumerate(sorted_models[:6]):
            row = Gtk.Box(spacing=6)
            models_box.pack_start(row, False, False, 0)
            color = _U["model_palette"][i % len(_U["model_palette"])]
            dot = Gtk.Label()
            dot.set_markup(f'<span foreground="{color}" size="x-small">\u25CF</span>')
            row.pack_start(dot, False, False, 0)
            m_lbl = Gtk.Label()
            m_lbl.set_markup(f'<span foreground="{_U["subtext"]}" size="x-small">{mname}</span>')
            m_lbl.set_xalign(0)
            m_lbl.set_size_request(120, -1)
            row.pack_start(m_lbl, False, False, 0)

            m_req = mdata.get("requests", 0)
            pct = m_req / total_req * 100 if total_req > 0 else 0

            m_bar = Gtk.ProgressBar()
            m_bar.set_fraction(m_req / total_req if total_req > 0 else 0)
            _apply_css(m_bar, f"""
                progress {{ background-color: {color}; border-radius: 3px; }}
                trough {{ background-color: {_U["surface1"]}; border-radius: 3px; min-height: 6px; }}
            """)
            m_bar.set_size_request(80, -1)
            row.pack_start(m_bar, False, False, 0)

            pct_lbl = Gtk.Label()
            pct_lbl.set_markup(f'<span foreground="{_U["dim"]}" size="x-small">{pct:.0f}% ({m_req})</span>')
            row.pack_start(pct_lbl, False, False, 0)

            m_in = mdata.get("tokens_in", 0)
            m_out = mdata.get("tokens_out", 0)
            if m_in or m_out:
                tok_lbl = Gtk.Label()
                tok_lbl.set_markup(f'<span foreground="{_U["dim"]}" size="x-small">in:{_fmt_tok(m_in)} out:{_fmt_tok(m_out)}</span>')
                row.pack_end(tok_lbl, False, False, 0)


def main():
    for d in [LOG_DIR, PROXY_CONFIG_DIR]:
        d.mkdir(parents=True, exist_ok=True)

    # Create default endpoints if none exist
    if not ENDPOINTS_FILE.exists():
        save_endpoints({
            "default": "OpenAI",
            "endpoints": [
                {"name": "OpenAI", "backend_type": "native", "base_url": "https://api.openai.com/v1",
                 "api_key": "", "default_model": "gpt-4o", "models": ["gpt-4o", "gpt-4o-mini"],
                 "provider_preset": "OpenAI"},
                {"name": "Z.AI", "backend_type": "openai-compat",
                 "base_url": "https://api.z.ai/api/coding/paas/v4",
                 "api_key": "", "default_model": "glm-5.1",
                 "models": ["glm-4.5", "glm-4.5-air", "glm-4.6", "glm-4.7", "glm-5", "glm-5-turbo", "glm-5.1"],
                 "provider_preset": "Custom"},
            ],
        })

    w = LauncherWin()
    w.connect("destroy", Gtk.main_quit)
    Gtk.main()

if __name__ == "__main__":
    main()
