"""MLX (mlx-vlm server) preference HTTP routes extracted from [mugi_backend._build_app](mugi_backend.py)."""
from __future__ import annotations

import asyncio
import json
import time
from collections.abc import Callable
from typing import Any

from fastapi import Request as FRequest
from fastapi.responses import JSONResponse

from utils.setup_log import setup_log_exception, setup_log_write


def mlx_native_tool_probe_api_base(body: dict | None, prefs: dict | None = None) -> str:
    """Normalize OpenAI ``/v1`` base URL for streaming native tool-call probe.

    Prefer ``body[\"base_url\"]`` from the client (Swift sends the local mlx-vlm server's
    ``/v1`` for the MLX prefs probe). Older clients omit it — then fall back to
    ``prefs[\"backend_base_url\"]``, then to the loopback mlx-vlm server.
    """
    prefs = prefs or {}
    raw = str((body or {}).get("base_url") or "").strip()
    if not raw:
        raw = str(prefs.get("backend_base_url") or "").strip()
    if raw:
        if not raw.endswith("/v1"):
            return raw.rstrip("/") + "/v1"
        return raw
    try:
        from app_mlx import MLX_SERVER_PORT

        port = int(MLX_SERVER_PORT)
    except Exception:
        port = 8000
    return f"http://127.0.0.1:{port}/v1"


def probe_agent_openai_engine(prefs: dict | None, *, timeout_s: float = 2.0) -> dict[str, Any]:
    """HTTP GET ``/v1/models`` at the same OpenAI base URL the agent uses (prefs ``backend_base_url``).

    Surfaces engine connectivity separately from ``mlx_server_running()`` (which always probes
    Mugi's default MLX listen port).
    """
    prefs = prefs or {}
    base = mlx_native_tool_probe_api_base({}, prefs)
    url = base.rstrip("/") + "/models"
    out: dict[str, Any] = {
        "openai_base_url_tail": base[-72:],
        "models_url_tail": url[-72:],
        "ok": False,
        "http_status": None,
        "error": "",
    }
    try:
        import urllib.error
        import urllib.request

        with urllib.request.urlopen(url, timeout=timeout_s) as resp:
            code = getattr(resp, "status", None) or getattr(resp, "code", None)
            out["http_status"] = int(code) if code is not None else None
            out["ok"] = 200 <= (out["http_status"] or 0) < 300
    except Exception as exc:
        out["error"] = str(exc)[:400]
    return out


def _mlx_probe_launcher_diagnostics(
    prefs: dict | None,
    *,
    probe_model_id: str | None,
    resolved_probe_model_for_api: str | None,
) -> dict[str, Any]:
    """Attach launcher-side facts so operators can correlate ``xml_only`` with parser reality.

    The streaming probe validates HTTP behavior only. With the mlx-vlm server the tool parser is
    auto-inferred from the model's chat template (no launch flag); the ``would_launch_*`` fields
    below are Mugi's advisory heuristic, and the server's actual choice is /health.loaded_tool_parser.
    """
    prefs = prefs or {}
    out: dict[str, Any] = {}
    path_for_fp = (
        (probe_model_id or "").strip()
        or str(prefs.get("mlx_model") or "").strip()
    )
    prefs_mlx_model = str(prefs.get("mlx_model") or "").strip()
    if not path_for_fp:
        out["mlx_model_for_fingerprint_missing"] = True
        out["hints"] = [
            "No mlx_model in prefs (and probe sent no model) — Mugi's launcher fingerprint is unknown.",
        ]
        return out

    hints: list[str] = []
    if prefs_mlx_model and probe_model_id and prefs_mlx_model != (probe_model_id or "").strip():
        out["probe_model_pref_mismatch"] = {
            "probe_model_preview": str(probe_model_id)[:240],
            "prefs_mlx_model_preview": prefs_mlx_model[:240],
        }
        hints.append(
            "Probe model is not identical to prefs mlx_model — HTTP probe may exercise a "
            "different model id/path than Mugi's MLX load-model fingerprint.",
        )

    try:
        from app_mlx import (
            build_mlx_runtime_fingerprint,
            get_last_mlx_runtime_fingerprint,
            read_mlx_runtime_state,
            mlx_server_running,
        )
    except Exception as exc:
        out["launcher_import_error"] = str(exc)[:300]
        return out

    try:
        fp = build_mlx_runtime_fingerprint(path_for_fp, "auto", None)
    except Exception as exc:
        out["launcher_fingerprint_build_error"] = str(exc)[:300]
        return out

    last = get_last_mlx_runtime_fingerprint() or {}
    state = read_mlx_runtime_state() or {}
    parsed = fp.get("parser") if isinstance(fp.get("parser"), dict) else {}

    out.update(
        {
            "mlx_model_used_for_launcher_fingerprint": path_for_fp[:400],
            "probe_api_model_preview": str(resolved_probe_model_for_api or "")[:400],
            "would_launch_tool_call_parser": fp.get("tool_call_parser"),
            "would_launch_reasoning_parser": fp.get("reasoning_parser"),
            "parser_resolution_source": parsed.get("source"),
            "parser_resolution_reason_preview": str(parsed.get("reason") or "")[:220],
            "last_runtime_tool_call_parser": last.get("tool_call_parser"),
            "last_runtime_reasoning_parser": last.get("reasoning_parser"),
            "runtime_status": state.get("status"),
            "runtime_message_preview": str(state.get("message") or "")[:220],
            "mlx_port_reported_up_by_mugi": mlx_server_running(),
        }
    )

    tcp_would = fp.get("tool_call_parser")
    tcp_last = last.get("tool_call_parser")

    if tcp_would and tcp_last and tcp_would != tcp_last:
        hints.append(
            "Mugi's advisory tool_call_parser heuristic changed for this path "
            f"('{tcp_last}' recorded vs '{tcp_would}' now). This is informational only — the mlx-vlm "
            "server auto-infers the parser from the chat template at load time.",
        )

    if not tcp_would:
        hints.append(
            "Mugi's heuristic resolved NO tool_call_parser for this model path. The mlx-vlm server still "
            "auto-infers a parser from the chat template; if streaming content shows raw Hermes/XML, the "
            "model's template lacks a recognized tool-call marker. Check /health.loaded_tool_parser.",
        )

    if hints:
        out["hints"] = hints

    return out


