#!/usr/bin/env python3
# SkillFish AI - KDE-native panel + first-run setup wizard for the local LLM
# stack (Ollama + Vulkan). Auto-themed by Kvantum/Breeze. Bilingual (IT/EN).
#
# Features:
#  - First-run wizard: installs/starts the stack and lets you pick a model.
#  - Model manager (also after the wizard): list installed, pull new, set active.
#  - Live hardware panel: GPU + CPU model, VRAM and RAM available.
#  - GTT slider: tune how much system RAM the GPU/LLM may use (amdgpu.gttsize).
#  - On the generic OS build the GPU is auto-detected (unknown hardware).
import sys, os, json, subprocess, shutil, urllib.request
from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal
from PyQt6.QtGui import QIcon, QPixmap
from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
                             QLabel, QGroupBox, QCheckBox, QPushButton, QComboBox, QSlider,
                             QProgressBar, QWizard, QWizardPage, QTextEdit, QMessageBox,
                             QGridLayout, QLineEdit)

STACK_DIR  = "/opt/stacks/skillfish-ai"
COMPOSE_NAMES = ("compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml")
CONTAINER  = "skillfish-ollama"
OLLAMA_API = "http://localhost:11434"
WEBUI_URL  = "http://localhost:8080"
DOCKGE_URL = "http://localhost:5001"
ICON       = "/usr/share/icons/hicolor/256x256/apps/skillfish-ai.png"
CFG_DIR    = os.path.expanduser("~/.config/skillfishos")
CFG_FILE   = os.path.join(CFG_DIR, "ai.json")

# Curated models that run well on the BC-250 (16 GB shared) and most iGPUs.
# Custom names are accepted too. (Ollama has no stable public library API, so we
# pair this curated catalogue with a live query of what is already installed.)
CATALOG = [
    # --- reasoning / general chat (<= 14B, all fit the BC-250's shared memory) ---
    ("qwen3:14b",            "~9 GB  · default, strong reasoning"),
    ("qwen3:8b",             "~5.2 GB · reasoning, lighter"),
    ("qwen3:4b",             "~2.6 GB · fast reasoning"),
    ("qwen3:1.7b",           "~1.4 GB · tiny reasoning"),
    ("qwen2.5:14b",          "~9 GB  · all-rounder"),
    ("qwen2.5:7b",           "~4.7 GB · fast all-rounder"),
    ("qwen2.5:3b",           "~1.9 GB · light"),
    ("qwen2.5:1.5b",         "~1 GB  · very light"),
    ("llama3.1:8b",          "~4.9 GB · Meta, general"),
    ("llama3.2:3b",          "~2 GB  · light & quick"),
    ("llama3.2:1b",          "~1.3 GB · ultra light"),
    ("gemma2:9b",            "~5.4 GB · Google"),
    ("gemma2:2b",            "~1.6 GB · Google, light"),
    ("gemma3:12b",           "~8 GB  · Google, multimodal"),
    ("gemma3:4b",            "~3.3 GB · Google, multimodal"),
    ("phi4:14b",             "~9 GB  · Microsoft, sharp"),
    ("phi3.5:3.8b",          "~2.2 GB · tiny, sharp"),
    ("mistral:7b",           "~4.1 GB · classic"),
    ("mistral-nemo:12b",     "~7 GB  · 128k context"),
    ("deepseek-r1:14b",      "~9 GB  · reasoning (R1)"),
    ("deepseek-r1:8b",       "~5 GB  · reasoning (R1)"),
    ("deepseek-r1:7b",       "~4.7 GB · reasoning (R1)"),
    ("deepseek-r1:1.5b",     "~1.1 GB · reasoning, tiny"),
    ("granite3.1-dense:8b",  "~4.9 GB · IBM, business"),
    ("olmo2:13b",            "~8 GB  · open, AllenAI"),
    ("smollm2:1.7b",         "~1 GB  · very small"),
    # --- coding ---
    ("qwen2.5-coder:14b",    "~9 GB  · coding (best)"),
    ("qwen2.5-coder:7b",     "~4.7 GB · coding"),
    ("qwen2.5-coder:3b",     "~1.9 GB · coding, light"),
    ("codellama:13b",        "~7.4 GB · coding"),
    ("codellama:7b",         "~3.8 GB · coding"),
    ("starcoder2:7b",        "~4 GB  · coding"),
    ("starcoder2:3b",        "~1.7 GB · coding, light"),
    # --- vision (image input) ---
    ("llama3.2-vision:11b",  "~7.8 GB · vision"),
    ("llava:13b",            "~8 GB  · vision"),
    ("llava:7b",             "~4.7 GB · vision"),
    ("minicpm-v:8b",         "~5.5 GB · vision, OCR"),
    # --- embeddings (for RAG / web search) ---
    ("nomic-embed-text",     "~0.3 GB · embeddings"),
]

