"""
LLMster (LM Studio headless) and local LLM catalog/install/server helpers.
Used by app_pyobjc2 delegate and preferences; does not import app_pyobjc2.
"""
import os
import sys
import re
import json
import time
import shlex
import tempfile
import subprocess
import platform
import shutil
import html as _html_module
from concurrent.futures import ThreadPoolExecutor, as_completed
from threading import Thread

from persistence import MUGI_LOGS_DIR

# Optional requests for catalog and API
try:
    import requests
except ImportError:
    requests = None

# PyObjC (macOS) — optional so backend (e.g. Swift app's Python) can run without pyobjc
try:
    import objc
    from Foundation import NSUserDefaults
    from AppKit import (
        NSWindow,
        NSMakeRect,
        NSBackingStoreBuffered,
        NSWindowStyleMaskTitled,
        NSWindowStyleMaskClosable,
        NSProgressIndicator,
        NSTextField,
        NSFont,
    )
    _has_pyobjc = True
except ImportError:
    objc = None
    NSUserDefaults = None
    NSWindow = NSMakeRect = NSBackingStoreBuffered = None
    NSWindowStyleMaskTitled = NSWindowStyleMaskClosable = None
    NSProgressIndicator = NSTextField = NSFont = None
    _has_pyobjc = False

# -----------------------------------------------------------------------------
# llmster (LM Studio headless): Mugi installs only under ~/.mugi/.lmstudio. Server on 1234. Apple Silicon only.
# We never install to or delete ~/.lmstudio (user may have their own LM Studio there).
# -----------------------------------------------------------------------------
LLMSTER_HOME = os.path.expanduser("~/.mugi")
# Mugi install location (only place we install or delete)
LLMSTER_LMS_BIN = os.path.join(LLMSTER_HOME, ".lmstudio", "bin", "lms")
# User's existing LM Studio install (we never write here; only use if present)
LLMSTER_DEFAULT_HOME_LMS = os.path.expanduser("~/.lmstudio/bin/lms")
# Persisted install path — survives sandbox HOME changes across restarts.
_LLMSTER_INSTALL_PATH_FILE = os.path.join(LLMSTER_HOME, ".llmster_install_path")
# Mugi-install marker: any lms binary whose path contains this substring is a Mugi install
# and needs HOME overridden to LLMSTER_HOME. Works even when the stored path was written
# under a different HOME (e.g. sandbox vs dev).
_MUGI_LMSTUDIO_PATH_MARKER = os.sep + ".mugi" + os.sep + ".lmstudio" + os.sep
LLMSTER_SERVER_URL = "http://127.0.0.1:1234"
LLMSTER_INSTALL_SCRIPT_URL = "https://lmstudio.ai/install.sh"
LLMSTER_USER_DEFAULTS_SETUP = "llmster_setup_done"

# Recommended models (LM Studio catalog IDs) for "Available to download" — Apple Silicon (fallback when catalog fetch fails)
LLMSTER_RECOMMENDED = [
    # All entries use LM Studio catalog identifiers (publisher/model format).
    # The download API resolves these to the correct HuggingFace repo + quantization.
    # Only models with tool-calling and vision support are listed (required for Mugi's agent features).
    # "caps" = capability tags shown to users: "tools", "vision".
    #
    # Gemma 4 QAT (tool use + vision) — recommended first by RAM tier
    {"name": "google/gemma-4-26b-a4b-qat", "display": "Gemma 4 26B-A4B QAT (MoE)", "size": "~28 GB", "ram": "33 GB+", "caps": ["tools", "vision"]},
    {"name": "google/gemma-4-12b-qat", "display": "Gemma 4 12B QAT", "size": "~13 GB", "ram": "17–32 GB", "caps": ["tools", "vision"]},
    {"name": "google/gemma-4-e2b-qat", "display": "Gemma 4 E2B QAT", "size": "~6 GB", "ram": "16 GB or less", "caps": ["tools", "vision"]},
    {"name": "qwen/qwen3.6-27b", "display": "Qwen 3.6 27B", "size": "~18 GB", "ram": "24 GB", "caps": ["tools", "vision"]},
    {"name": "qwen/qwen3.6-35b-a3b", "display": "Qwen 3.6 35B-A3B (MoE)", "size": "~24 GB", "ram": "32 GB", "caps": ["tools", "vision"]},
    {"name": "qwen/qwen3-vl-8b", "display": "Qwen 3 VL 8B", "size": "~6 GB", "ram": "6 GB", "caps": ["tools", "vision"]},
    {"name": "zai-org/glm-4.6v-flash", "display": "GLM-4.6V Flash (9B)", "size": "~8 GB", "ram": "8 GB", "caps": ["tools", "vision"]},
    {"name": "mistralai/mistral-small-3.2", "display": "Mistral Small 3.2 (24B)", "size": "~14 GB", "ram": "14 GB", "caps": ["tools", "vision"]},
    {"name": "qwen/qwen3-vl-30b", "display": "Qwen 3 VL 30B (MoE)", "size": "~18.4 GB", "ram": "18 GB", "caps": ["tools", "vision"]},
]

