#!/usr/bin/env python3
"""Article Index Server — with IDs, meta extraction, image upload, and multi-threading.
v2: Added 'Markierte veröffentlichen' (Joomla API publish), article-image association,
    and automatic VG-Wort tracking pixel injection.
v3: Added NanoBanana Image Generator (/nanobanana)."""

import os
import re
import glob
import json
import base64
import hashlib
import socket
import urllib.parse
import urllib.request
import io
import email.parser
import tempfile
import subprocess
import time
from http.server import HTTPServer, BaseHTTPRequestHandler
from datetime import datetime
from socketserver import ThreadingMixIn

WORKSPACE = "/home/hermes/workspace"
MEDIA_DIR = os.path.join(WORKSPACE, "media")
os.makedirs(MEDIA_DIR, exist_ok=True)
PORT = int(os.environ.get("ARTICLE_SERVER_PORT", "8890"))
ACCESS_TOKEN = "br3ttsp1el-n3ws-2026"
USERNAME = "DK-Adminchef"
PASSWORD = "Mcik7%vbdhXa_"

# ── Joomla API Configuration ──────────────────────────────────────────────
JOOMLA_API_URL = "https://www.brettspiel-news.de/api/index.php/v1/content/articles"
# JOOMLA_TOKEN is read from environment or from a token file
JOOMLA_TOKEN = os.environ.get("JOOMLA_API_TOKEN", "")
if not JOOMLA_TOKEN:
    token_paths = [
        "/tmp/alicia_skills/KEYS.txt",
        "/home/hermes/.hermes/joomla_token.txt",
    ]
    for tp in token_paths:
        if os.path.exists(tp):
            with open(tp, "r") as f:
                for line in f:
                    line = line.strip()
                    if line and not line.startswith("#"):
                        JOOMLA_TOKEN = line
                        break
            if JOOMLA_TOKEN:
                break

# ── VG-Wort Configuration ─────────────────────────────────────────────────
VG_WORT_COUNTER_ID = os.environ.get("VG_WORT_COUNTER_ID", "")
VG_WORT_PIXEL_HTML = (
    f'<img src="https://vg08.met.vgwort.de/na/{VG_WORT_COUNTER_ID}" '
    f'width="1" height="1" alt="">'
) if VG_WORT_COUNTER_ID else ""

# ── Article-Image Mapping ─────────────────────────────────────────────────
IMAGE_MAP_FILE = os.path.join(WORKSPACE, "article_images.json")

MONTHS = {
    "Januar": 1, "Februar": 2, "März": 3, "April": 4,
    "Mai": 5, "Juni": 6, "Juli": 7, "August": 8,
    "September": 9, "Oktober": 10, "November": 11, "Dezember": 12
}

# ── Gemini Flash Image API Configuration ────────────────────────────────────
GEMINI_IMAGE_MODEL = "gemini-2.5-flash-image"
GEMINI_API_URL = (
    "https://generativelanguage.googleapis.com/v1beta/"
    "models/" + GEMINI_IMAGE_MODEL + ":generateContent"
)
GEMINI_KEY_SECRET = "bsn/google-imagen-key"


def _get_gemini_key() -> str:
    """Google Gemini API-Key aus bsn-secrets lesen."""
    try:
        r = subprocess.run(
            ["/home/hermes/bin/bsn-secrets", "get", GEMINI_KEY_SECRET],
            capture_output=True, text=True, timeout=5
        )
        return r.stdout.strip()
    except Exception:
        return ""


def _detect_image_mime(image_bytes: bytes) -> str:
    """MIME-Type anhand Magic-Bytes erkennen."""
    if len(image_bytes) < 8:
        return "image/jpeg"  # Fallback
    if image_bytes[:4] == b"\x89PNG":
        return "image/png"
    if image_bytes[:2] == b"\xff\xd8":
        return "image/jpeg"
    if image_bytes[:4] == b"RIFF" and image_bytes[8:12] == b"WEBP":
        return "image/webp"
    if image_bytes[:4] in (b"ftyp", b"heic", b"heix", b"hevc", b"hevx"):
        return "image/heic"
    return "image/jpeg"  # Fallback


def _gemini_generate_image(
    prompt: str,
    image_base64: str | None,
    aspect_ratio: str,
    resolution: str
) -> dict:
    """Gemini Flash Image aufrufen — Text-to-Image und Image-to-Image.
    
    Args:
        prompt: Text-Prompt.
        image_base64: Optionales Referenzbild als Base64 (Image-to-Image).
        aspect_ratio: Seitenverhältnis (z.B. "16:9").
        resolution: Behält Kompatibilität (von Gemini ignoriert).
    
    Returns:
        dict mit "image_base64" (PNG) oder "error".
    """
    import base64 as b64
    
    key = _get_gemini_key()
    if not key:
        return {"error": "Kein Gemini API-Key"}

    parts = [{"text": f"{prompt}\n\nAspect ratio: {aspect_ratio}."}]
    if image_base64:
        # MIME-Type aus den tatsächlichen Bilddaten erkennen
        raw_bytes = b64.b64decode(image_base64)
        mime_type = _detect_image_mime(raw_bytes)
        parts.append({
            "inlineData": {
                "mimeType": mime_type,
                "data": image_base64
            }
        })

    body = json.dumps({
        "contents": [{"parts": parts}],
        "generationConfig": {
            "responseModalities": ["image", "text"]
        }
    }).encode()

    req = urllib.request.Request(GEMINI_API_URL, data=body, method="POST")
    req.add_header("x-goog-api-key", key)
    req.add_header("Content-Type", "application/json")

    try:
        with urllib.request.urlopen(req, timeout=45) as resp:
            result = json.loads(resp.read())
            candidates = result.get("candidates") or []
            for cand in candidates:
                for part in cand.get("content", {}).get("parts", []):
                    inline = part.get("inlineData")
                    if inline and inline.get("data"):
                        return {"image_base64": inline["data"]}
            return {"error": "Kein Bild in Gemini-Antwort"}
    except urllib.error.HTTPError as e:
        return {"error": f"HTTP {e.code}: {e.read().decode()[:300]}"}

def _get_unpublished_articles() -> list[dict]:
    """Liste aller unveröffentlichten Artikel (ohne joomla-id)."""
    articles = []
    for f in sorted(glob.glob(os.path.join(WORKSPACE, "*.html")), key=os.path.getmtime, reverse=True):
        name = os.path.basename(f)
        if any(re.search(p, name) for p in SKIP_PATTERNS):
            continue
        try:
            with open(f, "r") as fh:
                content = fh.read(5000)
            has_joomla_id = '<meta name="joomla-id"' in content
            if not has_joomla_id:
                title_match = re.search(r'<title>(.*?)</title>', content)
                title = title_match.group(1).strip() if title_match else name
                articles.append({"filename": name, "title": title})
        except Exception:
            pass
    return articles[:50]  # Max 50

SKIP_PATTERNS = [
    r'^ddg\\d*\\.html', r'^google', r'^bgg_(search|thread|game|price)',
    r'^bk_', r'^backerkit', r'^brave_search', r'^fb_post', r'^reddit',
    r'^rp_mg', r'^rp_gladbach', r'^spiegel', r'^sportschau', r'^transfermarkt',
    r'^tm_', r'^kicker', r'^brettspielbox', r'^bsg_', r'^bbb_',
    r'^envyborn', r'^brettspiel_news_newsletter', r'^boardgamewire',
    r'^ddg_', r'^blocked', r'^error', r'^just.a.moment',
    r'^duckduckgo', r'^update.regarding',
    r'deals', r'kennerspiele', r'kombi_deals', r'top_deals',
    r'kinderspiele_deals', r'amazon_deals', r'amazon_primeday',
]


# ── Image Mapping Helpers ─────────────────────────────────────────────────
def get_image_map() -> dict[str, list[str]]:
    """Load article→image mapping from JSON."""
    if os.path.exists(IMAGE_MAP_FILE):
        with open(IMAGE_MAP_FILE, "r") as f:
            return json.load(f)
    return {}


def save_image_map(mapping: dict[str, list[str]]) -> None:
    """Save article→image mapping to JSON."""
    with open(IMAGE_MAP_FILE, "w") as f:
        json.dump(mapping, f, indent=2)


def find_orphan_images() -> list[str]:
    """Find images in workspace that are NOT in article_images.json."""
    import glob as _glob
    existing = set()
    for paths in get_image_map().values():
        for p in paths:
            existing.add(os.path.abspath(p))
    
    orphans = []
    for ext in ("*.jpg", "*.jpeg", "*.png", "*.webp", "*.gif"):
        for p in _glob.iglob(os.path.join(WORKSPACE, "**", ext), recursive=True):
            # Skip bsn-chatbot, media dir (already managed), __pycache__, .git, venv
            if "/bsn-chatbot/" in p or "/__pycache__/" in p or "/.git/" in p or "/venv/" in p:
                continue
            if "/image_cache/" in p:
                continue
            abspath = os.path.abspath(p)
            if abspath not in existing:
                orphans.append(abspath)
    return sorted(orphans)


def is_article(filename: str) -> bool:
    for pattern in SKIP_PATTERNS:
        if re.search(pattern, filename, re.IGNORECASE):
            return False
    return filename.endswith(".html")


def extract_meta(filepath: str) -> dict | None:
    """Extract title, date, meta description, keywords, and Joomla ID from an article."""
    with open(filepath, "r", encoding="utf-8") as f:
        content = f.read()

    title_match = re.search(r'<title>([^<]+)</title>', content)
    if not title_match:
        return None
    title = title_match.group(1)

    date_match = re.search(r'<p class="meta">[^<]*?(\d{1,2}\.\s*(?:Januar|Februar|März|April|Mai|Juni|Juli|August|September|Oktober|November|Dezember)\s*\d{4})', content)
    if not date_match:
        return None
    date_str = date_match.group(1)

    try:
        parts = date_str.replace(".", "").split()
        day, month_name, year = int(parts[0]), parts[1], int(parts[2])
        month = MONTHS.get(month_name, 1)
        sort_key = f"{year:04d}-{month:02d}-{day:02d}"
        display_date = f"{day}.{month_name} {year}"
    except (ValueError, IndexError):
        return None

    meta_desc = ""
    desc_match = re.search(r'<meta\s+name="description"\s+content="([^"]*)"', content, re.IGNORECASE)
    if desc_match:
        meta_desc = desc_match.group(1)

    meta_tags = ""
    tag_match = re.search(r'<meta\s+name="keywords"\s+content="([^"]*)"', content, re.IGNORECASE)
    if tag_match:
        meta_tags = tag_match.group(1)

    joomla_id = ""
    jid_match = re.search(r'<meta\s+name="joomla-id"\s+content="(\d+|XXXXX)"', content, re.IGNORECASE)
    if jid_match:
        joomla_id = jid_match.group(1)

    return {
        "filename": os.path.basename(filepath),
        "title": title,
        "date_sort": sort_key,
        "date_display": display_date,
        "mtime": os.path.getmtime(filepath),
        "meta_desc": meta_desc,
        "meta_tags": meta_tags,
        "joomla_id": joomla_id,
    }


def get_articles() -> list[dict]:
    articles = []
    for f in glob.glob(os.path.join(WORKSPACE, "*.html")):
        filename = os.path.basename(f)
        if filename == "index.html":
            continue
        if not is_article(filename):
            continue
        meta = extract_meta(f)
        if meta:
            articles.append(meta)

    articles.sort(key=lambda a: (a["date_sort"], a["filename"]), reverse=True)
    for i, a in enumerate(articles, 1):
        a["local_id"] = i
    return articles