def _detect_lang():
    val = (os.environ.get("LC_ALL") or os.environ.get("LC_MESSAGES")
           or os.environ.get("LANG") or os.environ.get("LANGUAGE") or "")
    return "it" if val.lower().startswith("it") else "en"
LANG = _detect_lang()
def L(it, en): return it if LANG == "it" else en

def sh(cmd, t=60):
    try:
        r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=t)
        return r.returncode, (r.stdout or "").strip(), (r.stderr or "").strip()
    except Exception as e:
        return 1, "", str(e)

# ---------- config ----------
def load_cfg():
    try:
        with open(CFG_FILE) as f: return json.load(f)
    except Exception:
        return {}
def save_cfg(d):
    os.makedirs(CFG_DIR, exist_ok=True)
    try:
        with open(CFG_FILE, "w") as f: json.dump(d, f, indent=2)
    except Exception:
        pass  # best-effort: config save is non-critical

# ---------- hardware ----------
def cpu_model():
    try:
        with open("/proc/cpuinfo") as f:
            for line in f:
                if line.startswith("model name"):
                    return line.split(":", 1)[1].strip()
    except Exception:
        pass  # best-effort: /proc/cpuinfo unreadable
    return "?"

def gpu_model():
    rc, out, _ = sh(r"lspci -nn 2>/dev/null | grep -E 'VGA|3D|Display' | head -1", 8)
    if out:
        # strip the leading 'xx:xx.x VGA compatible controller: '
        if ":" in out:
            out = out.split(":", 2)[-1].strip() if out.count(":") >= 2 else out
        return out
    return L("sconosciuta", "unknown")

def _read_int(path):
    try:
        with open(path) as f:
            return int(f.read().strip())
    except Exception:
        return None

def _drm_device():
    import glob
    for p in sorted(glob.glob("/sys/class/drm/card[0-9]/device")):
        if os.path.exists(os.path.join(p, "mem_info_vram_total")):
            return p
    return None

def vram_info():
    d = _drm_device()
    if not d: return None
    tot = _read_int(os.path.join(d, "mem_info_vram_total"))
    used = _read_int(os.path.join(d, "mem_info_vram_used"))
    if tot is None: return None
    return (tot, used or 0)

def gtt_info():
    d = _drm_device()
    tot = used = None
    if d:
        tot = _read_int(os.path.join(d, "mem_info_gtt_total"))
        used = _read_int(os.path.join(d, "mem_info_gtt_used"))
    # current boot value of amdgpu.gttsize (MB)
    cur_mb = None
    try:
        with open("/proc/cmdline") as f:
            for tok in f.read().split():
                if tok.startswith("amdgpu.gttsize="):
                    cur_mb = int(tok.split("=", 1)[1])
    except Exception:
        pass  # best-effort: cmdline unreadable
    return tot, used, cur_mb

def ram_info():
    tot = avail = None
    try:
        with open("/proc/meminfo") as f:
            for line in f:
                if line.startswith("MemTotal"):     tot = int(line.split()[1]) * 1024
                elif line.startswith("MemAvailable"): avail = int(line.split()[1]) * 1024
    except Exception:
        pass  # best-effort: meminfo unreadable
    return tot, avail

