#!/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.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": [],
    },
}

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

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"],
        "cc_version": endpoint.get("cc_version", ""),
        "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,
        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.1.3</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)  # 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):
        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",
        }
        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._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)

        # 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"])
        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 _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["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"},
                {"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()
