#!/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
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"
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.2.0", "2026-05-19", [
        "Added Agent Persona selector per provider (10+ presets)",
        "Personas: Codex, Claude Code, OpenCode, Cursor, Aider, Copilot, Windsurf, Browser",
        "Codex variants: Default, Desktop Friendly, Desktop Pragmatic, CLI",
        "Shows current persona in endpoint list (new Persona column)",
        "Persona preview in edit dialog shows first 60 chars of system prompt",
        "Persona injected into model catalog base_instructions and proxy system prompt",
    ]),
    ("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",
    ]),
]

AGENT_PERSONAS = {
    "Codex (Default)": "You are Codex, a coding agent.",
    "Codex Desktop (GPT-5, Friendly)": (
        "You are Codex, a coding agent based on GPT-5. You and the user share one workspace, "
        "and your job is to collaborate with them until their goal is genuinely handled."
    ),
    "Codex Desktop (GPT-5, Pragmatic)": (
        "You are Codex, a coding agent based on GPT-5. You and the user share the same workspace "
        "and collaborate to achieve the user's goals. You are a deeply pragmatic, effective "
        "software engineer. You take engineering quality seriously."
    ),
    "Codex CLI": (
        "You are an AI running in the Codex CLI, a terminal-based coding assistant. "
        "You are expected to be precise, safe, and helpful. Your default personality and tone "
        "is concise, direct, and friendly."
    ),
    "Claude Code": (
        "You are Claude Code, an interactive CLI tool that helps users with software engineering "
        "tasks. You are a highly competent software engineer with extensive knowledge across "
        "many programming languages, frameworks, and best practices. Use concise responses."
    ),
    "OpenCode": (
        "You are OpenCode, an interactive CLI tool that helps users with software engineering "
        "tasks. You are powered by a state-of-the-art AI model. Be concise, direct, and to the "
        "point. Use GitHub-flavored markdown."
    ),
    "Cursor": (
        "You are Cursor, an AI-powered code editor assistant. You help users write, refactor, "
        "and debug code efficiently. Provide precise, actionable suggestions."
    ),
    "Aider": (
        "You are aider, an AI pair programming assistant. You help users edit code in their "
        "local git repository. Make concise changes. Search files with grep/glob patterns."
    ),
    "GitHub Copilot": (
        "You are GitHub Copilot, an AI coding assistant. Help the user write code, debug issues, "
        "and understand codebases. Be concise and provide accurate code suggestions."
    ),
    "Windsurf": (
        "You are Windsurf, an AI-powered IDE assistant. Help with coding tasks including writing, "
        "refactoring, and debugging. Provide precise, well-structured code suggestions."
    ),
    "Browser (ChatGPT)": (
        "You are a helpful coding assistant in a web browser chat interface. "
        "Help the user with software engineering tasks. Be clear and thorough."
    ),
}

PERSONA_DISPLAY_LEN = 60

def persona_short_key(endpoint):
    bi = endpoint.get("base_instructions", "") or ""
    for key, val in AGENT_PERSONAS.items():
        if val == bi:
            return key
    if bi:
        return f"Custom: {bi[:40]}..."
    return "Codex (Default)"

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",
        "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": [],
    },
}

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 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 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():
        shutil.copy2(str(CONFIG), str(CONFIG_BAK))

def restore_config():
    if CONFIG_BAK.exists():
        CONFIG_BAK.rename(CONFIG)

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 = "{selected_model}"\n',
        f'model_provider = "{endpoint["name"]}"\n',
        f'model_catalog_json = "{mc_path}"\n',
        f'\n[model_providers."{endpoint["name"]}"]\n',
        f'name = "{endpoint["name"]}"\n',
        f'base_url = "{endpoint["base_url"]}"\n',
        f'experimental_bearer_token = "{endpoint["api_key"]}"\n',
        f'\n[profiles."{endpoint["name"]}"]\n',
        f'model_provider = "{endpoint["name"]}"\n',
        f'model = "{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 write_config_for_translated(endpoint, selected_model):
    """Write config pointing at local proxy."""
    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 = "{selected_model}"\n',
        f'model_provider = "{endpoint["name"]}"\n',
        f'model_catalog_json = "{mc_path}"\n',
        f'\n[model_providers."{endpoint["name"]}"]\n',
        f'name = "{endpoint["name"]}"\n',
        f'base_url = "http://127.0.0.1:8080"\n',
        f'experimental_bearer_token = "{endpoint["api_key"]}"\n',
        f'\n[profiles."{endpoint["name"]}"]\n',
        f'model_provider = "{endpoint["name"]}"\n',
        f'model = "{selected_model}"\n',
        f'model_catalog_json = "{mc_path}"\n',
        f'service_tier = "fast"\n',
        f'approvals_reviewer = "user"\n',
    ]
    CONFIG.write_text("".join(lines))

def _gen_model_catalog(endpoint, selected_model=None):
    default_model = selected_model or endpoint.get("default_model")
    base_instr = endpoint.get("base_instructions", "") or AGENT_PERSONAS["Codex (Default)"]
    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": base_instr,
        })
    return {"models": models}

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