def gb(n): return "?" if n is None else f"{n/1e9:.1f} GB"

# ---------- stack / ollama ----------
def docker_ok():
    return shutil.which("docker") is not None

def compose_file():
    for n in COMPOSE_NAMES:
        p = os.path.join(STACK_DIR, n)
        if os.path.exists(p): return p
    return os.path.join(STACK_DIR, "compose.yaml")  # default target to create

def stack_present():
    return any(os.path.exists(os.path.join(STACK_DIR, n)) for n in COMPOSE_NAMES)

def engine_running():
    rc, out, _ = sh(f"docker inspect -f '{{{{.State.Running}}}}' {CONTAINER}", 8)
    return out == "true"

def installed_models():
    # prefer the HTTP API (works while the engine is up)
    try:
        with urllib.request.urlopen(OLLAMA_API + "/api/tags", timeout=4) as r:
            data = json.load(r)
            return sorted(m["name"] for m in data.get("models", []))
    except Exception:
        pass
    rc, out, _ = sh(f"docker exec {CONTAINER} ollama list", 10)
    names = []
    for line in out.splitlines()[1:]:
        if line.strip(): names.append(line.split()[0])
    return sorted(names)

def docker_image_present():
    rc, out, _ = sh("docker images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null", 10)
    return any(k in out for k in ("ollama", "skillfish-ollama"))

def container_exists():
    rc, out, _ = sh("docker ps -a --format '{{.Names}}' 2>/dev/null", 8)
    return CONTAINER in out.split()

def stack_installed():
    """True when the stack is really installed (compose + docker + image/container).
    The first-run wizard is shown ONLY when this is False."""
    return stack_present() and docker_ok() and (docker_image_present() or container_exists())

def is_configured():
    # wizard appears only if the stack is NOT already installed
    return stack_installed()

DEFAULT_COMPOSE = """services:
  ollama:
    image: ollama/ollama:rocm
    container_name: skillfish-ollama
    restart: unless-stopped
    devices:
      - /dev/kfd
      - /dev/dri
    volumes:
      - ./ollama_data:/root/.ollama
    ports:
      - "11434:11434"
  open-webui:
    image: ghcr.io/open-webui/open-webui:main
    container_name: skillfish-openwebui
    restart: unless-stopped
    depends_on:
      - ollama
    environment:
      - OLLAMA_BASE_URL=http://ollama:11434
    volumes:
      - ./openwebui_data:/app/backend/data
    ports:
      - "8080:8080"
"""

# ---------- worker threads ----------
class Worker(QThread):
    line = pyqtSignal(str)
    done = pyqtSignal(int)
    def __init__(self, cmd, t=1800):
        super().__init__(); self.cmd = cmd; self.t = t
    def run(self):
        try:
            p = subprocess.Popen(self.cmd, shell=True, stdout=subprocess.PIPE,
                                 stderr=subprocess.STDOUT, text=True, bufsize=1)
            for ln in iter(p.stdout.readline, ""):
                if ln: self.line.emit(ln.rstrip())
            p.wait(self.t); self.done.emit(p.returncode)
        except Exception as e:
            self.line.emit(str(e)); self.done.emit(1)


