"""
Cookbook Router: Verbindet die HuggingFace API mit der Odysseus-Hardware-Berechnung.
"""

import httpx
import json
import os
import re
import time
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
import psutil

from ruamel.yaml.scalarstring import LiteralScalarString

from auth import auth
from hw_math import evaluate_fit, max_ctx_for, extract_params_b
from config import (MODELS_DIR, CMD_TEMPLATE, DEFAULT_TTL, HF_DOWNLOAD_ENV, USER_RECIPES_PATH,
                    DISCOVER_CACHE_PATH, DISCOVER_TTL, hf_bin)
from llamaswap import read_config, write_config, model_id_from_path, set_role_alias
from jobengine import start_job, JOBS, attach_download_progress
from recipes import RECIPES, UPGRADES
from sources import TRUSTED_AUTHORS, CATEGORIES, SKIP_TOKENS

router = APIRouter(prefix="/api/cookbook", dependencies=[Depends(auth)])

_FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2}


# ---------------------------------------------------------------------------
# Eigene Setups (vom Nutzer) — persistente JSON-Datei, gleiches Schema wie RECIPES.
# ---------------------------------------------------------------------------
def load_user_recipes() -> list:
    try:
        if USER_RECIPES_PATH.exists():
            return json.loads(USER_RECIPES_PATH.read_text(encoding="utf-8")) or []
    except Exception:  # noqa: BLE001
        pass
    return []


def save_user_recipes(items: list) -> None:
    USER_RECIPES_PATH.parent.mkdir(parents=True, exist_ok=True)
    tmp = USER_RECIPES_PATH.with_name(USER_RECIPES_PATH.name + ".tmp")
    tmp.write_text(json.dumps(items, ensure_ascii=False, indent=2), encoding="utf-8")
    os.replace(tmp, USER_RECIPES_PATH)


def all_recipes() -> list:
    """Eingebaute + eigene Setups (eigene tragen 'user': True)."""
    return list(RECIPES) + load_user_recipes()

class AnalyzeRequest(BaseModel):
    repo_id: str
    ctx: int = 8192

class EvaluateRequest(BaseModel):
    params_b: float
    quant: str
    ctx: int

class InstallRecipeReq(BaseModel):
    recipe_id: str
    hf_token: str | None = None

class InstallModelReq(BaseModel):
    repo: str
    role: str
    params_b: float
    quant: str = "Q4_K_M"
    file: str | None = None   # konkrete GGUF-Datei; None → _pick_gguf wählt automatisch
    ctx: int | None = None    # None → max_ctx_for (Hardware-Optimum)
    hf_token: str | None = None


class UserRecipeModelReq(BaseModel):
    repo: str
    role: str
    quant: str = "Q4_K_M"
    name: str | None = None
    why: str | None = None


class UserRecipeReq(BaseModel):
    title: str
    desc: str = ""
    icon: str = "box"
    models: list[UserRecipeModelReq]

def extract_quant(filename: str) -> str:
    m = re.search(r"(Q\d_[A-Z0-9_]+|IQ\d_[A-Z0-9_]+|FP16|BF16)", filename, re.IGNORECASE)
    return m.group(1).upper() if m else "Q4_K_M"

@router.post("/analyze")
async def analyze_repo(req: AnalyzeRequest):
    """Holt die GGUF Dateien von HuggingFace und berechnet den Hardware-Fit."""
    url = f"https://huggingface.co/api/models/{req.repo_id}/tree/main"
    
    async with httpx.AsyncClient() as client:
        try:
            resp = await client.get(url, timeout=10.0)
            resp.raise_for_status()
            tree = resp.json()
        except Exception as e:
            raise HTTPException(status_code=500, detail=f"HuggingFace Fehler: {str(e)}")
            
    gguf_files = [f["path"] for f in tree if f.get("path", "").endswith(".gguf")]
    
    if not gguf_files:
        return {"files": []}
        
    params_b = extract_params_b(req.repo_id)
    
    # Ermittle RAM des Systems (da APU = Shared Memory)
    ram_gb = psutil.virtual_memory().total / (1024**3)
    
    results = []
    for f in gguf_files:
        quant = extract_quant(f)
        fit = evaluate_fit(params_b, quant, req.ctx, ram_gb, name=req.repo_id)
        
        # Priority-Score, um den besten Fit an oberste Stelle zu setzen.
        # "Q4_K_M" ist oft der Sweetspot.
        priority = 0
        if fit["level"] == "perfect":
            priority += 10
            if quant == "Q4_K_M": priority += 5
            elif quant.startswith("Q4"): priority += 4
            elif quant.startswith("Q5"): priority += 3
        
        results.append({
            "filename": f,
            "quant": quant,
            "fit": fit,
            "optimal_ctx": max_ctx_for(params_b, quant, ram_gb),
            "priority": priority
        })
        
    # Sortieren: Highest priority first, dann nach tps (schnellste zuerst)
    results.sort(key=lambda x: (x["priority"], x["fit"]["tps"]), reverse=True)
    
    return {
        "repo": req.repo_id,
        "params_b": params_b,
        "sys_ram_gb": round(ram_gb, 1),
        "files": results
    }