# Catalog IDs we have verified support tool/function calling (required for Mugi's agent features).
# Used to filter "Available to download" — never list models that can't use tools.
_TOOL_CAPABLE_CATALOG_IDS = frozenset(m["name"] for m in LLMSTER_RECOMMENDED)

LMSTUDIO_CATALOG_URL = "https://lmstudio.ai/models"
LMSTUDIO_CATALOG_CACHE_TTL = 300  # seconds
# Map catalog page slug -> catalog ID for download API (when API does not accept slug)
LLMSTER_SLUG_TO_CATALOG_ID = {
    "gpt-oss": "openai/gpt-oss-20b",
    "gpt-oss-safeguard": "openai/gpt-oss-safeguard-20b",
}
_lmstudio_catalog_cache = None
_lmstudio_catalog_time = 0.0


def _fetch_lmstudio_model_page(slug):
    """Fetch a single model/family page from lmstudio.ai/models/{slug}. Returns (slug, raw_html) or (slug, None) on failure."""
    if not requests:
        return slug, None
    try:
        url = f"https://lmstudio.ai/models/{slug}"
        r = requests.get(
            url,
            timeout=8,
            headers={"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) LMStudio-Catalog/1.0"},
        )
        r.raise_for_status()
        return slug, r.text
    except Exception:
        return slug, None


def _parse_model_page_for_catalog_ids(raw_html):
    """Extract catalog IDs (publisher/model) and metadata from a model/family page.
    Returns list of {name, display, size, ram, caps} with catalog IDs suitable for the download API."""
    if not raw_html:
        return []
    out = []
    # Only match <a href="..."> links to avoid meta/link/canonical tags; those have no </a>
    # and would grab garbage as "display" text.
    catalog_link_re = re.compile(
        r'<a\s[^>]*href="(?:https://lmstudio\.ai)?/models/([^/]+/[^"/]+)"',
        re.IGNORECASE,
    )
    for m in catalog_link_re.finditer(raw_html):
        catalog_id = m.group(1).strip()
        if catalog_id.count("/") != 1:
            continue
        # Find link text (content between > and </a>)
        display = catalog_id
        end_tag = raw_html.find("</a>", m.end())
        if end_tag > m.end():
            start_tag = raw_html.rfind(">", m.start(), end_tag)
            if start_tag >= m.start():
                raw_text = raw_html[start_tag + 1 : end_tag]
                raw_text = re.sub(r"<[^>]+>", " ", raw_text)
                raw_text = _html_module.unescape(raw_text).strip()
                if raw_text and len(raw_text) < 80 and '"' not in raw_text and "=" not in raw_text:
                    display = raw_text
        if display == catalog_id:
            display = catalog_id.split("/")[-1].replace("-", " ").title()
        # Storage size is in a sibling div after the link (e.g. "12.00 GB")
        end = min(m.end() + 500, len(raw_html))
        block = raw_html[m.start():end]
        size = ""
        gb_match = re.search(r"([\d.]+)\s*GB\b", block, re.IGNORECASE)
        if gb_match:
            size = f"{gb_match.group(1)} GB"
        else:
            mb_match = re.search(r"([\d.]+)\s*MB\b", block, re.IGNORECASE)
            if mb_match:
                size = f"{mb_match.group(1)} MB"
        # Use size as minimum RAM estimate when available (typical for inference)
        ram = size if size else ""
        out.append({"name": catalog_id, "display": display or catalog_id, "size": size, "ram": ram, "caps": []})
    return out


def _fetch_lmstudio_catalog_full():
    """Fetch full LM Studio catalog by scraping main page and each model page.
    Returns list of {name, display, size, ram, caps} with catalog IDs (publisher/model format)
    suitable for the download API. Returns None on failure."""
    if not requests:
        return None
    try:
        r = requests.get(
            LMSTUDIO_CATALOG_URL,
            timeout=12,
            headers={"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) LMStudio-Catalog/1.0"},
        )
        r.raise_for_status()
        raw = r.text
    except Exception:
        return None
    # Extract unique slugs from main page (links like /models/slug or /models/publisher/model)
    slugs = set()
    for m in re.finditer(r'href="/models/([^"]+)"', raw):
        slug = (m.group(1) or "").strip()
        if slug and "/" not in slug:
            slugs.add(slug)
    if not slugs:
        return None
    # Fetch each model page in parallel to get catalog IDs
    seen = set()
    result = []
    max_workers = min(8, len(slugs))
    with ThreadPoolExecutor(max_workers=max_workers) as ex:
        futures = {ex.submit(_fetch_lmstudio_model_page, s): s for s in slugs}
        for future in as_completed(futures, timeout=60):
            try:
                slug, html = future.result()
                if not html:
                    continue
                for entry in _parse_model_page_for_catalog_ids(html):
                    cid = entry["name"]
                    if cid not in seen:
                        seen.add(cid)
                        result.append(entry)
            except Exception:
                continue
    if not result:
        return None
    result.sort(key=lambda x: (x["display"].lower(), x["name"]))
    return result