# ======================= SETUP WIZARD =======================
class SetupWizard(QWizard):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setWindowTitle(L("SkillFish AI - Configurazione", "SkillFish AI - Setup"))
        self.setWindowIcon(QIcon(ICON))
        self.resize(680, 520)
        self.setWizardStyle(QWizard.WizardStyle.ModernStyle)
        self.chosen_model = None
        self.addPage(self._welcome())
        self.install_page = InstallPage(); self.addPage(self.install_page)
        self.model_page = ModelPage(self); self.addPage(self.model_page)
        self.addPage(self._finish())

    def _welcome(self):
        p = QWizardPage(); p.setTitle(L("Benvenuto in SkillFish AI", "Welcome to SkillFish AI"))
        v = QVBoxLayout(p)
        t = QLabel(L("Questa procedura installa e avvia il motore LLM locale (Ollama + "
                     "Vulkan) e ti fa scegliere un modello da scaricare.\n\nTutto gira "
                     "in locale sulla GPU: nessun dato lascia il PC.",
                     "This wizard installs and starts the local LLM engine (Ollama + "
                     "Vulkan) and lets you choose a model to download.\n\nEverything runs "
                     "locally on the GPU: no data leaves your PC."))
        t.setWordWrap(True); v.addWidget(t)
        # hardware summary
        gm = gpu_model(); cm = cpu_model(); rt, ra = ram_info()
        hw = QLabel(L(f"GPU rilevata: {gm}\nCPU: {cm}\nRAM: {gb(ra)} libera / {gb(rt)}",
                      f"Detected GPU: {gm}\nCPU: {cm}\nRAM: {gb(ra)} free / {gb(rt)}"))
        hw.setStyleSheet("color:#b9a07a;"); hw.setWordWrap(True); v.addWidget(hw)
        v.addStretch(1)
        return p

    def _finish(self):
        p = QWizardPage(); p.setTitle(L("Fatto!", "All set!"))
        v = QVBoxLayout(p)
        v.addWidget(QLabel(L("Il motore AI è pronto. Puoi cambiare modello e regolare la "
                             "memoria GPU (GTT) dal pannello in qualsiasi momento.",
                             "The AI engine is ready. You can switch models and tune the "
                             "GPU memory (GTT) from the panel anytime.")))
        v.addStretch(1)
        return p

class InstallPage(QWizardPage):
    def __init__(self):
        super().__init__()
        self.setTitle(L("Installazione dello stack", "Installing the stack"))
        self.ok = False
        v = QVBoxLayout(self)
        self.info = QLabel(L("Premi «Installa» per preparare docker, lo stack e l'immagine "
                             "Ollama-Vulkan.", "Press “Install” to set up docker, the stack "
                             "and the Ollama-Vulkan image."))
        self.info.setWordWrap(True); v.addWidget(self.info)
        self.btn = QPushButton(L("Installa / Avvia", "Install / Start")); self.btn.clicked.connect(self.run)
        v.addWidget(self.btn)
        self.bar = QProgressBar(); self.bar.setRange(0, 0); self.bar.hide(); v.addWidget(self.bar)
        self.log = QTextEdit(); self.log.setReadOnly(True); self.log.setMinimumHeight(220)
        v.addWidget(self.log)
        self._w = None
    def isComplete(self): return self.ok
    def _append(self, s): self.log.append(s)
    def run(self):
        self.btn.setEnabled(False); self.bar.show()
        steps = []
        if not docker_ok():
            steps.append("pkexec sh -c 'apt-get update && apt-get install -y docker.io docker-compose-v2 && systemctl enable --now docker'")
        steps.append(f"pkexec mkdir -p {STACK_DIR}" if not os.access(os.path.dirname(STACK_DIR), os.W_OK) else f"mkdir -p {STACK_DIR}")
        if not stack_present():
            # write a default compose if the SkillFishOS one is absent (needs root for /opt)
            tgt = os.path.join(STACK_DIR, "compose.yaml")
            steps.append("pkexec sh -c " + repr(f"cat > {tgt} <<'EOF'\n{DEFAULT_COMPOSE}EOF\n"))
        steps.append(f"sh -c 'cd {STACK_DIR} && docker compose up -d'")
        self._queue = steps; self._next()
    def _next(self):
        if not self._queue:
            self._append(L("\n✓ Stack avviato.", "\n✓ Stack started."))
            self.bar.hide(); self.ok = True; self.completeChanged.emit(); return
        cmd = self._queue.pop(0)
        self._append("$ " + (cmd[:90] + ("…" if len(cmd) > 90 else "")))
        self._w = Worker(cmd); self._w.line.connect(self._append)
        self._w.done.connect(self._step_done); self._w.start()
    def _step_done(self, rc):
        if rc != 0:
            self._append(L(f"! comando fallito (rc={rc}) — continuo",
                           f"! command failed (rc={rc}) — continuing"))
        self._next()