@router.post("/evaluate")
def evaluate_single(req: EvaluateRequest):
    ram_gb = psutil.virtual_memory().total / (1024**3)
    fit = evaluate_fit(req.params_b, req.quant, req.ctx, ram_gb)
    fit["optimal_ctx"] = max_ctx_for(req.params_b, req.quant, ram_gb)
    return fit


@router.get("/recipes")
def recipes():
    """Use-Case-Setups mit Hardware-Fit pro Modell + Stack-Gesamturteil. Da llama-swap nur EIN
    Modell gleichzeitig lädt, ist das Stack-Urteil der schlechteste (= größte) Einzel-Fit."""
    ram_gb = psutil.virtual_memory().total / (1024 ** 3)
    out = []
    for r in all_recipes():
        models, worst = [], "perfect"
        for m in r["models"]:
            fit = evaluate_fit(m["params_b"], m["quant"], 8192, ram_gb, name=m.get("name", ""))
            models.append({**m, "fit": fit, "optimal_ctx": max_ctx_for(m["params_b"], m["quant"], ram_gb)})
            if _FIT_ORDER[fit["level"]] > _FIT_ORDER[worst]:
                worst = fit["level"]
        out.append({**r, "models": models, "fit_level": worst, "user": bool(r.get("user"))})
    # „Beste Wahl": das reichste KURATIERTE Setup, das komplett auf die Hardware passt.
    fitting = [r for r in out if not r["user"] and r["fit_level"] != "too_tight"]
    rec = max(fitting, key=lambda r: len(r["models"]), default=None) if fitting else None
    return {"recipes": out, "sys_ram_gb": round(ram_gb, 1), "recommended_id": rec["id"] if rec else None}


@router.post("/user-recipe")
def create_user_recipe(req: UserRecipeReq):
    """Eigenes Cookbook-Setup anlegen. params_b wird aus dem Repo-Namen abgeleitet, damit der
    Nutzer nur Repo + Rolle + Quant angeben muss; Fit/Kontext kommen wie immer zur Laufzeit."""
    if not req.title.strip():
        raise HTTPException(400, "Bitte einen Titel angeben.")
    models = []
    for m in req.models:
        if not m.repo.strip():
            continue
        models.append({
            "role": (m.role or "modell").strip(),
            "name": (m.name or m.repo.split("/")[-1]).strip(),
            "repo": m.repo.strip(),
            "params_b": extract_params_b(m.repo),
            "quant": (m.quant or "Q4_K_M").strip(),
            "why": (m.why or "").strip(),
        })
    if not models:
        raise HTTPException(400, "Mindestens ein Modell (Repo) angeben.")
    items = load_user_recipes()
    base = "user-" + (re.sub(r"[^a-z0-9]+", "-", req.title.lower()).strip("-") or "setup")
    rid, n = base, 2
    existing = {r.get("id") for r in items} | {r["id"] for r in RECIPES}
    while rid in existing:
        rid, n = f"{base}-{n}", n + 1
    items.append({"id": rid, "title": req.title.strip(), "icon": (req.icon or "box").strip(),
                  "desc": req.desc.strip(), "models": models, "user": True})
    save_user_recipes(items)
    return {"ok": True, "id": rid}