def _fetch_lmstudio_catalog():
    """Legacy: fetch main page only (slugs). Kept for refresh_lmstudio_catalog_async compatibility."""
    full = _fetch_lmstudio_catalog_full()
    return full if full else None


def _get_lmstudio_catalog_cached():
    """Return cached full catalog list if fresh; else None (caller uses LLMSTER_RECOMMENDED)."""
    global _lmstudio_catalog_cache, _lmstudio_catalog_time
    now = time.time()
    if _lmstudio_catalog_cache is not None and (now - _lmstudio_catalog_time) < LMSTUDIO_CATALOG_CACHE_TTL:
        return _lmstudio_catalog_cache
    return None


def clear_lmstudio_catalog_cache():
    """Clear the catalog cache so the next get_llmster_recommended_models() fetches fresh data."""
    global _lmstudio_catalog_cache, _lmstudio_catalog_time
    _lmstudio_catalog_cache = None
    _lmstudio_catalog_time = 0.0


def _filter_to_tool_capable_only(catalog_list):
    """Filter catalog to only models that support tools. Never list models that can't use tools."""
    rec_by_name = {m["name"]: m for m in LLMSTER_RECOMMENDED}
    out = []
    for entry in catalog_list:
        cid = entry.get("name") or ""
        if cid not in _TOOL_CAPABLE_CATALOG_IDS:
            continue
        rec = rec_by_name.get(cid)
        caps = (rec.get("caps", []) if rec else []) or ["tools"]
        out.append({**entry, "caps": caps})
    # Ensure we include all recommended models (catalog may miss some)
    seen = {e["name"] for e in out}
    for m in LLMSTER_RECOMMENDED:
        if m["name"] not in seen:
            out.append(dict(m))
    out.sort(key=lambda x: (x["display"].lower(), x["name"]))
    return out


def llmster_catalog_model_ids(*, timeout: float = 12.0) -> frozenset[str] | None:
    """Return catalog model ids from lmstudio.ai, or None when the fetch fails."""
    cached = _get_lmstudio_catalog_cached()
    if cached is not None:
        return frozenset(str(m.get("name") or "").strip() for m in cached if m.get("name"))
    fresh = _fetch_lmstudio_catalog_full()
    if fresh is None:
        return None
    return frozenset(str(m.get("name") or "").strip() for m in fresh if m.get("name"))


def llmster_recommended_catalog_ids() -> list[str]:
    """Catalog ids from ``LLMSTER_RECOMMENDED`` (deduped, stable order)."""
    seen: set[str] = set()
    out: list[str] = []
    for entry in LLMSTER_RECOMMENDED:
        cid = str(entry.get("name") or "").strip()
        if cid and cid not in seen:
            seen.add(cid)
            out.append(cid)
    return out


def get_llmster_recommended_models():
    """Return (list, from_catalog) for 'Available to download'.
    Fetches full catalog from lmstudio.ai; falls back to LLMSTER_RECOMMENDED on failure.
    Only lists models that support tools (required for Mugi's agent features)."""
    cached = _get_lmstudio_catalog_cached()
    if cached is not None:
        return _filter_to_tool_capable_only(cached), True
    fresh = _fetch_lmstudio_catalog_full()
    if fresh is not None:
        global _lmstudio_catalog_cache, _lmstudio_catalog_time
        filtered = _filter_to_tool_capable_only(fresh)
        _lmstudio_catalog_cache = filtered
        _lmstudio_catalog_time = time.time()
        return filtered, True
    return LLMSTER_RECOMMENDED, False


def refresh_lmstudio_catalog_async(delegate, on_main_thread_callback):
    """Fetch full catalog in background; on success update cache and call on_main_thread_callback(delegate, filtered).
    Filtered list contains only tool-capable models."""
    def do():
        fresh = _fetch_lmstudio_catalog_full()
        if fresh is None:
            return
        filtered = _filter_to_tool_capable_only(fresh)
        global _lmstudio_catalog_cache, _lmstudio_catalog_time
        _lmstudio_catalog_cache = filtered
        _lmstudio_catalog_time = time.time()
        on_main_thread_callback(delegate, filtered)

    Thread(target=do, daemon=True).start()


_llmster_server_process = None
_llmster_daemon_process = None