def _mlx_vlm_update_from_stack(stack_status: list | None) -> str:
    """Read mlx-vlm's per-row update hint so the UI button matches the stack list."""
    for item in stack_status or []:
        if not isinstance(item, dict):
            continue
        if str(item.get("package") or "").strip() != "mlx-vlm":
            continue
        return str(item.get("update_available") or "").strip()
    return ""


def register_mlx_preferences_routes(
    fastapi_app: Any,
    *,
    load_app_prefs: Callable[[], dict[str, Any]],
    save_app_prefs: Callable[[dict[str, Any]], None],
    mlx_context_length_from_prefs: Callable[[dict], int | None],
    first_run_ready_for_chat: Callable[[dict | None], bool],
) -> None:
    """Register /preferences/mlx/* routes (Slice 6)."""

    # ------------------------------------------------------------------
    # MLX server routes (Advanced path: fine-tuning + mlx-vlm server)
    # ------------------------------------------------------------------

    @fastapi_app.get("/preferences/mlx/status")
    async def mlx_status():
        async def _executor_result_with_timeout(fn, *, timeout_s: float, default):
            started = time.perf_counter()
            try:
                value = await asyncio.wait_for(
                    loop.run_in_executor(None, fn),
                    timeout=timeout_s,
                )
                return value, False, False, int((time.perf_counter() - started) * 1000)
            except asyncio.TimeoutError:
                return default, True, False, int((time.perf_counter() - started) * 1000)
            except Exception:
                return default, False, True, int((time.perf_counter() - started) * 1000)

        prefs = load_app_prefs()
        cached_update = str(prefs.get("_mlx_last_update_available") or "")
        cached_stack_status_raw = prefs.get("_mlx_last_stack_status")
        cached_stack_status = cached_stack_status_raw if isinstance(cached_stack_status_raw, list) else []
        error = ""
        try:
            from app_mlx import (
                mlx_venv_exists, mlx_server_running,
                get_installed_version, check_update_available, get_pinned_mlx_stack_summary,
                build_mlx_runtime_fingerprint, get_last_mlx_runtime_fingerprint,
                check_package_update_available, get_mlx_stack_status, read_mlx_runtime_state,
                resolve_mlx_tool_call_parsers, runtime_fingerprint_matches,
            )
            installed = mlx_venv_exists()
            running = mlx_server_running() if installed else False
            version = get_installed_version() if installed else None
            loop = asyncio.get_event_loop()
            update_check_degraded = False
            if installed:
                # Keep /preferences/mlx/status responsive even when PyPI is slow.
                (
                    (update, update_timed_out, update_failed, update_elapsed_ms),
                    (stack_status, stack_timed_out, stack_failed, stack_elapsed_ms),
                    (mlx_core_update, mlx_core_timed_out, mlx_core_failed, _),
                    (mlx_vlm_update, mlx_vlm_timed_out, mlx_vlm_failed, _),
                ) = await asyncio.gather(
                    _executor_result_with_timeout(
                        lambda: check_update_available(timeout_seconds=4),
                        timeout_s=5.0,
                        default=None,
                    ),
                    _executor_result_with_timeout(
                        lambda: get_mlx_stack_status(check_updates=False),
                        timeout_s=8.0,
                        default=[],
                    ),
                    _executor_result_with_timeout(
                        lambda: check_package_update_available("mlx", timeout_seconds=4),
                        timeout_s=5.0,
                        default=None,
                    ),
                    _executor_result_with_timeout(
                        lambda: check_package_update_available("mlx-vlm", timeout_seconds=4),
                        timeout_s=5.0,
                        default=None,
                    ),
                )
                update_check_degraded = bool(
                    update_timed_out
                    or stack_timed_out
                    or mlx_core_timed_out
                    or mlx_vlm_timed_out
                    or update_failed
                    or stack_failed
                    or mlx_core_failed
                    or mlx_vlm_failed
                )
                if not isinstance(stack_status, list):
                    stack_status = []
                    update_check_degraded = True
                if update_check_degraded:
                    if not update and cached_update:
                        update = cached_update
                    if not stack_status and cached_stack_status:
                        stack_status = [dict(item) for item in cached_stack_status if isinstance(item, dict)]
                for item in stack_status:
                    pkg = item.get("package")
                    if pkg == "mlx":
                        if mlx_core_update:
                            item["update_available"] = mlx_core_update
                        elif not (mlx_core_timed_out or mlx_core_failed):
                            item["update_available"] = ""
                    elif pkg == "mlx-lm":
                        item["update_available"] = update or ""
                    elif pkg == "mlx-vlm":
                        if mlx_vlm_update:
                            item["update_available"] = mlx_vlm_update
                        elif not (mlx_vlm_timed_out or mlx_vlm_failed):
                            item["update_available"] = ""
                    elif pkg == "mlx-metal":
                        # mlx-metal tracks mlx; no separate PyPI probe — clear stale hints on a clean pass.
                        if not (mlx_core_timed_out or mlx_core_failed):
                            item["update_available"] = ""
                # When mlx / mlx-vlm PyPI probes time out, preserve last good per-row hints (no separate prefs keys).
                if update_check_degraded and cached_stack_status:
                    cached_by_pkg = {
                        str(it.get("package") or "").strip(): it
                        for it in cached_stack_status
                        if isinstance(it, dict) and str(it.get("package") or "").strip()
                    }
                    stale_overlay_pkgs: set[str] = set()
                    if mlx_core_timed_out or mlx_core_failed:
                        stale_overlay_pkgs.add("mlx")
                    if mlx_vlm_timed_out or mlx_vlm_failed:
                        stale_overlay_pkgs.add("mlx-vlm")
                    for item in stack_status:
                        pkg = str(item.get("package") or "").strip()
                        if pkg not in stale_overlay_pkgs:
                            continue
                        if str(item.get("update_available") or "").strip():
                            continue
                        prev = cached_by_pkg.get(pkg)
                        if not isinstance(prev, dict):
                            continue
                        hint = str(prev.get("update_available") or "").strip()
                        if hint:
                            item["update_available"] = hint
                if not update_check_degraded:
                    prefs_changed = False
                    if cached_update != str(update or ""):
                        prefs["_mlx_last_update_available"] = str(update or "")
                        prefs_changed = True
                    if cached_stack_status != stack_status:
                        prefs["_mlx_last_stack_status"] = stack_status
                        prefs_changed = True
                    if prefs_changed:
                        save_app_prefs(prefs)
            else:
                update = None
                mlx_vlm_update = None
                stack_status = []
                update_check_degraded = False
            pinned_stack = get_pinned_mlx_stack_summary()
            model_path = (prefs.get("mlx_model") or "").strip()
            context_length = mlx_context_length_from_prefs(prefs)
            intended_fingerprint = build_mlx_runtime_fingerprint(model_path, context_length=context_length) if model_path else {}
            running_fingerprint = get_last_mlx_runtime_fingerprint()
            runtime_state = read_mlx_runtime_state()
            parser_resolution = resolve_mlx_tool_call_parsers(model_path).to_dict() if model_path else {}
            runtime_stale = bool(running and intended_fingerprint and not runtime_fingerprint_matches(intended_fingerprint, running_fingerprint))
        except Exception:
            installed = False
            running = False
            version = None
            update = None
            mlx_vlm_update = None
            error = "mlx status unavailable"
            pinned_stack = ""
            stack_status = []
            intended_fingerprint = {}
            running_fingerprint = {}
            runtime_state = {}
            parser_resolution = {}
            runtime_stale = False
            update_check_degraded = False
        agent_engine_probe = probe_agent_openai_engine(prefs)
        mlx_vlm_update_hint = str(mlx_vlm_update or "").strip() or _mlx_vlm_update_from_stack(stack_status)
        return JSONResponse(content={
            "venv_installed": installed, "server_running": running,
            "version": version, "update_available": update,
            "stack": stack_status,
            "mlx_stack": stack_status,
            "mlx_vlm_update_available": mlx_vlm_update_hint,
            "pinned_stack": pinned_stack,
            "selected_model": (prefs.get("mlx_model") or "").strip(),
            "parser": parser_resolution,
            "runtime_fingerprint": running_fingerprint,
            "intended_runtime_fingerprint": intended_fingerprint,
            "runtime_state": runtime_state,
            "runtime_stale": runtime_stale,
            "latest_native_tool_probe": prefs.get("mlx_last_tool_probe") if isinstance(prefs.get("mlx_last_tool_probe"), dict) else {},
            "first_run_validation_required": bool(prefs.get("first_run_validation_required", False)),
            "first_run_ready_for_chat": first_run_ready_for_chat(prefs),
            "first_run_proactive_welcome_enabled": bool(prefs.get("first_run_proactive_welcome_enabled", False)),
            "first_run_setup_state": str(prefs.get("first_run_setup_state") or ""),
            "error": error,
            "agent_engine_probe": agent_engine_probe,
        })

    @fastapi_app.post("/preferences/mlx/verify_chat")
    async def mlx_verify_chat_route(request: FRequest):
        try:
            body = await request.json()
        except Exception:
            body = {}
        max_tokens = int(body.get("max_tokens", 1) or 1)
        prefs = load_app_prefs()
        prefs["first_run_setup_state"] = "verifying_chat"
        save_app_prefs(prefs)
        base_url = (prefs.get("backend_base_url") or "").strip() or "http://127.0.0.1:8000/v1"
        if not base_url.endswith("/v1"):
            base_url = base_url.rstrip("/") + "/v1"
        try:
            import urllib.request
            import urllib.error

            model_id = ""
            with urllib.request.urlopen(base_url + "/models", timeout=15) as resp:
                models_payload = json.loads(resp.read().decode("utf-8", errors="replace"))
                data = models_payload.get("data") if isinstance(models_payload, dict) else []
                if isinstance(data, list) and data:
                    model_id = str((data[0] or {}).get("id") or "").strip()
            if not model_id:
                return JSONResponse(content={"ok": False, "message": "No model is available on the MLX server.", "code": "mlx_no_models"})
            payload = {
                "model": model_id,
                "messages": [{"role": "user", "content": "Respond with: ok"}],
                "max_tokens": max(1, min(max_tokens, 16)),
                "temperature": 0.0,
                "stream": False,
            }
            req = urllib.request.Request(
                base_url + "/chat/completions",
                data=json.dumps(payload).encode("utf-8"),
                headers={"Content-Type": "application/json"},
                method="POST",
            )
            with urllib.request.urlopen(req, timeout=25) as resp:
                _ = resp.read()
            prefs["first_run_validation"] = {
                "ok": True,
                "ts": int(time.time()),
                "model": model_id,
                "base_url": base_url,
                "max_tokens": max(1, min(max_tokens, 16)),
            }
            prefs["first_run_setup_state"] = "ready"
            save_app_prefs(prefs)
            return JSONResponse(content={"ok": True, "message": "MLX chat verification passed.", "model": model_id})
        except Exception as exc:
            msg = str(exc)
            low = msg.lower()
            code = "mlx_verify_failed"
            if "apply_chat_template" in low or "chat template" in low or "multimodal stream" in low:
                code = "mlx_template_mismatch"
            # The raw client error ("HTTP Error 500: Internal Server Error") is meaningless on
            # its own — that 500 came FROM the local mlx-vlm server, which logs the real
            # generation failure (OOM, chat-template mismatch, runtime bug) to its own
            # server.log. Read that tail so the actual cause is captured and recoverable,
            # not just surfaced as an opaque HTTP code.
            server_tail = ""
            try:
                import os as _os

                from app_mlx import MLX_DATA_DIR, _tail_log

                server_tail = _tail_log(_os.path.join(MLX_DATA_DIR, "server.log"))
            except Exception:
                server_tail = ""
            if server_tail and server_tail not in ("(log is empty)", "(could not read log)"):
                low_tail = server_tail.lower()
                if (
                    "out of memory" in low_tail
                    or "insufficient memory" in low_tail
                    or ("metal" in low_tail and "memory" in low_tail)
                ):
                    code = "mlx_out_of_memory"
                elif "apply_chat_template" in low_tail or "chat template" in low_tail:
                    code = "mlx_template_mismatch"
            else:
                server_tail = ""
            # Tee the full failure (with the server's own traceback) to the durable setup log
            # so a clean-machine first-run failure is diagnosable from ~/.mugi/logs/setup.log.
            setup_log_write(f"FAILED: MLX chat verification ({code}): {msg}")
            if server_tail:
                setup_log_write("mlx-vlm server.log (tail):\n" + server_tail)
            prefs["first_run_validation"] = {
                "ok": False,
                "ts": int(time.time()),
                "message": msg[:500],
                "code": code,
                "base_url": base_url,
                "server_log_tail": server_tail[:2000],
            }
            prefs["first_run_setup_state"] = "verification_failed"
            save_app_prefs(prefs)
            return JSONResponse(
                content={
                    "ok": False,
                    "message": msg[:500],
                    "code": code,
                    "server_log_tail": server_tail[:2000],
                }
            )

    def _mlx_native_probe_result(
        base_url: str,
        model_id: str | None = None,
        *,
        prefs: dict[str, Any],
        max_tokens: int = 128,
        timeout: float = 60.0,
    ) -> dict:
        from utils.llm_tool_call_probe import probe_streaming_native_tool_calls

        result = probe_streaming_native_tool_calls(
            base_url,
            api_key="lm-studio",
            model=model_id,
            max_tokens=max_tokens,
            timeout=timeout,
        )
        result["launcher_diagnostics"] = _mlx_probe_launcher_diagnostics(
            prefs,
            probe_model_id=model_id,
            resolved_probe_model_for_api=str(result.get("model") or "") or None,
        )
        error_code = result.get("error_code")
        result["native_tool_calls_ok"] = bool(result.get("native_tool_calls"))
        result["probe_status"] = "native_ok" if result["native_tool_calls_ok"] else (str(error_code or "no_native_tool_calls"))
        if not result["native_tool_calls_ok"]:
            if error_code == "xml_only":
                result["fallback_warning"] = "Native tool calls were not observed; Mugi will use XML fallback for this model/parser."
            elif error_code:
                result["fallback_warning"] = f"Native tool-call probe did not pass ({error_code}); XML fallback remains available."
            else:
                result["fallback_warning"] = "Native tool calls were not observed; XML fallback remains available."
        else:
            result["fallback_warning"] = ""
        return result

    def _save_mlx_last_probe(result: dict | None) -> None:
        if not isinstance(result, dict):
            return
        prefs = load_app_prefs()
        prefs["mlx_last_tool_probe"] = {
            "ts": int(time.time()),
            "ok": bool(result.get("ok")),
            "native_tool_calls": bool(result.get("native_tool_calls")),
            "native_tool_calls_ok": bool(result.get("native_tool_calls_ok")),
            "xml_like_in_content": bool(result.get("xml_like_in_content")),
            "required_tool_choice_retry": bool(result.get("required_tool_choice_retry")),
            "non_stream_fallback": bool(result.get("non_stream_fallback")),
            "probe_status": str(result.get("probe_status") or ""),
            "error_code": result.get("error_code"),
            "message": str(result.get("message") or "")[:500],
            "fallback_warning": str(result.get("fallback_warning") or "")[:500],
            "model": str(result.get("model") or "")[:200],
        }
        save_app_prefs(prefs)

    @fastapi_app.post("/preferences/mlx/probe_tool_calls")
    async def mlx_probe_tool_calls_route(request: FRequest):
        """Streaming probe: detect whether the backend emits native delta.tool_calls (vs XML-in-content)."""
        try:
            body = await request.json()
        except Exception:
            body = {}
        prefs = load_app_prefs()
        base_url = mlx_native_tool_probe_api_base(body, prefs)
        model_id = (body.get("model") or "").strip() or None
        if not model_id:
            model_id = (prefs.get("mlx_model") or "").strip() or None
        try:
            max_tokens = int(body.get("max_tokens", 128) or 128)
        except Exception:
            max_tokens = 128
        try:
            timeout = float(body.get("timeout", 60.0) or 60.0)
        except Exception:
            timeout = 60.0
        timeout = max(15.0, min(timeout, 120.0))
        max_tokens = max(16, min(max_tokens, 512))

        prefs_snapshot = dict(prefs)
        loop = asyncio.get_event_loop()
        try:
            result = await loop.run_in_executor(
                None,
                lambda bu=base_url, mid=model_id, ps=prefs_snapshot, mt=max_tokens, to=timeout: _mlx_native_probe_result(
                    bu,
                    mid,
                    prefs=ps,
                    max_tokens=mt,
                    timeout=to,
                ),
            )
            _save_mlx_last_probe(result)
            return JSONResponse(content=result)
        except Exception as exc:
            return JSONResponse(
                content={
                    "ok": False,
                    "native_tool_calls": False,
                    "native_tool_calls_ok": False,
                    "xml_like_in_content": False,
                    "model": model_id or "",
                    "base_url": base_url,
                    "message": str(exc),
                    "probe_status": "probe_exception",
                    "error_code": "probe_exception",
                    "error_detail": str(exc)[:800],
                    "fallback_warning": "Native tool-call probe failed; XML fallback remains available.",
                }
            )

    @fastapi_app.post("/preferences/mlx/install")
    async def mlx_install_route():
        try:
            from app_mlx import ensure_mlx_venv
        except Exception:
            return JSONResponse(content={"ok": False, "message": "app_mlx not available"}, status_code=500)
        loop = asyncio.get_event_loop()
        try:
            ok, msg = await loop.run_in_executor(None, ensure_mlx_venv)
            if not ok:
                setup_log_write(f"FAILED: MLX install (ensure_mlx_venv): {msg}")
            return JSONResponse(content={"ok": ok, "message": msg})
        except Exception as e:
            setup_log_exception("MLX install (/preferences/mlx/install)", e)
            return JSONResponse(content={"ok": False, "message": str(e)})

    @fastapi_app.post("/preferences/mlx/upgrade")
    async def mlx_upgrade_route():
        try:
            from app_mlx import upgrade_mlx_venv, get_installed_version
        except Exception:
            return JSONResponse(content={"ok": False, "message": "app_mlx not available"}, status_code=500)
        loop = asyncio.get_event_loop()
        try:
            ok, msg = await loop.run_in_executor(None, upgrade_mlx_venv)
            version = get_installed_version() or ""
            return JSONResponse(content={"ok": ok, "message": msg, "version": version})
        except Exception as e:
            return JSONResponse(content={"ok": False, "message": str(e)})

    @fastapi_app.post("/preferences/mlx/packages/{package}/upgrade")
    async def mlx_upgrade_package_route(package: str):
        """Upgrade one allowlisted MLX stack package, e.g. mlx-vlm."""
        try:
            from app_mlx import get_installed_package_version, upgrade_mlx_stack_package
        except Exception:
            return JSONResponse(content={"ok": False, "message": "app_mlx not available"}, status_code=500)
        loop = asyncio.get_event_loop()
        try:
            ok, msg = await loop.run_in_executor(None, lambda: upgrade_mlx_stack_package(package))
            version = get_installed_package_version(package) or ""
            return JSONResponse(content={
                "ok": ok,
                "message": msg,
                "package": package,
                "version": version,
            })
        except Exception as e:
            return JSONResponse(content={"ok": False, "message": str(e)})

    @fastapi_app.post("/preferences/mlx/upgrade-pre")
    async def mlx_upgrade_pre_route():
        """Upgrade MLX venv with pip --pre (PyPI pre-releases when published)."""
        try:
            from app_mlx import upgrade_mlx_venv_pre_releases, get_installed_version
        except Exception:
            return JSONResponse(content={"ok": False, "message": "app_mlx not available"}, status_code=500)
        loop = asyncio.get_event_loop()
        try:
            ok, msg = await loop.run_in_executor(None, upgrade_mlx_venv_pre_releases)
            version = get_installed_version() or ""
            return JSONResponse(content={"ok": ok, "message": msg, "version": version})
        except Exception as e:
            return JSONResponse(content={"ok": False, "message": str(e)})

    @fastapi_app.post("/preferences/mlx/revert")
    async def mlx_revert_route():
        """Install exact versions from requirements-mlx-pinned.txt (stable escape hatch)."""
        try:
            from app_mlx import revert_mlx_venv_to_pinned, get_installed_version
        except Exception:
            return JSONResponse(content={"ok": False, "message": "app_mlx not available"}, status_code=500)
        loop = asyncio.get_event_loop()
        try:
            ok, msg = await loop.run_in_executor(None, revert_mlx_venv_to_pinned)
            version = get_installed_version() or ""
            return JSONResponse(content={"ok": ok, "message": msg, "version": version})
        except Exception as e:
            return JSONResponse(content={"ok": False, "message": str(e)})

    @fastapi_app.post("/preferences/mlx/update")
    async def mlx_update_route():
        """Reinstall the MLX venv from scratch and restart the server (keeps models/adapters)."""
        try:
            from app_mlx import reinstall_mlx_venv_and_start, get_installed_version
        except Exception:
            return JSONResponse(content={"ok": False, "message": "app_mlx not available"}, status_code=500)
        prefs = load_app_prefs()
        model_path = (prefs.get("mlx_model") or "").strip()
        context_length = mlx_context_length_from_prefs(prefs)
        loop = asyncio.get_event_loop()
        try:
            ok, msg = await loop.run_in_executor(
                None, lambda: reinstall_mlx_venv_and_start(model_path, context_length)
            )
            version = get_installed_version() or ""
            return JSONResponse(content={"ok": ok, "message": msg, "version": version})
        except Exception as e:
            return JSONResponse(content={"ok": False, "message": str(e)})

    @fastapi_app.post("/preferences/mlx/delete")
    async def mlx_delete_route(request: FRequest):
        try:
            body = await request.json()
        except Exception:
            body = {}
        remove_data = bool(body.get("remove_data", False))
        try:
            from app_mlx import delete_mlx_venv
        except Exception:
            return JSONResponse(content={"ok": False, "message": "app_mlx not available"}, status_code=500)
        loop = asyncio.get_event_loop()
        try:
            ok, msg = await loop.run_in_executor(None, lambda: delete_mlx_venv(remove_data))
            return JSONResponse(content={"ok": ok, "message": msg})
        except Exception as e:
            return JSONResponse(content={"ok": False, "message": str(e)})

    @fastapi_app.post("/preferences/mlx/start")
    async def mlx_start_route(request: FRequest):
        try:
            body = await request.json()
        except Exception:
            body = {}
        model_path = (body.get("model_path") or "").strip()
        prefs = load_app_prefs()
        if not model_path:
            model_path = (prefs.get("mlx_model") or "").strip()
        if not model_path:
            return JSONResponse(content={"ok": False, "message": "model_path required"}, status_code=400)
        model_type = (body.get("model_type") or "auto").strip()
        context_length = mlx_context_length_from_prefs(prefs)
        try:
            from app_mlx import build_mlx_runtime_fingerprint, start_mlx_server
        except Exception:
            return JSONResponse(content={"ok": False, "message": "app_mlx not available"}, status_code=500)
        loop = asyncio.get_event_loop()
        try:
            fingerprint = build_mlx_runtime_fingerprint(model_path, model_type, context_length)
            ok, msg = await loop.run_in_executor(
                None, lambda: start_mlx_server(model_path, model_type, context_length)
            )
            return JSONResponse(content={
                "ok": ok,
                "message": msg,
                "parser": fingerprint.get("parser") or {},
                "runtime_fingerprint": fingerprint,
                "runtime_stale": False,
            })
        except Exception as e:
            return JSONResponse(content={"ok": False, "message": str(e)})

    @fastapi_app.post("/preferences/mlx/stop")
    async def mlx_stop_route():
        try:
            from app_mlx import stop_mlx_server
            stop_mlx_server()
        except Exception as exc:
            return JSONResponse(content={"ok": False, "message": str(exc)}, status_code=500)
        return JSONResponse(content={"ok": True, "message": "MLX server stopped."})

    @fastapi_app.get("/preferences/mlx/models")
    async def mlx_models():
        error = ""
        try:
            from app_mlx import mlx_find_compatible_models
            loop = asyncio.get_event_loop()
            models = await loop.run_in_executor(None, mlx_find_compatible_models)
        except Exception as exc:
            models = []
            error = str(exc)
        return JSONResponse(content={"models": models, "error": error})

    @fastapi_app.get("/preferences/mlx/recommended")
    async def mlx_recommended():
        error = ""
        try:
            from app_mlx import get_mlx_recommended_models
            recommended = get_mlx_recommended_models()
        except Exception as exc:
            recommended = []
            error = str(exc)
            setup_log_exception("MLX recommended models (/preferences/mlx/recommended)", exc)
        return JSONResponse(content={"recommended": recommended, "error": error})

    @fastapi_app.post("/preferences/mlx/download")
    async def mlx_download_route(request: FRequest):
        try:
            body = await request.json()
        except Exception:
            body = {}
        hf_repo = (body.get("hf_repo") or body.get("model_id") or "").strip()
        revision = (body.get("revision") or "").strip()
        if not hf_repo:
            return JSONResponse(content={"ok": False, "message": "hf_repo required"}, status_code=400)
        try:
            from app_mlx import mlx_download_model
        except Exception:
            return JSONResponse(content={"ok": False, "message": "app_mlx not available"}, status_code=500)
        loop = asyncio.get_event_loop()
        try:
            result = await loop.run_in_executor(None, lambda: mlx_download_model(hf_repo, revision=revision or None))
            return JSONResponse(content=result)
        except Exception as e:
            setup_log_exception(f"MLX model download (/preferences/mlx/download repo={hf_repo})", e)
            return JSONResponse(content={"ok": False, "message": str(e)})

    @fastapi_app.get("/preferences/mlx/download/status/{job_id}")
    async def mlx_download_status_route(job_id: str):
        from utils.llm_token_normalize import normalize_hex_id_token

        job_id = normalize_hex_id_token((job_id or "").strip(), allowed_lengths=(12,))
        try:
            from app_mlx import mlx_download_status
        except Exception:
            return JSONResponse(content={"status": "error", "message": "app_mlx not available"})
        return JSONResponse(content=mlx_download_status(job_id))

    @fastapi_app.post("/preferences/mlx/load_model")
    async def mlx_load_model_route(request: FRequest):
        try:
            body = await request.json()
        except Exception:
            body = {}
        model_path = (body.get("model_path") or "").strip()
        if not model_path:
            return JSONResponse(content={"ok": False, "message": "model_path required"}, status_code=400)
        model_type = (body.get("model_type") or "auto").strip()
        prefs = load_app_prefs()
        prefs["mlx_model"] = model_path
        req_ctx = body.get("context_length")
        if req_ctx is not None:
            try:
                req_ctx_int = int(req_ctx)
                if 16384 <= req_ctx_int <= 1_048_576:
                    prefs["llm_context_length"] = req_ctx_int
            except (TypeError, ValueError):
                pass
        if bool(prefs.get("first_run_validation_required")):
            prefs["first_run_ready_for_chat"] = False
            prefs["first_run_proactive_welcome_enabled"] = False
            prefs["first_run_setup_state"] = "starting_server"
        save_app_prefs(prefs)
        context_length = mlx_context_length_from_prefs(prefs)
        try:
            from app_mlx import build_mlx_runtime_fingerprint, stop_mlx_server, start_mlx_server
            loop = asyncio.get_event_loop()
            fingerprint = build_mlx_runtime_fingerprint(model_path, model_type, context_length)
            await loop.run_in_executor(None, stop_mlx_server)
            ok, msg = await loop.run_in_executor(
                None, lambda: start_mlx_server(model_path, model_type, context_length)
            )
            probe_result = None
            if ok:
                try:
                    probe_prefs = dict(prefs)
                    probe_result = await loop.run_in_executor(
                        None,
                        lambda ps=probe_prefs: _mlx_native_probe_result(
                            "http://127.0.0.1:8000/v1",
                            None,
                            prefs=ps,
                            max_tokens=96,
                            timeout=90.0,
                        ),
                    )
                except Exception as probe_exc:
                    probe_result = {
                        "ok": False,
                        "native_tool_calls": False,
                        "native_tool_calls_ok": False,
                        "probe_status": "probe_exception",
                        "error_code": "probe_exception",
                        "error_detail": str(probe_exc)[:800],
                        "message": str(probe_exc),
                        "fallback_warning": "Native tool-call probe failed; XML fallback remains available.",
                    }
                _save_mlx_last_probe(probe_result)
            return JSONResponse(content={
                "ok": ok,
                "message": msg,
                "parser": fingerprint.get("parser") or {},
                "runtime_fingerprint": fingerprint,
                "runtime_stale": False,
                "native_tool_calls_ok": bool((probe_result or {}).get("native_tool_calls_ok")),
                "probe": probe_result,
                "probe_status": (probe_result or {}).get("probe_status", "not_run"),
                "probe_error_code": (probe_result or {}).get("error_code"),
                "fallback_warning": (probe_result or {}).get("fallback_warning", ""),
            })
        except Exception as e:
            setup_log_exception(f"MLX server start (/preferences/mlx/load_model path={model_path})", e)
            return JSONResponse(content={"ok": False, "message": str(e)})