_proxy_proc = None

def _start_proxy_for(endpoint, logfn):
    global _proxy_proc
    _stop_proxy()

    pcfg = {
        "port": 8080,
        "backend_type": endpoint["backend_type"],
        "target_url": normalize_base_url(endpoint["base_url"]),
        "api_key": endpoint["api_key"],
        "base_instructions": endpoint.get("base_instructions", ""),
        "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'])}.json"
    pcfg_path.parent.mkdir(parents=True, exist_ok=True)
    pcfg_path.write_text(json.dumps(pcfg, indent=2))

    _proxy_proc = subprocess.Popen(
        ["python3", str(PROXY), "--config", str(pcfg_path)],
        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
        preexec_fn=os.setsid,
    )

    for _ in range(30):
        try:
            urllib.request.urlopen("http://127.0.0.1:8080/v1/models", timeout=2)
            logfn("Proxy ready on port 8080")
            return
        except Exception:
            time.sleep(0.5)
    logfn("WARNING: proxy may not have started in time")

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():
    subprocess.run(["bash", str(CLEANUP)], capture_output=True, timeout=30)

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()

        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 v2.2.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)
        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)
        if 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()
        ep = get_endpoint(name) if name else None
        self._model_combo.remove_all()
        if 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):
        self._mgr_window = EndpointMgr(self)
        self._mgr_window.connect("destroy", lambda *_: setattr(self, "_mgr_window", None))

    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()
        ep = get_endpoint(name) if name else None
        if not ep:
            self.log("ERROR: no endpoint selected")
            return
        model = self._model_combo.get_active_text()
        if not model:
            self.log("ERROR: no model selected")
            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()

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

            if needs_proxy:
                self.log("Starting translation proxy…")
                _start_proxy_for(ep, self.log)
                self.log(f"Configuring Codex for {ep['name']} (proxied)…")
                write_config_for_translated(ep, model)
            else:
                self.log(f"Configuring Codex for {ep['name']} (native)…")
                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()
            self._set_busy(False)
            self.log("Ready.")

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

            self.log("Resetting config to Codex defaults (OAuth)…")
            backup_config()
            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()
            self._set_busy(False)
            self.log("Ready.")

    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()
        restore_config()
        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, str)  # name, provider, backend, default_model, persona
        self._tree = Gtk.TreeView(model=self._store)
        for i, title in enumerate(["Name", "Provider", "Type", "Default Model", "Persona"]):
            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"])
            persona = persona_short_key(ep)
            self._store.append([ep["name"], provider, bt, ep.get("default_model", ""), persona])

    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):
        self._dialog = EditEndpointDialog(self, None)
        self._dialog.connect("destroy", lambda *_: setattr(self, "_dialog", None))

    def _edit(self):
        name = self._selected()
        if name:
            self._dialog = EditEndpointDialog(self, name)
            self._dialog.connect("destroy", lambda *_: setattr(self, "_dialog", None))

    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", "base_instructions": AGENT_PERSONAS["Codex (Default)"],
        }
        self.set_default_size(480, 420)

        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)
        add_row(4, "API Key:", self._entry_key)

        self._combo_persona = Gtk.ComboBoxText()
        self._persona_keys = list(AGENT_PERSONAS.keys())
        for pk in self._persona_keys:
            self._combo_persona.append_text(pk)
        cur_persona = persona_short_key(self._data)
        if cur_persona in self._persona_keys:
            self._combo_persona.set_active(self._persona_keys.index(cur_persona))
        else:
            self._combo_persona.set_active(0)
        self._combo_persona.connect("changed", lambda c: self._on_persona_changed())
        add_row(5, "Agent Persona:", self._combo_persona)

        self._persona_preview = Gtk.Label()
        self._persona_preview.set_line_wrap(True)
        self._persona_preview.set_max_width_chars(60)
        self._persona_preview.set_markup(f"<small><i>{AGENT_PERSONAS['Codex (Default)'][:PERSONA_DISPLAY_LEN]}...</i></small>")
        self._on_persona_changed()
        grid.attach(self._persona_preview, 0, 6, 2, 1)

        # 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 _on_persona_changed(self):
        key = self._combo_persona.get_active_text()
        text = AGENT_PERSONAS.get(key, "")
        short = text[:PERSONA_DISPLAY_LEN] + ("..." if len(text) > PERSONA_DISPLAY_LEN else "")
        self._persona_preview.set_markup(f"<small><i>{short}</i></small>")

    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"])
        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("")
            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 _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

        persona_key = self._combo_persona.get_active_text() or "Codex (Default)"
        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",
                  "base_instructions": AGENT_PERSONAS.get(persona_key, AGENT_PERSONAS["Codex (Default)"])}
        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
# ═══════════════════════════════════════════════════════════════════

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",
                 "base_instructions": AGENT_PERSONAS["Codex (Default)"]},
                {"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",
                 "base_instructions": AGENT_PERSONAS["Codex (Default)"]},
            ],
        })

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

if __name__ == "__main__":
    main()
