"""
Engine-Service: liest/schreibt die llama-swap config.yaml und spricht die
llama-swap-API. Portiert & erweitert aus Mission Control v1.

NEU in 2.0: `groups` für Ko-Residenz (schnell + schwer gleichzeitig geladen,
`swap:false`) → Multi-Model-Delegation ohne Nachlade-Latenz.
"""

import logging
import os
import re

import httpx
from ruamel.yaml.scalarstring import LiteralScalarString

from config import (
    CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, DRAFTS_DIR, LLAMA_SWAP_URL,
    SPEC_DRAFT_MODEL_PATH, SPEC_DRAFT_N_MAX, SPEC_TYPE,
)

log = logging.getLogger(__name__)

# Kanonische Serving-Rollen — EINE Quelle der Wahrheit (identisch zu sources.ROLE_IDS,
# maintenance, frontend ModelBadges.ROLES). `hermes` = Lucys Agent-Hirn (warm + ko-resident
# in der `brains`-Gruppe); UI-Label „Hirn".
ROLE_IDS = {"fast", "heavy", "coder", "vision", "scout", "hermes", "kritiker", "reranker"}

_CTX_RE = re.compile(r"-(?:c|-ctx-size)\s+(\d+)")
_PATH_RE = re.compile(r"-(?:m|-model)\s+([^\s]+)")
_QUANT_RE = re.compile(r"(Q\d_[A-Z0-9_]+|IQ\d_[A-Z0-9_]+|fp16|bf16)\.gguf", re.IGNORECASE)
_SPLIT_RE = re.compile(r"-(\d+)-of-(\d+)\.gguf$", re.IGNORECASE)


def _gguf_total_size(path: str) -> int | None:
    """Gesamtgröße eines GGUF inkl. ALLER Split-Teile (…-00001-of-00003.gguf).
    Die Größe nur des ersten Teils ist bei Splits irreführend (oft nur ein Header)."""
    try:
        base = os.path.basename(path)
        m = _SPLIT_RE.search(base)
        if not m:
            return os.path.getsize(path)
        prefix, dirn = base[:m.start()], os.path.dirname(path)
        total = sum(os.path.getsize(os.path.join(dirn, f))
                    for f in os.listdir(dirn)
                    if f.startswith(prefix) and _SPLIT_RE.search(f))
        return total or os.path.getsize(path)
    except OSError:
        return None


# --- Lesen -------------------------------------------------------------------
def read_config() -> dict:
    if not CONFIG_PATH.exists():
        return {"models": {}}
    from ruamel.yaml import YAML
    r_yaml = YAML()
    r_yaml.preserve_quotes = True
    with CONFIG_PATH.open("r", encoding="utf-8") as f:
        data = r_yaml.load(f) or {}
    if not data.get("models"):
        data["models"] = {}
    return data


