#!/usr/bin/env python3
"""
KI-KOLLEKTIV — Windows Desktop Client (Steuerung + Live-Ansicht)
================================================================
Sieht dem Schwarm bei der Arbeit zu UND gibt ihm Auftraege: Strateg -> N Macher
(parallel, ueber alle freien Quellen) -> Synthese, plus Live-Log und Auftrags-Eingabe.

Laeuft mit reinem Python: 'python kollektiv_client.py' — tkinter ist Standard, KEIN
Extra-Install. Als .exe (PyInstaller --onefile --noconsole) doppelklickbar.

SICHERHEIT (dieser Client wird oeffentlich verteilt):
  - KEIN Token im Code. Der Token wird im Programm eingegeben und NUR lokal
    gespeichert (~/.kollektiv_client.json). Der Download enthaelt also keinen Schluessel.
  - Spricht ausschliesslich die token-geschuetzte API per urllib (HTTPS).
  - Keine Systembefehle, keine Code-Ausfuehrung, keine Subprozesse.
  - Sendet NUR Text-Auftraege; der Schwarm fuehrt nichts automatisch aus.
"""
import hashlib
import json
import os
import re
import sys
import subprocess
import threading
import urllib.request
import urllib.error
import tkinter as tk
from tkinter import ttk

VERSION = 2                                            # bei jedem Release hochzaehlen (== version.txt auf dem Server)
API = "https://mini-malbuch.de/kollektiv-api"          # token-only Tuer (S3)
BASIS = "https://boschkoo.dev/kollektiv"               # statische Downloads (S1)
VERSION_URL = BASIS + "/version.txt"
EXE_URL = BASIS + "/KollektivClient.exe"
CFG = os.path.join(os.path.expanduser("~"), ".kollektiv_client.json")

INK, BONE, DIM, FAINT, SIGNAL = "#08080a", "#ece7dd", "#9a948a", "#56524b", "#c8ff3c"
PANEL, PANEL2, ALARM = "#101014", "#15151b", "#ff6b6b"
MONO = ("Consolas", 9)
MACHER_FARBEN = ["#f4b03c", "#c8ff3c", "#5eead4", "#e879f9", "#7c9cff", "#fb7185", "#a3e635", "#38bdf8"]


def token_laden():
    try:
        return json.load(open(CFG, encoding="utf-8")).get("token", "")
    except Exception:
        return ""


def token_speichern(tok):
    try:
        fd = os.open(CFG, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)  # nie kurz world-readable
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            json.dump({"token": tok}, f)
    except Exception:
        pass