# When PyObjC is missing (e.g. backend), persist setup_done in ~/.mugi/.llmster_setup_done
_LLMSTER_SETUP_DONE_FILE = os.path.join(LLMSTER_HOME, ".llmster_setup_done")


def _llmster_user_defaults():
    """Return NSUserDefaults when PyObjC available; else None (callers use file fallback)."""
    if _has_pyobjc:
        return NSUserDefaults.standardUserDefaults()
    return None


def llmster_setup_done():
    if _has_pyobjc:
        return bool(_llmster_user_defaults().boolForKey_(LLMSTER_USER_DEFAULTS_SETUP))
    return os.path.isfile(_LLMSTER_SETUP_DONE_FILE)


def set_llmster_setup_done(done):
    if _has_pyobjc:
        _llmster_user_defaults().setBool_forKey_(bool(done), LLMSTER_USER_DEFAULTS_SETUP)
        _llmster_user_defaults().synchronize()
    else:
        try:
            os.makedirs(LLMSTER_HOME, exist_ok=True)
            if done:
                open(_LLMSTER_SETUP_DONE_FILE, "a").close()
            elif os.path.isfile(_LLMSTER_SETUP_DONE_FILE):
                os.remove(_LLMSTER_SETUP_DONE_FILE)
        except OSError:
            pass


def _read_stored_install_path() -> str | None:
    """Read the persisted lms binary path written at install time."""
    try:
        if os.path.isfile(_LLMSTER_INSTALL_PATH_FILE):
            with open(_LLMSTER_INSTALL_PATH_FILE, "r", encoding="utf-8") as f:
                p = f.read().strip()
            if p and os.path.isfile(p) and os.access(p, os.X_OK):
                return p
    except Exception:
        pass
    return None


def _write_stored_install_path(path: str) -> None:
    """Persist the verified lms binary path so it survives sandbox HOME changes."""
    try:
        os.makedirs(os.path.dirname(_LLMSTER_INSTALL_PATH_FILE), exist_ok=True)
        with open(_LLMSTER_INSTALL_PATH_FILE, "w", encoding="utf-8") as f:
            f.write(path)
    except Exception:
        pass


def find_llmster_path():
    """Return path to lms binary or None.
    Check order: stored install path > Mugi install > user's existing LM Studio."""
    stored = _read_stored_install_path()
    if stored:
        return stored
    if os.path.isfile(LLMSTER_LMS_BIN) and os.access(LLMSTER_LMS_BIN, os.X_OK):
        _write_stored_install_path(LLMSTER_LMS_BIN)
        return LLMSTER_LMS_BIN
    if os.path.isfile(LLMSTER_DEFAULT_HOME_LMS) and os.access(LLMSTER_DEFAULT_HOME_LMS, os.X_OK):
        _write_stored_install_path(LLMSTER_DEFAULT_HOME_LMS)
        return LLMSTER_DEFAULT_HOME_LMS
    return None


def _patch_llmster_settings_for_local_service():
    """Set enableLocalService and cliInstalled in ~/.mugi/.lmstudio/settings.json so the local API server can start."""
    path = os.path.join(LLMSTER_HOME, ".lmstudio", "settings.json")
    if not os.path.isfile(path):
        return
    try:
        with open(path, "r", encoding="utf-8") as f:
            data = json.load(f)
        data["enableLocalService"] = True
        data["cliInstalled"] = True
        with open(path, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=2)
    except Exception:
        pass


def _install_llmster():
    """Install llmster under ~/.mugi/.lmstudio only (Apple Silicon only). Does not touch ~/.lmstudio. Returns (success, message)."""
    if platform.machine() != "arm64" or sys.platform != "darwin":
        return False, "llmster is only supported on Apple Silicon Macs."
    env = os.environ.copy()
    env["INSTALLER_PRINT_QUIET"] = "1"
    env["HOME"] = LLMSTER_HOME
    script_path = None
    try:
        if not requests:
            return False, "requests library required to download the installer. pip install requests"
        r = requests.get(LLMSTER_INSTALL_SCRIPT_URL, timeout=30)
        r.raise_for_status()
        fd, script_path = tempfile.mkstemp(suffix=".sh", prefix="llmster_install_")
        os.close(fd)
        with open(script_path, "wb") as f:
            f.write(r.content)
        os.chmod(script_path, 0o755)
        proc = subprocess.run(
            ["bash", script_path],
            env=env,
            capture_output=True,
            text=True,
            timeout=300,
            cwd=LLMSTER_HOME,
        )
        if proc.returncode != 0:
            err = (proc.stderr or proc.stdout or "").strip() or "Install script failed."
            return False, err
        if not find_llmster_path():
            return False, "Install script ran but lms binary not found."
        # Enable local API service so "lms server start" can bind; install script leaves it off by default.
        _patch_llmster_settings_for_local_service()
        set_llmster_setup_done(True)
        return True, "llmster installed."
    except subprocess.TimeoutExpired:
        return False, "Install timed out."
    except Exception as e:
        return False, str(e)
    finally:
        if script_path and os.path.isfile(script_path):
            try:
                os.remove(script_path)
            except OSError:
                pass


