#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
S.W.A.R.M-Watchdog lokaler Backend-Server.

Ersetzt `python -m http.server` und liefert zusaetzlich API-Endpunkte:
  GET  /<pfad>                     - statische Dateien aus COWORK_ROOT
  POST /api/mail/send              - Outlook-Mail senden (via pywin32 COM)
  POST /api/plaene/upload          - Plan-Datei hochladen
  DELETE /api/plaene/<projekt>/<datei>  - Plan loeschen
  POST /api/plaene/rename          - Plan umbenennen
  POST /api/kontakte/add           - Neuen Kontakt anhaengen
  POST /api/kontakte/update        - Kontakt aendern (index-basiert)
  POST /api/kontakte/delete        - Kontakt entfernen (index-basiert)

Architektur:
  - Ein-Datei-Server auf stdlib (http.server) + optional pywin32 fuer Mail
  - CORS offen fuer http://localhost (eigener Browser)
  - Kein Auth - lokaler Dev-Use only. Port 8765 (nicht nach aussen freigeben!)
"""

from __future__ import annotations

import base64
import json
import os
import re
import shutil
import sys
import unicodedata
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import unquote, urlparse

# === Konfiguration ===
PORT = 8765
SCRIPT_DIR = Path(__file__).resolve().parent                  # .../04_Fristen-Watchdog/scripts
WATCHDOG_DIR = SCRIPT_DIR.parent                              # .../04_Fristen-Watchdog
COWORK_ROOT = WATCHDOG_DIR.parent                             # .../Cowork
PROJEKTE_DIR = COWORK_ROOT / "02_Projekte"
ADRESSLISTE_PATH = PROJEKTE_DIR / "042_Pilgerbrunnen" / "02_Adressliste" / "adressliste_data.json"

# Inbox fuer externe Eingaben (OpenClaw / WhatsApp via Tunnel)
INBOX_PATH = WATCHDOG_DIR / "inbox.json"
TOKEN_FILE = WATCHDOG_DIR / "api_token.txt"

def _load_or_create_api_token() -> str:
    """Bearer-Token aus Env, sonst aus api_token.txt, sonst neu erzeugen."""
    token = os.environ.get("WATCHDOG_API_TOKEN", "").strip()
    if token:
        return token
    if TOKEN_FILE.exists():
        try:
            t = TOKEN_FILE.read_text(encoding="utf-8").strip()
            if t:
                return t
        except Exception:
            pass
    import secrets
    new = secrets.token_urlsafe(32)
    try:
        TOKEN_FILE.write_text(new, encoding="utf-8")
        print(f"[watchdog-server] Neuer API-Token erzeugt: {TOKEN_FILE}")
    except Exception as exc:
        print(f"[watchdog-server] Token konnte nicht gespeichert werden: {exc}")
    return new

API_TOKEN = _load_or_create_api_token()


def _safe_name(name: str) -> str:
    """Entfernt Pfad-Traversal, limitiert Zeichen auf sichere Dateinamen."""
    name = unicodedata.normalize("NFC", name)
    # Keine Pfad-Trenner
    name = name.replace("/", "_").replace("\\", "_")
    name = re.sub(r"[\x00-\x1f]", "", name)
    name = name.strip(" .")
    if not name or name in (".", ".."):
        raise ValueError("ungueltiger Dateiname")
    return name


def _resolve_within(root: Path, rel: str) -> Path:
    """Resolve rel relativ zu root, verhindert Traversal ausserhalb root."""
    p = (root / rel).resolve()
    root_r = root.resolve()
    if root_r not in p.parents and p != root_r:
        raise ValueError(f"Pfad {p} liegt ausserhalb {root_r}")
    return p


# === Outlook Calendar (via pywin32) ===
def outlook_calendar_scan(past_days: int = 2, future_days: int = 21) -> dict:
    """Liest Outlook-Kalender: Termine von (heute - past_days) bis (heute + future_days).
    Inkl. wiederkehrende Termine (via IncludeRecurrences + Restrict).
    """
    try:
        import win32com.client  # type: ignore
        import pythoncom  # type: ignore
        from datetime import datetime, timedelta
    except ImportError:
        return {"ok": False, "error": "pywin32 nicht installiert."}

    pythoncom.CoInitialize()
    try:
        outlook = win32com.client.Dispatch("Outlook.Application").GetNamespace("MAPI")
        cal = outlook.GetDefaultFolder(9)  # olFolderCalendar
        from_dt = datetime.now() - timedelta(days=past_days)
        to_dt = datetime.now() + timedelta(days=future_days)
        items = cal.Items
        try:
            items.IncludeRecurrences = True
            items.Sort("[Start]")
        except Exception:
            pass
        # Restrict nutzt Outlook-Locale-Format. Wir filtern lieber manuell weil Restrict bei recurrences zickig ist.
        out = []
        inspected = 0
        for item in items:
            if inspected > 2000:
                break
            inspected += 1
            try:
                if getattr(item, "Class", None) != 26:  # olAppointment
                    continue
                start = getattr(item, "Start", None)
                end = getattr(item, "End", None)
                if start is None:
                    continue
                try:
                    start_py = datetime(start.year, start.month, start.day, start.hour, start.minute)
                    end_py = datetime(end.year, end.month, end.day, end.hour, end.minute) if end else None
                except Exception:
                    continue
                if start_py < from_dt or start_py > to_dt:
                    continue
                body = (getattr(item, "Body", "") or "")[:2000]
                out.append({
                    "subject": getattr(item, "Subject", "") or "",
                    "start": start_py.strftime("%Y-%m-%dT%H:%M"),
                    "end": end_py.strftime("%Y-%m-%dT%H:%M") if end_py else "",
                    "duration_min": int((end_py - start_py).total_seconds() / 60) if end_py else 0,
                    "location": getattr(item, "Location", "") or "",
                    "organizer": getattr(item, "Organizer", "") or "",
                    "required_attendees": getattr(item, "RequiredAttendees", "") or "",
                    "optional_attendees": getattr(item, "OptionalAttendees", "") or "",
                    "all_day": bool(getattr(item, "AllDayEvent", False)),
                    "busy_status": getattr(item, "BusyStatus", 2),  # 0=frei, 1=vorgemerkt, 2=beschaeftigt, 3=abwesend
                    "meeting_status": getattr(item, "MeetingStatus", 0),
                    "is_recurring": bool(getattr(item, "IsRecurring", False)),
                    "body_snippet": body.strip().replace("\r\n", "\n")[:2000],
                })
            except Exception:
                continue
        # Sortiere chronologisch
        out.sort(key=lambda x: x.get("start", ""))
        return {
            "ok": True,
            "scanned_at": datetime.now().isoformat(timespec="seconds"),
            "from": from_dt.isoformat(timespec="minutes"),
            "to": to_dt.isoformat(timespec="minutes"),
            "count": len(out),
            "termine": out,
        }
    except Exception as exc:  # noqa: BLE001
        return {"ok": False, "error": f"Calendar-Scan-Fehler: {exc}"}
    finally:
        pythoncom.CoUninitialize()


# === Mail via Outlook COM (pywin32) ===
def outlook_scan(since_days: int = 7, limit: int = 80) -> dict:
    """Liest Posteingang + Gesendet-Ordner der letzten `since_days` Tage via Outlook-COM.

    Nutzt KEINEN Restrict-Filter (der war unzuverlaessig bei gemischten Locales) -
    iteriert stattdessen alle Items sortiert nach ReceivedTime desc und filtert manuell.
    Sent-Ordner nutzt SentOn als Timestamp, nicht ReceivedTime (Restrict haette sonst leere Liste).
    """
    try:
        import win32com.client  # type: ignore
        import pythoncom  # type: ignore
        from datetime import datetime, timedelta, timezone
    except ImportError:
        return {"ok": False, "error": "pywin32 nicht installiert."}

    pythoncom.CoInitialize()
    diagnostics = {}
    try:
        outlook = win32com.client.Dispatch("Outlook.Application").GetNamespace("MAPI")
        inbox = outlook.GetDefaultFolder(6)   # olFolderInbox
        sent = outlook.GetDefaultFolder(5)    # olFolderSentMail
        cutoff = datetime.now() - timedelta(days=since_days)

        def coerce_dt(val):
            """pywin32 gibt pywintypes.datetime mit tzinfo zurueck - auf naive local umrechnen."""
            if val is None:
                return None
            try:
                # pywintypes.datetime -> datetime
                if hasattr(val, "tzinfo") and val.tzinfo is not None:
                    return val.astimezone().replace(tzinfo=None)
                return datetime(val.year, val.month, val.day, val.hour, val.minute, val.second)
            except Exception:
                return None

        def read_folder(folder, name: str, time_attr: str, max_n: int) -> tuple[list[dict], dict]:
            items = folder.Items
            try:
                items.Sort("[" + time_attr + "]", True)  # desc
            except Exception:
                pass
            out = []
            inspected = 0
            skipped_class = 0
            skipped_old = 0
            skipped_err = 0
            for item in items:
                if len(out) >= max_n:
                    break
                inspected += 1
                if inspected > 500:  # Sicherheits-Limit
                    break
                try:
                    cls = getattr(item, "Class", None)
                    if cls != 43:  # olMail - keine Meetings/Tasks
                        skipped_class += 1
                        continue
                    raw_ts = getattr(item, time_attr, None)
                    ts = coerce_dt(raw_ts)
                    if ts is None:
                        # Fallback: andere Zeit-Felder probieren
                        ts = coerce_dt(getattr(item, "ReceivedTime", None)) or coerce_dt(getattr(item, "SentOn", None)) or coerce_dt(getattr(item, "CreationTime", None))
                    if ts and ts < cutoff:
                        skipped_old += 1
                        # Da wir DESC sortiert sind, alle weiteren noch aelter
                        break
                    # Voller Mail-Body (bis 20'000 Zeichen - deckt 99% der Mails ab, verhindert 10-MB-Attachments)
                    body = (getattr(item, "Body", "") or "")[:20000]
                    # Fuer Sent: "To" ist bereits gesetzt. Fuer Inbox: SenderName.
                    out.append({
                        "folder": name,
                        "subject": getattr(item, "Subject", "") or "",
                        "from_name": getattr(item, "SenderName", "") or "",
                        "from_email": getattr(item, "SenderEmailAddress", "") or "",
                        "to": getattr(item, "To", "") or "",
                        "cc": getattr(item, "CC", "") or "",
                        "received": (ts or datetime.now()).strftime("%Y-%m-%dT%H:%M:%S"),
                        "unread": bool(getattr(item, "UnRead", False)),
                        "has_attachments": getattr(item.Attachments, "Count", 0) > 0,
                        "importance": getattr(item, "Importance", 1),  # 2 = hoch
                        "body_snippet": body.strip().replace("\r\n", "\n"),
                    })
                except Exception as e:  # noqa: BLE001
                    skipped_err += 1
                    continue
            return out, {"inspected": inspected, "kept": len(out), "skipped_class": skipped_class, "skipped_old": skipped_old, "skipped_err": skipped_err, "total_in_folder": folder.Items.Count}

        inbox_mails, diag_in = read_folder(inbox, "Posteingang", "ReceivedTime", max(limit // 2, 20))
        sent_mails, diag_sent = read_folder(sent, "Gesendet", "SentOn", max(limit // 2, 20))
        diagnostics["inbox"] = diag_in
        diagnostics["sent"] = diag_sent
        return {
            "ok": True,
            "inbox": inbox_mails,
            "sent": sent_mails,
            "scanned_at": datetime.now().isoformat(timespec="seconds"),
            "since": cutoff.isoformat(timespec="seconds"),
            "since_days": since_days,
            "diagnostics": diagnostics,
        }
    except Exception as exc:  # noqa: BLE001
        return {"ok": False, "error": f"Outlook-Scan-Fehler: {exc}", "diagnostics": diagnostics}
    finally:
        pythoncom.CoUninitialize()


def outlook_send(to: str, subject: str, body: str, cc: str = "", attachments: list | None = None, html: bool = False) -> dict:
    """Sendet eine Mail via Outlook-Desktop-COM.

    attachments: Liste von Pfaden ODER {"filename": str, "data_base64": str} fuer Base64-Content.
    """
    try:
        import win32com.client  # type: ignore
        import pythoncom  # type: ignore
    except ImportError:
        return {"ok": False, "error": "pywin32 nicht installiert. Installiere mit: py -3 -m pip install pywin32"}

    pythoncom.CoInitialize()
    try:
        outlook = win32com.client.Dispatch("Outlook.Application")
        mail = outlook.CreateItem(0)  # 0 = olMailItem
        mail.To = to or ""
        if cc:
            mail.CC = cc
        mail.Subject = subject or ""
        if html:
            mail.HTMLBody = body or ""
        else:
            mail.Body = body or ""
        # Anhaenge
        temp_files: list[Path] = []
        try:
            for att in (attachments or []):
                if isinstance(att, str):
                    if not os.path.exists(att):
                        return {"ok": False, "error": f"Anhang nicht gefunden: {att}"}
                    mail.Attachments.Add(att)
                elif isinstance(att, dict):
                    fn = _safe_name(att.get("filename") or "anhang.bin")
                    data_b64 = att.get("data_base64") or ""
                    data = base64.b64decode(data_b64)
                    temp = WATCHDOG_DIR / "scripts" / f"__mailtmp_{os.getpid()}_{fn}"
                    temp.write_bytes(data)
                    temp_files.append(temp)
                    mail.Attachments.Add(str(temp))
            mail.Display(False)  # Sicht-Modus, Benutzer sieht Mail + kann bearbeiten
            # Alternativ mail.Send() fuer sofortigen Versand ohne Bestaetigung
            return {"ok": True, "detail": "Mail in Outlook geoeffnet (Display). Zum direkten Versand: mail.Send() nutzen."}
        finally:
            for f in temp_files:
                try: f.unlink()
                except OSError: pass
    except Exception as exc:  # noqa: BLE001
        return {"ok": False, "error": f"Outlook-COM-Fehler: {exc}"}
    finally:
        pythoncom.CoUninitialize()


# === Handler ===
# ============================================================
# === FILE-EXTRACTORS (Excel/Word/PDF -> Text fuer KI) ========
# ============================================================
def _extract_xlsx(stream, filename: str) -> dict:
    """openpyxl: alle Sheets als Markdown-Tabellen, Zellgrenze 200 Zeilen × 30 Spalten."""
    try:
        import openpyxl
    except ImportError:
        return {"ok": False, "error": "openpyxl nicht installiert (pip install openpyxl)"}
    wb = openpyxl.load_workbook(stream, data_only=True, read_only=True)
    parts = []
    sheet_meta = []
    MAX_ROWS, MAX_COLS = 200, 30
    for sheet in wb.sheetnames:
        ws = wb[sheet]
        rows = []
        n_rows = 0
        for row in ws.iter_rows(values_only=True):
            if n_rows >= MAX_ROWS:
                rows.append(["… (weitere Zeilen ausgelassen)"])
                break
            cells = [("" if c is None else str(c)) for c in row[:MAX_COLS]]
            # Trailing-Empties weg
            while cells and cells[-1] == "":
                cells.pop()
            if cells:
                rows.append(cells)
                n_rows += 1
        if not rows:
            continue
        # Markdown-Tabelle: erste Zeile als Header
        max_cols = max(len(r) for r in rows)
        norm = [r + [""] * (max_cols - len(r)) for r in rows]
        header = norm[0]
        body = norm[1:] if len(norm) > 1 else []
        md = "## Sheet: " + sheet + "\n\n"
        md += "| " + " | ".join(header) + " |\n"
        md += "| " + " | ".join(["---"] * max_cols) + " |\n"
        for r in body:
            md += "| " + " | ".join(str(c).replace("|", "\\|").replace("\n", " ") for c in r) + " |\n"
        parts.append(md)
        sheet_meta.append({"name": sheet, "rows": len(norm), "cols": max_cols})
    text = "\n\n".join(parts)
    return {"ok": True, "kind": "xlsx", "text": text[:200000], "preview": text[:500], "meta": {"sheets": sheet_meta, "filename": filename}}

def _extract_docx(stream, filename: str) -> dict:
    """python-docx: Absaetze + Tabellen als Plain-Text."""
    try:
        from docx import Document
    except ImportError:
        return {"ok": False, "error": "python-docx nicht installiert (pip install python-docx)"}
    doc = Document(stream)
    parts = []
    for p in doc.paragraphs:
        t = (p.text or "").strip()
        if t:
            parts.append(t)
    for tbl in doc.tables:
        parts.append("")  # Trenner
        for row in tbl.rows:
            cells = [(c.text or "").strip().replace("\n", " ") for c in row.cells]
            parts.append(" | ".join(cells))
    text = "\n".join(parts)
    return {"ok": True, "kind": "docx", "text": text[:200000], "preview": text[:500], "meta": {"paragraphs": len(doc.paragraphs), "tables": len(doc.tables), "filename": filename}}

def _extract_pdf(stream, filename: str) -> dict:
    """pypdf: Text aus allen Seiten."""
    try:
        from pypdf import PdfReader
    except ImportError:
        return {"ok": False, "error": "pypdf nicht installiert (pip install pypdf)"}
    reader = PdfReader(stream)
    parts = []
    for i, page in enumerate(reader.pages):
        try:
            t = page.extract_text() or ""
        except Exception:
            t = ""
        if t.strip():
            parts.append(f"--- Seite {i+1} ---\n{t.strip()}")
    text = "\n\n".join(parts)
    return {"ok": True, "kind": "pdf", "text": text[:200000], "preview": text[:500], "meta": {"pages": len(reader.pages), "filename": filename}}


class WatchdogHandler(SimpleHTTPRequestHandler):
    def __init__(self, *args, **kwargs):
        # Statischer Root = Cowork-Ordner
        super().__init__(*args, directory=str(COWORK_ROOT), **kwargs)

    # Unter pythonw.exe ist sys.stderr auf NUL, was SimpleHTTPRequestHandler's log_message
    # beim Versuch einer Ausgabe crashen laesst -> Connection-Reset. Logging komplett stumm.
    def log_message(self, format, *args):  # noqa: A003
        return
    def log_error(self, format, *args):
        return

    def _json_response(self, code: int, payload: dict) -> None:
        data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(data)))
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Content-Type")
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(data)

    def _read_json(self) -> dict:
        length = int(self.headers.get("Content-Length") or 0)
        raw = self.rfile.read(length) if length else b""
        # BOM tolerieren (PowerShell schreibt manchmal mit UTF-8 BOM)
        if raw.startswith(b"\xef\xbb\xbf"):
            raw = raw[3:]
        try:
            return json.loads(raw.decode("utf-8") or "{}")
        except json.JSONDecodeError as exc:
            raise ValueError(f"Ungueltiger JSON-Body: {exc}")

    # --- Externe API: Bearer-Auth ---
    def _check_bearer(self) -> bool:
        """Pruefe Authorization: Bearer <token>. Token aus WATCHDOG_API_TOKEN env / api_token.txt."""
        if not API_TOKEN:
            return False
        auth = self.headers.get("Authorization", "")
        return auth == f"Bearer {API_TOKEN}"

    def _append_inbox(self, entry: dict) -> int:
        """Append entry to inbox.json im {pending:[...], lastUpdate} Format
        (kompatibel mit bestehender window.syncInbox-Logik im Browser)."""
        from datetime import datetime
        data = {"pending": [], "lastUpdate": datetime.now().isoformat()}
        if INBOX_PATH.exists():
            try:
                with open(INBOX_PATH, "r", encoding="utf-8") as f:
                    loaded = json.load(f)
                if isinstance(loaded, dict) and isinstance(loaded.get("pending"), list):
                    data["pending"] = loaded["pending"]
                elif isinstance(loaded, list):
                    # Legacy migration on the fly
                    data["pending"] = loaded
            except Exception:
                pass
        data["pending"].append(entry)
        data["lastUpdate"] = datetime.now().isoformat()
        tmp = INBOX_PATH.with_suffix(".tmp")
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False, indent=2)
        tmp.replace(INBOX_PATH)
        return len(data["pending"])

    def _handle_inbox_rundgang(self) -> None:
        """POST /api/inbox/rundgang
        Body: {projekt, ort?, gewerk?, text, fotos?[base64..], person?, personEmail?, personFirma?, datum?, status?}
        Auth: Bearer <API_TOKEN>
        Schreibt Eintrag in inbox.json — Browser-Watcher sync zu IndexedDB.
        """
        if not self._check_bearer():
            return self._json_response(401, {"ok": False, "error": "Bearer-Token fehlt oder ungueltig"})
        data = self._read_json()
        if not data.get("projekt") or not data.get("text"):
            return self._json_response(400, {"ok": False, "error": "Felder 'projekt' und 'text' sind Pflicht"})
        from datetime import datetime
        now = datetime.now()
        entry = {
            "id": data.get("id") or "wa-" + now.strftime("%Y%m%dT%H%M%S%f")[:-3],
            "ts": now.isoformat(),
            "source": data.get("source") or "openclaw",
            "typ": "rundgang",
            "projekt": data.get("projekt"),
            "ort": data.get("ort", ""),
            "gewerk": data.get("gewerk", ""),
            "personName": data.get("person") or data.get("personName", ""),
            "personEmail": data.get("personEmail", ""),
            "personFirma": data.get("personFirma", ""),
            "text": data.get("text"),
            "datum": data.get("datum") or now.strftime("%Y-%m-%d"),
            "status": data.get("status", "offen"),
            "fotos": data.get("fotos") or [],
        }
        n = self._append_inbox(entry)
        return self._json_response(200, {"ok": True, "id": entry["id"], "queued": n})

    def _handle_inbox_frist(self) -> None:
        """POST /api/inbox/frist
        Body: {projekt, aufgabe, frist (YYYY-MM-DD oder Klartext), verantw?, quelle?}
        Auth: Bearer <API_TOKEN>
        """
        if not self._check_bearer():
            return self._json_response(401, {"ok": False, "error": "Bearer-Token fehlt oder ungueltig"})
        data = self._read_json()
        if not data.get("projekt") or not data.get("aufgabe"):
            return self._json_response(400, {"ok": False, "error": "Felder 'projekt' und 'aufgabe' sind Pflicht"})
        from datetime import datetime
        now = datetime.now()
        entry = {
            "id": data.get("id") or "fr-" + now.strftime("%Y%m%dT%H%M%S%f")[:-3],
            "ts": now.isoformat(),
            "source": data.get("source") or "openclaw",
            "typ": "frist",
            "projekt": data.get("projekt"),
            "aufgabe": data.get("aufgabe"),
            "frist": data.get("frist") or "Pruefen",
            "verantw": data.get("verantw") or "Gueven Kaplangil",
            "quelle": data.get("quelle") or {"typ": "whatsapp", "ref": data.get("source") or "openclaw"},
        }
        n = self._append_inbox(entry)
        return self._json_response(200, {"ok": True, "id": entry["id"], "queued": n})

    # --- READ-API fuer OpenClaw / Tunnel-Zugriff (Bearer-Auth, alle GET) ---
    def _handle_get_kontakte(self) -> None:
        """GET /api/kontakte[?q=&gewerk=&projekt=]
        Liefert Kontakte. Filter: q (Name/Firma volltext), gewerk (exact-ish), projekt (042/043/047)."""
        if not self._check_bearer():
            return self._json_response(401, {"ok": False, "error": "Bearer-Token fehlt"})
        try:
            with open(ADRESSLISTE_PATH, "r", encoding="utf-8") as f:
                data = json.load(f)
        except Exception as exc:
            return self._json_response(500, {"ok": False, "error": f"Adressliste-Lesefehler: {exc}"})
        kontakte = data.get("kontakte", [])
        from urllib.parse import parse_qs
        qs = parse_qs(urlparse(self.path).query)
        q = (qs.get("q", [""])[0] or "").lower().strip()
        gewerk = (qs.get("gewerk", [""])[0] or "").lower().strip()
        projekt = (qs.get("projekt", [""])[0] or "").strip()
        out = []
        for k in kontakte:
            if gewerk and gewerk not in (k.get("gewerk") or "").lower():
                continue
            if projekt and projekt not in str(k.get("projekt") or ""):
                continue
            if q:
                blob = " ".join(str(k.get(f) or "") for f in ("name", "firma", "vorname", "gewerk", "email", "tel")).lower()
                if q not in blob:
                    continue
            out.append(k)
        return self._json_response(200, {"ok": True, "count": len(out), "kontakte": out})

    def _handle_get_projekte(self) -> None:
        """GET /api/projekte - Liste aller Projekte aus 02_Projekte/ + Plan-Counts."""
        if not self._check_bearer():
            return self._json_response(401, {"ok": False, "error": "Bearer-Token fehlt"})
        plan_index_path = WATCHDOG_DIR / "plan_index.json"
        try:
            with open(plan_index_path, "r", encoding="utf-8") as f:
                idx = json.load(f)
            projekte = idx.get("projekte", [])
            # Schlankere Zusammenfassung (ohne Plan-Liste)
            summary = [{"nr": p["nr"], "name": p["name"], "label": p["label"], "ordner": p["ordner"], "planCount": p["planCount"]} for p in projekte]
            return self._json_response(200, {"ok": True, "stand": idx.get("stand"), "count": len(summary), "projekte": summary})
        except Exception as exc:
            return self._json_response(500, {"ok": False, "error": f"plan_index.json-Lesefehler: {exc}"})

    def _handle_get_projekt(self, nr: str) -> None:
        """GET /api/projekt/<nr> - Vollinfo zu einem Projekt: Plaene, Terminprogramm-Stand, projekt_info.json."""
        if not self._check_bearer():
            return self._json_response(401, {"ok": False, "error": "Bearer-Token fehlt"})
        plan_index_path = WATCHDOG_DIR / "plan_index.json"
        result = {"nr": nr}
        try:
            with open(plan_index_path, "r", encoding="utf-8") as f:
                idx = json.load(f)
            match = next((p for p in idx.get("projekte", []) if str(p.get("nr")) == nr), None)
            if match:
                result.update({"name": match.get("name"), "label": match.get("label"), "ordner": match.get("ordner"), "plaene": match.get("plaene", [])})
        except Exception:
            pass
        # Projekt-Info + Terminprogramm wenn vorhanden
        if result.get("ordner"):
            tpb_dir = PROJEKTE_DIR / result["ordner"] / "01_Terminprogramm"
            for fname, key in [("projekt_info.json", "info"), ("terminprogramm_data.json", "terminprogramm")]:
                fpath = tpb_dir / fname
                if fpath.exists():
                    try:
                        with open(fpath, "r", encoding="utf-8") as f:
                            result[key] = json.load(f)
                    except Exception:
                        pass
        return self._json_response(200, {"ok": True, "projekt": result})

    def _handle_get_inbox(self) -> None:
        """GET /api/inbox - aktueller inbox.json Inhalt (lesbar fuer KI-Kontext)."""
        if not self._check_bearer():
            return self._json_response(401, {"ok": False, "error": "Bearer-Token fehlt"})
        if not INBOX_PATH.exists():
            return self._json_response(200, {"ok": True, "pending": [], "lastUpdate": None})
        try:
            with open(INBOX_PATH, "r", encoding="utf-8") as f:
                return self._json_response(200, {"ok": True, **json.load(f)})
        except Exception as exc:
            return self._json_response(500, {"ok": False, "error": f"inbox.json-Lesefehler: {exc}"})

    def _handle_get_kalender(self) -> None:
        """GET /api/kalender[?past=2&future=21] - Outlook-Kalender via COM."""
        if not self._check_bearer():
            return self._json_response(401, {"ok": False, "error": "Bearer-Token fehlt"})
        from urllib.parse import parse_qs
        qs = parse_qs(urlparse(self.path).query)
        past = int(qs.get("past", ["2"])[0])
        future = int(qs.get("future", ["21"])[0])
        result = outlook_calendar_scan(past_days=past, future_days=future)
        return self._json_response(200 if result.get("ok") else 500, result)

    # --- GET-Router ---
    def do_GET(self) -> None:  # noqa: N802
        path = urlparse(self.path).path
        try:
            if path == "/api/kontakte":
                return self._handle_get_kontakte()
            if path == "/api/projekte":
                return self._handle_get_projekte()
            if path.startswith("/api/projekt/"):
                nr = path[len("/api/projekt/"):].strip("/")
                if nr:
                    return self._handle_get_projekt(nr)
            if path == "/api/inbox":
                return self._handle_get_inbox()
            if path == "/api/kalender":
                return self._handle_get_kalender()
            if path.startswith("/api/"):
                return self._json_response(404, {"ok": False, "error": f"Unbekannter GET-Endpunkt: {path}"})
        except Exception as exc:  # noqa: BLE001
            return self._json_response(500, {"ok": False, "error": f"Server-Fehler: {exc}"})
        # Fallback: statische Datei
        return super().do_GET()

    # --- CORS Preflight ---
    def do_OPTIONS(self) -> None:  # noqa: N802
        self.send_response(204)
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Content-Type")
        self.end_headers()

    # --- POST-Router ---
    def do_POST(self) -> None:  # noqa: N802
        path = urlparse(self.path).path
        try:
            if path == "/api/mail/send":
                return self._handle_mail_send()
            if path == "/api/mail/scan":
                return self._handle_mail_scan()
            if path == "/api/calendar/scan":
                return self._handle_calendar_scan()
            if path == "/api/file/save":
                return self._handle_file_save()
            if path == "/api/status/save":
                return self._handle_status_save()
            if path == "/api/file/extract":
                return self._handle_file_extract()
            if path == "/api/mail/stage-attachments":
                return self._handle_mail_stage()
            if path == "/api/plaene/upload":
                return self._handle_plan_upload()
            if path == "/api/plaene/rename":
                return self._handle_plan_rename()
            if path == "/api/kontakte/add":
                return self._handle_kontakt_add()
            if path == "/api/kontakte/update":
                return self._handle_kontakt_update()
            if path == "/api/kontakte/delete":
                return self._handle_kontakt_delete()
            # Externe API (Bearer-Auth) — fuer OpenClaw / WhatsApp-Bridge
            if path == "/api/inbox/rundgang":
                return self._handle_inbox_rundgang()
            if path == "/api/inbox/frist":
                return self._handle_inbox_frist()
            self._json_response(404, {"ok": False, "error": f"Unbekannter POST-Endpunkt: {path}"})
        except Exception as exc:  # noqa: BLE001
            self._json_response(500, {"ok": False, "error": f"Server-Fehler: {exc}"})

    # --- DELETE-Router ---
    def do_DELETE(self) -> None:  # noqa: N802
        path = urlparse(self.path).path
        try:
            m = re.match(r"^/api/plaene/([^/]+)/(.+)$", path)
            if m:
                projekt = unquote(m.group(1))
                datei = unquote(m.group(2))
                return self._handle_plan_delete(projekt, datei)
            self._json_response(404, {"ok": False, "error": f"Unbekannter DELETE-Endpunkt: {path}"})
        except Exception as exc:  # noqa: BLE001
            self._json_response(500, {"ok": False, "error": f"Server-Fehler: {exc}"})

    # --- Mail ---
    def _handle_mail_scan(self) -> None:
        data = self._read_json()
        since_days = int(data.get("since_days", 3))
        limit = int(data.get("limit", 50))
        result = outlook_scan(since_days=since_days, limit=limit)
        self._json_response(200 if result.get("ok") else 500, result)

    # --- Kalender ---
    def _handle_calendar_scan(self) -> None:
        data = self._read_json()
        past = int(data.get("past_days", 2))
        future = int(data.get("future_days", 21))
        result = outlook_calendar_scan(past_days=past, future_days=future)
        self._json_response(200 if result.get("ok") else 500, result)

    def _handle_mail_send(self) -> None:
        data = self._read_json()
        to = data.get("to") or ""
        if not to:
            return self._json_response(400, {"ok": False, "error": "Feld 'to' fehlt"})
        result = outlook_send(
            to=to,
            cc=data.get("cc") or "",
            subject=data.get("subject") or "",
            body=data.get("body") or "",
            attachments=data.get("attachments") or [],
            html=bool(data.get("html")),
        )
        self._json_response(200 if result.get("ok") else 500, result)

    # --- Mail-Attachments in Staging-Ordner + Explorer oeffnen fuer Drag&Drop ins Neue Outlook ---
    def _handle_mail_stage(self) -> None:
        import subprocess
        from datetime import datetime
        data = self._read_json()
        attachments = data.get("attachments") or []
        if not attachments:
            return self._json_response(400, {"ok": False, "error": "Keine Anhaenge"})
        stamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
        stage_dir = WATCHDOG_DIR / "mail-outgoing" / stamp
        stage_dir.mkdir(parents=True, exist_ok=True)
        saved = []
        for a in attachments:
            try:
                fn = _safe_name(a.get("filename") or "anhang.bin")
                (stage_dir / fn).write_bytes(base64.b64decode(a.get("data_base64") or ""))
                saved.append(fn)
            except Exception as exc:  # noqa: BLE001
                return self._json_response(500, {"ok": False, "error": f"Stage-Fehler {fn}: {exc}"})
        # Windows-Explorer im Staging-Ordner oeffnen - User kann Strg+A + Drag ins Neue Outlook
        try:
            subprocess.Popen(["explorer.exe", str(stage_dir)])
        except Exception:
            pass
        self._json_response(200, {"ok": True, "stage_dir": str(stage_dir), "count": len(saved), "files": saved})

    # --- Generisches File-Save (fuer KI-gesteuerte Uploads) ---
    def _handle_status_save(self) -> None:
        """Speichert den Status der Fristen direkt in status_latest.json — unabhaengig von der FSS-File-Picker-Verknuepfung.
        Body: { "fristen": { "<rowid>": { "status": "...", "notiz": "..." }, ... } }
        Der naechste Generator-Lauf liest die Datei und verschiebt erledigte Fristen ins Archiv."""
        from datetime import datetime as _dt
        data = self._read_json()
        fristen = data.get("fristen")
        if not isinstance(fristen, dict):
            return self._json_response(400, {"ok": False, "error": "Body muss { fristen: {...} } sein"})
        target = COWORK_ROOT / "04_Fristen-Watchdog" / "status_latest.json"
        stichtag = data.get("stichtag") or _dt.now().strftime("%Y-%m-%d")
        payload = {
            "version": 1,
            "stichtag": stichtag,
            "bearbeiter": data.get("bearbeiter") or "Gueven Kaplangil",
            "schreibender": "watchdog_client_autosave",
            "updatedAt": _dt.now().isoformat(timespec="seconds"),
            "fristen": fristen
        }
        try:
            target.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
        except Exception as exc:  # noqa: BLE001
            return self._json_response(500, {"ok": False, "error": f"Schreib-Fehler: {exc}"})
        self._json_response(200, {"ok": True, "pfad": str(target.relative_to(COWORK_ROOT)).replace("\\", "/"), "count": len(fristen), "updatedAt": payload["updatedAt"]})

    def _handle_file_extract(self) -> None:
        """Extrahiert Text/Tabellen-Inhalt aus Office/PDF-Dateien.
        Body: { "filename": "x.xlsx", "data_base64": "..." }
        Antwort: { "ok": true, "kind": "xlsx|docx|pdf", "text": "...", "preview": "...", "meta": {...} }
        Dadurch kann die Sidebar-KI Excel-/Word-Inhalte lesen, ohne dass man sie vorher in PDF konvertiert."""
        import io as _io
        data = self._read_json()
        filename = (data.get("filename") or "").strip()
        b64 = data.get("data_base64") or ""
        if not filename or not b64:
            return self._json_response(400, {"ok": False, "error": "filename + data_base64 erforderlich"})
        try:
            raw = base64.b64decode(b64)
        except Exception as exc:
            return self._json_response(400, {"ok": False, "error": f"Base64-Fehler: {exc}"})
        if len(raw) > 25 * 1024 * 1024:
            return self._json_response(413, {"ok": False, "error": "Datei > 25 MB"})

        lower = filename.lower()
        try:
            if lower.endswith(".xlsx") or lower.endswith(".xlsm"):
                return self._json_response(200, _extract_xlsx(_io.BytesIO(raw), filename))
            if lower.endswith(".docx"):
                return self._json_response(200, _extract_docx(_io.BytesIO(raw), filename))
            if lower.endswith(".pdf"):
                return self._json_response(200, _extract_pdf(_io.BytesIO(raw), filename))
            if lower.endswith(".csv") or lower.endswith(".txt") or lower.endswith(".md") or lower.endswith(".json") or lower.endswith(".log"):
                txt = raw.decode("utf-8", errors="replace")
                return self._json_response(200, {"ok": True, "kind": "text", "text": txt[:200000], "preview": txt[:500], "meta": {"bytes": len(raw)}})
            return self._json_response(415, {"ok": False, "error": f"Format nicht unterstuetzt: {filename}"})
        except Exception as exc:
            return self._json_response(500, {"ok": False, "error": f"Extraktions-Fehler: {exc}"})

    def _handle_file_save(self) -> None:
        data = self._read_json()
        target_rel = (data.get("target_path") or "").strip()
        b64 = data.get("data_base64") or ""
        if not target_rel or not b64:
            return self._json_response(400, {"ok": False, "error": "target_path + data_base64 erforderlich"})
        # Whitelist: nur innerhalb Cowork-Root, und in bekannten Unterordnern
        try:
            target = _resolve_within(COWORK_ROOT, target_rel)
        except ValueError as exc:
            return self._json_response(400, {"ok": False, "error": str(exc)})
        allowed_prefixes = ["02_Projekte/", "04_Fristen-Watchdog/fotos/", "04_Fristen-Watchdog/uploads/"]
        rel_posix = str(target.relative_to(COWORK_ROOT)).replace("\\", "/")
        if not any(rel_posix.startswith(p) for p in allowed_prefixes):
            return self._json_response(400, {"ok": False, "error": f"Zielpfad ausserhalb erlaubter Bereiche: {rel_posix}"})
        target.parent.mkdir(parents=True, exist_ok=True)
        try:
            target.write_bytes(base64.b64decode(b64))
        except Exception as exc:  # noqa: BLE001
            return self._json_response(500, {"ok": False, "error": f"Schreib-Fehler: {exc}"})
        self._json_response(200, {"ok": True, "pfad": rel_posix, "size": target.stat().st_size})

    # --- Plaene ---
    def _handle_plan_upload(self) -> None:
        data = self._read_json()
        projekt = data.get("projekt") or ""
        filename = data.get("filename") or ""
        data_b64 = data.get("data_base64") or ""
        if not projekt or not filename or not data_b64:
            return self._json_response(400, {"ok": False, "error": "projekt, filename, data_base64 erforderlich"})
        projekt_dir = PROJEKTE_DIR / _safe_name(projekt) / "00_Pläne"
        projekt_dir.mkdir(parents=True, exist_ok=True)
        safe_fn = _safe_name(filename)
        target = projekt_dir / safe_fn
        target.write_bytes(base64.b64decode(data_b64))
        self._json_response(200, {"ok": True, "pfad": str(target.relative_to(COWORK_ROOT)).replace("\\", "/"), "size": target.stat().st_size})

    def _handle_plan_delete(self, projekt: str, datei: str) -> None:
        projekt_dir = PROJEKTE_DIR / _safe_name(projekt) / "00_Pläne"
        if not projekt_dir.exists():
            return self._json_response(404, {"ok": False, "error": f"Projekt-Plan-Ordner nicht gefunden: {projekt}"})
        target = _resolve_within(projekt_dir, datei)
        if not target.exists() or not target.is_file():
            return self._json_response(404, {"ok": False, "error": f"Datei nicht gefunden: {datei}"})
        target.unlink()
        self._json_response(200, {"ok": True, "geloescht": str(target.relative_to(COWORK_ROOT)).replace("\\", "/")})

    def _handle_plan_rename(self) -> None:
        data = self._read_json()
        projekt = _safe_name(data.get("projekt") or "")
        alt = data.get("alt_name") or ""
        neu = _safe_name(data.get("neu_name") or "")
        if not projekt or not alt or not neu:
            return self._json_response(400, {"ok": False, "error": "projekt, alt_name, neu_name erforderlich"})
        projekt_dir = PROJEKTE_DIR / projekt / "00_Pläne"
        alt_path = _resolve_within(projekt_dir, alt)
        if not alt_path.exists():
            return self._json_response(404, {"ok": False, "error": f"Datei nicht gefunden: {alt}"})
        neu_path = projekt_dir / neu
        alt_path.rename(neu_path)
        self._json_response(200, {"ok": True, "alt": alt, "neu": neu})

    # --- Kontakte ---
    def _load_adressliste(self) -> dict:
        if not ADRESSLISTE_PATH.exists():
            return {"meta": {}, "kontakte": []}
        return json.loads(ADRESSLISTE_PATH.read_text(encoding="utf-8"))

    def _save_adressliste(self, data: dict) -> None:
        ADRESSLISTE_PATH.parent.mkdir(parents=True, exist_ok=True)
        ADRESSLISTE_PATH.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")

    def _handle_kontakt_add(self) -> None:
        data = self._read_json()
        kontakt = data.get("kontakt") or {}
        if not kontakt.get("person") and not kontakt.get("firma"):
            return self._json_response(400, {"ok": False, "error": "person oder firma erforderlich"})
        adr = self._load_adressliste()
        adr.setdefault("kontakte", []).append(kontakt)
        self._save_adressliste(adr)
        self._json_response(200, {"ok": True, "index": len(adr["kontakte"]) - 1})

    def _handle_kontakt_update(self) -> None:
        data = self._read_json()
        idx = int(data.get("index", -1))
        kontakt = data.get("kontakt") or {}
        adr = self._load_adressliste()
        kontakte = adr.get("kontakte") or []
        if idx < 0 or idx >= len(kontakte):
            return self._json_response(400, {"ok": False, "error": f"Ungueltiger Index {idx}"})
        kontakte[idx] = kontakt
        self._save_adressliste(adr)
        self._json_response(200, {"ok": True, "index": idx})

    def _handle_kontakt_delete(self) -> None:
        data = self._read_json()
        idx = int(data.get("index", -1))
        adr = self._load_adressliste()
        kontakte = adr.get("kontakte") or []
        if idx < 0 or idx >= len(kontakte):
            return self._json_response(400, {"ok": False, "error": f"Ungueltiger Index {idx}"})
        removed = kontakte.pop(idx)
        self._save_adressliste(adr)
        self._json_response(200, {"ok": True, "entfernt": removed})


def main() -> None:
    print(f"SWARM-Watchdog-Server startet auf http://localhost:{PORT}")
    print(f"Root:     {COWORK_ROOT}")
    print(f"Watchdog: {WATCHDOG_DIR}")
    print(f"API:      /api/mail/send, /api/plaene/*, /api/kontakte/*")
    print("(Strg+C zum Beenden)")
    server = ThreadingHTTPServer(("127.0.0.1", PORT), WatchdogHandler)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\nBeendet.")
        server.server_close()


if __name__ == "__main__":
    main()