class KollektivClient:
    def __init__(self, root):
        self.root = root
        root.title("DAS KOLLEKTIV — Schwarm Steuerung")
        root.configure(bg=INK)
        root.geometry("1200x900")
        root.minsize(820, 600)
        self.token = token_laden()
        self.data = None
        self.err = None
        self.lock = threading.Lock()
        self.running = True
        self.cards = []
        self._build()
        threading.Thread(target=self._poll, daemon=True).start()
        self._refresh()

    # ---------- UI-Aufbau ----------
    def _build(self):
        head = tk.Frame(self.root, bg=INK); head.pack(fill=tk.X, padx=16, pady=(12, 2))
        tk.Label(head, text="DAS KOLLEKTIV", bg=INK, fg=BONE,
                 font=("Bahnschrift", 20, "bold")).pack(side=tk.LEFT)
        tk.Button(head, text="⚙ Schlüssel & Update", command=self._einstellungen, bg=PANEL, fg=DIM,
                  font=("Consolas", 8), relief="flat", activebackground=PANEL2).pack(side=tk.RIGHT, padx=(8, 0))
        self.status = tk.Label(head, text="verbinde…", bg=INK, fg=DIM, font=MONO)
        self.status.pack(side=tk.RIGHT)

        # Token-Zeile
        trow = tk.Frame(self.root, bg=INK); trow.pack(fill=tk.X, padx=16, pady=(4, 2))
        tk.Label(trow, text="SCHLÜSSEL", bg=INK, fg=DIM, font=("Consolas", 8, "bold")).pack(side=tk.LEFT)
        self.tok_entry = tk.Entry(trow, bg=PANEL, fg=BONE, insertbackground=BONE, show="•",
                                  font=MONO, width=24, relief="flat")
        self.tok_entry.pack(side=tk.LEFT, padx=6, ipady=3)
        self.tok_entry.insert(0, self.token)
        tk.Button(trow, text="speichern", command=self._token_speichern, bg=PANEL2, fg=BONE,
                  font=("Consolas", 8), relief="flat", activebackground=PANEL).pack(side=tk.LEFT)
        self.tok_msg = tk.Label(trow, text="", bg=INK, fg=FAINT, font=("Consolas", 8))
        self.tok_msg.pack(side=tk.LEFT, padx=8)

        # Auftrags-Zeile
        arow = tk.Frame(self.root, bg=INK); arow.pack(fill=tk.X, padx=16, pady=(2, 6))
        tk.Label(arow, text="AUFTRAG", bg=INK, fg=SIGNAL, font=("Consolas", 8, "bold")).pack(side=tk.LEFT)
        self.auftrag = tk.Entry(arow, bg=PANEL, fg=BONE, insertbackground=SIGNAL, font=MONO, relief="flat")
        self.auftrag.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=6, ipady=4)
        self.auftrag.bind("<Return>", lambda e: self._senden())
        self.send_btn = tk.Button(arow, text="senden ▸", command=self._senden, bg=SIGNAL, fg=INK,
                                  font=("Consolas", 9, "bold"), relief="flat", activebackground="#a3e635")
        self.send_btn.pack(side=tk.LEFT)
        self.send_msg = tk.Label(arow, text="", bg=INK, fg=FAINT, font=("Consolas", 8))
        self.send_msg.pack(side=tk.LEFT, padx=8)

        self.mission = tk.Label(self.root, text="", bg=PANEL, fg=BONE, font=MONO, anchor="w",
                                justify="left", wraplength=1150, padx=12, pady=8)
        self.mission.pack(fill=tk.X, padx=16, pady=2)

        self.strateg = tk.Label(self.root, text="", bg=INK, fg="#7c9cff", font=MONO, anchor="w",
                                justify="left", wraplength=1150)
        self.strateg.pack(fill=tk.X, padx=18, pady=(2, 6))

        # Macher-Bereich (dynamisch — N Spalten je nach Team)
        self.grid = tk.Frame(self.root, bg=INK)
        self.grid.pack(fill=tk.BOTH, expand=True, padx=14)

        # Synthese = das Werk
        sframe = tk.Frame(self.root, bg=PANEL2, highlightbackground=SIGNAL, highlightthickness=1)
        sframe.pack(fill=tk.X, padx=16, pady=4)
        self.synth_head = tk.Label(sframe, text="★ DAS WERK", bg=PANEL2, fg=SIGNAL,
                                   font=("Consolas", 9, "bold"))
        self.synth_head.pack(anchor="w", padx=8, pady=(4, 0))
        self.synth = tk.Text(sframe, height=6, bg=PANEL2, fg=BONE, bd=0, font=MONO, wrap=tk.WORD, state="disabled")
        self.synth.pack(fill=tk.X, padx=6, pady=4)

        self.timing = tk.Label(self.root, text="", bg=INK, fg=FAINT, font=("Consolas", 8))
        self.timing.pack(pady=2)

        tk.Label(self.root, text="LIVE-LOG", bg=INK, fg=DIM, font=("Consolas", 8, "bold")).pack(anchor="w", padx=18)
        lframe = tk.Frame(self.root, bg=INK); lframe.pack(fill=tk.X, padx=16, pady=(0, 10))
        self.log = tk.Text(lframe, height=8, bg="#0b0b0e", fg="#cfcabf", bd=0, font=("Consolas", 8),
                           wrap=tk.WORD, state="disabled")
        self.log.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        sb = ttk.Scrollbar(lframe, command=self.log.yview); sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.log.configure(yscrollcommand=sb.set)

    def _baue_karten(self, n):
        """Macher-Spalten (neu) anlegen — genau n Stueck."""
        for c in self.cards:
            c["col"].destroy()
        self.cards = []
        n = max(1, n)
        for i in range(n):
            farbe = MACHER_FARBEN[i % len(MACHER_FARBEN)]
            col = tk.Frame(self.grid, bg=PANEL, highlightbackground=farbe, highlightthickness=1)
            col.grid(row=0, column=i, sticky="nsew", padx=3, pady=2)
            self.grid.columnconfigure(i, weight=1, uniform="m")
            tk.Label(col, text=f"MACHER {i+1}", bg=PANEL, fg=farbe,
                     font=("Consolas", 9, "bold")).pack(anchor="w", padx=6, pady=(4, 0))
            city = tk.Label(col, text="—", bg=PANEL, fg=DIM, font=("Consolas", 8),
                            anchor="w", justify="left", wraplength=190)
            city.pack(anchor="w", fill=tk.X, padx=6)
            sf = tk.Label(col, text="", bg=PANEL, fg=ALARM, font=("Consolas", 8))
            sf.pack(anchor="w", padx=6)
            txt = tk.Text(col, height=14, width=24, bg=INK, fg=BONE, bd=0, font=("Consolas", 8),
                          wrap=tk.WORD, state="disabled")
            txt.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)
            self.cards.append({"city": city, "sf": sf, "txt": txt, "col": col})

    @staticmethod
    def _set(widget, text):
        widget.config(state="normal"); widget.delete("1.0", tk.END)
        widget.insert(tk.END, text or ""); widget.config(state="disabled")

    # ---------- Aktionen ----------
    def _token_speichern(self):
        self.token = self.tok_entry.get().strip()
        token_speichern(self.token)
        self.tok_msg.config(text="gespeichert ✓", fg=SIGNAL)
        self.root.after(2000, lambda: self.tok_msg.config(text=""))

    def _senden(self):
        text = self.auftrag.get().strip()
        if not text:
            self.send_msg.config(text="leer", fg=FAINT); return
        if not self.token:
            self.send_msg.config(text="erst Schlüssel speichern", fg=ALARM); return
        self.send_btn.config(state="disabled")
        self.send_msg.config(text="sende…", fg=DIM)
        threading.Thread(target=self._senden_thread, args=(text,), daemon=True).start()

    def _senden_thread(self, text):
        try:
            body = json.dumps({"text": text}).encode("utf-8")
            req = urllib.request.Request(API + "/auftrag", data=body, method="POST",
                                         headers={"Content-Type": "application/json",
                                                  "X-Token": self.token,
                                                  "User-Agent": "Kollektiv-Client"})
            with urllib.request.urlopen(req, timeout=10) as r:
                res = json.loads(r.read().decode("utf-8"))
            pos = res.get("position", "?")
            self.root.after(0, lambda: self._senden_fertig(f"in Queue (Platz {pos}) ✓", SIGNAL, True))
        except urllib.error.HTTPError as e:
            msg = "Schlüssel falsch" if e.code == 401 else f"Fehler {e.code}"
            self.root.after(0, lambda: self._senden_fertig(msg, ALARM, False))
        except Exception as e:
            m = str(e)[:30]
            self.root.after(0, lambda: self._senden_fertig(m, ALARM, False))

    def _senden_fertig(self, msg, farbe, ok):
        self.send_btn.config(state="normal")
        self.send_msg.config(text=msg, fg=farbe)
        if ok:
            self.auftrag.delete(0, tk.END)
        self.root.after(4000, lambda: self.send_msg.config(text=""))

    # ---------- Anzeige ----------
    def _refresh(self):
        with self.lock:
            d, err = self.data, self.err
        if err:
            self.status.config(text=f"offline — {err}", fg=ALARM)
        elif d:
            phase = d.get("phase", "?"); lauf = d.get("lauf", "?")
            offen = d.get("queue_offen", 0)
            badge = f" · {offen} in Queue" if offen else ""
            self.status.config(text=f"● {phase} · Lauf {lauf}{badge}",
                               fg=SIGNAL if d.get("status") == "running" else DIM)
            self.mission.config(text="MISSION:  " + (d.get("mission") or ""))
            st = d.get("strateg") or {}
            self.strateg.config(text="STRATEG (" + (st.get("city") or "—") + "):  " +
                                     (st.get("text") or "wartet…")[:220])
            macher = d.get("macher") or []
            if len(macher) != len(self.cards):
                self._baue_karten(len(macher))
            for i, c in enumerate(self.cards):
                m = macher[i] if i < len(macher) else {}
                prov = m.get("provider")
                kopf = (m.get("city") or "—") + (f" @{prov}" if prov else "") + "  ·  " + (m.get("status") or "—")
                c["city"].config(text=kopf)
                c["sf"].config(text=("⚠ SAFETY: " + " ".join(m.get("safety"))) if m.get("safety") else "")
                self._set(c["txt"], m.get("text") or "")
            syn = d.get("synthese") or {}
            mu = re.search(r"URTEIL:\s*(GO|NACHBESSERN)", syn.get("text") or "", re.I)  # wie schwarm.html
            urteil = mu.group(1).upper() if mu else ""
            kopf = "★ DAS WERK" + (f"  ({syn.get('city')})" if syn.get("city") else "")
            if urteil:
                kopf += f"   —  URTEIL: {urteil}"
            self.synth_head.config(text=kopf)
            self._set(self.synth, syn.get("text") or "(wartet auf die Macher…)")
            t = d.get("timing") or {}
            if t.get("total_s"):
                self.timing.config(text=f"Strateg {t.get('strateg_s')}s · Macher parallel "
                                        f"{t.get('macher_parallel_s')}s · Synthese {t.get('synthese_s')}s "
                                        f"· gesamt {t.get('total_s')}s")
            self._set(self.log, "\n".join(f"[{e.get('ts','')}] {e.get('text','')}" for e in d.get("log", [])))
            self.log.see(tk.END)
        self.root.after(2500, self._refresh)

    def _poll(self):
        while self.running:
            try:
                req = urllib.request.Request(API + "/status", headers={"X-Token": self.token,
                                                                       "User-Agent": "Kollektiv-Client"})
                with urllib.request.urlopen(req, timeout=8) as r:
                    fresh = json.loads(r.read().decode("utf-8"))
                with self.lock:
                    self.data, self.err = fresh, None
            except urllib.error.HTTPError as e:
                with self.lock:
                    self.err = "Schlüssel falsch/fehlt" if e.code == 401 else f"HTTP {e.code}"
            except Exception as e:
                with self.lock:
                    self.err = str(e)[:40]
            for _ in range(30):
                if not self.running:
                    return
                threading.Event().wait(0.1)

    # ---------- Einstellungen: Provider-Schlüssel + Update ----------
    def _ui(self, fn):
        """Fuehrt fn nur aus, wenn das Einstellungs-Fenster noch lebt (kein TclError auf totem Widget)."""
        if getattr(self, "_win", None) is not None and self._win.winfo_exists():
            try:
                fn()
            except tk.TclError:
                pass

    def _einstellungen(self):
        if getattr(self, "_win", None) is not None and self._win.winfo_exists():
            self._win.lift(); return
        self._erwarteter_hash = ""
        w = tk.Toplevel(self.root); self._win = w
        w.title("Einstellungen — Schlüssel & Update")
        w.configure(bg=INK); w.geometry("640x580")

        tk.Label(w, text="PROVIDER-SCHLÜSSEL", bg=INK, fg=SIGNAL,
                 font=("Consolas", 9, "bold")).pack(anchor="w", padx=16, pady=(14, 0))
        tk.Label(w, text="Neue kostenlose KI-Quelle freischalten: Key bei der Quelle holen, hier eintragen.",
                 bg=INK, fg=DIM, font=("Consolas", 8)).pack(anchor="w", padx=16)
        self.keys_box = tk.Frame(w, bg=INK); self.keys_box.pack(fill=tk.X, padx=16, pady=6)

        form = tk.Frame(w, bg=INK); form.pack(fill=tk.X, padx=16, pady=4)
        tk.Label(form, text="Provider", bg=INK, fg=DIM, font=("Consolas", 8)).grid(row=0, column=0, sticky="w")
        tk.Label(form, text="Key", bg=INK, fg=DIM, font=("Consolas", 8)).grid(row=0, column=1, sticky="w")
        self.prov_var = tk.StringVar()
        self.prov_menu = ttk.Combobox(form, textvariable=self.prov_var, state="readonly",
                                      width=13, font=("Consolas", 9))
        self.prov_menu.grid(row=1, column=0, padx=(0, 8), sticky="w")
        self.key_entry = tk.Entry(form, bg=PANEL, fg=BONE, insertbackground=SIGNAL, show="•",
                                  font=MONO, width=34, relief="flat")
        self.key_entry.grid(row=1, column=1, ipady=3, padx=(0, 8))
        tk.Button(form, text="Key speichern", command=self._key_speichern, bg=SIGNAL, fg=INK,
                  font=("Consolas", 9, "bold"), relief="flat").grid(row=1, column=2)
        self.key_msg = tk.Label(w, text="", bg=INK, fg=FAINT, font=("Consolas", 8),
                                wraplength=600, justify="left", anchor="w")
        self.key_msg.pack(anchor="w", fill=tk.X, padx=16)

        tk.Frame(w, bg=PANEL2, height=1).pack(fill=tk.X, padx=16, pady=12)
        tk.Label(w, text="VERSION & UPDATE", bg=INK, fg=SIGNAL,
                 font=("Consolas", 9, "bold")).pack(anchor="w", padx=16)
        urow = tk.Frame(w, bg=INK); urow.pack(fill=tk.X, padx=16, pady=4)
        tk.Label(urow, text=f"Installiert: v{VERSION}", bg=INK, fg=BONE, font=MONO).pack(side=tk.LEFT)
        tk.Button(urow, text="auf Updates prüfen", command=self._update_pruefen, bg=PANEL, fg=BONE,
                  font=("Consolas", 8), relief="flat").pack(side=tk.LEFT, padx=8)
        self.update_btn = tk.Button(urow, text="⬇ Update laden", command=self._update_laden, bg=SIGNAL, fg=INK,
                                    font=("Consolas", 9, "bold"), relief="flat")  # erst bei Bedarf gepackt
        self.upd_msg = tk.Label(w, text="", bg=INK, fg=FAINT, font=("Consolas", 8),
                                wraplength=600, justify="left", anchor="w")
        self.upd_msg.pack(anchor="w", fill=tk.X, padx=16, pady=4)

        if not self.token:
            self.key_msg.config(text="Erst im Hauptfenster den SCHLÜSSEL (Token) speichern, dann Keys verwalten.",
                                fg=ALARM)
        else:
            threading.Thread(target=self._keys_laden, daemon=True).start()

    def _keys_laden(self):
        try:
            req = urllib.request.Request(API + "/keys", headers={"X-Token": self.token,
                                                                 "User-Agent": "Kollektiv-Client"})
            with urllib.request.urlopen(req, timeout=8) as r:
                data = json.loads(r.read().decode("utf-8"))
            self.root.after(0, lambda: self._keys_zeigen(data))
        except Exception as e:
            msg = f"Keys laden fehlgeschlagen: {str(e)[:40]}"
            self.root.after(0, lambda: self._ui(lambda: self.key_msg.config(text=msg, fg=ALARM)))

    def _keys_zeigen(self, data):
        if not (getattr(self, "_win", None) and self._win.winfo_exists()):
            return
        for c in self.keys_box.winfo_children():
            c.destroy()
        provs = data.get("provider", [])
        self.prov_menu["values"] = [p["provider"] for p in provs]
        if provs and not self.prov_var.get():
            self.prov_var.set(provs[0]["provider"])
        for p in provs:
            zeile = tk.Frame(self.keys_box, bg=INK); zeile.pack(fill=tk.X)
            mark = "●" if p["gesetzt"] else "○"
            tk.Label(zeile, text=f"{mark} {p['provider']:11}", bg=INK, fg=(SIGNAL if p["gesetzt"] else FAINT),
                     font=MONO).pack(side=tk.LEFT)
            hint = "aktiv" if p["gesetzt"] else f"Key holen: {p['anmeldung']}"
            tk.Label(zeile, text=hint, bg=INK, fg=DIM, font=("Consolas", 8)).pack(side=tk.LEFT, padx=8)

    def _key_speichern(self):
        prov = self.prov_var.get().strip()
        key = self.key_entry.get().strip()
        if not prov or not key:
            self.key_msg.config(text="Provider + Key nötig.", fg=FAINT); return
        if not self.token:
            self.key_msg.config(text="Erst den SCHLÜSSEL (Token) im Hauptfenster speichern.", fg=ALARM); return
        self.key_msg.config(text="speichere…", fg=DIM)
        threading.Thread(target=self._key_speichern_thread, args=(prov, key), daemon=True).start()

    def _key_speichern_thread(self, prov, key):
        try:
            body = json.dumps({"provider": prov, "key": key}).encode("utf-8")
            req = urllib.request.Request(API + "/key", data=body, method="POST",
                                         headers={"Content-Type": "application/json",
                                                  "X-Token": self.token, "User-Agent": "Kollektiv-Client"})
            with urllib.request.urlopen(req, timeout=10) as r:
                res = json.loads(r.read().decode("utf-8"))
            aktiv = ", ".join(res.get("aktiv", []))
            self.root.after(0, lambda: self._ui(lambda: (self.key_entry.delete(0, tk.END),
                                        self.key_msg.config(text=f"✓ {prov} gespeichert. Aktive Quellen: {aktiv}", fg=SIGNAL),
                                        self._keys_laden())))
        except urllib.error.HTTPError as e:
            msg = "Schlüssel falsch" if e.code == 401 else (f"Unbekannter Provider/Key (Fehler {e.code})" if e.code == 400 else f"Fehler {e.code}")
            self.root.after(0, lambda: self._ui(lambda: self.key_msg.config(text=msg, fg=ALARM)))
        except Exception as e:
            emsg = str(e)[:50]
            self.root.after(0, lambda: self._ui(lambda: self.key_msg.config(text=emsg, fg=ALARM)))

    def _update_pruefen(self):
        self.upd_msg.config(text="prüfe…", fg=DIM)
        threading.Thread(target=self._update_pruefen_thread, daemon=True).start()

    def _update_pruefen_thread(self):
        try:
            req = urllib.request.Request(API + "/update_meta", headers={"X-Token": self.token,
                                                                        "User-Agent": "Kollektiv-Client"})
            with urllib.request.urlopen(req, timeout=8) as r:
                meta = json.loads(r.read().decode("utf-8"))
            neu = int(meta.get("version", 0))
            self._erwarteter_hash = (meta.get("sha256") or "").strip().lower()
        except urllib.error.HTTPError as e:
            m = "Schlüssel (Token) falsch/fehlt" if e.code == 401 else f"Prüfen fehlgeschlagen ({e.code})"
            self.root.after(0, lambda: self._ui(lambda: self.upd_msg.config(text=m, fg=ALARM))); return
        except Exception as e:
            em = f"Prüfen fehlgeschlagen: {str(e)[:40]}"
            self.root.after(0, lambda: self._ui(lambda: self.upd_msg.config(text=em, fg=ALARM))); return
        if neu > VERSION and self._erwarteter_hash:
            self.root.after(0, lambda: self._ui(lambda: (
                self.upd_msg.config(text=f"Neue Version v{neu} verfügbar (installiert: v{VERSION}).", fg=SIGNAL),
                self.update_btn.pack(side=tk.LEFT, padx=8))))
        elif neu > VERSION:
            self.root.after(0, lambda: self._ui(lambda: self.upd_msg.config(
                text=f"v{neu} verfügbar, aber kein Prüf-Hash vom Server — Update gesperrt (Sicherheit).", fg=ALARM)))
        else:
            self.root.after(0, lambda: self._ui(lambda: self.upd_msg.config(text=f"Du hast die neueste Version (v{VERSION}).", fg=DIM)))

    def _update_laden(self):
        if not getattr(sys, "frozen", False):
            self.upd_msg.config(text="Auto-Update gibt es nur in der .exe-Version. Im Python-Modus einfach neu downloaden.",
                                fg=FAINT)
            return
        self.upd_msg.config(text="lade Update…", fg=DIM)
        threading.Thread(target=self._update_laden_thread, daemon=True).start()

    def _update_laden_thread(self):
        exe = sys.executable
        ordner = os.path.dirname(exe)
        neu = os.path.join(ordner, "KollektivClient_neu.exe")
        try:
            req = urllib.request.Request(EXE_URL, headers={"User-Agent": "Kollektiv-Client"})
            with urllib.request.urlopen(req, timeout=180) as r:
                roh = r.read()
            if len(roh) < 1_000_000:
                raise ValueError("Download zu klein")
            # INTEGRITAET: SHA256 muss exakt zum token-gegateten Hash von S3 passen.
            # EXE kommt von S1, Hash von S3 -> ein Angreifer muesste BEIDE Server brechen.
            ist = hashlib.sha256(roh).hexdigest().lower()
            if not self._erwarteter_hash or ist != self._erwarteter_hash:
                raise ValueError("Prüfsumme stimmt nicht — Update verworfen (mögliche Manipulation)")
            with open(neu, "wb") as f:
                f.write(roh)
        except Exception as e:
            try:
                if os.path.exists(neu):
                    os.remove(neu)
            except Exception:
                pass
            em = f"Update fehlgeschlagen: {str(e)[:60]}"
            self.root.after(0, lambda: self._ui(lambda: self.upd_msg.config(text=em, fg=ALARM)))
            return
        try:
            bat = os.path.join(ordner, "_update.bat")       # defensiv: alte EXE als .bak sichern, Retry, kein Datenverlust
            with open(bat, "w") as f:
                f.write('@echo off\r\nping 127.0.0.1 -n 3 >nul\r\n'
                        f'move /y "{exe}" "{exe}.bak" >nul 2>&1\r\n'
                        ':try\r\n'
                        f'move /y "{neu}" "{exe}" >nul 2>&1\r\n'
                        f'if not exist "{exe}" (ping 127.0.0.1 -n 2 >nul & goto try)\r\n'
                        f'del "{exe}.bak" >nul 2>&1\r\n'
                        f'start "" "{exe}"\r\ndel "%~f0"\r\n')
            subprocess.Popen(["cmd", "/c", bat], creationflags=0x08000000)  # DETACHED_PROCESS
            self.root.after(0, lambda: self._ui(lambda: self.upd_msg.config(text="Update geprüft & geladen — starte neu…", fg=SIGNAL)))
            self.root.after(1000, self.on_close)
        except Exception as e:
            em = f"Update-Start fehlgeschlagen: {str(e)[:50]}"
            self.root.after(0, lambda: self._ui(lambda: self.upd_msg.config(text=em, fg=ALARM)))

    def on_close(self):
        self.running = False
        self.root.destroy()


if __name__ == "__main__":
    root = tk.Tk()
    app = KollektivClient(root)
    root.protocol("WM_DELETE_WINDOW", app.on_close)
    root.mainloop()