def _parse_model(name: str, spec: dict) -> dict:
    spec = spec or {}
    cmd = str(spec.get("cmd", "")).strip()
    ctx = int(m.group(1)) if (m := _CTX_RE.search(cmd)) else None

    path = filename = quant = ""
    size_bytes = None
    if (m := _PATH_RE.search(cmd)):
        path = m.group(1).replace("'", "").replace('"', "")
        filename = os.path.basename(path)
        if os.path.exists(path):
            size_bytes = _gguf_total_size(path)
        if (q := _QUANT_RE.search(path)):
            quant = q.group(1).upper()

    aliases = spec.get("aliases") or []
    if isinstance(aliases, str):
        aliases = [aliases]
    aliases = [str(a) for a in aliases]
    role = aliases[0].lower() if aliases else (name.lower() if name.lower() in ROLE_IDS else None)

    prompt_cache = "--prompt-cache " in cmd or cmd.endswith("--prompt-cache") or "--prompt-cache-all" in cmd
    # Draft-Modell erkennen — klassisch (--spec-draft-model) ODER MTP (--model-draft / -md).
    spec_draft = None
    if (m_draft := re.search(r"--(?:spec-draft-model|model-draft)\s+([^\s]+)", cmd)) \
            or (m_draft := re.search(r"(?<![\w-])-md\s+([^\s]+)", cmd)):
        spec_draft = os.path.basename(m_draft.group(1).replace("'", "").replace('"', ""))
    spec_type = None
    if (m_st := re.search(r"--spec-type\s+([^\s]+)", cmd)):
        spec_type = m_st.group(1)
    # Spec ist nur AKTIV, wenn BEIDES gesetzt ist (Draft-Modell UND --spec-type).
    spec_active = bool(spec_draft and spec_type)
    parallel_match = re.search(r"--parallel\s+(\d+)", cmd)
    parallel_slots = int(parallel_match.group(1)) if parallel_match else 1

    from services.caps import capabilities
    return {
        "name": name,
        "role": role,
        "aliases": aliases,
        "api_ids": [name] + aliases,
        "ctx": ctx,
        "ttl": spec.get("ttl"),
        "cmd": cmd,
        "gguf_path": path,
        "filename": filename,
        "quant": quant,
        "size_bytes": size_bytes,
        "incomplete": not path,
        "prompt_cache": prompt_cache,
        "spec_draft_model": spec_draft,
        "spec_type": spec_type,
        "spec_active": spec_active,
        "parallel_slots": parallel_slots,
        "capabilities": capabilities(
            name=filename or name, cmd=cmd,
            gguf_path=(path if (path and os.path.exists(path)) else ""),
        ),
    }


def list_models() -> list[dict]:
    cfg = read_config()
    return [_parse_model(name, spec) for name, spec in (cfg.get("models") or {}).items()]


def engine_reachable() -> bool:
    try:
        with httpx.Client(timeout=3.0) as c:
            return c.get(f"{LLAMA_SWAP_URL}/v1/models").status_code == 200
    except Exception:
        return False


# --- Schreiben ---------------------------------------------------------------
def model_id_from_path(model_path: str) -> str:
    """Sprechende Modell-ID (= API-Name) aus dem GGUF-Pfad: Repo-Ordnername ohne
    '-GGUF'. Fallback: Dateiname ohne Quant-Suffix.
    Split-GGUFs liegen oft in einem Quant-Unterordner (…/Q4_K_M/file-00001-of-…) →
    dann eine Ebene höher (Repo-Ordner) nehmen, sonst hieße das Modell 'Q4_K_M'."""
    d = os.path.basename(os.path.dirname(model_path))
    if re.fullmatch(r"(I?Q\d[\w]*|UD-Q\d[\w]*|F16|BF16|FP16|F32)", d, flags=re.I):
        d = os.path.basename(os.path.dirname(os.path.dirname(model_path)))
    name = re.sub(r"[-_]?GGUF$", "", d, flags=re.I).strip("-_")
    if not name:
        fn = re.sub(r"\.gguf$", "", os.path.basename(model_path), flags=re.I)
        fn = re.sub(r"-\d+-of-\d+$", "", fn)
        name = re.sub(r"[-_](Q\d[\w]*|IQ\d[\w]*|F16|BF16|FP16|F32)$", "", fn, flags=re.I)
    return name or "modell"


def set_role_alias(cfg: dict, model_id: str, role: str | None) -> None:
    """Rolle als eindeutigen llama-swap-`aliases`-Eintrag setzen (vorher bei allen
    anderen Modellen entfernen). role=None/leer entfernt den Alias."""
    models = cfg.get("models") or {}
    role = (role or "").strip().lower()
    if role:
        for mid, spec in models.items():
            if mid == model_id or not isinstance(spec, dict):
                continue
            al = [a for a in (spec.get("aliases") or []) if str(a).lower() != role]
            if al:
                spec["aliases"] = al
            else:
                spec.pop("aliases", None)
    spec = models.get(model_id)
    if isinstance(spec, dict):
        if role and role != model_id.lower():
            spec["aliases"] = [role]
        else:
            spec.pop("aliases", None)