class ModelPage(QWizardPage):
    def __init__(self, wiz):
        super().__init__(); self.wiz = wiz
        self.setTitle(L("Scegli un modello", "Choose a model"))
        self.ok = False
        v = QVBoxLayout(self)
        v.addWidget(QLabel(L("Seleziona un modello da scaricare (o scrivine uno qualsiasi "
                             "dalla libreria Ollama):", "Pick a model to download (or type "
                             "any name from the Ollama library):")))
        self.combo = QComboBox()
        for name, desc in CATALOG: self.combo.addItem(f"{name}   —   {desc}", name)
        self.combo.setEditable(False); v.addWidget(self.combo)
        cr = QHBoxLayout()
        cr.addWidget(QLabel(L("oppure nome libero:", "or custom name:")))
        self.custom = QLineEdit(); self.custom.setPlaceholderText("es. gemma3:12b")
        cr.addWidget(self.custom); v.addLayout(cr)
        self.btn = QPushButton(L("Scarica modello", "Download model")); self.btn.clicked.connect(self.pull)
        v.addWidget(self.btn)
        self.bar = QProgressBar(); self.bar.setRange(0, 0); self.bar.hide(); v.addWidget(self.bar)
        self.log = QTextEdit(); self.log.setReadOnly(True); self.log.setMinimumHeight(150); v.addWidget(self.log)
        self._w = None
    def isComplete(self): return self.ok
    def pull(self):
        name = self.custom.text().strip() or self.combo.currentData()
        if not name: return
        self.wiz.chosen_model = name
        self.btn.setEnabled(False); self.bar.show(); self.log.append(L(f"Scarico {name}…", f"Pulling {name}…"))
        self._w = Worker(f"docker exec {CONTAINER} ollama pull {name}", 3600)
        self._w.line.connect(lambda s: self.log.append(s))
        self._w.done.connect(self._done); self._w.start()
    def _done(self, rc):
        self.bar.hide(); self.btn.setEnabled(True)
        if rc == 0:
            cfg = load_cfg(); cfg["model"] = self.wiz.chosen_model; cfg["setup_done"] = True; save_cfg(cfg)
            self.log.append(L("✓ Modello pronto.", "✓ Model ready.")); self.ok = True; self.completeChanged.emit()
        else:
            self.log.append(L("! download fallito", "! download failed"))