def llmster_server_running():
    """Check if LM Studio server is reachable at 127.0.0.1:1234. Uses /api/v1/models (LM Studio headless API)."""
    try:
        r = requests.get(f"{LLMSTER_SERVER_URL}/api/v1/models", timeout=2) if requests else None
        return r is not None and r.status_code == 200
    except Exception:
        return False


def _is_mugi_install(path: str) -> bool:
    """True when `path` points to a Mugi-managed lms binary (under .mugi/.lmstudio/)."""
    return _MUGI_LMSTUDIO_PATH_MARKER in path


def _llmster_env_cwd(path: str) -> tuple[dict, str]:
    """Return (env, cwd) appropriate for the given lms binary path.
    Mugi installs need HOME overridden; user LM Studio installs use the real HOME."""
    if _is_mugi_install(path):
        # Derive the .mugi directory from the stored path so it works even when
        # the current process HOME differs (e.g. sandbox).
        idx = path.find(_MUGI_LMSTUDIO_PATH_MARKER)
        mugi_home = path[:idx + len(os.sep + ".mugi")]
        return {**os.environ, "HOME": mugi_home}, mugi_home
    return os.environ.copy(), os.path.expanduser("~")


def _read_log_tail(path: str, max_bytes: int = 2000) -> str:
    """Return up to max_bytes from the end of a log file (UTF-8, replace errors)."""
    try:
        if not os.path.isfile(path):
            return ""
        with open(path, "rb") as f:
            f.seek(0, os.SEEK_END)
            sz = f.tell()
            f.seek(max(0, sz - max_bytes))
            raw = f.read().decode("utf-8", errors="replace")
        return raw.strip()
    except Exception:
        return ""


def _clear_stale_daemon_lock(lmstudio_dir: str) -> None:
    """Remove the daemon PID lock file if the PID it references is dead.
    `lmstudio_dir` is the .lmstudio (or .cache/lm-studio) directory itself.
    A stale lock causes 'Invalid passkey' errors because the lms binary tries
    to connect to a daemon that no longer exists."""
    lock_path = os.path.join(lmstudio_dir, ".internal", "llmster-pid.lock")
    if not os.path.isfile(lock_path):
        return
    try:
        with open(lock_path, "r") as f:
            pid_str = f.read().strip()
        if not pid_str.isdigit():
            os.remove(lock_path)
            return
        pid = int(pid_str)
        try:
            os.kill(pid, 0)
        except ProcessLookupError:
            os.remove(lock_path)
        except PermissionError:
            pass  # process exists but we can't signal it — leave the lock
    except Exception:
        pass