def _augment_vision(cmd: str, model_path: str, mmproj_path: str | None) -> str:
    """Vision-Modelle brauchen --mmproj <projektor> und --jinja."""
    if mmproj_path:
        if "--mmproj" not in cmd:
            cmd += f" --mmproj {mmproj_path}"
        if "--jinja" not in cmd:
            cmd += " --jinja"
    return cmd


def write_config(cfg: dict) -> None:
    """Atomar schreiben (tmp + os.replace), damit llama-swap mit -watch-config nie
    eine halbe Datei sieht. Fehlende Schreibrechte → klare Meldung."""
    try:
        CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
        tmp = CONFIG_PATH.with_name(CONFIG_PATH.name + ".tmp")
        from ruamel.yaml import YAML
        r_yaml = YAML()
        r_yaml.preserve_quotes = True
        with tmp.open("w", encoding="utf-8") as f:
            r_yaml.dump(cfg, f)
        os.replace(tmp, CONFIG_PATH)
        # llama-swap (-watch-config) lädt jetzt neu und verwirft dabei alle Modelle inkl. Hirn.
        # Den Re-Warm-Wächter anstoßen, damit das Hirn nicht bis zum nächsten Tick kalt liegt.
        try:
            from services import warmer
            warmer.nudge()
        except Exception:  # noqa: BLE001 — Vorwärmen ist Komfort, nie ein Schreib-Blocker
            pass
    except PermissionError as exc:
        raise PermissionError(
            f"Mission Control darf '{CONFIG_PATH}' nicht schreiben. "
            f"Einmalig: sudo chown -R hitonabi:hitonabi {CONFIG_PATH.parent}"
        ) from exc



def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
                   ttl: int | None = None, mmproj_path: str | None = None,
                   jinja: bool = False) -> str:
    """Ein GGUF als llama-swap-Modell eintragen (cmd + Rolle-Alias). Gibt die
    Modell-ID zurück. jinja=True erzwingt --jinja (Tool-Calling, z.B. fürs Agent-Hirn)."""
    cfg = read_config()
    model_id = model_id_from_path(model_path)
    cmd = CMD_TEMPLATE.replace("{model}", model_path).replace("{ctx}", str(ctx))
    cmd = _augment_vision(cmd, model_path, mmproj_path)
    if jinja and "--jinja" not in cmd:
        cmd += " --jinja"
        
    role_lower = (role or "").strip().lower()
    # KV-Cache-Reuse über Turns (Prompt-Cache wiederverwenden) — hilft allen Chat-Modellen
    # (Agent-Hirn, Coding, Multi-Turn). Spiegelt die auf der Box bewährten Flags wider, damit
    # neu installierte Modelle nicht hinter dem hand-getunten Stand zurückbleiben (Drift-Fix).
    # NICHT bei Vision-Modellen: --cache-reuse + --mmproj bricht llama-server (live verifiziert,
    # deshalb fahren vision/scout auf der Box ohne cache-reuse).
    if "--cache-reuse" not in cmd and "--mmproj" not in cmd:
        cmd += " --cache-reuse 256 -cram 16384"
    # IDE-Coding profitiert von Nebenläufigkeit; sonst Default 1 Slot = voller Kontext/Anfrage
    # (--parallel teilt den Kontext HART auf die Slots auf, s. docs/OPTIMIZATION_PLAN.md §9.4 V6).
    if role_lower == "coder" and "--parallel" not in cmd:
        cmd += " --parallel 2"
    # Vocab-kompatiblen Draft automatisch anhängen — klassisch (DRAFTS_DIR) ODER MTP-Kopf neben
    # dem Modell. Self-guarding: ohne kompatiblen/vorhandenen Draft passiert nichts (später im UI
    # setzbar). Bei frischem Install existiert die Modell-GGUF noch nicht → ebenfalls kein Draft.
    if "--spec-draft-model" not in cmd and "--model-draft" not in cmd:
        cmd += spec_draft_flags(model_path)
            
    cfg.setdefault("models", {})[model_id] = {
        "cmd": LiteralScalarString(cmd + "\n"),
        "ttl": ttl if ttl is not None else DEFAULT_TTL,
    }
    set_role_alias(cfg, model_id, role)
    write_config(cfg)
    return model_id