@router.put("/user-recipe/{recipe_id}")
def update_user_recipe(recipe_id: str, req: UserRecipeReq):
    """Bestehendes eigenes Setup aktualisieren (Titel, Beschreibung, Modelle)."""
    items = load_user_recipes()
    idx = next((i for i, r in enumerate(items) if r.get("id") == recipe_id), None)
    if idx is None:
        raise HTTPException(404, "Eigenes Setup nicht gefunden.")
    if not req.title.strip():
        raise HTTPException(400, "Bitte einen Titel angeben.")
    models = []
    for m in req.models:
        if not m.repo.strip():
            continue
        models.append({
            "role": (m.role or "modell").strip(),
            "name": (m.name or m.repo.split("/")[-1]).strip(),
            "repo": m.repo.strip(),
            "params_b": extract_params_b(m.repo),
            "quant": (m.quant or "Q4_K_M").strip(),
            "why": (m.why or "").strip(),
        })
    if not models:
        raise HTTPException(400, "Mindestens ein Modell (Repo) angeben.")
    items[idx] = {**items[idx], "title": req.title.strip(), "icon": (req.icon or "box").strip(),
                  "desc": req.desc.strip(), "models": models}
    save_user_recipes(items)
    return {"ok": True, "id": recipe_id}


@router.delete("/user-recipe/{recipe_id}")
def delete_user_recipe(recipe_id: str):
    items = load_user_recipes()
    kept = [r for r in items if r.get("id") != recipe_id]
    if len(kept) == len(items):
        raise HTTPException(404, "Eigenes Setup nicht gefunden.")
    save_user_recipes(kept)
    return {"ok": True}


@router.post("/install-recipe")
def install_recipe(req: InstallRecipeReq):
    """Komplettes Setup installieren: jedes Modell als Download-Job starten UND sofort mit
    optimalem (gedeckeltem) Kontext in die config.yaml eintragen. llama-swap (-watch-config)
    übernimmt es, sobald die Datei da ist."""
    recipe = next((r for r in all_recipes() if r["id"] == req.recipe_id), None)
    if not recipe:
        raise HTTPException(404, "Setup nicht gefunden.")
    ram_gb = psutil.virtual_memory().total / (1024 ** 3)
    env = dict(HF_DOWNLOAD_ENV)
    if req.hf_token:
        env["HF_TOKEN"] = req.hf_token
    cfg = read_config()
    job_ids = []
    for m in recipe["models"]:
        file = _pick_gguf(m["repo"], m.get("quant", "Q4_K_M"))
        if not file:
            continue  # kein GGUF im Repo gefunden -> Modell ueberspringen (Rest installiert trotzdem)
        # Vision-Modelle: mmproj-Projektor mitholen (nötig für --mmproj in llama.cpp).
        mmproj = _pick_mmproj(m["repo"])
        target = MODELS_DIR / m["repo"].split("/")[-1]
        target.mkdir(parents=True, exist_ok=True)
        args = [hf_bin(), "download", m["repo"], file]
        if mmproj:
            args.append(mmproj)   # hf-cli unterstützt mehrere Dateien in einem Aufruf
        args += ["--local-dir", str(target)]
        jid = start_job(args, f"download {m['name']}", env=env)
        JOBS[jid]["result_path"] = str(target / file)
        attach_download_progress(jid, str(target), hf_file_size(m["repo"], file))
        job_ids.append(jid)
        # Eintrag jetzt schon schreiben — optimaler Kontext für die Hardware.
        ctx = max_ctx_for(m["params_b"], m["quant"], ram_gb)
        path = str(target / file)
        cmd = CMD_TEMPLATE.replace("{model}", path).replace("{ctx}", str(ctx))
        if mmproj:
            cmd = cmd.rstrip() + f" --mmproj {target / mmproj} --jinja"
        # Schluessel = sprechender Modellname; Rolle (aus dem Rezept) als Alias.
        mid = model_id_from_path(path)
        cfg["models"][mid] = {"cmd": LiteralScalarString(cmd + "\n"), "ttl": DEFAULT_TTL}
        set_role_alias(cfg, mid, m["role"])
    write_config(cfg)
    return {"job_ids": job_ids, "count": len(job_ids)}