def start_llmster_server():
    """Start llmster server if not already running.

    Returns {"ok": bool, "message": str} for API/UI (legacy callers ignore the return value).
    Logs server stdout/stderr to ~/.mugi/logs/llmster_server.log.
    """
    global _llmster_server_process
    if llmster_server_running():
        return {"ok": True, "message": "Already running."}
    path = find_llmster_path()
    if not path:
        return {
            "ok": False,
            "message": (
                "llmster is not installed or the lms binary was not found. "
                "Use Install llmster under Settings → LLMs → Advanced."
            ),
        }
    env, cwd = _llmster_env_cwd(path)
    qpath = shlex.quote(path)
    try:
        os.makedirs(MUGI_LOGS_DIR, exist_ok=True)
    except OSError:
        pass
    log_path = os.path.join(MUGI_LOGS_DIR, "llmster_server.log")
    _server_log_file = None
    try:
        global _llmster_daemon_process
        daemon_up = False
        try:
            r = subprocess.run(
                ["/bin/bash", "-c", f"exec {qpath} daemon status"],
                env=env,
                cwd=cwd,
                capture_output=True,
                text=True,
                timeout=5,
            )
            out_lower = ((r.stdout or "") + (r.stderr or "")).lower()
            daemon_up = r.returncode == 0 and "running" in out_lower and "not running" not in out_lower
        except Exception:
            pass
        if not daemon_up:
            _llmster_daemon_process = None
            mugi_home = env.get("HOME", LLMSTER_HOME)
            _clear_stale_daemon_lock(os.path.join(mugi_home, ".lmstudio"))
            _clear_stale_daemon_lock(os.path.expanduser("~/.lmstudio"))
            _clear_stale_daemon_lock(os.path.expanduser("~/.cache/lm-studio"))
            # Bash + exec: avoids Mach-O / posix_spawn issues with the lms Node SEA binary.
            try:
                dr = subprocess.run(
                    ["/bin/bash", "-c", f"exec {qpath} daemon up"],
                    cwd=cwd,
                    env=env,
                    capture_output=True,
                    text=True,
                    timeout=90,
                )
                if dr.returncode != 0:
                    err = (dr.stderr or dr.stdout or "").strip() or f"exit {dr.returncode}"
                    return {"ok": False, "message": f"llmster daemon failed to start: {err}"}
            except subprocess.TimeoutExpired:
                return {
                    "ok": False,
                    "message": "Starting the llmster daemon timed out. See ~/.mugi/logs/llmster_server.log.",
                }
            except Exception as e:
                return {"ok": False, "message": str(e)}
        _server_log_file = open(log_path, "ab")
        try:
            banner = f"\n\n--- llmster server start {time.strftime('%Y-%m-%d %H:%M:%S')} ---\n"
            _server_log_file.write(banner.encode("utf-8", errors="replace"))
            _server_log_file.flush()
        except Exception:
            pass
        _llmster_server_process = subprocess.Popen(
            ["/bin/bash", "-c", f"exec {qpath} server start --port 1234"],
            stdout=_server_log_file,
            stderr=subprocess.STDOUT,
            cwd=cwd,
            env=env,
        )
    except Exception as e:
        _llmster_server_process = None
        return {"ok": False, "message": str(e)}
    finally:
        if _server_log_file is not None:
            try:
                _server_log_file.close()
            except Exception:
                pass

    proc = _llmster_server_process
    for _ in range(90):
        if llmster_server_running():
            return {"ok": True, "message": "Server is running."}
        if proc is not None and proc.poll() is not None:
            time.sleep(2)
            if llmster_server_running():
                return {"ok": True, "message": "Server is running."}
            tail = _read_log_tail(log_path, 1500)
            _llmster_server_process = None
            msg = "The llmster server process exited before the API on port 1234 was ready."
            if tail:
                msg = f"{msg}\n\nLast log output:\n{tail}"
            return {"ok": False, "message": msg}
        time.sleep(1)
    return {
        "ok": False,
        "message": (
            "Timed out waiting for the server on port 1234. "
            "See ~/.mugi/logs/llmster_server.log for details."
        ),
    }


def stop_llmster_server():
    """Stop the llmster server and daemon. Terminates any processes we spawned, then runs
    lms server stop and lms daemon down (via bash+exec like start).

    Returns {"ok": bool, "message": str}.
    """
    global _llmster_server_process, _llmster_daemon_process
    if _llmster_server_process is not None:
        try:
            _llmster_server_process.terminate()
            _llmster_server_process.wait(timeout=5)
        except Exception:
            try:
                _llmster_server_process.kill()
            except Exception:
                pass
        _llmster_server_process = None
    if _llmster_daemon_process is not None:
        try:
            _llmster_daemon_process.terminate()
            _llmster_daemon_process.wait(timeout=5)
        except Exception:
            try:
                _llmster_daemon_process.kill()
            except Exception:
                pass
        _llmster_daemon_process = None
    path = find_llmster_path()
    if path:
        qpath = shlex.quote(path)
        env, cwd = _llmster_env_cwd(path)
        for subcmd in ("server stop", "daemon down"):
            try:
                subprocess.run(
                    ["/bin/bash", "-c", f"exec {qpath} {subcmd}"],
                    env=env,
                    cwd=cwd,
                    capture_output=True,
                    text=True,
                    timeout=20,
                )
            except Exception:
                pass
    for _ in range(50):
        if not llmster_server_running():
            return {"ok": True, "message": "Stopped."}
        time.sleep(0.2)
    return {
        "ok": False,
        "message": (
            "Something is still responding on port 1234. "
            "Quit LM Studio or other apps using that port, then try Stop again."
        ),
    }


def delete_llmster_and_reset():
    """Stop the llmster server and remove only Mugi-installed llmster under ~/.mugi/.lmstudio. Does NOT touch ~/.lmstudio (user's existing LM Studio). Returns (success, message)."""
    stop_llmster_server()
    set_llmster_setup_done(False)
    if os.path.isfile(_LLMSTER_SETUP_DONE_FILE):
        try:
            os.remove(_LLMSTER_SETUP_DONE_FILE)
        except OSError:
            pass
    if os.path.isfile(_LLMSTER_INSTALL_PATH_FILE):
        try:
            os.remove(_LLMSTER_INSTALL_PATH_FILE)
        except OSError:
            pass
    mugi_lmstudio = os.path.join(LLMSTER_HOME, ".lmstudio")
    if os.path.isdir(mugi_lmstudio):
        try:
            shutil.rmtree(mugi_lmstudio)
        except Exception as e:
            return False, f"Could not remove {mugi_lmstudio}: {e}"
    return True, "llmster and associated files have been removed. You can reinstall from the llmster section above."