# --- Speculative-Draft / Vocab-Kompatibilität --------------------------------
def list_drafts() -> list[dict]:
    """Alle Draft-GGUFs in DRAFTS_DIR mit Tokenizer-Fingerprint."""
    from services import gguf_meta
    out = []
    if DRAFTS_DIR.is_dir():
        for p in sorted(DRAFTS_DIR.glob("*.gguf")):
            out.append({
                "path": str(p), "filename": p.name,
                "size_bytes": p.stat().st_size if p.exists() else None,
                "vocab": gguf_meta.fingerprint(str(p)),
            })
    return out


def _is_mtp_draft(draft_path: str) -> bool:
    """Ist dieser Draft ein MTP-Kopf (Multi-Token-Prediction) statt eines klassischen
    Draft-Modells? MTP-Köpfe (z.B. gemma-4) laden mit `--model-draft … --spec-type
    draft-mtp` statt `--spec-draft-model … --spec-type draft-simple`. Erkennung am Arch
    ('…-assistant' / 'mtp') oder Dateinamen ('mtp-*', '*-MTP', '*-assistant')."""
    base = os.path.basename(draft_path).lower()
    if base.startswith("mtp-") or "-mtp" in base or "assistant" in base:
        return True
    from services import gguf_meta
    arch = ((gguf_meta.fingerprint(draft_path) or {}).get("arch") or "").lower()
    return arch.endswith("-assistant") or "mtp" in arch


def _spec_flags_for_draft(draft_path: str) -> str:
    """Korrekte llama-server-Spec-Flags für einen (vocab-kompatiblen) Draft. MTP-Kopf →
    `--model-draft … --spec-type draft-mtp --spec-draft-n-max N`; klassischer Draft →
    `--spec-draft-model … --spec-type draft-simple`. (Beides nötig, sonst Spec inaktiv.)"""
    if _is_mtp_draft(draft_path):
        return (f" --model-draft {draft_path} --spec-type draft-mtp"
                f" --spec-draft-n-max {SPEC_DRAFT_N_MAX}")
    return f" --spec-draft-model {draft_path} --spec-type {SPEC_TYPE}"


def _sibling_mtp_drafters(target_path: str) -> list[str]:
    """MTP-Kopf-GGUFs NEBEN dem Zielmodell (gleicher Ordner): 'mtp-*.gguf', '*-MTP.gguf',
    '*-assistant*.gguf'. Per Konstruktion vocab-identisch zum Modell → idealer Draft."""
    out: list[str] = []
    d = os.path.dirname(target_path)
    if os.path.isdir(d):
        for f in sorted(os.listdir(d)):
            fl = f.lower()
            if fl.endswith(".gguf") and (fl.startswith("mtp-") or "-mtp" in fl or "assistant" in fl):
                p = os.path.join(d, f)
                if p != target_path:
                    out.append(p)
    return out


def find_compatible_draft(target_path: str) -> str | None:
    """Pfad eines vocab-kompatiblen Drafts für target_path, oder None.
    Bevorzugt einen MTP-Kopf NEBEN dem Modell (höchste Qualität, by-construction),
    dann MC_SPEC_DRAFT_MODEL (falls gesetzt+kompatibel), sonst der erste kompatible
    Draft in DRAFTS_DIR. None auch, wenn target (noch) fehlt (nicht verifizierbar →
    bewusst KEIN Draft anhängen)."""
    if not target_path or not os.path.exists(target_path):
        return None
    from services import gguf_meta
    candidates: list[str] = list(_sibling_mtp_drafters(target_path))
    if SPEC_DRAFT_MODEL_PATH and os.path.exists(SPEC_DRAFT_MODEL_PATH):
        candidates.append(SPEC_DRAFT_MODEL_PATH)
    for d in list_drafts():
        if d["path"] not in candidates:
            candidates.append(d["path"])
    for c in candidates:
        if gguf_meta.compatible(target_path, c) is True:
            return c
    return None