# ======================= MAIN PANEL =======================
class Panel(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("SkillFish AI"); self.setWindowIcon(QIcon(ICON))
        self.resize(620, 660); self.busy = False; self._t = None
        cfg = load_cfg(); self.model = cfg.get("model", "qwen3:14b")

        w = QWidget(); self.setCentralWidget(w)
        v = QVBoxLayout(w); v.setContentsMargins(20, 16, 20, 16); v.setSpacing(12)

        # header
        h = QHBoxLayout()
        img = QLabel(); pm = QPixmap(ICON)
        if not pm.isNull():
            img.setPixmap(pm.scaled(40, 40, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
        h.addWidget(img)
        tcol = QVBoxLayout(); tcol.setSpacing(0)
        title = QLabel("SkillFish AI"); title.setStyleSheet("font-size:20px;font-weight:800;color:#e8c878;")
        self.sub = QLabel(""); self.sub.setStyleSheet("color:#b9a07a;")
        tcol.addWidget(title); tcol.addWidget(self.sub)
        h.addLayout(tcol); h.addStretch(1); v.addLayout(h)

        # engine switch
        g = QGroupBox(L("Motore AI (LLM)", "AI Engine (LLM)")); gv = QVBoxLayout(g)
        self.cb = QCheckBox(L("Acceso — accende/spegne Ollama. Spegnilo prima di giocare.",
                              "On — starts/stops Ollama. Turn it off before gaming."))
        self.cb.toggled.connect(self.on_toggle); gv.addWidget(self.cb)
        self.status = QLabel("…"); self.status.setWordWrap(True); gv.addWidget(self.status)
        v.addWidget(g)

        # model manager
        gm = QGroupBox(L("Modello", "Model")); mv = QGridLayout(gm)
        self.modelbox = QComboBox(); self.modelbox.setEditable(True)
        self.modelbox.lineEdit().setPlaceholderText(L("modello attivo / nome da scaricare", "active model / name to pull"))
        mv.addWidget(self.modelbox, 0, 0, 1, 2)
        self.bset = QPushButton(L("Usa", "Use")); self.bset.clicked.connect(self.set_active)
        self.bpull = QPushButton(L("Scarica", "Pull")); self.bpull.clicked.connect(self.pull_model)
        mv.addWidget(self.bset, 0, 2); mv.addWidget(self.bpull, 0, 3)
        self.modelmsg = QLabel(""); self.modelmsg.setStyleSheet("color:#b9a07a;"); self.modelmsg.setWordWrap(True)
        mv.addWidget(self.modelmsg, 1, 0, 1, 4)
        v.addWidget(gm)

        # hardware panel
        gh = QGroupBox(L("Hardware", "Hardware")); hv = QGridLayout(gh)
        self.l_gpu = QLabel("—"); self.l_cpu = QLabel("—"); self.l_vram = QLabel("—"); self.l_ram = QLabel("—")
        for lbl in (self.l_gpu, self.l_cpu, self.l_vram, self.l_ram): lbl.setWordWrap(True)
        hv.addWidget(QLabel("GPU:"), 0, 0); hv.addWidget(self.l_gpu, 0, 1)
        hv.addWidget(QLabel("CPU:"), 1, 0); hv.addWidget(self.l_cpu, 1, 1)
        hv.addWidget(QLabel("VRAM:"), 2, 0); hv.addWidget(self.l_vram, 2, 1)
        hv.addWidget(QLabel("RAM:"), 3, 0); hv.addWidget(self.l_ram, 3, 1)
        v.addWidget(gh)

        # Shared memory for the model (GTT)
        gg = QGroupBox(L("Memoria condivisa per il modello", "Shared memory for the model")); ggv = QVBoxLayout(gg)
        self.gtt_lbl = QLabel("—"); self.gtt_lbl.setWordWrap(True); ggv.addWidget(self.gtt_lbl)
        sr = QHBoxLayout()
        self.gtt = QSlider(Qt.Orientation.Horizontal); self.gtt.setMinimum(2); self.gtt.setMaximum(14); self.gtt.setSingleStep(1)
        self.gtt.valueChanged.connect(lambda x: self.gtt_val.setText(f"{x} GB"))
        self.gtt_val = QLabel("— GB"); self.gtt_val.setMinimumWidth(56)
        sr.addWidget(self.gtt); sr.addWidget(self.gtt_val); ggv.addLayout(sr)
        self.gtt_apply = QPushButton(L("Applica e riavvia", "Apply and reboot"))
        self.gtt_apply.clicked.connect(self.apply_gtt); ggv.addWidget(self.gtt_apply)
        hint = QLabel(L("Sul BC-250 la memoria è condivisa GPU/CPU: questo alza la quota di RAM di "
                        "sistema (GTT) che il modello può usare oltre alla VRAM, così entrano modelli "
                        "più grandi. È un parametro del kernel → serve un riavvio (a caldo non è "
                        "possibile su questo hardware, verificato).",
                        "On the BC-250 memory is shared GPU/CPU: this raises the share of system RAM "
                        "(GTT) the model may use on top of VRAM, so larger models fit. It is a kernel "
                        "parameter → a reboot is required (a live change is not possible on this "
                        "hardware, verified)."))
        hint.setStyleSheet("color:#b9a07a;"); hint.setWordWrap(True); ggv.addWidget(hint)
        v.addWidget(gg)

        # buttons
        b = QHBoxLayout()
        bw = QPushButton(L("Apri Chat (web)", "Open Chat (web)")); bw.clicked.connect(lambda: sh(f"xdg-open {WEBUI_URL} &"))
        bd = QPushButton(L("Dockge", "Dockge")); bd.clicked.connect(lambda: sh(f"xdg-open {DOCKGE_URL} &"))
        br = QPushButton(L("Aggiorna", "Refresh")); br.clicked.connect(self.refresh_all)
        for x in (bw, bd, br): b.addWidget(x)
        v.addLayout(b)

        self.statusBar()
        self.refresh_all()
        self.timer = QTimer(self); self.timer.timeout.connect(self._tick); self.timer.start(5000)

    # ---- refresh ----
    def _tick(self):
        if not self.busy: self.refresh_engine(); self.refresh_hw()
    def refresh_all(self):
        self.refresh_engine(); self.refresh_hw(); self.refresh_models()
    def refresh_engine(self):
        run = engine_running()
        self.cb.blockSignals(True); self.cb.setChecked(run); self.cb.blockSignals(False)
        self.sub.setText(L(f"Motore LLM locale · modello: {self.model}", f"Local LLM engine · model: {self.model}"))
        if run:
            self.status.setText(L("● Motore ACCESO — pronto", "● Engine ON — ready")
                                + f"   ·   API {OLLAMA_API}")
            self.status.setStyleSheet("color:#9ccf6a;font-weight:600;")
        else:
            self.status.setText(L("○ Motore SPENTO — GPU/memoria libere per i giochi",
                                  "○ Engine OFF — GPU/memory free for games"))
            self.status.setStyleSheet("color:#cf8a6a;font-weight:600;")
    def refresh_hw(self):
        self.l_gpu.setText(gpu_model()); self.l_cpu.setText(cpu_model())
        vr = vram_info()
        if vr:
            tot, used = vr; self.l_vram.setText(f"{gb(tot-used)} {L('libera','free')} / {gb(tot)}")
        else:
            self.l_vram.setText(L("n/d (GPU non-AMD o VRAM condivisa)", "n/a (non-AMD GPU or shared VRAM)"))
        rt, ra = ram_info(); self.l_ram.setText(f"{gb(ra)} {L('libera','free')} / {gb(rt)}")
        gt, gu, cur = gtt_info()
        rt2, _ = ram_info(); vr2 = vram_info()
        # GTT draws on system RAM -> cap the slider at total RAM minus headroom for the OS
        ram_gb = int(rt2 / 1e9) if rt2 else 14
        smax = max(4, min(60, ram_gb - 1)); self.gtt.setMaximum(smax)
        budget = (vr2[0] + gt) if (vr2 and gt is not None) else None
        parts = []
        if budget is not None: parts.append(L(f"budget modello ≈ {gb(budget)}", f"model budget ≈ {gb(budget)}"))
        if gt is not None: parts.append(f"GTT {gb(gt)}")
        if cur is not None: parts.append(f"amdgpu.gttsize={cur} MB")
        self.gtt_lbl.setText("  ·  ".join(parts) if parts else L("non disponibile su questa GPU", "not available on this GPU"))
        if cur is not None:
            self.gtt.blockSignals(True); self.gtt.setValue(max(2, min(smax, round(cur/1024)))); self.gtt.blockSignals(False)
            self.gtt_val.setText(f"{self.gtt.value()} GB")
            self.gtt.setEnabled(True); self.gtt_apply.setEnabled(True)
        else:
            self.gtt.setEnabled(False); self.gtt_apply.setEnabled(False); self.gtt_val.setText(L("n/d","n/a"))
    def refresh_models(self):
        cur = self.modelbox.currentText()
        self.modelbox.blockSignals(True); self.modelbox.clear()
        inst = installed_models()
        if inst: self.modelbox.addItems(inst)
        else:
            for name, _ in CATALOG: self.modelbox.addItem(name)
        # select active model
        i = self.modelbox.findText(self.model)
        if i >= 0: self.modelbox.setCurrentIndex(i)
        elif cur: self.modelbox.setEditText(cur)
        self.modelbox.blockSignals(False)
        self.modelmsg.setText(L(f"{len(inst)} modelli installati" if inst else "nessun modello installato",
                                f"{len(inst)} models installed" if inst else "no models installed"))

    # ---- engine ----
    def on_toggle(self, state):
        if self.busy: return
        self.busy = True; self.cb.setEnabled(False)
        self.status.setText(L("… operazione in corso …", "… working …"))
        cmd = (f"cd {STACK_DIR} && docker compose up -d") if state else (f"cd {STACK_DIR} && docker compose stop")
        self._t = Worker(cmd, 180); self._t.done.connect(self._toggled); self._t.start()
    def _toggled(self, rc):
        self.busy = False; self.cb.setEnabled(True); self.refresh_engine()

    # ---- model actions ----
    def set_active(self):
        name = self.modelbox.currentText().strip()
        if not name: return
        self.model = name; cfg = load_cfg(); cfg["model"] = name; cfg["setup_done"] = True; save_cfg(cfg)
        self.modelmsg.setText(L(f"Modello attivo: {name}", f"Active model: {name}")); self.refresh_engine()
    def pull_model(self):
        name = self.modelbox.currentText().strip()
        if not name: return
        if not engine_running():
            QMessageBox.information(self, "SkillFish AI", L("Accendi prima il motore AI.", "Turn the AI engine on first."))
            return
        self.bpull.setEnabled(False); self.modelmsg.setText(L(f"Scarico {name}…", f"Pulling {name}…"))
        self._p = Worker(f"docker exec {CONTAINER} ollama pull {name}", 3600)
        self._p.line.connect(lambda s: self.statusBar().showMessage(s, 1500))
        self._p.done.connect(lambda rc: self._pulled(rc, name)); self._p.start()
    def _pulled(self, rc, name):
        self.bpull.setEnabled(True)
        self.modelmsg.setText(L(f"✓ {name} scaricato" if rc == 0 else f"! errore su {name}",
                                f"✓ {name} pulled" if rc == 0 else f"! error pulling {name}"))
        self.refresh_models()

    # ---- GTT ----
    def apply_gtt(self):
        mb = self.gtt.value() * 1024
        if QMessageBox.question(self, "SkillFish AI",
                L(f"Impostare GTT a {self.gtt.value()} GB? Serve un riavvio per applicare.",
                  f"Set GTT to {self.gtt.value()} GB? A reboot is needed to apply.")) != QMessageBox.StandardButton.Yes:
            return
        rc, out, err = sh(f"pkexec skillfish-gtt {mb}", 60)
        if rc == 0:
            if QMessageBox.question(self, "SkillFish AI",
                    L(f"Memoria impostata a {self.gtt.value()} GB. Riavviare ora per applicarla?",
                      f"Memory set to {self.gtt.value()} GB. Reboot now to apply it?")) == QMessageBox.StandardButton.Yes:
                sh("pkexec systemctl reboot")
        else:
            QMessageBox.warning(self, "SkillFish AI", (out or err or L("operazione annullata", "operation cancelled")))


def main():
    app = QApplication(sys.argv)
    app.setApplicationName("SkillFish AI")
    app.setDesktopFileName("os.skillfish.ai")
    app.setWindowIcon(QIcon(ICON))
    # First run: wizard (only if the stack/model isn't set up yet).
    if not is_configured():
        wiz = SetupWizard()
        if wiz.exec():
            cfg = load_cfg()
            if wiz.chosen_model: cfg["model"] = wiz.chosen_model
            cfg["setup_done"] = True; save_cfg(cfg)
    p = Panel(); p.show()
    sys.exit(app.exec())

if __name__ == "__main__":
    main()