def llmster_list_models():
    """Return list of installed LLM models from local server: [{"key": id, "display_name": str, "size_bytes": int, ...}, ...]."""
    if not requests:
        return []
    try:
        r = requests.get(f"{LLMSTER_SERVER_URL}/api/v1/models", timeout=10)
        if r.status_code != 200:
            return []
        data = r.json()
        models = data.get("models") or []
        out = []
        _REJECTED_TYPES = {"embedding", "tts", "stt", "reranker"}
        for m in models:
            mtype = (m.get("type") or "").lower()
            if mtype in _REJECTED_TYPES:
                continue
            out.append({
                "key": m.get("key") or "",
                "display_name": (m.get("display_name") or m.get("key") or "").strip(),
                "size_bytes": m.get("size_bytes") or 0,
                "params_string": m.get("params_string") or "",
            })
        return out
    except Exception:
        return []


def _unload_all_models():
    """Unload every currently loaded model instance."""
    if not requests:
        return
    try:
        r = requests.get(f"{LLMSTER_SERVER_URL}/api/v1/models", timeout=10)
        if r.status_code != 200:
            return
        for m in r.json().get("models") or []:
            for inst in m.get("loaded_instances") or []:
                inst_id = inst.get("id") or ""
                if inst_id:
                    try:
                        requests.post(
                            f"{LLMSTER_SERVER_URL}/api/v1/models/unload",
                            json={"instance_id": inst_id},
                            timeout=30,
                        )
                    except Exception:
                        pass
    except Exception:
        pass


def llmster_load_model(model_id, context_length=None):
    """Unload all models, then load model_id with the given context_length.
    Always unloads first so context_length is guaranteed to be applied
    (LM Studio may ignore context_length if the model is already loaded).
    Returns dict with 'ok' and 'message'."""
    if not requests:
        return {"ok": False, "message": "requests required"}
    _unload_all_models()
    body = {"model": model_id, "echo_load_config": True}
    if context_length is not None:
        body["context_length"] = int(context_length)
    try:
        r = requests.post(
            f"{LLMSTER_SERVER_URL}/api/v1/models/load",
            json=body,
            timeout=120,
        )
        if r.status_code != 200:
            return {"ok": False, "message": r.text or f"HTTP {r.status_code}"}
        data = r.json()
        loaded_ctx = (data.get("load_config") or {}).get("context_length")
        msg = "Model loaded."
        if loaded_ctx:
            msg = f"Model loaded with {loaded_ctx:,} token context."
        return {"ok": True, "message": msg}
    except Exception as e:
        return {"ok": False, "message": str(e)}


def llmster_start_download(model_id):
    """Initiate a model download and return immediately.
    Returns dict with 'ok', 'message', and optionally 'job_id' and 'already'."""
    if not requests:
        return {"ok": False, "message": "requests required"}
    # Ensure the LM Studio server is running before attempting the download.
    if not llmster_server_running():
        res = start_llmster_server()
        if not res.get("ok"):
            return {
                "ok": False,
                "message": res.get("message") or "LM Studio server failed to start. Please try again.",
            }
    try:
        r = requests.post(
            f"{LLMSTER_SERVER_URL}/api/v1/models/download",
            json={"model": model_id},
            timeout=30,
        )
        if r.status_code != 200:
            return {"ok": False, "message": r.text or f"HTTP {r.status_code}"}
        data = r.json()
        status = (data.get("status") or "").strip()
        if status == "already_downloaded":
            return {"ok": True, "message": "Model already installed.", "already": True}
        job_id = data.get("job_id")
        if not job_id:
            return {"ok": False, "message": "No job_id in response."}
        return {"ok": True, "message": "Download started.", "job_id": job_id}
    except Exception as e:
        return {"ok": False, "message": str(e)}


def llmster_download_status(job_id):
    """Check download progress. Returns dict with 'status', 'downloaded_bytes', 'total_bytes'."""
    if not requests:
        return {"status": "error", "message": "requests required"}
    try:
        url = f"{LLMSTER_SERVER_URL}/api/v1/models/download/status/{job_id}"
        r = requests.get(url, timeout=10)
        if r.status_code != 200:
            return {"status": "error", "message": f"HTTP {r.status_code}"}
        d = r.json()
        return {
            "status": (d.get("status") or "unknown").strip(),
            "downloaded_bytes": d.get("downloaded_bytes") or 0,
            "total_bytes": d.get("total_size_bytes") or 0,
            "message": d.get("message") or "",
        }
    except Exception as e:
        return {"status": "error", "message": str(e)}