def hf_file_size(repo: str, file: str) -> int:
    """Groesse einer Datei im HF-Repo (Bytes) fuer die Fortschrittsanzeige. 0 wenn unbekannt.
    GGUFs sind LFS -> ggf. unter 'lfs.size'."""
    try:
        with httpx.Client(timeout=10.0) as c:
            tree = c.get(f"https://huggingface.co/api/models/{repo}/tree/main").json()
        for f in tree:
            if isinstance(f, dict) and f.get("path") == file:
                return int(f.get("size") or (f.get("lfs") or {}).get("size") or 0)
    except Exception:  # noqa: BLE001
        return 0
    return 0


def _pick_gguf(repo: str, quant: str = "Q4_K_M") -> str | None:
    """Beste GGUF-Datei eines Repos auflösen: bevorzugt gewünschten Quant, keine Split-Teile."""
    try:
        with httpx.Client(timeout=10.0) as c:
            tree = c.get(f"https://huggingface.co/api/models/{repo}/tree/main").json()
    except Exception:  # noqa: BLE001
        return None
    ggufs = [f["path"] for f in tree if isinstance(f, dict) and str(f.get("path", "")).endswith(".gguf")]
    if not ggufs:
        return None
    pref = [g for g in ggufs if quant.lower() in g.lower() and "-of-" not in g]
    nosplit = [g for g in ggufs if "-of-" not in g]
    return (pref or nosplit or ggufs)[0]


def _pick_mmproj(repo: str) -> str | None:
    """Sucht den mmproj-Projektor (Vision-Modelle) im Repo — F16/BF16 bevorzugt.
    Gibt None zurueck, wenn kein Projektor vorhanden (= kein Vision-Modell)."""
    try:
        with httpx.Client(timeout=10.0) as c:
            tree = c.get(f"https://huggingface.co/api/models/{repo}/tree/main").json()
    except Exception:  # noqa: BLE001
        return None
    candidates = [
        f["path"] for f in tree
        if isinstance(f, dict) and "mmproj" in str(f.get("path", "")).lower()
        and str(f.get("path", "")).endswith(".gguf")
    ]
    if not candidates:
        return None
    # F16/BF16 hat die beste Projektor-Qualität; nimm das erste wenn keins passt.
    pref = [c for c in candidates if "f16" in c.lower() or "bf16" in c.lower()]
    return (pref or candidates)[0]


# ---------------------------------------------------------------------------
# Automatische Modell-Entdeckung ("aktuell beste Modelle", live von HF + Cache).
# ---------------------------------------------------------------------------
def _categorize(repo_id: str) -> str:
    low = repo_id.lower()
    for cat in CATEGORIES:
        if any(k in low for k in cat["kw"]):
            return cat["role"]
    return "scout"  # Fallback: Allrounder


def _installed_index() -> dict:
    """alias -> kleingeschriebene cmd-Zeile der installierten Modelle (fuer Bestandsabgleich)."""
    return {alias: str(spec.get("cmd", "")).lower()
            for alias, spec in (read_config().get("models") or {}).items()}


def _model_installed(repo: str, idx: dict) -> str | None:
    """Liefert den Alias, unter dem dieses Repo schon installiert ist — sonst None."""
    base = repo.split("/")[-1].lower()
    # GGUF-Repos heissen meist '<modell>-GGUF' -> auch ohne Suffix vergleichen
    stem = base[:-5] if base.endswith("-gguf") else base
    for alias, cmd in idx.items():
        if base in cmd or (stem and stem in cmd):
            return alias
    return None


def _model_requirements(role: str, quant: str, fit: dict) -> list[str]:
    """Ehrliche Voraussetzungen/Hinweise pro Modell (Klartext fuer Anfaenger)."""
    reqs = []
    if role == "vision":
        reqs.append("Bild-Modell: Projektor (mmproj-F16) wird automatisch mitgeladen und --jinja gesetzt.")
    if quant.upper() in ("FP16", "BF16", "F16", "F32"):
        reqs.append("Unquantisiert — sehr groß. Eine Q4/Q5-Variante ist meist die bessere Wahl.")
    if fit["level"] == "too_tight":
        reqs.append("Größer als dein Speicher — nur mit kleinerem Quant realistisch.")
    elif fit["level"] == "marginal":
        reqs.append("Läuft, wird aber knapp — andere Programme währenddessen besser schließen.")
    return reqs