def build_index(articles: list[dict]) -> str:
    by_date_sort: dict[str, list[dict]] = {}
    for a in articles:
        by_date_sort.setdefault(a["date_sort"], []).append(a)

    month_names = {"01":"Januar","02":"Februar","03":"März","04":"April","05":"Mai","06":"Juni",
                   "07":"Juli","08":"August","09":"September","10":"Oktober","11":"November","12":"Dezember"}

    # Read image map for badge display
    img_map = get_image_map()

    items_html = ""
    for sort_key in sorted(by_date_sort.keys(), reverse=True):
        day_articles = by_date_sort[sort_key]
        try:
            y, m, d = sort_key.split("-")
            nice_date = f"{int(d)}. {month_names.get(m, m)} {y}"
        except ValueError:
            nice_date = day_articles[0]["date_display"]
        items_html += f'<div class="date-group">\n'
        items_html += f'<div class="date-header">{nice_date}<span class="date-count">{len(day_articles)}</span></div>\n'
        for a in day_articles:
            jid = a.get("joomla_id", "")
            local_id = a.get("local_id", "")
            id_str = f"#{jid}" if jid else f"#{local_id}"
            id_class = "id-joomla" if jid else "id-local"
            meta_desc = a.get("meta_desc", "")
            meta_tags = a.get("meta_tags", "")
            meta_preview = ""
            if meta_desc or meta_tags:
                meta_parts = []
                if meta_desc:
                    meta_parts.append(meta_desc[:120] + ("…" if len(meta_desc) > 120 else ""))
                if meta_tags:
                    meta_parts.append(f'<span class="tags">🏷 {meta_tags[:60]}{"…" if len(meta_tags) > 60 else ""}</span>')
                meta_preview = '<div class="meta-preview">' + " · ".join(meta_parts) + '</div>'

            # Image badge
            image_badge = ""
            image_ok = False
            if a["filename"] in img_map and img_map[a["filename"]]:
                img_count = len(img_map[a["filename"]])
                image_badge = f'<span class="img-badge" title="{img_count} Bild(er) zugeordnet">🖼{img_count}</span>'
                image_ok = True

            # Readiness indicators
            meta_desc_ok = bool(meta_desc)
            meta_tags_ok = bool(meta_tags)
            all_ready = meta_desc_ok and meta_tags_ok and image_ok

            def ready_dot(ok: bool, title: str) -> str:
                cls = "ready-ok" if ok else "ready-missing"
                symbol = "●" if ok else "○"
                return f'<span class="ready-dot {cls}" title="{title}: {"✓" if ok else "✗ fehlt"}">{symbol}</span>'

            readiness = (
                ready_dot(meta_desc_ok, "Meta-Description") +
                ready_dot(meta_tags_ok, "Keywords") +
                ready_dot(image_ok, "Bild")
            )

            items_html += f"""<div class="article-row" data-ready="{str(all_ready).lower()}" data-filename="{a['filename']}">
  <label class="checkbox-label">
    <input type="checkbox" class="article-check" value="{a['filename']}">
    <span class="checkmark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></span>
  </label>
  <span class="article-id {id_class}">{id_str}</span>
  <div class="article-info">
    <a href="{a['filename']}?key={ACCESS_TOKEN}" class="article-link" target="_blank">{a['title']}</a>
    {meta_preview}
  </div>
  <span class="readiness">{readiness}</span>
  {image_badge}
  <label class="row-upload-btn" title="Bild für diesen Artikel hochladen">
    <input type="file" class="row-file-input" onchange="uploadForRow(this, '{a['filename']}')">
    <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
  </label>
</div>\n"""
        items_html += "</div>\n"

    count = len(articles)
    has_token = bool(JOOMLA_TOKEN)
    token_status = "🟢 API-Token vorhanden" if has_token else "🔴 Kein API-Token — Veröffentlichung deaktiviert"
    publish_disabled = "" if has_token else "disabled"
    publish_title = "" if has_token else ' title="Kein Joomla API-Token konfiguriert (JOOMLA_API_TOKEN env oder /tmp/alicia_skills/KEYS.txt)"'

    return f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Artikel-Übersicht · brettspiel-news.de</title>