def spec_draft_flags(target_path: str) -> str:
    """llama-server-Flags für Speculative Decoding (Draft + --spec-type), oder ''
    wenn kein kompatibler Draft existiert. MTP-bewusst (s. _spec_flags_for_draft)."""
    d = find_compatible_draft(target_path)
    return _spec_flags_for_draft(d) if d else ""


def drafts_for(target_path: str) -> dict:
    """Für die UI: alle Drafts + ihre Kompatibilität zum Ziel-Modell. Schließt MTP-Köpfe
    NEBEN dem Zielmodell ein (DRAFTS_DIR kennt sie nicht). `mtp:true` markiert MTP-Drafts.
    compatible=None heißt 'nicht prüfbar' (Ziel- oder Draft-GGUF fehlt)."""
    from services import gguf_meta
    exists = bool(target_path and os.path.exists(target_path))
    drafts = list_drafts()
    seen = {d["path"] for d in drafts}
    for p in _sibling_mtp_drafters(target_path):
        if p not in seen:
            drafts.append({"path": p, "filename": os.path.basename(p),
                           "size_bytes": os.path.getsize(p) if os.path.exists(p) else None,
                           "vocab": gguf_meta.fingerprint(p)})
    for d in drafts:
        d["compatible"] = gguf_meta.compatible(target_path, d["path"]) if exists else None
        d["mtp"] = _is_mtp_draft(d["path"])
    return {
        "target_path": target_path,
        "target_exists": exists,
        "target_vocab": gguf_meta.fingerprint(target_path) if exists else None,
        "drafts": drafts,
    }


def set_spec_draft(model_id: str, draft_path: str | None) -> dict:
    """Setzt (oder entfernt mit draft_path=None) den Spec-Draft eines Modells.
    Validiert die Vocab-Kompatibilität — ein inkompatibler/unprüfbarer Draft wird
    abgelehnt (idiotensicher). Returns {ok, reason}."""
    cfg = read_config()
    spec = (cfg.get("models") or {}).get(model_id)
    if not isinstance(spec, dict):
        return {"ok": False, "reason": "Modell nicht gefunden"}
    cmd = str(spec.get("cmd", ""))
    # vorhandene Spec-Flags entfernen (idempotent) — klassisch UND MTP.
    cmd = re.sub(r"\s+--(?:spec-draft-model|model-draft)\s+\S+", "", cmd)
    cmd = re.sub(r"\s+-md\s+\S+", "", cmd)
    cmd = re.sub(r"\s+--spec-type\s+\S+", "", cmd)
    cmd = re.sub(r"\s+--spec-draft-n-(?:max|min)\s+\S+", "", cmd)

    if draft_path:
        # relative Angabe (nur Dateiname) gegen DRAFTS_DIR auflösen
        if not os.path.isabs(draft_path) and "/" not in draft_path:
            draft_path = str(DRAFTS_DIR / draft_path)
        if not os.path.exists(draft_path):
            return {"ok": False, "reason": "Draft-Datei nicht gefunden"}
        from services import gguf_meta
        target = ""
        if (mt := _PATH_RE.search(cmd)):
            target = mt.group(1).replace("'", "").replace('"', "")
        comp = gguf_meta.compatible(target, draft_path) if os.path.exists(target) else None
        if comp is not True:
            reason = ("Draft ist NICHT vocab-kompatibel zum Modell — Speculative Decoding "
                      "würde beim Laden scheitern."
                      if comp is False else
                      "Kompatibilität nicht prüfbar (Modell-GGUF fehlt) — Draft nicht gesetzt.")
            return {"ok": False, "reason": reason}
        cmd = cmd.rstrip() + _spec_flags_for_draft(draft_path)

    spec["cmd"] = LiteralScalarString(cmd.rstrip() + "\n")
    write_config(cfg)
    return {"ok": True, "reason": ""}