def llmster_download_model(model_id, progress_callback=None):
    """Start download via API, poll until complete. Returns (success, message).
    For fire-and-forget use llmster_start_download() instead."""
    if not requests:
        return False, "requests required"
    try:
        r = requests.post(
            f"{LLMSTER_SERVER_URL}/api/v1/models/download",
            json={"model": model_id},
            timeout=30,
        )
        if r.status_code != 200:
            return False, r.text or f"HTTP {r.status_code}"
        data = r.json()
        job_id = data.get("job_id")
        status = (data.get("status") or "").strip()
        if status == "already_downloaded":
            return True, "Model already installed."
        if not job_id:
            return False, "No job_id in response."
        total = data.get("total_size_bytes") or 0
        while True:
            time.sleep(1)
            r2 = requests.get(f"{LLMSTER_SERVER_URL}/api/v1/models/download/status/{job_id}", timeout=10)
            if r2.status_code != 200:
                return False, "Failed to get download status."
            d = r2.json()
            status = (d.get("status") or "").strip()
            downloaded = d.get("downloaded_bytes") or 0
            total = d.get("total_size_bytes") or total
            if progress_callback and total:
                progress_callback(downloaded, total, status)
            if status == "completed":
                return True, "Model installed."
            if status == "failed":
                return False, d.get("message") or "Download failed."
    except Exception as e:
        return False, str(e)


def _show_ollama_progress_sheet(delegate, parent_win, title, indeterminate=False, modal=True):
    """Show a progress window with native NSProgressIndicator. If modal=True, use a sheet; if modal=False, use a floating window. No-op when PyObjC not available."""
    if not _has_pyobjc:
        return
    sheet = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
        NSMakeRect(0, 0, 380, 120),
        NSWindowStyleMaskTitled | NSWindowStyleMaskClosable,
        NSBackingStoreBuffered,
        False,
    )
    sheet.setTitle_(title)
    content = sheet.contentView()
    if indeterminate:
        # Spinning style (circular) for unknown-duration waits
        ind = NSProgressIndicator.alloc().initWithFrame_(NSMakeRect(160, 52, 60, 60))
        ind.setStyle_(1)  # NSProgressIndicatorStyleSpinning (1=spinning, 0=bar)
        ind.setIndeterminate_(True)
        ind.setDisplayedWhenStopped_(True)
        ind.startAnimation_(None)
    else:
        # Horizontal bar
        ind = NSProgressIndicator.alloc().initWithFrame_(NSMakeRect(24, 64, 332, 14))
        ind.setStyle_(0)  # NSProgressIndicatorStyleBar (0=bar, 1=spinning)
        ind.setIndeterminate_(False)
        ind.setDisplayedWhenStopped_(True)
        ind.setMinValue_(0.0)
        ind.setMaxValue_(1.0)
        ind.setDoubleValue_(0.0)
    content.addSubview_(ind)
    label = NSTextField.alloc().initWithFrame_(NSMakeRect(24, 28, 332, 28))
    label.setStringValue_("…")
    label.setBordered_(False)
    label.setEditable_(False)
    label.setDrawsBackground_(False)
    label.setFont_(NSFont.systemFontOfSize_(NSFont.smallSystemFontSize()))
    label.setLineBreakMode_(4)
    content.addSubview_(label)
    delegate._prefs_llms_progress_is_sheet = modal
    if modal and parent_win is not None:
        try:
            parent_win.beginSheet_completionHandler_(sheet, lambda rc: None)
        except Exception:
            sheet.orderFront_(None)
            delegate._prefs_llms_progress_is_sheet = False
    else:
        if parent_win is not None:
            try:
                parent_frame = parent_win.frame()
                sheet_frame = sheet.frame()
                x = parent_frame.origin.x + (parent_frame.size.width - sheet_frame.size.width) / 2
                y = parent_frame.origin.y + parent_frame.size.height - sheet_frame.size.height - 80
                sheet.setFrameOrigin_((x, y))
            except Exception:
                pass
        sheet.orderFront_(None)
        sheet.makeKeyWindow()
    delegate._prefs_llms_progress_window = sheet
    delegate._prefs_llms_progress_indicator = ind
    delegate._prefs_llms_progress_label = label
    return sheet, ind, label


def _close_ollama_progress_sheet(delegate, parent_win):
    """End the progress sheet/window and clear refs on delegate."""
    sheet = getattr(delegate, "_prefs_llms_progress_window", None)
    if sheet is not None:
        try:
            if getattr(delegate, "_prefs_llms_progress_is_sheet", True) and parent_win is not None:
                parent_win.endSheet_returnCode_(sheet, 0)
            sheet.orderOut_(None)
        except Exception:
            try:
                sheet.orderOut_(None)
            except Exception:
                pass
        delegate._prefs_llms_progress_window = None
        delegate._prefs_llms_progress_indicator = None
        delegate._prefs_llms_progress_label = None