def _fetch_author_models(author: str) -> list:
    url = (f"https://huggingface.co/api/models?author={author}"
           f"&filter=gguf&sort=downloads&direction=-1&limit=40")
    try:
        with httpx.Client(timeout=12.0) as c:
            data = c.get(url).json()
        return data if isinstance(data, list) else []
    except Exception:  # noqa: BLE001
        return []


def refresh_discover(ram_gb: float) -> dict:
    """Quellen live abfragen, kategorisieren, nach Hardware-Fit + Beliebtheit ranken
    und cachen. Wirft nur, wenn KEINE einzige Quelle erreichbar war."""
    raw, seen, ok = [], set(), 0
    for author in TRUSTED_AUTHORS:
        models = _fetch_author_models(author)
        if models:
            ok += 1
        for m in models:
            rid = m.get("id")
            if not rid or rid in seen:
                continue
            seen.add(rid)
            raw.append(m)
    if ok == 0:
        raise RuntimeError("Keine Quelle erreichbar.")

    by_cat = {c["role"]: [] for c in CATEGORIES}
    for m in raw:
        rid = m["id"]
        low = rid.lower()
        if any(tok in low for tok in SKIP_TOKENS):
            continue
        role = _categorize(rid)
        params_b = extract_params_b(rid)
        quant = "Q4_K_M"  # Referenz-Quant fuer die Fit-Einschaetzung
        fit = evaluate_fit(params_b, quant, 8192, ram_gb, name=rid)
        by_cat[role].append({
            "name": rid.split("/")[-1], "author": rid.split("/")[0], "repo": rid,
            "role": role, "params_b": params_b, "quant": quant,
            "downloads": int(m.get("downloads") or 0), "likes": int(m.get("likes") or 0),
            "lastModified": m.get("lastModified"),
            "fit": fit, "optimal_ctx": max_ctx_for(params_b, quant, ram_gb),
        })

    cats = []
    for c in CATEGORIES:
        items = by_cat[c["role"]]
        # Passendes zuerst (perfect/marginal vor too_tight), dann nach Downloads.
        items.sort(key=lambda x: (x["fit"]["level"] != "too_tight", x["downloads"]), reverse=True)
        top = items[:4]
        if top:
            cats.append({"role": c["role"], "title": c["title"], "icon": c["icon"], "models": top})

    data = {"updated": time.time(), "categories": cats}
    try:
        DISCOVER_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
        tmp = DISCOVER_CACHE_PATH.with_name(DISCOVER_CACHE_PATH.name + ".tmp")
        tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
        os.replace(tmp, DISCOVER_CACHE_PATH)
    except Exception:  # noqa: BLE001
        pass  # Cache ist nur Beschleunigung — Entdeckung funktioniert auch ohne Schreibrecht
    return data


def _load_discover() -> dict | None:
    try:
        if DISCOVER_CACHE_PATH.exists():
            return json.loads(DISCOVER_CACHE_PATH.read_text(encoding="utf-8"))
    except Exception:  # noqa: BLE001
        pass
    return None