# --- Groups (Ko-Residenz) ----------------------------------------------------
def set_group(group: str, members: list[str], swap: bool = False, persist: bool = False) -> None:
    """llama-swap-`groups`-Eintrag setzen. swap=False → alle Mitglieder dürfen
    GLEICHZEITIG laufen (Ko-Residenz, keine Nachlade-Latenz). persist=True →
    Mitglieder werden nie von anderen Gruppen verdrängt.

    llama-swap kennt dafür AUSSCHLIESSLICH den Key `persistent` — `persist` wird von ihm
    stillschweigend ignoriert (so verlor das Hirn seinen Verdrängungsschutz; live gefunden
    03.07.2026: Coder-Last warf die brains-Gruppe raus). `persist` wird zusätzlich weiter
    geschrieben, weil MC2-API/UI (routers/models.py, agent.py, Frontend) diesen Key lesen."""
    cfg = read_config()
    groups = cfg.setdefault("groups", {})
    groups[group] = {"swap": swap, "persist": persist, "persistent": persist,
                     "members": list(members)}
    # Härtung: eine persistente (immer-warme) Gruppe verlangt ttl:0 je Mitglied.
    # `persistent` schützt NUR gegen Verdrängung durch andere Modelle, NICHT gegen
    # ttl-Selbstentladen — ein Mitglied mit ttl>0 fällt trotz Gruppen-Mitgliedschaft
    # nach Leerlauf aus dem Warm-Set (live gefunden 03.07.2026: VL-30B mit ttl 300
    # entlud sich alle 5 Min). So kann dieser Fehler beim Warm-Set-Umbau nie wieder passieren.
    if persist:
        models = cfg.get("models") or {}
        for mid in members:
            spec = models.get(mid)
            if isinstance(spec, dict):
                spec["ttl"] = 0
    write_config(cfg)


def list_groups() -> dict:
    return read_config().get("groups") or {}


def set_role(model_id: str, role: str | None) -> bool:
    """Rolle (llama-swap-Alias) eines bestehenden Modells setzen/ändern. So tauscht man
    z.B. das `fast`-Hirn: Rolle `fast` auf ein anderes Modell legen (Alias wandert)."""
    cfg = read_config()
    if model_id not in (cfg.get("models") or {}):
        return False
    set_role_alias(cfg, model_id, role)
    write_config(cfg)
    return True


def set_ctx(model_id: str, ctx: int) -> bool:
    """Kontextlänge (-c) eines bestehenden Modells ändern."""
    cfg = read_config()
    spec = (cfg.get("models") or {}).get(model_id)
    if not spec:
        return False
    cmd = str(spec.get("cmd", ""))
    if _CTX_RE.search(cmd):
        cmd = re.sub(r"-(?:c|-ctx-size)\s+\d+", f"-c {ctx}", cmd)
    else:
        cmd = cmd.rstrip() + f" -c {ctx}"
    spec["cmd"] = LiteralScalarString(cmd if cmd.endswith("\n") else cmd + "\n")
    write_config(cfg)
    return True


def set_ttl(model_id: str, ttl: int) -> bool:
    """Idle-TTL (Sekunden) eines bestehenden Modells setzen. ttl=0 → nie automatisch
    entladen (für das Agent-Hirn, das dauerhaft warm bleiben muss)."""
    cfg = read_config()
    spec = (cfg.get("models") or {}).get(model_id)
    if not isinstance(spec, dict):
        return False
    spec["ttl"] = int(ttl)
    write_config(cfg)
    return True