<style>
  :root {{ --accent: #20228a; --accent-light: #3b3da8; --accent-ghost: rgba(32,34,138,0.06); --accent-ghost-hover: rgba(32,34,138,0.10); --danger: #c0392b; --danger-hover: #a93226; --success: #10b981; --success-hover: #059669; --bg: #f8f9fb; --surface: #ffffff; --text: #1a1a2e; --text-muted: #6b7280; --border: #e5e7eb; --radius: 10px; --shadow-sm: 0 1px 3px rgba(0,0,0,0.04); --shadow: 0 2px 12px rgba(0,0,0,0.06); --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; }}
  * {{ box-sizing: border-box; margin: 0; padding: 0; }}
  body {{ font-family: var(--font); background: var(--bg); color: var(--text); line-height: 1.5; -webkit-font-smoothing: antialiased; }}
  .container {{ max-width: 900px; margin: 0 auto; padding: 0 1.5rem 2rem; }}

  header {{ background: linear-gradient(135deg, var(--accent) 0%, var(--accent-light) 100%); padding: 2rem 0; margin-bottom: 2rem; box-shadow: 0 4px 24px rgba(32,34,138,0.15); }}
  header .header-inner {{ max-width: 900px; margin: 0 auto; padding: 0 1.5rem; display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 0.5rem; }}
  header h1 {{ font-size: 1.4rem; font-weight: 700; color: #fff; letter-spacing: -0.02em; }}
  header .subtitle {{ color: rgba(255,255,255,0.8); font-size: 0.88rem; font-weight: 400; }}
  header .subtitle a {{ color: rgba(255,255,255,0.95); text-decoration: underline; text-underline-offset: 3px; }}
  header .subtitle a:hover {{ color: #fff; }}

  .toolbar {{ display: flex; gap: 0.6rem; align-items: center; margin-bottom: 1.8rem; flex-wrap: wrap; }}
  .btn {{ padding: 0.45rem 1rem; border: none; border-radius: 7px; font-size: 0.84rem; cursor: pointer; font-weight: 500; font-family: var(--font); transition: all 0.15s ease; white-space: nowrap; }}
  .btn-select {{ background: var(--surface); color: var(--text); border: 1px solid var(--border); box-shadow: var(--shadow-sm); }}
  .btn-select:hover {{ background: var(--accent-ghost-hover); border-color: var(--accent); color: var(--accent); }}
  .btn-upload {{ background: var(--accent); color: #fff; box-shadow: 0 2px 8px rgba(32,34,138,0.25); }}
  .btn-upload:hover {{ background: var(--accent-light); box-shadow: 0 4px 14px rgba(32,34,138,0.35); }}
  .btn-delete {{ background: var(--danger); color: #fff; box-shadow: 0 2px 8px rgba(192,57,43,0.25); }}
  .btn-delete:hover {{ background: var(--danger-hover); box-shadow: 0 4px 14px rgba(192,57,43,0.35); }}
  .btn-delete:disabled {{ background: #e5e7eb; color: #9ca3af; cursor: not-allowed; box-shadow: none; }}
  .btn-publish {{ background: var(--success); color: #fff; box-shadow: 0 2px 8px rgba(16,185,129,0.25); }}
  .btn-publish:hover {{ background: var(--success-hover); box-shadow: 0 4px 14px rgba(16,185,129,0.35); }}
  .btn-publish:disabled {{ background: #e5e7eb; color: #9ca3af; cursor: not-allowed; box-shadow: none; }}
  .live-toggle-label {{ display: flex; align-items: center; gap: 0.3rem; cursor: pointer; font-size: 0.82rem; font-weight: 600; padding: 0.35rem 0.6rem; border-radius: 7px; background: var(--surface); border: 2px solid var(--border); transition: all 0.15s; user-select: none; }}
  .live-toggle-label:hover {{ border-color: var(--accent); }}
  .live-toggle-label:has(input:checked) {{ background: #fef2f2; border-color: #dc3545; color: #dc3545; }}
  .live-toggle-label input {{ accent-color: #dc3545; }}

  .rubric-live-select {{ font-family: var(--font); font-size: 0.8rem; padding: 0.35rem 0.7rem; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--text); cursor: pointer; }}
  .rubric-live-select:focus {{ outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px rgba(32,34,138,0.15); }}
  .upload-label {{ display: inline-flex; }}

  .token-indicator {{ font-size: 0.7rem; margin-left: 0.6rem; opacity: 0.8; }}

  .date-group {{ margin-bottom: 1.6rem; background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }}
  .date-header {{ font-size: 0.82rem; font-weight: 600; color: var(--accent); text-transform: uppercase; letter-spacing: 0.06em; padding: 0.7rem 1rem; background: var(--accent-ghost); border-bottom: 1px solid rgba(32,34,138,0.08); display: flex; align-items: center; justify-content: space-between; }}
  .date-count {{ background: var(--accent); color: #fff; font-size: 0.7rem; padding: 0.15rem 0.55rem; border-radius: 20px; font-weight: 600; letter-spacing: 0; }}

  .article-row {{ display: flex; align-items: flex-start; gap: 0.6rem; padding: 0.6rem 1rem; transition: background 0.12s; border-bottom: 1px solid rgba(0,0,0,0.03); }}
  .article-row:last-child {{ border-bottom: none; }}
  .article-row:hover {{ background: var(--accent-ghost); }}
  .article-row.selected {{ background: var(--accent-ghost-hover); }}
  .article-row.selected .article-link {{ color: var(--accent); }}

  .checkbox-label {{ position: relative; display: flex; align-items: flex-start; cursor: pointer; flex-shrink: 0; padding-top: 2px; }}
  .checkbox-label input {{ display: none; }}
  .checkmark {{ width: 20px; height: 20px; border: 2px solid #d1d5db; border-radius: 5px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: all 0.15s; color: transparent; }}
  .checkmark svg {{ width: 13px; height: 13px; display: none; }}
  .checkbox-label input:checked + .checkmark {{ background: var(--accent); border-color: var(--accent); color: #fff; }}
  .checkbox-label input:checked + .checkmark svg {{ display: block; }}
  .article-row:hover .checkmark {{ border-color: var(--accent); }}

  .article-id {{ font-size: 0.75rem; font-weight: 600; padding: 2px 7px; border-radius: 4px; flex-shrink: 0; margin-top: 1px; min-width: 32px; text-align: center; }}
  .id-joomla {{ background: #dbeafe; color: #1e40af; }}
  .id-local {{ background: #f3f4f6; color: #6b7280; }}

  .article-info {{ flex: 1; min-width: 0; }}
  .article-link {{ color: var(--text); text-decoration: none; font-size: 0.93rem; line-height: 1.4; display: block; }}
  .article-link:hover {{ color: var(--accent); }}
  .meta-preview {{ font-size: 0.78rem; color: var(--text-muted); margin-top: 0.2rem; line-height: 1.35; }}
  .meta-preview .tags {{ color: var(--accent); font-size: 0.75rem; }}

  .img-badge {{ font-size: 0.7rem; background: #dbeafe; color: #1e40af; padding: 1px 6px; border-radius: 10px; margin-left: 0.5rem; white-space: nowrap; flex-shrink: 0; }}

  .readiness {{ display: flex; gap: 3px; align-items: center; flex-shrink: 0; margin: 0 0.3rem; }}
  .ready-dot {{ font-size: 0.62rem; line-height: 1; }}
  .ready-ok {{ color: #10b981; }}
  .ready-missing {{ color: #d1d5db; }}

  .counter {{ color: var(--text-muted); font-size: 0.84rem; margin-left: auto; font-variant-numeric: tabular-nums; }}

  .upload-zone {{ display: none; margin-bottom: 1.5rem; padding: 2rem; border: 2px dashed var(--border); border-radius: var(--radius); text-align: center; background: var(--surface); transition: all 0.2s; }}
  .upload-zone.active {{ display: block; }}
  .upload-zone.dragover {{ border-color: var(--accent); background: var(--accent-ghost); }}
  .upload-zone h3 {{ font-size: 1rem; color: var(--text); margin-bottom: 0.5rem; }}
  .upload-zone p {{ font-size: 0.84rem; color: var(--text-muted); margin-bottom: 0.25rem; }}
  .upload-zone .upload-help {{ font-size: 0.75rem; color: var(--text-muted); margin-bottom: 1rem; font-style: italic; }}
  .upload-zone input[type="file"] {{ font-family: var(--font); font-size: 0.84rem; }}
  .upload-zone .upload-row {{ display: flex; gap: 0.5rem; align-items: center; justify-content: center; flex-wrap: wrap; }}
  .upload-result {{ margin-top: 0.8rem; font-size: 0.82rem; font-family: monospace; word-break: break-all; padding: 0.5rem; background: #f0fdf4; border-radius: 6px; display: none; }}
  .upload-result.show {{ display: block; }}

  .row-upload-btn {{ display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 8px; cursor: pointer; color: var(--accent); background: var(--accent-ghost); border: 1px solid transparent; transition: all 0.15s; flex-shrink: 0; margin-left: 0.25rem; }}
  .row-upload-btn:hover {{ background: var(--accent); color: #fff; border-color: var(--accent); }}
  .row-upload-btn:active {{ transform: scale(0.93); }}
  .row-upload-btn.uploading {{ background: var(--success); color: #fff; pointer-events: none; animation: pulse 0.8s infinite; }}
  @keyframes pulse {{ 0%,100%{{opacity:1}} 50%{{opacity:0.5}} }}

  .row-file-input {{ display: none; }}

  /* Orphan images */
  .orphan-box {{ margin-bottom: 1rem; background: #fffbeb; border: 1px solid #fcd34d; border-radius: var(--radius); overflow: hidden; }}
  .orphan-box summary {{ padding: 0.5rem 1rem; font-size: 0.82rem; font-weight: 600; color: #92400e; cursor: pointer; user-select: none; }}
  .orphan-box summary:hover {{ color: #78350f; }}
  .orphan-grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 0.4rem; padding: 0.5rem 1rem 0.7rem; }}
  .orphan-row {{ display: flex; align-items: center; gap: 0.5rem; font-size: 0.78rem; }}
  .orphan-preview {{ color: #1a1a2e; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 200px; }}
  .orphan-select {{ font-family: var(--font); font-size: 0.75rem; padding: 0.25rem 0.5rem; border: 1px solid #d1d5db; border-radius: 5px; background: #fff; max-width: 220px; }}

  .toast {{ position: fixed; bottom: 2rem; right: 2rem; background: var(--success); color: #fff; padding: 0.8rem 1.6rem; border-radius: 10px; font-weight: 500; font-family: var(--font); box-shadow: 0 6px 24px rgba(16,185,129,0.3); opacity: 0; transform: translateY(16px); transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); pointer-events: none; z-index: 1000; }}
  .toast.show {{ opacity: 1; transform: translateY(0); }}
  .toast.error {{ background: var(--danger); box-shadow: 0 6px 24px rgba(192,57,43,0.3); }}
  .empty {{ text-align: center; color: var(--text-muted); padding: 4rem; font-size: 1.1rem; }}

  .rubric-legend {{ margin-bottom: 1.2rem; background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow-sm); overflow: hidden; }}
  .rubric-legend summary {{ padding: 0.5rem 1rem; font-size: 0.78rem; font-weight: 600; color: var(--text-muted); cursor: pointer; user-select: none; }}
  .rubric-legend summary:hover {{ color: var(--accent); }}
  .rubric-grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 0.3rem; padding: 0.5rem 1rem 0.7rem; }}
  .rubric-item {{ font-size: 0.75rem; padding: 0.2rem 0.5rem; border-radius: 4px; background: var(--accent-ghost); }}
  .rubric-item .rubric-id {{ font-weight: 600; color: var(--accent); margin-right: 0.3rem; }}
  .rubric-item .rubric-sub {{ color: var(--text-muted); font-size: 0.7rem; }}
  .rubric-item .rubric-lock {{ margin-left: 0.3rem; font-size: 0.65rem; }}

  @media (max-width: 600px) {{
    header {{ padding: 1.5rem 0; }}
    header h1 {{ font-size: 1.2rem; }}
    .container {{ padding: 0 0.8rem 1.5rem; }}
    .toolbar {{ gap: 0.4rem; }}
    .btn {{ padding: 0.4rem 0.7rem; font-size: 0.8rem; }}
    .article-row {{ flex-wrap: wrap; }}
    .article-id {{ order: -1; }}
  }}
</style>
</head>
<body>
<header>
  <div class="header-inner">
    <h1>Artikel-Übersicht</h1>
    <p class="subtitle">{count} Artikel im Archiv · <a href="/?key={ACCESS_TOKEN}">Aktualisieren</a> · <a href="/nanobanana?key={ACCESS_TOKEN}" style="font-weight:600">🎨 Bild-Generator</a><span class="token-indicator">{token_status}</span></p>
  </div>
</header>
<div class="container">
  <details class="rubric-legend">
    <summary>📂 Rubriken (Kategorien) — zum Nachschlagen · 📌 Öffentliche = 3 Tage Hauptartikel</summary>
    <div class="rubric-grid">
      <div class="rubric-item"><span class="rubric-id">61</span> Korrektur <span class="rubric-lock">🔒 Redaktion</span></div>
      <div class="rubric-item"><span class="rubric-id">9</span> Nachrichten</div>
      <div class="rubric-item"><span class="rubric-id rubric-sub">└ 64</span> Angebot</div>
      <div class="rubric-item"><span class="rubric-id rubric-sub">└ 65</span> Crowdfunding</div>
      <div class="rubric-item"><span class="rubric-id rubric-sub">└ 66</span> Branche</div>
      <div class="rubric-item"><span class="rubric-id rubric-sub">└ 67</span> Neuerscheinungen</div>
      <div class="rubric-item"><span class="rubric-id rubric-sub">└ 68</span> Ankündigungen</div>
      <div class="rubric-item"><span class="rubric-id rubric-sub">└ 69</span> Zubehör</div>
      <div class="rubric-item"><span class="rubric-id">8</span> Brettspieltest</div>
      <div class="rubric-item"><span class="rubric-id">11</span> Magazin</div>
      <div class="rubric-item"><span class="rubric-id">10</span> Video</div>
      <div class="rubric-item"><span class="rubric-id">55</span> Podcast</div>
    </div>
  </details>
  <div class="toolbar">
    <button class="btn btn-select" onclick="selectAll()">Alle auswählen</button>
    <button class="btn btn-select" onclick="deselectAll()">Auswahl aufheben</button>
    <button class="btn btn-upload" onclick="toggleUpload()">📷 Bild hochladen</button>
    <label class="live-toggle-label" title="Ohne Haken: ALLE Artikel gehen auf Korrektur (Redaktion-only). MIT Haken: Live (öffentlich).">
      <input type="checkbox" id="liveToggle" onchange="updateLiveMode()">
      <span class="live-toggle-text">🔴 Live</span>
    </label>
    <select id="rubricSelect" class="rubric-live-select" onchange="updateLiveMode()">
      <option value="0">📂 Rubrik wählen…</option>
      <option value="9">📰 Nachrichten</option>
      <option value="64" class="sub">└ Angebot</option>
      <option value="65" class="sub">└ Crowdfunding</option>
      <option value="66" class="sub">└ Branche</option>
      <option value="67" class="sub">└ Neuerscheinungen</option>
      <option value="68" class="sub">└ Ankündigungen</option>
      <option value="69" class="sub">└ Zubehör</option>
      <option value="8">🎲 Brettspieltest</option>
      <option value="11">📖 Magazin</option>
      <option value="10">🎬 Video</option>
      <option value="55">🎙 Podcast</option>
    </select>
    <button class="btn btn-publish" id="publishBtn" onclick="publishSelected()" disabled{publish_title}>📢 Veröffentlichen</button>
    <button class="btn btn-delete" id="deleteBtn" onclick="deleteSelected()" disabled>Markierte löschen</button>
    <span class="counter" id="counter">0 markiert</span>
  </div>
  <div class="upload-zone" id="uploadZone">
    <h3>📷 Bild hochladen</h3>
    <p style="margin-bottom:0.5rem;">Ziehe Bilder hierher oder klicke zum Auswählen.</p>
    <p class="upload-help">Das Bild wird automatisch dem <strong>ersten markierten Artikel</strong> zugeordnet.<br>Oder nutze das 📷 Icon direkt neben dem gewünschten Artikel.</p>
    <div class="upload-row">
      <input type="file" id="fileInput" onchange="uploadFile()" style="font-family:var(--font);font-size:0.84rem;">
    </div>
    <div class="upload-result" id="uploadResult"></div>
  </div>
  {items_html}
</div>
<div class="toast" id="toast"></div>
<script>
  const checks = document.querySelectorAll('.article-check');
  const deleteBtn = document.getElementById('deleteBtn');
  const publishBtn = document.getElementById('publishBtn');
  const counter = document.getElementById('counter');
  const toast = document.getElementById('toast');

  function updateUI() {{
    const checked = document.querySelectorAll('.article-check:checked');
    const count = checked.length;
    counter.textContent = count + ' markiert';
    deleteBtn.disabled = count === 0;
    if (publishBtn) publishBtn.disabled = count === 0;
    document.querySelectorAll('.article-row').forEach(row => row.classList.remove('selected'));
    checked.forEach(cb => cb.closest('.article-row').classList.add('selected'));
  }}

  checks.forEach(cb => cb.addEventListener('change', updateUI));

  function selectAll() {{ checks.forEach(cb => {{ cb.checked = true; }}); updateUI(); }}
  function deselectAll() {{ checks.forEach(cb => {{ cb.checked = false; }}); updateUI(); }}

  function toggleUpload() {{
    const zone = document.getElementById('uploadZone');
    zone.classList.toggle('active');
  }}

  function uploadFile() {{
    const file = document.getElementById('fileInput').files[0];
    if (!file) return;

    // Nimm den ersten markierten Artikel als Ziel
    const checked = document.querySelectorAll('.article-check:checked');
    if (checked.length === 0) {{
      showToast('Bitte zuerst einen Artikel markieren (Checkbox links)', true);
      return;
    }}
    const articleFile = checked[0].value;

    const formData = new FormData();
    formData.append('file', file);
    formData.append('article', articleFile);
    const result = document.getElementById('uploadResult');
    result.textContent = 'Lade hoch…';
    result.classList.add('show');
    fetch('/upload?key={ACCESS_TOKEN}', {{ method: 'POST', body: formData }})
      .then(r => r.json())
      .then(data => {{
        if (data.ok) {{
          result.textContent = '✅ ' + file.name + ' → ' + articleFile;
          result.style.background = '#f0fdf4';
          showToast('✅ Bild zugeordnet: ' + articleFile.split('_2026')[0].replace(/_/g,' ').replace('artikel ',''), false);
          // Reload nach 1.5s um Badge-Counter zu aktualisieren
          setTimeout(() => location.reload(), 1500);
        }} else {{
          result.textContent = '❌ ' + (data.error || 'Fehler');
          result.style.background = '#fef2f2';
        }}
      }})
      .catch(e => {{
        result.textContent = '❌ ' + e.message;
        result.style.background = '#fef2f2';
      }});
  }}

  async function uploadForRow(input, articleFile) {{
    const file = input.files[0];
    if (!file) return;
    const btn = input.closest('.row-upload-btn');
    if (btn) btn.classList.add('uploading');

    const formData = new FormData();
    formData.append('file', file);
    formData.append('article', articleFile);

    try {{
      const resp = await fetch('/upload?key={ACCESS_TOKEN}', {{ method: 'POST', body: formData }});
      const data = await resp.json();
      if (btn) btn.classList.remove('uploading');
      if (data.ok) {{
        showToast('✅ ' + file.name + ' → ' + articleFile.split('_2026')[0].replace(/_/g,' ').replace('artikel ',''), false);
        // Update image badge (bump count visually)
        const row = input.closest('.article-row');
        if (row) {{
          const badge = row.querySelector('.img-badge');
          if (badge) {{
            const match = badge.textContent.match(/\\d+/);
            const current = match ? parseInt(match[0]) : 0;
            badge.textContent = '🖼' + (current + 1);
          }} else {{
            const info = row.querySelector('.article-info');
            if (info) info.insertAdjacentHTML('afterend', '<span class=\"img-badge\">🖼1</span>');
          }}
        }}
        // Clear the file input so the same file can be re-uploaded
        input.value = '';
      }} else {{
        showToast('❌ ' + (data.error || 'Fehler'), true);
      }}
    }} catch(e) {{
      if (btn) btn.classList.remove('uploading');
      showToast('❌ ' + e.message, true);
    }}
  }}

  async function assignOrphan(select, imagePath) {{
    const article = select.value;
    if (!article) return;
    try {{
      const resp = await fetch('/assign-image?key={ACCESS_TOKEN}', {{
        method: 'POST',
        headers: {{'Content-Type': 'application/json'}},
        body: JSON.stringify({{image: imagePath, article: article}})
      }});
      const data = await resp.json();
      if (data.ok) {{
        showToast('✅ Bild zugeordnet', false);
        select.closest('.orphan-row').style.opacity = '0.4';
        select.disabled = true;
        setTimeout(() => location.reload(), 1200);
      }} else {{
        showToast('❌ ' + (data.error || 'Fehler'), true);
      }}
    }} catch(e) {{
      showToast('❌ ' + e.message, true);
    }}
  }}

  const uploadZone = document.getElementById('uploadZone');
  uploadZone.addEventListener('dragover', e => {{ e.preventDefault(); uploadZone.classList.add('dragover'); }});
  uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('dragover'));
  uploadZone.addEventListener('drop', e => {{
    e.preventDefault();
    uploadZone.classList.remove('dragover');
    document.getElementById('fileInput').files = e.dataTransfer.files;
    uploadFile();
  }});

  function showToast(msg, isError) {{
    toast.textContent = msg;
    toast.className = 'toast' + (isError ? ' error' : '') + ' show';
    setTimeout(() => toast.classList.remove('show'), 3000);
  }}

  function updateLiveMode() {{
    const live = document.getElementById('liveToggle').checked;
    const publishBtn = document.getElementById('publishBtn');
    if (live) {{
      publishBtn.textContent = '🔴 LIVE veröffentlichen';
      publishBtn.style.background = '#dc3545';
      publishBtn.style.boxShadow = '0 2px 8px rgba(220,53,69,0.25)';
    }} else {{
      publishBtn.textContent = '📢 Veröffentlichen';
      publishBtn.style.background = '';
      publishBtn.style.boxShadow = '';
    }}
  }}

  async function publishSelected() {{
    const selected = Array.from(document.querySelectorAll('.article-check:checked')).map(cb => cb.value);
    if (selected.length === 0) return;
    const live = document.getElementById('liveToggle').checked;
    const rubric = document.getElementById('rubricSelect').value;

    if (live && rubric === '0') {{
      showToast('❌ Bitte eine Rubrik wählen (Dropdown rechts neben 🔴 Live)', true);
      return;
    }}

    // Readiness-Warnung (soft, blockiert nicht)
    const notReady = [];
    document.querySelectorAll('.article-check:checked').forEach(cb => {{
      const row = cb.closest('.article-row');
      if (row && row.dataset.ready === 'false') {{
        notReady.push(row.querySelector('.article-link')?.textContent || cb.value);
      }}
    }});
    let confirmMsg;
    if (live) {{
      const rubricName = document.getElementById('rubricSelect').selectedOptions[0].text;
      confirmMsg = '🔴 LIVE in »' + rubricName + '«: ' + selected.length + ' Artikel ÖFFENTLICH veröffentlichen?\\n\\nDies ist für 10.000+ Leser sichtbar!';
    }} else {{
      confirmMsg = selected.length + ' Artikel auf KORREKTUR hochladen?\\n\\nNur Redaktion sichtbar. Sicher.';
    }}
    if (notReady.length > 0) {{
      confirmMsg += '\\n\\n⚠️ ' + notReady.length + ' Artikel ohne Meta-Daten/Bild.';
    }}
    if (!confirm(confirmMsg)) return;

    publishBtn.disabled = true;
    publishBtn.textContent = 'Veröffentliche…';

    try {{
      const auth = btoa('{USERNAME}:{PASSWORD}');
      const resp = await fetch('/publish', {{
        method: 'POST',
        headers: {{'Content-Type': 'application/json', 'Authorization': 'Basic ' + auth}},
        body: JSON.stringify({{files: selected, live: live, rubric: parseInt(rubric)}})
      }});
      const data = await resp.json();
      if (data.ok) {{
        showToast(data.published + ' Artikel veröffentlicht (' + (data.live_mode ? 'LIVE' : 'Korrektur') + '). ' + (data.images_deleted || 0) + ' Bilder gelöscht. Seite wird neu geladen...', false);
        setTimeout(() => location.reload(), 2000);
      }} else {{
        showToast('Fehler: ' + (data.error || 'unbekannt'), true);
      }}
    }} catch(e) {{
      showToast('Fehler: ' + e.message, true);
    }}
    publishBtn.disabled = false;
    publishBtn.textContent = '📢 Veröffentlichen';
  }}

  async function deleteSelected() {{
    const selected = Array.from(document.querySelectorAll('.article-check:checked')).map(cb => cb.value);
    if (selected.length === 0) return;
    if (!confirm(selected.length + ' Artikel wirklich löschen? Das kann nicht rückgängig gemacht werden.')) return;

    deleteBtn.disabled = true;
    deleteBtn.textContent = 'Lösche…';

    try {{
      const auth = btoa('{USERNAME}:{PASSWORD}');
      const resp = await fetch('/delete', {{
        method: 'POST',
        headers: {{'Content-Type': 'application/json', 'Authorization': 'Basic ' + auth}},
        body: JSON.stringify({{files: selected}})
      }});
      const data = await resp.json();
      if (data.ok) {{
        showToast(data.deleted + ' Artikel gelöscht. Seite wird neu geladen...', false);
        setTimeout(() => location.reload(), 1500);
      }} else {{
        showToast('Fehler: ' + (data.error || 'unbekannt'), true);
      }}
    }} catch(e) {{
      showToast('Fehler: ' + e.message, true);
    }}
    deleteBtn.disabled = false;
    deleteBtn.textContent = 'Markierte löschen';
  }}
</script>
</body>
</html>"""


class ThreadingArticleServer(ThreadingMixIn, HTTPServer):
    """HTTPServer with threading support — handles multiple requests concurrently."""
    daemon_threads = True
    allow_reuse_address = True

class ArticleHandler(BaseHTTPRequestHandler):
    timeout = 30

    def _check_auth(self) -> bool:
        auth_header = self.headers.get("Authorization", "")
        if not auth_header.startswith("Basic "):
            self._send_auth_required()
            return False
        try:
            creds = base64.b64decode(auth_header[6:]).decode("utf-8")
            username, password = creds.split(":", 1)
        except Exception:
            self._send_auth_required()
            return False
        if username == USERNAME and password == PASSWORD:
            return True
        self._send_auth_required()
        return False

    def _send_auth_required(self):
        self.send_response(401)
        self.send_header("WWW-Authenticate", 'Basic realm="Artikel-Archiv"')
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.end_headers()
        html = """<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"><title>Zugriff verweigert</title>
<style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f5f0ea;color:#2c2416;}
.box{text-align:center;padding:2rem;background:#fff;border-radius:8px;box-shadow:0 2px 12px rgba(0,0,0,.08);}
h1{color:#c0392b;}p{color:#888;}</style></head>
<body><div class="box"><h1>Zugriff verweigert</h1><p>Bitte einloggen, um die Artikel-Übersicht zu sehen.</p></div></body></html>"""
        self.wfile.write(html.encode("utf-8"))

    def _check_token(self) -> bool:
        parsed = urllib.parse.urlparse(self.path)
        params = urllib.parse.parse_qs(parsed.query)
        key = params.get("key", [None])[0]
        if key == ACCESS_TOKEN:
            return True
        self.send_response(403)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.end_headers()
        html = "<!DOCTYPE html><html lang=\"de\"><head><meta charset=\"UTF-8\"><title>Zugriff verweigert</title><style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f5f0ea;color:#2c2416;}.box{text-align:center;padding:2rem;background:#fff;border-radius:8px;box-shadow:0 2px 12px rgba(0,0,0,.08);}h1{color:#c0392b;}p{color:#888;}</style></head><body><div class=\"box\"><h1>Zugriff verweigert</h1><p>Diese Seite ist nicht öffentlich.</p></div></body></html>"
        self.wfile.write(html.encode("utf-8"))
        return False

    def do_GET(self):
        try:
            self._do_GET_impl()
        except (ConnectionResetError, BrokenPipeError, socket.timeout):
            pass
        except Exception as e:
            try:
                self.send_response(500)
                self.end_headers()
                self.wfile.write(b"Internal Server Error")
            except Exception:
                pass
            print(f"[ERROR] {self.client_address} - {e}", flush=True)

    def _do_GET_impl(self):
        if not self._check_token():
            return

        parsed = urllib.parse.urlparse(self.path)
        path = parsed.path

        if path == "/" or path == "/index.html":
            articles = get_articles()
            html = build_index(articles)
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.end_headers()
            self.wfile.write(html.encode("utf-8"))
        elif path == "/nanobanana":
            html = self._build_nanobanana_page()
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.end_headers()
            self.wfile.write(html.encode("utf-8"))
        elif path == "/nanobanana/articles":
            self._handle_nanobanana_articles()
        elif path.startswith("/media/"):
            rel = path[len("/"):]
            filepath = os.path.normpath(os.path.join(WORKSPACE, rel))
            if os.path.isfile(filepath) and filepath.startswith(WORKSPACE):
                with open(filepath, "rb") as f:
                    data = f.read()
                self.send_response(200)
                self.send_header("Content-Length", str(len(data)))
                ext = os.path.splitext(filepath)[1].lower()
                mime_map = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
                           ".webp": "image/webp", ".gif": "image/gif", ".svg": "image/svg+xml"}
                self.send_header("Content-Type", mime_map.get(ext, "application/octet-stream"))
                self.send_header("Cache-Control", "public, max-age=86400")
                self.end_headers()
                self.wfile.write(data)
            else:
                self.send_response(404)
                self.end_headers()
        elif path.startswith("/"):
            filepath = os.path.join(WORKSPACE, path.lstrip("/"))
            if os.path.isfile(filepath) and filepath.startswith(WORKSPACE):
                with open(filepath, "rb") as f:
                    data = f.read()
                self.send_response(200)
                self.send_header("Content-Length", str(len(data)))
                if filepath.endswith(".html"):
                    self.send_header("Content-Type", "text/html; charset=utf-8")
                elif filepath.endswith(".css"):
                    self.send_header("Content-Type", "text/css")
                elif filepath.endswith(".js"):
                    self.send_header("Content-Type", "application/javascript")
                else:
                    self.send_header("Content-Type", "application/octet-stream")
                self.end_headers()
                self.wfile.write(data)
            else:
                self.send_response(404)
                self.end_headers()
                self.wfile.write(b"Not found")
        else:
            self.send_response(404)
            self.end_headers()

    def do_POST(self):
        try:
            self._do_POST_impl()
        except Exception as e:
            print(f"[POST ERROR] {e}", flush=True)
            try:
                self._json_reply(500, {"ok": False, "error": str(e)})
            except Exception:
                pass

    def _do_POST_impl(self):
        parsed = urllib.parse.urlparse(self.path)
        path = parsed.path

        if path == "/upload":
            if not self._check_token():
                return
            self._handle_upload()
        elif path == "/assign-image":
            if not self._check_token():
                return
            self._handle_assign_image()
        elif path == "/nanobanana/generate":
            if not self._check_token():
                return
            self._handle_image_generate()
        elif path == "/nanobanana/articles":
            if not self._check_token():
                return
            self._handle_nanobanana_articles()
        elif path == "/nanobanana/assign":
            if not self._check_token():
                return
            self._handle_nanobanana_assign()
        elif path == "/delete":
            if not self._check_auth():
                return
            self._handle_delete()
        elif path == "/publish":
            if not self._check_auth():
                return
            self._handle_publish()
        else:
            self.send_response(404)
            self.end_headers()

    # ═══════════════════════════════════════════════════════════════════
    # Imagen Handler (Google Imagen — synchron, kein Polling)
    # ═══════════════════════════════════════════════════════════════════

    def _handle_image_generate(self):
        """Bildgenerierung via Google Imagen (synchron, Base64→JPEG)."""
        import base64 as b64
        
        content_type = self.headers.get("Content-Type", "")
        if "multipart/form-data" not in content_type:
            self._json_reply(400, {"ok": False, "error": "Expected multipart/form-data"})
            return

        boundary = content_type.split("boundary=")[-1].strip()
        if not boundary:
            self._json_reply(400, {"ok": False, "error": "Kein Boundary"})
            return

        length = int(self.headers.get("Content-Length", 0))
        raw = self.rfile.read(length)

        # Multipart manuell parsen (Python 3.13 hat kein cgi)
        parts = raw.split(b"--" + boundary.encode())
        prompt = ""
        aspect_ratio = "16:9"
        resolution = "1K"
        image_base64 = None

        for part in parts:
            if b"Content-Disposition" not in part:
                continue
            headers_end = part.find(b"\r\n\r\n")
            if headers_end == -1:
                continue
            headers_raw = part[:headers_end].decode("utf-8", errors="ignore")
            content = part[headers_end + 4:].rstrip(b"\r\n")

            if 'name="prompt"' in headers_raw:
                prompt = content.decode("utf-8", errors="ignore")
            elif 'name="aspectRatio"' in headers_raw:
                aspect_ratio = content.decode("utf-8", errors="ignore")
            elif 'name="resolution"' in headers_raw:
                resolution = content.decode("utf-8", errors="ignore")
            elif 'name="image"' in headers_raw and len(content) > 100:
                # Referenzbild als Base64 für Image-to-Image
                image_base64 = b64.b64encode(content).decode()

        if not prompt:
            self._json_reply(400, {"ok": False, "error": "Prompt fehlt"})
            return

        # Gemini Flash Image aufrufen (synchron!)
        result = _gemini_generate_image(prompt, image_base64, aspect_ratio, resolution)
        if "error" in result:
            self._json_reply(500, {"ok": False, "error": result["error"]})
            return

        # Base64-Bild (PNG) dekodieren
        b64_data = result.get("image_base64", "")
        if not b64_data:
            self._json_reply(500, {"ok": False, "error": "Leeres Bild von Gemini"})
            return

        img_bytes = b64.b64decode(b64_data)

        # Im media-Ordner speichern
        now = datetime.now()
        month_dir = os.path.join(MEDIA_DIR, now.strftime("%Y"), now.strftime("%m"))
        os.makedirs(month_dir, exist_ok=True)
        base_name = f"gemini_{now.strftime('%H%M%S')}"
        ext = ".png"  # Gemini liefert PNG
        filepath = os.path.join(month_dir, base_name + ext)

        with open(filepath, "wb") as f:
            f.write(img_bytes)

        # Optional: PIL-Konvertierung zu JPEG (1920x1080 @ 72 DPI)
        try:
            from PIL import Image
            img = Image.open(filepath)
            img = img.convert("RGB")
            img = img.resize((1920, 1080), Image.LANCZOS)
            jpg_path = os.path.join(month_dir, base_name + ".jpg")
            img.save(jpg_path, "JPEG", dpi=(72, 72), quality=92)
            filepath = jpg_path
            ext = ".jpg"
        except Exception:
            pass  # PNG behalten wenn PIL fehlschlägt

        img_url = f"/media/{now.strftime('%Y')}/{now.strftime('%m')}/{base_name}{ext}?key={ACCESS_TOKEN}"
        self._json_reply(200, {
            "ok": True,
            "imageUrl": img_url,
            "filepath": filepath,
        })

    def _handle_nanobanana_articles(self):
        """Liste unveröffentlichter Artikel als JSON."""
        articles = _get_unpublished_articles()
        self._json_reply(200, {"ok": True, "articles": articles})

    def _handle_nanobanana_assign(self):
        """Bild einem Artikel zuordnen."""
        content_type = self.headers.get("Content-Type", "")
        if "application/json" not in content_type:
            self._json_reply(400, {"ok": False, "error": "Expected JSON"})
            return
        
        length = int(self.headers.get("Content-Length", 0))
        raw = self.rfile.read(length)
        try:
            data = json.loads(raw)
        except json.JSONDecodeError:
            self._json_reply(400, {"ok": False, "error": "Invalid JSON"})
            return
        
        article = data.get("article", "")
        image_url = data.get("imageUrl", "")
        
        if not article or not image_url:
            self._json_reply(400, {"ok": False, "error": "article und imageUrl erforderlich"})
            return
        
        # Bild herunterladen oder lokal lesen
        try:
            if image_url.startswith("/media/"):
                # Lokales Bild — direkt von Platte lesen
                local_path = os.path.join(WORKSPACE, image_url.lstrip("/"))
                if not os.path.isfile(local_path):
                    self._json_reply(500, {"ok": False, "error": f"Lokales Bild nicht gefunden: {local_path}"})
                    return
                with open(local_path, "rb") as lf:
                    img_data = lf.read()
            else:
                req = urllib.request.Request(image_url)
                req.add_header("User-Agent", "Mozilla/5.0")
                with urllib.request.urlopen(req, timeout=30) as resp:
                    img_data = resp.read()
        except Exception as e:
            self._json_reply(500, {"ok": False, "error": f"Download fehlgeschlagen: {e}"})
            return
        
        # In media/YYYY/MM/ speichern
        now = datetime.now()
        month_dir = os.path.join(MEDIA_DIR, now.strftime("%Y"), now.strftime("%m"))
        os.makedirs(month_dir, exist_ok=True)
        ts = now.strftime("%H%M%S")
        fname = f"nanobanana_{ts}.jpg"
        fpath = os.path.join(month_dir, fname)
        
        with open(fpath, "wb") as f:
            f.write(img_data)
        
        # article_images.json updaten
        img_map = get_image_map()
        if article not in img_map:
            img_map[article] = []
        img_map[article].append(fpath)
        save_image_map(img_map)
        
        self._json_reply(200, {"ok": True, "path": fpath, "filename": fname})

    def _build_nanobanana_page(self) -> str:
        """Baut die NanoBanana Editor HTML-Seite."""
        articles = _get_unpublished_articles()
        article_options = ""
        for a in articles[:30]:
            article_options += f'<option value="{a["filename"]}">{a["title"]}</option>\n'
        
        return NANOBANANA_HTML.format(
            key=ACCESS_TOKEN,
            article_options=article_options,
        )

    # ═══════════════════════════════════════════════════════════════════
    # End NanoBanana Handler
    # ═══════════════════════════════════════════════════════════════════

    def _handle_upload(self):
        """Handle multipart file upload — now accepts 'article' field for association."""
        content_type = self.headers.get("Content-Type", "")
        if "multipart/form-data" not in content_type:
            self._json_reply(400, {"ok": False, "error": "Expected multipart/form-data"})
            return

        content_length = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(content_length)

        boundary = None
        for part in content_type.split(";"):
            part = part.strip()
            if part.startswith("boundary="):
                boundary = part[9:].strip('"').strip("'").strip()
                break

        if not boundary:
            self._json_reply(400, {"ok": False, "error": "Kein Boundary in Content-Type"})
            return

        b_boundary = boundary.encode()
        parts = body.split(b"--" + b_boundary)
        file_data = None
        filename = None
        article_file = None

        for part in parts:
            if b"Content-Disposition" not in part:
                continue
            header_end = part.find(b"\r\n\r\n")
            if header_end == -1:
                continue
            headers = part[:header_end].decode("utf-8", errors="replace")

            if b"filename=" in part:
                fn_match = re.search(r'filename="([^"]*)"', headers)
                if fn_match:
                    filename = os.path.basename(fn_match.group(1))
                file_data = part[header_end + 4:]
                if file_data:
                    file_data = file_data.rstrip(b"\r\n").rstrip(b"-")
            elif 'name="article"' in headers:
                article_file = part[header_end + 4:].decode("utf-8", errors="replace").strip()
                if article_file:
                    article_file = article_file.rstrip("\r\n").rstrip("-")

        if not file_data or not filename:
            self._json_reply(400, {"ok": False, "error": "Keine Datei gefunden"})
            return

        ext = os.path.splitext(filename)[1].lower()
        if ext not in (".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg"):
            self._json_reply(400, {"ok": False, "error": f"Nicht erlaubtes Format: {ext}. Erlaubt: jpg, png, webp, gif, svg"})
            return

        now = datetime.now()
        month_dir = os.path.join(MEDIA_DIR, now.strftime("%Y"), now.strftime("%m"))
        os.makedirs(month_dir, exist_ok=True)

        base, ext = os.path.splitext(filename)
        safe_base = re.sub(r'[^a-zA-Z0-9_-]', '_', base)
        unique_name = f"{safe_base}_{now.strftime('%H%M%S')}{ext}"
        filepath = os.path.join(month_dir, unique_name)

        with open(filepath, "wb") as f:
            f.write(file_data)

        # ── Article-Image Association ──────────────────────────────────────
        association_msg = ""
        if article_file:
            img_map = get_image_map()
            if article_file not in img_map:
                img_map[article_file] = []
            img_map[article_file].append(filepath)
            save_image_map(img_map)
            association_msg = f" → zugeordnet zu: {article_file}"

        rel_path = os.path.relpath(filepath, WORKSPACE)
        self._json_reply(200, {
            "ok": True,
            "filename": unique_name,
            "path": filepath,
            "url": f"MEDIA:{filepath}",
            "html_src": f"/media/{now.strftime('%Y')}/{now.strftime('%m')}/{unique_name}",
            "article": article_file,
        })

    def _handle_assign_image(self):
        """Associate an existing image file with an article."""
        length = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(length)
        try:
            data = json.loads(body)
            image_path = data.get("image", "")
            article_file = data.get("article", "")
        except json.JSONDecodeError:
            self._json_reply(400, {"ok": False, "error": "Invalid JSON"})
            return

        if not image_path or not os.path.exists(image_path):
            self._json_reply(400, {"ok": False, "error": f"Bild nicht gefunden: {image_path}"})
            return
        if not article_file:
            self._json_reply(400, {"ok": False, "error": "Kein Artikel angegeben"})
            return

        img_map = get_image_map()
        if article_file not in img_map:
            img_map[article_file] = []
        if image_path not in img_map[article_file]:
            img_map[article_file].append(image_path)
        save_image_map(img_map)
        self._json_reply(200, {"ok": True, "image": image_path, "article": article_file})

    def _handle_delete(self):
        length = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(length)
        try:
            data = json.loads(body)
            files = data.get("files", [])
        except json.JSONDecodeError:
            self._json_reply(400, {"ok": False, "error": "Invalid JSON"})
            return

        deleted = 0
        errors = []
        for fname in files:
            fname = os.path.basename(fname)
            if not fname.endswith(".html"):
                errors.append(f"{fname}: not an HTML file")
                continue
            filepath = os.path.join(WORKSPACE, fname)
            if not os.path.exists(filepath):
                errors.append(f"{fname}: not found")
                continue
            try:
                os.remove(filepath)
                deleted += 1
            except OSError as e:
                errors.append(f"{fname}: {e}")

        self._json_reply(200, {"ok": True, "deleted": deleted, "errors": errors})

    def _handle_publish(self):
        """Publish selected articles via Joomla API with VG-Wort pixel injection
        and automatic cleanup of associated images.

        SAFETY GATE (Daniel 18.06.): Ohne 'live': true im Request-Payload
        gehen ALLE Artikel auf Korrektur (catid=61, state=0) — niemals live.
        """
        if not JOOMLA_TOKEN:
            self._json_reply(400, {
                "ok": False,
                "error": "Kein Joomla API-Token konfiguriert. "
                         "Bitte JOOMLA_API_TOKEN Umgebungsvariable setzen "
                         "oder /tmp/alicia_skills/KEYS.txt anlegen."
            })
            return

        length = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(length)
        try:
            data = json.loads(body)
            files = data.get("files", [])
            allow_live = data.get("live", False)  # SAFETY GATE: Nur wenn explizit true
            rubric = data.get("rubric", 0)  # 0 = nicht gesetzt
        except json.JSONDecodeError:
            self._json_reply(400, {"ok": False, "error": "Invalid JSON"})
            return

        published = 0
        published_ids = []
        images_deleted_total = 0
        errors = []
        img_map = get_image_map()

        # Safety log
        mode = "🔴 LIVE (state=1)" if allow_live else "🟡 KORREKTUR (state=0, Redaktion-only)"
        print(f"[PUBLISH] {len(files)} Artikel, Modus: {mode}", flush=True)

        # Try importing requests
        try:
            import requests
        except ImportError:
            self._json_reply(500, {
                "ok": False,
                "error": "Python 'requests' Modul nicht installiert. "
                         "Bitte ausführen: /home/hermes/venv/bin/pip install requests"
            })
            return

        for fname in files:
            fname = os.path.basename(fname)
            if not fname.endswith(".html"):
                errors.append(f"{fname}: not an HTML file")
                continue
            filepath = os.path.join(WORKSPACE, fname)
            if not os.path.exists(filepath):
                errors.append(f"{fname}: not found")
                continue

            try:
                with open(filepath, "r", encoding="utf-8") as f:
                    html_content = f.read()

                # ── Extract article metadata ───────────────────────────────
                title_match = re.search(r'<title>([^<]+)</title>', html_content)
                if not title_match:
                    errors.append(f"{fname}: kein <title> gefunden")
                    continue
                title = title_match.group(1)

                # ── Readiness-Warnung (soft, blockiert nicht) ───────────
                readiness_warnings = []

                meta_desc_match = re.search(
                    r'<meta\s+name="description"\s+content="([^"]+)"',
                    html_content, re.IGNORECASE
                )
                if not (meta_desc_match and meta_desc_match.group(1).strip()):
                    readiness_warnings.append("keine Meta-Description")

                meta_key_match = re.search(
                    r'<meta\s+name="keywords"\s+content="([^"]+)"',
                    html_content, re.IGNORECASE
                )
                if not (meta_key_match and meta_key_match.group(1).strip()):
                    readiness_warnings.append("keine Meta-Keywords")

                if fname not in img_map or not img_map[fname]:
                    readiness_warnings.append("kein Bild zugewiesen")

                if readiness_warnings:
                    # Soft warning — Publizieren trotzdem möglich
                    errors.append(f"{fname}: ⚠️ {', '.join(readiness_warnings)} (trotzdem publiziert)")

                # Extract the article body (content inside <article> or <body>)
                body_match = re.search(r'<article[^>]*>(.*?)</article>', html_content, re.DOTALL)
                if not body_match:
                    # Fallback: use everything between </header> and <footer> or end
                    body_match = re.search(r'(?:</header>|</head>)(.*?)(?:<footer|<div class="quellen"|</body)', html_content, re.DOTALL)
                article_body = body_match.group(1) if body_match else html_content

                # ── Strip internal markers from article body ──────────────
                # These are editorial metadata, not part of the published article.
                # Applied AFTER extraction but BEFORE read-more split.
                article_body = re.sub(r'<h1[^>]*>.*?</h1>\s*', '', article_body, flags=re.DOTALL)
                article_body = re.sub(r'<p\s+class="meta"[^>]*>.*?</p>\s*', '', article_body, flags=re.DOTALL)
                article_body = re.sub(r'<p\s+class="qc"[^>]*>.*?</p>\s*', '', article_body, flags=re.DOTALL)
                # ── Fallback: strip any remaining h1 or meta (edge cases) ──
                article_body = re.sub(r'<h1\b[^>]*>.*?</h1>\s*', '', article_body, flags=re.DOTALL | re.IGNORECASE)
                article_body = re.sub(r'<p\b[^>]*\bclass\s*=\s*"[^"]*\bmeta\b[^"]*"[^>]*>.*?</p>\s*', '', article_body, flags=re.DOTALL | re.IGNORECASE)
                # ── Umbruch vor Überschriften (Daniel 19.06.: bessere Lesbarkeit) ──
                article_body = re.sub(r'(</(?:p|/div|/blockquote)>)\s*(<h[234])', r'\1\n<br>\n\2', article_body)
                # ── Remove <br> after read-more (muss NACH Umbruch-Insertion laufen!) ──
                article_body = re.sub(
                    r'(<hr\s+id="system-readmore"\s*/?>)\s*<br\s*/?>\s*',
                    r'\1\n',
                    article_body, flags=re.IGNORECASE
                )
                # ── Strip trailing whitespace/newlines before closing </article> ──
                article_body = re.sub(r'\s*</article>', '</article>', article_body)

                # Extract Joomla ID if present (for updates)
                jid_match = re.search(r'<meta\s+name="joomla-id"\s+content="(\d+|XXXXX)"', html_content, re.IGNORECASE)
                joomla_id = jid_match.group(1) if jid_match else None

                # ── Kategorie: Rubrik vom Dropdown oder meta ──────────
                catid = 61  # Default: Korrektur
                if allow_live and rubric > 0:
                    catid = rubric
                elif allow_live:
                    cat_match = re.search(r'<meta\s+name="category-id"\s+content="(\d+)"', html_content, re.IGNORECASE)
                    if cat_match:
                        catid = int(cat_match.group(1))

                # Korrektur (61) → state=0 (unpublished/Redaktion-only)
                # Alles andere → state=1 (published/public) – NUR bei allow_live
                state = 0 if catid == 61 else 1
                access = 6 if catid == 61 else 1  # 6=Redaktion, 1=Public

                # ── Featured (Hauptartikel) ─────────────────────────
                featured = 0
                featured_up = None
                featured_down = None
                if allow_live and catid != 61:
                    # Default: 3 Tage featured, via <meta name="featured-days"> anpassbar
                    featured_days = 3
                    fd_match = re.search(
                        r'<meta\s+name="featured-days"\s+content="(\d+)"',
                        html_content, re.IGNORECASE
                    )
                    if fd_match:
                        featured_days = int(fd_match.group(1))

                    from datetime import datetime as dt, timedelta
                    now = dt.now()
                    featured = 1
                    featured_up = now.strftime("%Y-%m-%d %H:%M:%S")
                    featured_down = (now + timedelta(days=featured_days)).strftime("%Y-%m-%d %H:%M:%S")

                # Generate alias from title
                alias = re.sub(r'[^a-z0-9]+', '-', title.lower().strip())[:100]

                # ── VG-Wort Zählmarke IMMER einbauen (auch bei Korrektur) ──
                # Daniel 19.06.: Pixel muss bei JEDEM Publish rein, weil er
                # später manuell live stellt und sonst kein Geld bekommt.
                vgwort_pixel_html = ""
                try:
                    import subprocess
                    result = subprocess.run(
                        ["python3", os.path.expanduser("~/.hermes/vgwort_pool.py"), "get"],
                        capture_output=True, text=True, timeout=30
                    )
                    if result.returncode == 0:
                        pixel_data = json.loads(result.stdout)
                        if pixel_data.get("ok"):
                            vgwort_pixel_html = pixel_data.get("pixel_html", "")
                            print(f"[VG-Wort] Zählmarke {pixel_data.get('public_id')} eingebaut, "
                                  f"Pool: {pixel_data.get('pool_remaining')} übrig", flush=True)
                        else:
                            print(f"[VG-Wort] FEHLER: {pixel_data.get('error', 'unbekannt')}", flush=True)
                    else:
                        print(f"[VG-Wort] Pool-Script fehlgeschlagen: {result.stderr}", flush=True)
                except Exception as e:
                    print(f"[VG-Wort] Ausnahme: {e}", flush=True)

                if vgwort_pixel_html:
                    if '</article>' in article_body:
                        article_body = article_body.replace('</article>', f'{vgwort_pixel_html}\n</article>')
                    else:
                        article_body = article_body.rstrip() + f'\n{vgwort_pixel_html}\n'

                # ── Joomla API call ─────────────────────────────────────────
                headers = {
                    "Accept": "application/vnd.api+json",
                    "Content-Type": "application/json",
                    "X-Joomla-Token": JOOMLA_TOKEN,
                }

                # Joomla 5/6 PATCH-Fix: articletext wird ignoriert, muss in introtext + fulltext aufgeteilt werden
                introtext = ""
                fulltext = article_body.strip()
                sr_match = re.search(r'<hr\s+id="system-readmore"\s*/?>', article_body, re.IGNORECASE)
                if sr_match:
                    split_idx = sr_match.start()
                    introtext = article_body[:split_idx].strip()
                    fulltext = article_body[split_idx:].strip()
                    # Remove the <hr id="system-readmore" /> from fulltext start
                    # (Joomla inserts its own read-more between introtext and fulltext)
                    fulltext = re.sub(r'^<hr\s+id="system-readmore"\s*/?>\s*', '', fulltext)

                # ── CSS aus <head> in fulltext injizieren ──────────────────
                # Problem: <style> liegt außerhalb <article>, wird beim
                # Body-Extract nicht mitgenommen → Tabellen-Styling fehlt.
                style_match = re.search(r'<style>(.*?)</style>', html_content, re.DOTALL)
                if style_match:
                    style_css = style_match.group(1).strip()
                    fulltext = f'<style>\n{style_css}\n</style>\n\n{fulltext}'

                # ── target="_blank" für externe Links erzwingen ──────
                # Daniel 24.06.: Alle externen Links müssen _blank sein,
                # damit brettspiel-news.de nicht verlassen wird.
                def add_target_blank(m):
                    href = m.group(1)
                    rest = m.group(2)
                    if 'brettspiel-news.de' in href:
                        return m.group(0)  # interne Links unverändert
                    if 'target=' in rest:
                        return m.group(0)  # hat bereits target
                    return f'<a href="{href}" target="_blank"{rest}>'
                fulltext = re.sub(r'<a\s+href="([^"]+)"([^>]*)>', add_target_blank, fulltext)
                introtext = re.sub(r'<a\s+href="([^"]+)"([^>]*)>', add_target_blank, introtext)

                article_payload = {
                    "title": title,
                    "alias": alias,
                    "introtext": introtext,
                    "fulltext": fulltext,
                    "catid": catid,
                    "language": "*",
                    "state": state,
                    "access": access,
                    "featured": featured,
                    "created_by": 560,  # Daniel Krause
                }
                # ── Meta-Description & Keywords ─────────────────────────
                if meta_desc_match and meta_desc_match.group(1).strip():
                    article_payload["metadesc"] = meta_desc_match.group(1).strip()
                if meta_key_match and meta_key_match.group(1).strip():
                    article_payload["metakey"] = meta_key_match.group(1).strip()
                # ── Image upload to Joomla (Joomla 5/6: Base64 + json, nicht multipart) ──
                if fname in img_map and img_map[fname]:
                    image_path = img_map[fname][0]  # First image
                    if os.path.exists(image_path):
                        try:
                            import base64 as b64
                            from datetime import datetime as dt
                            img_filename = os.path.basename(image_path)
                            year = dt.now().strftime("%Y")
                            j_img_path = f"Spiele/{year}/{img_filename}"  # OHNE images/ prefix (local-images root = images/)

                            with open(image_path, "rb") as img_f:
                                img_content = b64.b64encode(img_f.read()).decode()

                            img_resp = requests.post(
                                "https://www.brettspiel-news.de/api/index.php/v1/media/files",
                                headers={
                                    "X-Joomla-Token": JOOMLA_TOKEN,
                                    "Accept": "application/vnd.api+json",
                                    "Content-Type": "application/json",
                                },
                                json={"path": j_img_path, "content": img_content},
                                timeout=30
                            )

                            if img_resp.status_code == 400 and "exists" in img_resp.text:
                                # Überschreiben existierender Datei
                                print(f"[Image] {img_filename} existiert — überschreibe via PATCH", flush=True)
                                img_resp = requests.patch(
                                    f"https://www.brettspiel-news.de/api/index.php/v1/media/files/local-images:/{j_img_path}",
                                    headers={
                                        "X-Joomla-Token": JOOMLA_TOKEN,
                                        "Accept": "application/vnd.api+json",
                                        "Content-Type": "application/json",
                                    },
                                    json={"path": j_img_path, "content": img_content},
                                    timeout=30
                                )

                            if img_resp.status_code in (200, 201):
                                j_img_url = f"https://www.brettspiel-news.de/images/{j_img_path}"  # Web-URL braucht images/ Prefix
                                # Joomla requires relative + joomlaImage pseudo-protocol
                                # Format: images/Spiele/2026/file.jpg#joomlaImage://local-images/Spiele/2026/file.jpg
                                # image_intro braucht images/ prefix, joomlaImage NICHT
                                # ── Bild-Dimensionen erkennen (Joomla 6 braucht ?width=&height=) ──
                                try:
                                    from PIL import Image
                                    img_pil = Image.open(image_path)
                                    iw, ih = img_pil.size
                                    img_pil.close()
                                except:
                                    iw, ih = 1920, 1080  # Fallback: Standard-BSN-Bildmaße
                                image_intro_value = f"images/{j_img_path}#joomlaImage://local-images/{j_img_path}?width={iw}&height={ih}"
                                article_payload["images"] = {
                                    "image_intro": image_intro_value,
                                    "image_intro_alt": title,
                                    "image_fulltext": image_intro_value,
                                    "image_fulltext_alt": title,
                                }
                                print(f"[Image] Uploaded {img_filename} → {j_img_path}", flush=True)
                            else:
                                print(f"[Image] Upload failed HTTP {img_resp.status_code}: {img_resp.text[:200]}", flush=True)
                        except Exception as e:
                            print(f"[Image] Upload error: {e}", flush=True)
                if featured_up:
                    article_payload["featured_up"] = featured_up
                if featured_down:
                    article_payload["featured_down"] = featured_down

                if joomla_id:
                    # Update existing article
                    resp = requests.patch(
                        f"{JOOMLA_API_URL}/{joomla_id}",
                        headers=headers, json=article_payload, timeout=30
                    )
                else:
                    # Create new article
                    resp = requests.post(
                        JOOMLA_API_URL,
                        headers=headers, json=article_payload, timeout=30
                    )

                if resp.status_code in (200, 201):
                    published += 1

                    # ── Write back Joomla ID to HTML ────────────────────────
                    resp_data = resp.json()
                    new_jid = None
                    if "data" in resp_data and "id" in resp_data["data"]:
                        new_jid = str(resp_data["data"]["id"])
                    elif "data" in resp_data and "attributes" in resp_data["data"]:
                        # Some responses nest the id differently
                        pass

                    if new_jid:
                        published_ids.append(new_jid)

                    if new_jid:
                        # Immer Joomla-ID in die HTML-Datei schreiben (auch bei Updates)
                        if '<meta name="joomla-id"' not in html_content:
                            jid_meta = f'\n<meta name="joomla-id" content="{new_jid}">'
                            html_content = re.sub(
                                r'(<meta[^>]*>)',
                                lambda m: m.group(1) + jid_meta,
                                html_content,
                                count=1
                            )
                        else:
                            html_content = re.sub(
                                r'<meta\s+name="joomla-id"\s+content="([^"]*)"',
                                f'<meta name="joomla-id" content="{new_jid}"',
                                html_content
                            )
                        with open(filepath, "w", encoding="utf-8") as f:
                            f.write(html_content)

                    # ── Image mapping cleanup: remove association but keep files ──
                    # Daniel 19.06.: Bilder NICHT löschen — sie werden für
                    # spätere Live-Schaltungen und Referenz benötigt.
                    if fname in img_map:
                        del img_map[fname]
                        save_image_map(img_map)

                else:
                    error_msg = f"HTTP {resp.status_code}"
                    try:
                        err_data = resp.json()
                        if "errors" in err_data:
                            error_msg += ": " + str(err_data["errors"][0].get("title", err_data["errors"]))
                    except Exception:
                        error_msg += ": " + resp.text[:200]
                    errors.append(f"{fname}: {error_msg}")

            except Exception as e:
                errors.append(f"{fname}: {e}")

        self._json_reply(200 if published > 0 else 500, {
            "ok": published > 0,
            "error": None if published > 0 else (errors[0] if errors else "Unbekannter Fehler beim Veröffentlichen"),
            "published": published,
            "published_ids": published_ids,
            "live_mode": allow_live,
            "images_deleted": images_deleted_total,
            "errors": errors,
            "vg_wort_enabled": not allow_live or True,  # immer enabled, aber nur bei live genutzt
        })

    def _json_reply(self, status, data):
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(json.dumps(data).encode("utf-8"))


# ═══════════════════════════════════════════════════════════════════════════
# NanoBanana Editor HTML
# ═══════════════════════════════════════════════════════════════════════════

NANOBANANA_HTML = """<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BSN Bild-Generator — Google Imagen</title>
<style>
:root {{
    --bsn-blue: #20228a;
    --bsn-blue-light: #2d30b0;
    --bg: #f5f6fa;
    --card-bg: #fff;
    --text: #1a1a2e;
    --text-muted: #666;
    --radius: 12px;
    --shadow: 0 2px 12px rgba(0,0,0,0.08);
}}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
    background: var(--bg);
    color: var(--text);
    min-height: 100vh;
}}
.header {{
    background: var(--bsn-blue);
    color: #fff;
    padding: 16px 24px;
    font-size: 1.3em;
    font-weight: 700;
    display: flex;
    align-items: center;
    gap: 10px;
}}
.header a {{
    color: #fff;
    font-size: 0.7em;
    font-weight: 400;
    opacity: 0.8;
    text-decoration: none;
    margin-left: auto;
}}
.container {{
    max-width: 900px;
    margin: 0 auto;
    padding: 20px;
}}
.card {{
    background: var(--card-bg);
    border-radius: var(--radius);
    box-shadow: var(--shadow);
    padding: 24px;
    margin-bottom: 20px;
}}
.card h2 {{
    font-size: 1.1em;
    margin-bottom: 16px;
    color: var(--bsn-blue);
}}
/* Upload-Zone */
.dropzone {{
    border: 2px dashed #ccc;
    border-radius: var(--radius);
    padding: 40px 20px;
    text-align: center;
    cursor: pointer;
    transition: all 0.3s;
    background: #fafbfc;
    min-height: 120px;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 8px;
}}
.dropzone:hover, .dropzone.drag-over {{
    border-color: var(--bsn-blue);
    background: #eef0ff;
}}
.dropzone .icon {{ font-size: 2.5em; }}
.dropzone .hint {{ color: var(--text-muted); font-size: 0.9em; }}
.dropzone input[type=file] {{ display: none; }}
.preview-box {{
    position: relative;
    display: inline-block;
    margin-top: 12px;
}}
.preview-box img {{
    max-height: 200px;
    border-radius: 8px;
    box-shadow: var(--shadow);
}}
.preview-box .remove-btn {{
    position: absolute;
    top: -8px;
    right: -8px;
    background: #e74c3c;
    color: #fff;
    border: none;
    width: 28px;
    height: 28px;
    border-radius: 50%;
    cursor: pointer;
    font-size: 1.1em;
    line-height: 1;
}}
/* Form */
.form-row {{
    display: flex;
    gap: 12px;
    margin-bottom: 12px;
    flex-wrap: wrap;
}}
.form-row label {{
    font-weight: 600;
    font-size: 0.85em;
    color: var(--text-muted);
    display: block;
    margin-bottom: 4px;
}}
.form-row > div {{ flex: 1; min-width: 140px; }}
textarea, select, button {{
    font-family: inherit;
    font-size: 1em;
    border-radius: 8px;
    border: 1px solid #ddd;
    padding: 10px 14px;
    width: 100%;
    background: #fff;
    color: var(--text);
}}
textarea {{
    min-height: 100px;
    resize: vertical;
    line-height: 1.5;
}}
textarea:focus, select:focus {{ border-color: var(--bsn-blue); outline: none; box-shadow: 0 0 0 3px rgba(32,34,138,0.1); }}
.char-count {{ text-align: right; font-size: 0.8em; color: var(--text-muted); margin-top: 4px; }}
/* Buttons */
.btn {{
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    font-weight: 600;
    cursor: pointer;
    border: none;
    border-radius: 8px;
    padding: 12px 24px;
    font-size: 1em;
    transition: all 0.2s;
    min-height: 48px;
}}
.btn-primary {{
    background: var(--bsn-blue);
    color: #fff;
}}
.btn-primary:hover {{ background: var(--bsn-blue-light); }}
.btn-primary:disabled {{
    background: #999;
    cursor: not-allowed;
}}
.btn-secondary {{
    background: #e0e0e0;
    color: var(--text);
}}
.btn-download {{
    background: #27ae60;
    color: #fff;
}}
.btn-download:hover {{ background: #219a52; }}
/* Ergebnis */
.result-box {{
    display: none;
}}
.result-box.visible {{
    display: block;
}}
.result-box img {{
    width: 100%;
    max-height: 500px;
    object-fit: contain;
    border-radius: var(--radius);
    box-shadow: var(--shadow);
    margin-bottom: 16px;
}}
.result-actions {{
    display: flex;
    gap: 12px;
    flex-wrap: wrap;
}}
/* Spinner */
.spinner {{
    display: none;
    text-align: center;
    padding: 40px 20px;
}}
.spinner.active {{
    display: block;
}}
.spinner-icon {{
    width: 48px;
    height: 48px;
    border: 4px solid #e0e0e0;
    border-top-color: var(--bsn-blue);
    border-radius: 50%;
    animation: spin 0.8s linear infinite;
    margin: 0 auto 16px;
}}
@keyframes spin {{ to {{ transform: rotate(360deg); }} }}
.spinner-text {{ color: var(--text-muted); font-size: 0.95em; }}
/* Toast */
.toast {{
    position: fixed;
    bottom: 20px;
    left: 50%;
    transform: translateX(-50%);
    background: #333;
    color: #fff;
    padding: 12px 24px;
    border-radius: 8px;
    font-size: 0.95em;
    z-index: 1000;
    opacity: 0;
    transition: opacity 0.3s;
    pointer-events: none;
}}
.toast.show {{ opacity: 1; }}
.toast.success {{ background: #27ae60; }}
.toast.error {{ background: #e74c3c; }}
/* Mobile */
@media (max-width: 600px) {{
    .container {{ padding: 12px; }}
    .card {{ padding: 16px; }}
    .header {{ padding: 12px 16px; font-size: 1.1em; }}
    .form-row {{ flex-direction: column; gap: 8px; }}
    .result-actions {{ flex-direction: column; }}
    .result-actions .btn {{ width: 100%; }}
}}
</style>
</head>
<body>
<div class="header">
    🎨 BSN Bild-Generator
    <a href="/?key={key}">← Zurück zur Artikelübersicht</a>
</div>

<div class="container">

    <!-- Foto-Upload -->
    <div class="card">
        <h2>📷 Referenzbild (optional)</h2>
        <div class="dropzone" id="dropZone">
            <span class="icon">🖼️</span>
            <span class="hint">Foto hier ablegen oder klicken zum Auswählen</span>
            <input type="file" id="fileInput" accept="image/*">
        </div>
        <div class="preview-box" id="previewBox" style="display:none">
            <img id="previewImg" src="" alt="Vorschau">
            <button class="remove-btn" id="removeBtn" title="Entfernen">✕</button>
        </div>
    </div>

    <!-- Prompt -->
    <div class="card">
        <h2>✍️ Prompt</h2>
        <textarea id="prompt" placeholder="Beschreibe das gewünschte Bild…&#10;&#10;Beispiele:&#10;• Professional board game photography of Catan, warm lighting, product shot&#10;• Transform this cover into a cinematic movie poster style, epic composition"></textarea>
        <div class="char-count"><span id="charCount">0</span> Zeichen</div>
        
        <div class="form-row" style="margin-top:12px">
            <div>
                <label>📐 Seitenverhältnis</label>
                <select id="aspectRatio">
                    <option value="16:9">16:9 — Header (Standard)</option>
                    <option value="1:1">1:1 — Quadrat</option>
                    <option value="2:3">2:3 — Brettspiel-Box</option>
                    <option value="9:16">9:16 — Instagram Story</option>
                    <option value="21:9">21:9 — Breitformat</option>
                </select>
            </div>
            <div>
                <label>🔍 Auflösung</label>
                <select id="resolution">
                    <option value="1K">1K — Schnell (geringere Qualität)</option>
                    <option value="2K">2K — 1920×1080 (Standard)</option>
                    <option value="4K">4K — Höchste Qualität</option>
                </select>
            </div>
        </div>
    </div>

    <!-- Start Button -->
    <button class="btn btn-primary" id="startBtn" style="width:100%; font-size:1.1em; padding:16px">
        🚀 Bild generieren
    </button>

    <!-- Spinner -->
    <div class="spinner" id="spinner">
        <div class="spinner-icon"></div>
        <div class="spinner-text" id="spinnerText">Generiere Bild…</div>
    </div>

    <!-- Ergebnis -->
    <div class="result-box card" id="resultBox">
        <h2>✅ Ergebnis</h2>
        <img id="resultImg" src="" alt="Generiertes Bild">
        <div class="result-actions">
            <button class="btn btn-download" id="downloadBtn">⬇ Download</button>
            <button class="btn btn-secondary" id="retryBtn">🔄 Neues Bild</button>
        </div>
        <div style="margin-top:16px">
            <label style="font-weight:600;font-size:0.85em;color:var(--text-muted)">📄 Artikel zuordnen</label>
            <div class="form-row" style="margin-top:4px">
                <select id="articleSelect">
                    <option value="">-- Artikel wählen --</option>
                    {article_options}
                </select>
                <button class="btn btn-primary" id="assignBtn" style="flex:0 0 auto;min-width:auto">Zuordnen</button>
            </div>
        </div>
    </div>

</div>

<div class="toast" id="toast"></div>

<script>
const KEY = "{key}";
const BASE = window.location.origin;
let uploadedImageData = null;  // File object
let currentResultUrl = null;

// ── Upload-Zone ──
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const previewBox = document.getElementById('previewBox');
const previewImg = document.getElementById('previewImg');
const removeBtn = document.getElementById('removeBtn');

dropZone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', handleFile);

dropZone.addEventListener('dragover', (e) => {{ e.preventDefault(); dropZone.classList.add('drag-over'); }});
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
dropZone.addEventListener('drop', (e) => {{
    e.preventDefault();
    dropZone.classList.remove('drag-over');
    const files = e.dataTransfer.files;
    if (files.length) handleFile({{ target: {{ files }} }});
}});

function handleFile(e) {{
    const file = e.target.files[0];
    if (!file) return;
    uploadedImageData = file;
    const reader = new FileReader();
    reader.onload = (ev) => {{
        previewImg.src = ev.target.result;
        previewBox.style.display = 'inline-block';
        dropZone.querySelector('.icon').textContent = '✅';
        dropZone.querySelector('.hint').textContent = file.name + ' (' + (file.size/1024).toFixed(1) + ' KB)';
    }};
    reader.readAsDataURL(file);
}}

removeBtn.addEventListener('click', () => {{
    uploadedImageData = null;
    fileInput.value = '';
    previewBox.style.display = 'none';
    dropZone.querySelector('.icon').textContent = '🖼️';
    dropZone.querySelector('.hint').textContent = 'Foto hier ablegen oder klicken zum Auswählen';
}});

// ── Char-Zähler ──
const promptEl = document.getElementById('prompt');
const charCount = document.getElementById('charCount');
promptEl.addEventListener('input', () => charCount.textContent = promptEl.value.length);

// ── Start ──
document.getElementById('startBtn').addEventListener('click', startGeneration);

async function startGeneration() {{
    const prompt = promptEl.value.trim();
    if (!prompt) {{
        showToast('Bitte gib einen Prompt ein', 'error');
        return;
    }}
    
    const btn = document.getElementById('startBtn');
    btn.disabled = true;
    btn.textContent = '⏳ Generiere…';
    
    document.getElementById('resultBox').classList.remove('visible');
    document.getElementById('spinner').classList.add('active');
    document.getElementById('spinnerText').textContent = 'Google Imagen generiert…';
    
    try {{
        const formData = new FormData();
        formData.append('prompt', prompt);
        formData.append('aspectRatio', document.getElementById('aspectRatio').value);
        formData.append('resolution', document.getElementById('resolution').value);
        if (uploadedImageData) {{
            formData.append('image', uploadedImageData);
        }}
        
        const resp = await fetch(BASE + '/nanobanana/generate?key=' + KEY, {{
            method: 'POST',
            body: formData
        }});
        
        let data;
        try {{
            data = await resp.json();
        }} catch (e) {{
            const text = await resp.text().catch(() => '');
            throw new Error('Server-Antwort nicht lesbar (Status ' + resp.status + '): ' + text.substring(0, 200));
        }}
        
        if (!data.ok) {{
            throw new Error(data.error || 'Unbekannter Fehler (Status ' + resp.status + ')');
        }}
        
        // DIREKT Ergebnis anzeigen — Google Imagen ist synchron!
        document.getElementById('spinner').classList.remove('active');
        btn.disabled = false;
        btn.textContent = '🚀 Bild generieren';
        
        currentResultUrl = data.imageUrl;
        document.getElementById('resultImg').src = currentResultUrl;
        document.getElementById('resultBox').classList.add('visible');
        showToast('✅ Bild fertig!', 'success');
        loadArticles();
        
    }} catch (err) {{
        document.getElementById('spinner').classList.remove('active');
        btn.disabled = false;
        btn.textContent = '🚀 Bild generieren';
        showToast('❌ Fehler: ' + err.message, 'error');
    }}
}}

// ── Artikel laden ──
async function loadArticles() {{
    try {{
        const resp = await fetch(BASE + '/nanobanana/articles?key=' + KEY);
        const data = await resp.json();
        const sel = document.getElementById('articleSelect');
        sel.innerHTML = '<option value="">-- Artikel wählen --</option>';
        (data.articles || []).forEach(a => {{
            sel.innerHTML += '<option value="' + a.filename + '">' + a.title + '</option>';
        }});
    }} catch (e) {{}}
}}

// ── Zuordnen ──
document.getElementById('assignBtn').addEventListener('click', async () => {{
    const article = document.getElementById('articleSelect').value;
    if (!article) {{
        showToast('Bitte Artikel auswählen', 'error');
        return;
    }}
    if (!currentResultUrl) {{
        showToast('Kein Bild zum Zuordnen', 'error');
        return;
    }}
    
    const btn = document.getElementById('assignBtn');
    btn.disabled = true;
    btn.textContent = '⏳…';
    
    try {{
        const resp = await fetch(BASE + '/nanobanana/assign?key=' + KEY, {{
            method: 'POST',
            headers: {{ 'Content-Type': 'application/json' }},
            body: JSON.stringify({{ article: article, imageUrl: currentResultUrl }})
        }});
        const data = await resp.json();
        
        if (data.ok) {{
            showToast('✅ Bild zugeordnet: ' + data.filename, 'success');
            btn.textContent = '✓ Zugeordnet';
        }} else {{
            throw new Error(data.error);
        }}
    }} catch (err) {{
        showToast('Fehler: ' + err.message, 'error');
        btn.disabled = false;
        btn.textContent = 'Zuordnen';
    }}
}});

// ── Download ──
document.getElementById('downloadBtn').addEventListener('click', async () => {{
    if (!currentResultUrl) return;
    try {{
        const resp = await fetch(currentResultUrl);
        const blob = await resp.blob();
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = 'bsn-bild-' + Date.now() + '.jpg';
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        URL.revokeObjectURL(url);
    }} catch (err) {{
        // Fallback: direkt öffnen
        window.open(currentResultUrl, '_blank');
    }}
}});

// ── Neues Bild ──
document.getElementById('retryBtn').addEventListener('click', () => {{
    document.getElementById('resultBox').classList.remove('visible');
    currentResultUrl = null;
    window.scrollTo({{ top: 0, behavior: 'smooth' }});
}});

// ── Toast ──
function showToast(msg, type) {{
    const t = document.getElementById('toast');
    t.textContent = msg;
    t.className = 'toast ' + type + ' show';
    clearTimeout(t._timeout);
    t._timeout = setTimeout(() => t.classList.remove('show'), 3000);
}}
</script>
</body>
</html>"""


def main():
    import sys
    os.chdir(WORKSPACE)
    server = ThreadingArticleServer(("0.0.0.0", PORT), ArticleHandler)
    has_token = "JA" if JOOMLA_TOKEN else "NEIN (Veröffentlichung deaktiviert)"
    has_vgwort = VG_WORT_COUNTER_ID or "NICHT KONFIGURIERT"
    print(f"Article server running on http://0.0.0.0:{PORT}")
    print(f"  Joomla-Token: {has_token}")
    print(f"  VG-Wort-ID: {has_vgwort}")
    sys.stdout.flush()
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\nShutting down.")
        server.server_close()


if __name__ == "__main__":
    main()