@router.get("/discover")
def discover(force: bool = False):
    """Aktuell beste Modelle je Kategorie — live aus den Quellen (gecacht, TTL).
    Markiert pro Modell, ob es schon installiert ist, und nennt Voraussetzungen."""
    ram_gb = psutil.virtual_memory().total / (1024 ** 3)
    cached = _load_discover()
    fresh = bool(cached) and (time.time() - cached.get("updated", 0) < DISCOVER_TTL)
    if fresh and not force:
        data = cached
    else:
        try:
            data = refresh_discover(ram_gb)
        except Exception:  # noqa: BLE001
            if cached:
                data = cached  # Quellen down -> alter Cache ist besser als nichts
            else:
                raise HTTPException(502, "Modell-Quellen gerade nicht erreichbar — später erneut versuchen.")
    # Bestand + Voraussetzungen frisch dazurechnen (aendern sich unabhaengig vom Cache).
    idx = _installed_index()
    out = []
    for c in data["categories"]:
        ms = [{**m, "installed": _model_installed(m["repo"], idx),
               "requirements": _model_requirements(m["role"], m.get("quant", "Q4_K_M"), m["fit"])}
              for m in c["models"]]
        # „Beste Wahl" je Kategorie: das lauffähige Modell mit dem besten Fit (perfect vor
        # marginal), bei Gleichstand das meistgeladene. Zu große Modelle kommen nie in Frage.
        ranked = sorted([m for m in ms if m["fit"]["level"] != "too_tight"],
                        key=lambda m: (_FIT_ORDER[m["fit"]["level"]], -int(m.get("downloads") or 0)))
        out.append({**c, "models": ms, "recommended": ranked[0]["repo"] if ranked else None})
    return {"updated": data["updated"], "categories": out,
            "sys_ram_gb": round(ram_gb, 1), "stale": not fresh}


def compute_upgrades(ram_gb):
    """Liste relevanter Modell-Upgrades für die installierten Modelle (UPGRADES-Map)."""
    installed = (read_config().get("models") or {})
    out, seen = [], set()
    for up in UPGRADES:
        new_base = up["repo"].split("/")[-1].lower()
        if any(new_base in str(s.get("cmd", "")).lower() for s in installed.values()):
            continue  # neueres Modell schon installiert
        old = None
        for alias, spec in installed.items():
            hay = (alias + " " + str(spec.get("cmd", ""))).lower()
            if any(k in hay for k in up["match"]):
                old = alias
                break
        if not old or up["repo"] in seen:
            continue
        seen.add(up["repo"])
        out.append({
            "name": up["name"], "repo": up["repo"], "params_b": up["params_b"], "quant": up["quant"],
            "why": up["why"], "old": old, "role": old,
            "fit": evaluate_fit(up["params_b"], up["quant"], 8192, ram_gb, name=up.get("name", "")),
            "optimal_ctx": max_ctx_for(up["params_b"], up["quant"], ram_gb),
        })
    return out


@router.get("/upgrades")
def upgrades():
    return {"upgrades": compute_upgrades(psutil.virtual_memory().total / (1024 ** 3))}


@router.post("/install-model")
def install_model(req: InstallModelReq):
    """Ein einzelnes Modell installieren (Download + Einpflegen unter 'role', optimaler ctx).
    Wenn 'file' übergeben wird, wird genau diese GGUF-Datei geladen (Profi-Suche-Auswahl);
    andernfalls wählt _pick_gguf automatisch die beste Variante für den gewünschten Quant."""
    file = req.file or _pick_gguf(req.repo, req.quant)
    if not file:
        raise HTTPException(404, "Keine GGUF-Datei im Repo gefunden.")
    mmproj = _pick_mmproj(req.repo)
    ram_gb = psutil.virtual_memory().total / (1024 ** 3)
    env = dict(HF_DOWNLOAD_ENV)
    if req.hf_token:
        env["HF_TOKEN"] = req.hf_token
    target = MODELS_DIR / req.repo.split("/")[-1]
    target.mkdir(parents=True, exist_ok=True)
    args = [hf_bin(), "download", req.repo, file]
    if mmproj:
        args.append(mmproj)
    args += ["--local-dir", str(target)]
    jid = start_job(args, f"download {req.repo.split('/')[-1]}", env=env)
    JOBS[jid]["result_path"] = str(target / file)
    attach_download_progress(jid, str(target), hf_file_size(req.repo, file))
    cfg = read_config()
    ctx = req.ctx if req.ctx else max_ctx_for(req.params_b, req.quant, ram_gb)
    path = str(target / file)
    cmd = CMD_TEMPLATE.replace("{model}", path).replace("{ctx}", str(ctx))
    if mmproj:
        cmd = cmd.rstrip() + f" --mmproj {target / mmproj} --jinja"
    mid = model_id_from_path(path)
    cfg["models"][mid] = {"cmd": LiteralScalarString(cmd + "\n"), "ttl": DEFAULT_TTL}
    set_role_alias(cfg, mid, req.role)
    write_config(cfg)
    return {"job_id": jid, "model_id": mid}