def delete_model(model_id: str) -> bool:
    """Entfernt einen Modell-Eintrag aus der config.yaml, löscht die zugehörigen
    GGUF-Dateien (auch Splits) vom Datenträger und bereinigt leere Ordner.
    """
    cfg = read_config()
    models = cfg.get("models") or {}
    if model_id not in models:
        return False

    model_spec = models[model_id] or {}
    cmd = str(model_spec.get("cmd", "")).strip()
    if (m := _PATH_RE.search(cmd)):
        path = m.group(1).replace("'", "").replace('"', "")
        if path:
            # 1. Haupt-GGUF-Datei löschen
            if os.path.exists(path):
                try:
                    os.remove(path)
                except Exception:
                    pass

            # 2. Split-GGUF-Teile löschen (z.B. dateiname-00001-of-00005.gguf etc.)
            dirname = os.path.dirname(path)
            basename = os.path.basename(path)
            if os.path.isdir(dirname):
                split_idx = basename.find("-00001-of-")
                if split_idx != -1:
                    prefix = basename[:split_idx]
                    for f in os.listdir(dirname):
                        if f.startswith(prefix) and f.endswith(".gguf"):
                            try:
                                os.remove(os.path.join(dirname, f))
                            except Exception:
                                pass

                # mmproj-Datei (Vision adapter) aus dem Befehl parsen & löschen
                if "mmproj" in cmd:
                    mmproj_match = re.search(r'--mmproj\s+[\'"]?([^\s\'"]+)[\'"]?', cmd)
                    if mmproj_match:
                        m_path = mmproj_match.group(1)
                        if os.path.exists(m_path):
                            try:
                                os.remove(m_path)
                            except Exception:
                                pass

                # 3. Eltern-Ordner löschen, falls er leer ist und nicht der Modelle-Wurzelordner selbst ist
                try:
                    if not os.listdir(dirname) and os.path.basename(dirname) != "models":
                        os.rmdir(dirname)
                except Exception:
                    pass

    del models[model_id]
    for g in (cfg.get("groups") or {}).values():
        if isinstance(g, dict) and model_id in (g.get("members") or []):
            g["members"] = [m for m in g["members"] if m != model_id]
    write_config(cfg)
    return True


def brain_model_name() -> str | None:
    """Modellname von Lucys Agent-Hirn. Bevorzugt das Modell mit dem 'hermes'-Alias/-Rolle;
    fällt auf Hermes' aktives `model.default` zurück (deckt den Fall ab, dass die Config direkt
    auf einen Modellnamen statt den Alias zeigt)."""
    models = list_models()
    for m in models:
        names = {str(a).lower() for a in (m.get("aliases") or [])}
        if m.get("role"):
            names.add(str(m["role"]).lower())
        if "hermes" in names:
            return m["name"]
    # Fallback: das real von Hermes genutzte Hirn (model.default), per Alias/Name auflösen.
    try:
        from services.agent import _active_brain_name
        brain = (_active_brain_name() or "").lower()
        if brain and brain != "auto":
            cur = next((m for m in models if (m.get("role") or "").lower() == brain), None) \
                or next((m for m in models if brain in (m["name"] or "").lower()), None)
            if cur:
                return cur["name"]
    except Exception:
        log.debug("brain_model_name: Hermes-Fallback fehlgeschlagen", exc_info=True)
    return None


def brain_status() -> dict:
    """Ist Lucys Agent-Hirn (Rolle 'hermes') WIRKLICH geladen & bereit? Prüft /running — ein
    abgestürztes Modell (z.B. OOM/Crash nach Engine-Update) erscheint dort NICHT als running.
    Fängt damit den Fall 'Engine erreichbar, aber Hirn tot', den engine_reachable() nicht sieht."""
    name = brain_model_name()
    running = get_running_models()
    return {"role": "hermes", "model": name, "ready": bool(name and name in running)}


def get_running_models() -> list[str]:
    """Fragt den /running Endpunkt von llama-swap ab. Gibt die Namen der geladenen
    Modelle zurück. Neuere llama-swap-Versionen liefern Objekte ({model, state, ...})
    statt Strings — beide Formen werden auf Namens-Strings normalisiert."""
    try:
        with httpx.Client(timeout=2.0) as c:
            r = c.get(f"{LLAMA_SWAP_URL}/running")
            if r.status_code == 200:
                data = r.json().get("running") or []
                return [x.get("model", "") if isinstance(x, dict) else x for x in data]
    except Exception:
        log.warning("get_running_models fehlgeschlagen", exc_info=True)
    return []
