from __future__ import annotations

import hashlib
import json
import re
import shutil
import zipfile
from datetime import datetime
from html import escape as html_escape
from pathlib import Path
from typing import Any

ROOT = Path(r"E:\codex\WDR-Ticket")
SITE = ROOT / "website"
ASSETS = SITE / "assets"
DOWNLOADS = SITE / "downloads"
SITE_EXPORTS = SITE / "exports"
STATE = SITE / "state"
APP_NAME = "WDR eTicket-Werkstatt"
VERSION = "2026.07.22.1"
MAIL = "wdr-etickets@jostjahn.de"
PUBLIC_BASE = "https://www.jostjahn.de/amrum-software/WDRTicket/"

REPORT_FILES = [
    "eticket_zeiten_tabelle.html",
    "eticket_kalender.html",
    "eticket_preis_scatter.html",
    "eticket_storno_risiko.html",
    "eticket_pruefansicht.html",
    "eticket_wdr_fehler.html",
    "eticket_zeiten_muster20.html",
    "eticket_kalender_muster20.html",
]

BASE_NAV = [
    ("index.html", "Start"),
    ("auswertung.html", "Auswertung"),
    ("kalender.html", "Kalender"),
    ("preisgrafik.html", "Preisgrafik"),
    ("storno-risiko.html", "Storno-Risiko"),
    ("pruefansicht.html", "Prüfansicht"),
    ("aenderungen.html", "Änderungen"),
    ("lauf.html", "Letzter Lauf"),
    ("ampel.html", "Finanzamt-Ampel"),
    ("pdfcheck.html", "PDF-Ordner"),
    ("exports.html", "Exporte"),
    ("public-demo.html", "Demo"),
    ("websitecheck.html", "WEBSITECHECK"),
    ("hilfe.html", "Hilfe"),
    ("faq.html", "FAQ"),
    ("info.html", "Info"),
    ("handbuch.html", "Handbuch"),
    ("versionen.html", "Versionen"),
    ("signatur.html", "Signatur/SmartScreen"),
    ("impressum.html", "Impressum"),
    ("datenschutz.html", "Datenschutz"),
]


def esc(value: Any) -> str:
    return html_escape("" if value is None else str(value), quote=True)


def read_text(path: Path) -> str:
    try:
        return path.read_text(encoding="utf-8", errors="ignore")
    except FileNotFoundError:
        return ""


def write_text(path: Path, text: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(text, encoding="utf-8", newline="\n")


def load_json(path: Path) -> dict[str, Any]:
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
        return data if isinstance(data, dict) else {}
    except Exception:
        return {}


def sha256(path: Path) -> str:
    h = hashlib.sha256()
    with path.open("rb") as fh:
        for chunk in iter(lambda: fh.read(1024 * 1024), b""):
            h.update(chunk)
    return h.hexdigest()


def data() -> tuple[dict[str, Any], list[dict[str, Any]]]:
    raw = load_json(ROOT / "exports" / "etickets_wdr.json")
    summary = raw.get("zusammenfassung") if isinstance(raw.get("zusammenfassung"), dict) else {}
    rows = raw.get("zeilen") if isinstance(raw.get("zeilen"), list) else []
    return summary, [r for r in rows if isinstance(r, dict)]


def row_key(row: dict[str, Any]) -> str:
    return "|".join(str(row.get(k, "")) for k in ("buchungsnummer", "pdf_name", "abfahrt_datum", "abfahrt_uhrzeit"))


def row_snapshot(row: dict[str, Any]) -> dict[str, Any]:
    return {
        "key": row_key(row),
        "buchungsnummer": row.get("buchungsnummer", ""),
        "pdf_name": row.get("pdf_name", ""),
        "abfahrt": f"{row.get('abfahrt_datum','')} {row.get('abfahrt_uhrzeit','')}".strip(),
        "rueckfahrt": f"{row.get('rueckfahrt_datum','')} {row.get('rueckfahrt_uhrzeit','')}".strip(),
        "route": f"{row.get('abfahrtshafen','')} - {row.get('zielhafen','')}",
        "storniert": bool(row.get("storniert")),
        "gezaehlt": bool(row.get("gezaehlt")),
        "endgueltig": bool(row.get("endgueltig")),
        "gesamtpreis": row.get("gesamtpreis", ""),
        "mwst": row.get("mwst", ""),
        "kennzeichen": row.get("kfz_kennzeichen", []),
    }


def load_previous_snapshot() -> dict[str, Any]:
    return load_json(STATE / "last_snapshot.json")


def write_current_snapshot(rows: list[dict[str, Any]], summary: dict[str, Any]) -> None:
    current = {
        "created": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        "summary": summary,
        "rows": {row_key(r): row_snapshot(r) for r in rows},
    }
    write_text(STATE / "last_snapshot.json", json.dumps(current, ensure_ascii=False, indent=2))


def compare_rows(rows: list[dict[str, Any]], previous: dict[str, Any]) -> list[dict[str, Any]]:
    old_rows = previous.get("rows") if isinstance(previous.get("rows"), dict) else {}
    current = {row_key(r): row_snapshot(r) for r in rows}
    result: list[dict[str, Any]] = []
    if not old_rows:
        for item in current.values():
            item = dict(item)
            item["status"] = "Basis"
            item["detail"] = "Erster Vergleichslauf; diese Zeile bildet den aktuellen Ausgangsstand."
            result.append(item)
        return result
    for key, item in current.items():
        old = old_rows.get(key)
        item = dict(item)
        if old is None:
            item["status"] = "Neu"
            item["detail"] = "Diese Buchung war im vorherigen Snapshot nicht vorhanden."
            result.append(item)
            continue
        changes = []
        for field, label in (("storniert", "Storno"), ("gezaehlt", "Zählstatus"), ("gesamtpreis", "Gesamtpreis"), ("mwst", "MwSt")):
            if old.get(field) != item.get(field):
                changes.append(f"{label}: {old.get(field)} -> {item.get(field)}")
        if changes:
            item["status"] = "Geändert"
            item["detail"] = "; ".join(changes)
            result.append(item)
    for key, old in old_rows.items():
        if key not in current:
            item = dict(old)
            item["status"] = "Entfallen"
            item["detail"] = "Diese Buchung ist im aktuellen Lauf nicht mehr vorhanden."
            result.append(item)
    return sorted(result, key=lambda x: (str(x.get("status", "")), str(x.get("abfahrt", "")), str(x.get("buchungsnummer", ""))))


def nav_items(summary: dict[str, Any]) -> list[tuple[str, str]]:
    items = list(BASE_NAV)
    if int(summary.get("wdr_fehlerfahrkarten") or 0) > 0:
        items.insert(9, ("falschmeldung.html", "WDR-Falschmeldung"))
    if int(summary.get("storniert") or 0) <= 0:
        items = [item for item in items if item[0] != "storno-risiko.html"]
    return items


def write_css() -> None:
    css = """
:root{--bg:#eaf2f5;--paper:#fff;--ink:#102538;--muted:#5c6f82;--line:#cddbe6;--nav:#12384f;--nav2:#0b2536;--head:#195579;--gold:#c99724;--ok:#217a52;--warn:#9a6a00;--bad:#b42335;--soft:#f4f9fb;}
*{box-sizing:border-box}html{background:var(--bg)}body{margin:0;color:var(--ink);font-family:"Segoe UI",Verdana,sans-serif;background:linear-gradient(180deg,#e8f1f4 0,#fff 58%);overflow-x:auto}.layout{display:grid;grid-template-columns:260px minmax(0,max-content);min-height:100vh;align-items:start}.side{position:sticky;top:0;min-height:100vh;height:100vh;background:linear-gradient(180deg,var(--nav),var(--nav2));padding:2px;color:#fff;z-index:20;overflow:auto}.brand{display:flex;gap:2px;align-items:center;margin:0 0 2px}.brand img{width:42px;height:42px;filter:drop-shadow(0 6px 10px rgba(0,0,0,.25))}.brand strong{line-height:1.15}.nav-toggle{position:fixed;top:10px;left:10px;z-index:40;width:34px;height:34px;border:1px solid rgba(255,255,255,.8);border-radius:9px;background:#fff;color:var(--nav);font-weight:900;box-shadow:0 8px 22px rgba(0,0,0,.2);cursor:pointer}nav{display:flex;flex-direction:column;gap:6px}nav a{color:#ecf7ff;text-decoration:none;font-weight:700;border-radius:8px;padding:2px;white-space:nowrap}nav a:hover,nav a.active{background:rgba(255,255,255,.16);color:#fff}main{padding:2px;min-width:0}.hero,.panel,.card{background:rgba(255,255,255,.94);border:1px solid var(--line);border-radius:16px;box-shadow:0 12px 30px rgba(33,64,91,.09)}.hero{padding:2px;margin:0 0 2px;max-width:1180px}.hero h1{margin:0 0 2px;font-size:34px;line-height:1.1;color:#0f3952}.lead{color:var(--muted);max-width:980px;line-height:1.58}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:2px;max-width:1180px}.card,.panel{padding:2px;margin:2px 0}.card h2,.card h3,.panel h2,.panel h3{margin-top:0;color:#174f78}.button,.filter-btn{display:inline-flex;align-items:center;gap:6px;border:1px solid #174f78;background:#fff;color:#073b5c;border-radius:8px;padding:2px;text-decoration:none;font-weight:800;cursor:pointer}.button:hover,.filter-btn:hover{background:#edf7fb}.button.primary{background:#174f78;color:#fff}.note{border-left:5px solid var(--gold);background:#fff8df;border-radius:9px;padding:2px;max-width:1180px}.ok{color:var(--ok);font-weight:800}.warn{color:var(--warn);font-weight:800}.bad{color:var(--bad);font-weight:800}.muted{color:var(--muted)}.money,.num{text-align:right;font-variant-numeric:tabular-nums}.mono{font-family:"Cascadia Mono",Consolas,"Courier New",monospace;font-variant-numeric:tabular-nums}.kpi{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:2px;max-width:1180px}.kpi div{background:#fff;border:1px solid var(--line);border-radius:13px;padding:2px}.kpi strong{display:block;font-size:24px;color:#12384f}.site-table{border-collapse:collapse;width:auto;max-width:none;background:#fff;border:1px solid var(--line);font-size:14px}.site-table th,.site-table td{padding:2px;border-bottom:1px solid var(--line);border-right:1px solid #edf2f6;text-align:left;vertical-align:top}.site-table th{position:sticky;top:0;background:#195579;color:#fff;z-index:3;white-space:normal;hyphens:manual;overflow-wrap:normal;cursor:pointer}.site-table td.num,.site-table td.money{text-align:right}.site-table tr:nth-child(even) td{background:#f7fbfd}.status-pill{display:inline-block;min-width:5.5em;text-align:center;border-radius:999px;padding:2px;font-weight:800}.status-Grün{background:#dff5e8;color:#11613e;border:1px solid #8fcfac}.status-Gelb{background:#fff0bf;color:#765100;border:1px solid #d9ad33}.status-Rot{background:#ffe0e4;color:#9a1528;border:1px solid #e58d9a}.report-inline{max-width:none}.report-inline table{max-width:none}.report-inline th,.report-inline td{padding:2px}.report-inline .table-wrap{overflow:visible!important;max-width:none!important}.report-inline .toolbar{position:sticky;top:0;z-index:10}.pdf-reader input{display:block;margin:10px 0}.pdf-reader .hint{color:var(--muted)}footer{margin-top:28px;padding-top:2px;border-top:1px solid var(--line);color:var(--muted);font-size:13px;max-width:1180px}body.nav-collapsed .layout{grid-template-columns:0 minmax(0,max-content)}body.nav-collapsed .side{transform:translateX(-270px);width:0;padding-left:0;padding-right:0;overflow:hidden}@media(max-width:760px){.layout{grid-template-columns:1fr}.side{position:relative;height:auto;min-height:0}body.nav-collapsed .side{display:none}main{padding:2px}.hero h1{font-size:28px}.grid{grid-template-columns:1fr}.site-table th{position:static}}
"""
    write_text(SITE / "site.css", css.strip() + "\n")


def table(headers: list[tuple[str, str, str]], rows: list[list[Any]], cls: str = "site-table") -> str:
    head = "\n".join(
        f'      <th title="{esc(title)}" data-type="{esc(kind)}">{label}</th>'
        for label, title, kind in headers
    )
    body: list[str] = []
    for row in rows:
        cells: list[str] = []
        for idx, value in enumerate(row):
            kind = headers[idx][2] if idx < len(headers) else "text"
            klass = ' class="money"' if kind == "money" else (' class="num"' if kind == "number" else "")
            cell_text = str(value)
            if cell_text.startswith("http://") or cell_text.startswith("https://"):
                value_html = f'<a href="{esc(cell_text)}" target="_blank" rel="noopener">{esc(cell_text)}</a>'
            else:
                value_html = cell_text if cell_text.startswith("<") else esc(value)
            cells.append(f"      <td{klass}>{value_html}</td>")
        body.append("    <tr>\n" + "\n".join(cells) + "\n    </tr>")
    body_html = "\n".join(body)
    return f'''<table class="{esc(cls)}">
  <thead>
    <tr>
{head}
    </tr>
  </thead>
  <tbody>
{body_html}
  </tbody>
</table>'''

def page(title: str, active: str, body: str, summary: dict[str, Any], extra_head: str = "") -> str:
    nav = "\n".join(f'<a class="{"active" if href == active else ""}" href="{href}">{esc(label)}</a>' for href, label in nav_items(summary))
    year = datetime.now().year
    return f"""<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{esc(title)} - {esc(APP_NAME)}</title>
<link rel="icon" type="image/svg+xml" href="assets/wdr_ticket_logo.svg">
<link rel="stylesheet" href="site.css">
{extra_head}
</head>
<body>
<button id="navToggle" class="nav-toggle" type="button" aria-expanded="true" title="Navigation ein- oder ausklappen">☰</button>
<div class="layout">
<aside class="side">
  <div class="brand"><img src="assets/wdr_ticket_logo.svg" alt=""><strong>{esc(APP_NAME)}</strong></div>
  <nav>{nav}</nav>
</aside>
<main>
{body}
<footer>© {year} Jost Jahn. Amrum Software ist eine Projekt- und Sammelbezeichnung der Softwareangebote von Jost Jahn.</footer>
</main>
</div>
<script>
localStorage.removeItem('wdrTicketSiteNavCollapsed');
const navKey='wdrTicketSiteNavCollapsedV2';
const navButton=document.getElementById('navToggle');
function applyNavState(){{const collapsed=localStorage.getItem(navKey)==='1';document.body.classList.toggle('nav-collapsed',collapsed);navButton.setAttribute('aria-expanded',String(!collapsed));}}
navButton.addEventListener('click',()=>{{localStorage.setItem(navKey,document.body.classList.contains('nav-collapsed')?'0':'1');applyNavState();}});applyNavState();
document.querySelectorAll('table.site-table th').forEach((th,i)=>{{th.addEventListener('click',()=>{{const table=th.closest('table');const tbody=table.tBodies[0];const dir=th.dataset.dir==='asc'?'desc':'asc';table.querySelectorAll('th').forEach(h=>h.dataset.dir='');th.dataset.dir=dir;const type=th.dataset.type||'text';Array.from(tbody.rows).sort((a,b)=>{{let av=a.cells[i]?.innerText.trim()||'';let bv=b.cells[i]?.innerText.trim()||'';if(type==='number'||type==='money'){{av=Number(av.replace(/[^0-9,-]/g,'').replace('.','').replace(',','.'))||0;bv=Number(bv.replace(/[^0-9,-]/g,'').replace('.','').replace(',','.'))||0;}}return (av>bv?1:av<bv?-1:0)*(dir==='asc'?1:-1);}}).forEach(r=>tbody.appendChild(r));}});}});
</script>
</body>
</html>
"""


def sanitize_report_html(html: str, filename: str) -> str:
    for prefix in (ROOT.as_uri() + "/", "file:///E:/codex/WDR-Ticket/", "file:///e:/codex/WDR-Ticket/"):
        html = html.replace(prefix, "")
    html = html.replace('href="eticket_kalender.html"', 'href="kalender.html"')
    html = html.replace('href="eticket_zeiten_tabelle.html"', 'href="auswertung.html"')
    html = html.replace('href="website/exports.html"', 'href="exports.html"')
    if filename == "eticket_zeiten_tabelle.html" and "Nur Änderungen seit letztem Lauf" not in html:
        html = re.sub(
            r'(<button id="finalToggle"[\s\S]*?</button>)',
            r'\1<a class="filter-btn" href="aenderungen.html" title="Neue oder geänderte Buchungen seit dem letzten Website-Lauf anzeigen.">Nur Änderungen seit letztem Lauf</a>',
            html,
            count=1,
        )
    return html


def copy_reports_and_data() -> None:
    for name in REPORT_FILES:
        src = ROOT / name
        if src.exists():
            write_text(SITE / name, sanitize_report_html(read_text(src), name))
    for dirname in ("previews",):
        src = ROOT / dirname
        dst = SITE / dirname
        if src.exists():
            shutil.copytree(src, dst, dirs_exist_ok=True)
    src_mbox = ROOT / "etickets_aus_mbox"
    dst_mbox = SITE / "etickets_aus_mbox"
    if src_mbox.exists():
        for sub in ("mails", "pdfs", "stornos"):
            src = src_mbox / sub
            if src.exists():
                dst = dst_mbox / sub
                shutil.copytree(src, dst, dirs_exist_ok=True)
        for name in ("index.json", "manifest.csv"):
            if (src_mbox / name).exists():
                dst_mbox.mkdir(parents=True, exist_ok=True)
                shutil.copy2(src_mbox / name, dst_mbox / name)
    if (ROOT / "wdr_ticket_logo.svg").exists():
        shutil.copy2(ROOT / "wdr_ticket_logo.svg", SITE / "wdr_ticket_logo.svg")


def report_inline(name: str) -> tuple[str, str]:
    path = SITE / name
    html = read_text(path)
    if not html:
        return "", f'<section class="panel"><p class="bad">Bericht fehlt: {esc(name)}</p></section>'
    styles = "\n".join(re.findall(r"<style[^>]*>([\s\S]*?)</style>", html, flags=re.I))
    match = re.search(r"<body[^>]*>([\s\S]*?)</body>", html, flags=re.I)
    body = match.group(1) if match else html
    body = f'<div class="report-inline">{body}</div>'
    extra = f"<style>{styles}\n.report-inline .table-wrap{{overflow:visible!important;max-width:none!important}}</style>" if styles else ""
    return extra, body


def update_finance_zip_manifest() -> Path | None:
    zip_path = ROOT / "exports" / "finanzamt_paket.zip"
    manifest = ROOT / "exports" / "finanzamt_paket_manifest_sha256.txt"
    if not zip_path.exists():
        return None
    lines = [
        "WDR eTicket Finanzamt-Paket - SHA256-Manifest",
        f"Erstellt: {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}",
        "Format: SHA256  Dateiname",
        "",
    ]
    with zipfile.ZipFile(zip_path, "r") as old_zip:
        payloads = [(info, old_zip.read(info.filename)) for info in old_zip.infolist() if info.filename != "MANIFEST-SHA256.txt"]
    for info, payload in payloads:
        if info.filename.lower().endswith((".pdf", ".csv", ".txt", ".html", ".json")):
            lines.append(f"{hashlib.sha256(payload).hexdigest()}  {info.filename}")
    tmp = zip_path.with_suffix(".tmp.zip")
    with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as new_zip:
        for info, payload in payloads:
            new_zip.writestr(info, payload)
        new_zip.writestr("MANIFEST-SHA256.txt", "\n".join(lines) + "\n")
    tmp.replace(zip_path)
    write_text(manifest, "\n".join(lines) + "\n")
    return manifest


def copy_if_changed(src: Path, dst: Path) -> None:
    if not src.exists():
        return
    dst.parent.mkdir(parents=True, exist_ok=True)
    if dst.exists() and sha256(dst) == sha256(src):
        return
    shutil.copy2(src, dst)


def copy_downloads_and_exports() -> dict[str, str]:
    DOWNLOADS.mkdir(parents=True, exist_ok=True)
    SITE_EXPORTS.mkdir(parents=True, exist_ok=True)
    hashes: dict[str, str] = {}
    setup = ROOT / "release" / "WDRTicketSetup.exe"
    if setup.exists():
        copy_if_changed(setup, DOWNLOADS / "WDRTicketSetup.exe")
        hashes["WDRTicketSetup.exe"] = sha256(setup)
    dist_dir = ROOT / "dist" / "WDRTicketApp"
    portable = DOWNLOADS / "WDRTicketPortable.zip"
    if portable.exists():
        hashes["WDRTicketPortable.zip"] = sha256(portable)
    elif dist_dir.exists():
        with zipfile.ZipFile(portable, "w", zipfile.ZIP_DEFLATED) as zf:
            for path in sorted(dist_dir.rglob("*")):
                if path.is_file():
                    zf.write(path, path.relative_to(dist_dir.parent).as_posix())
        hashes["WDRTicketPortable.zip"] = sha256(portable)
    exe = dist_dir / "WDRTicketApp.exe"
    if exe.exists():
        hashes["WDRTicketApp.exe"] = sha256(exe)
    src_exports = ROOT / "exports"
    if src_exports.exists():
        SITE_EXPORTS.mkdir(parents=True, exist_ok=True)
        for path in src_exports.iterdir():
            if path.is_file():
                copy_if_changed(path, SITE_EXPORTS / path.name)
    return hashes


def file_anchor(path: Path) -> str:
    name = esc(path.name)
    if path.parent == DOWNLOADS:
        href = f"downloads/{path.name}"
    elif path.parent == ROOT / "exports":
        href = f"exports/{path.name}"
    elif path.parent == ROOT / "release":
        href = f"../release/{path.name}"
    else:
        href = path.name
    return f'<a href="{esc(href)}" download>{name}</a>'


def file_table(paths: list[Path]) -> str:
    rows = []
    for path in paths:
        if path.exists() and path.is_file():
            rows.append([file_anchor(path), path.suffix.upper().lstrip("."), f"{path.stat().st_size:,}".replace(",", "."), sha256(path)])
    return table(
        [
            ("Datei", "Stabiler Dateiname des Artefakts oder Exports.", "text"),
            ("Format", "Dateiformat für Download, Weiterverarbeitung oder Prüfung.", "text"),
            ("Größe", "Dateigröße in Byte mit deutscher Tausendertrennung.", "number"),
            ("SHA256", "Prüfsumme zur Kontrolle, dass Download und Build-Artefakt identisch sind.", "text"),
        ],
        rows,
    )


def index_body(summary: dict[str, Any]) -> str:
    return f"""
<section class="hero"><h1>{APP_NAME}</h1><p class="lead">Lokale Windows-Werkstatt für WDR-eTickets: PDF-Fahrkarten, Thunderbird-/Outlook-/EML-/MSG-Mails, Stornomeldungen, Kalender, Finanzamt-Pakete und prüfbare Exporte.</p><p><a class="button primary" href="downloads/WDRTicketSetup.exe">Setup herunterladen</a> <a class="button" href="downloads/WDRTicketPortable.zip">Portable ZIP</a> <a class="button" href="auswertung.html">Lokale Auswertung öffnen</a></p></section>
<div class="kpi"><div><span>Gezählte Fahrkarten</span><strong>{esc(summary.get('gezaehlt','0'))}</strong></div><div><span>Stornierte Fahrkarten</span><strong>{esc(summary.get('storniert','0'))}</strong></div><div><span>Gesamtpreis</span><strong>{esc(summary.get('summe_gesamtpreis','0,00'))} €</strong></div><div><span>MwSt</span><strong>{esc(summary.get('summe_mwst','0,00'))} €</strong></div></div>
<div class="grid"><article class="card"><h2>Für normale Anwender</h2><p>Setup installieren, Datenquelle wählen und Start drücken. Thunderbird/MBOX ist am vollständigsten, reine PDF-Ordner sind ideal für Jahresbelege.</p><p><a class="button" href="hilfe.html">Hilfe lesen</a></p></article><article class="card"><h2>Für Finanzamt-Belege</h2><p>Die App erzeugt Jahreslisten, PDF-Berichte, CSV, TXT und ein ZIP-Paket mit Manifest.</p><p><a class="button" href="ampel.html">Finanzamt-Ampel</a></p></article><article class="card"><h2>Für Prüfung und Support</h2><p>Hashes, Versionsdatei, SmartScreen-Hinweise und Prüfansicht sind direkt auf der Webseite dokumentiert.</p><p><a class="button" href="signatur.html">Signatur/SmartScreen</a></p></article></div>
<p class="note">Die lokale Auswertung enthält echte Ticketdaten. Vor einer öffentlichen Veröffentlichung sollten Echtdaten entfernt oder anonymisiert werden.</p>
"""


def downloads_body() -> str:
    files = [DOWNLOADS / "WDRTicketSetup.exe", DOWNLOADS / "WDRTicketPortable.zip", ROOT / "release" / "SHA256SUMS.txt"]
    export_files = sorted((ROOT / "exports").glob("*")) if (ROOT / "exports").exists() else []
    return f"""
<section class="hero"><h1>Exporte und Downloads</h1><p class="lead">Stabile Downloadnamen, maschinenlesbare Exporte und prüfbare Hashes. Die Namen enthalten bewusst keine Versionsnummer.</p></section>
<section class="panel"><h2>Downloads</h2>{file_table(files)}<p class="muted">Windows-Zielplattform. Keine Mac-, Linux- oder Smartphone-Version geplant.</p></section>
<section class="panel"><h2>Datenexporte</h2>{file_table(export_files)}<p>Kommandozeile: <code>make_eticket_table.py --json ziel.json --csv ziel.csv --text ziel.txt</code></p></section>
"""


def lauf_body(summary: dict[str, Any], rows: list[dict[str, Any]]) -> str:
    source_rows = [
        ["PDF-Ordner", summary.get("quelle_pdf_ordner", "")],
        ["MBOX-Index", summary.get("quelle_mbox_index", "")],
        ["JSON-Export", str(ROOT / "exports" / "etickets_wdr.json")],
        ["Website", str(SITE)],
    ]
    kpis = [
        ["PDF-Zeilen", summary.get("pdf_zeilen", len(rows))],
        ["Endgültige Fahrkarten", summary.get("endgueltige_fahrkarten", "")],
        ["Storniert", summary.get("storniert", "")],
        ["Gezählt", summary.get("gezaehlt", "")],
        ["Ältere Dubletten", summary.get("aeltere_dubletten", "")],
        ["WDR-Fehlerfahrkarten", summary.get("wdr_fehlerfahrkarten", "")],
        ["Datum ohne Uhrzeit", summary.get("mit_datum_ohne_uhrzeit", "")],
        ["Erstellt", summary.get("erstellt", "")],
    ]
    return f"""
<section class="hero"><h1>Letzter Lauf</h1><p class="lead">Quellen, Zählstände, Artefakte und Skriptversion des letzten lokalen Erzeugungslaufs.</p></section>
<section class="panel"><h2>Zählstände</h2>{table([('Wert','Gezählter oder berechneter Laufwert.','text'),('Anzahl','Zahl oder Textwert aus der Auswertung.','number')], kpis)}</section>
<section class="panel"><h2>Quellen</h2>{table([('Quelle','Art der Datenquelle.','text'),('Pfad','Lokaler Pfad oder erzeugtes Artefakt.','text')], source_rows)}</section>
<section class="panel"><h2>Erzeugung</h2><p>Website-Version: <strong>{VERSION}</strong>. Website neu erzeugt: <strong>{datetime.now().strftime('%d.%m.%Y %H:%M')}</strong>.</p></section>
"""


def changes_body(changes: list[dict[str, Any]], previous: dict[str, Any]) -> str:
    rows = [[x.get("status", ""), x.get("abfahrt", ""), x.get("route", ""), x.get("buchungsnummer", ""), x.get("pdf_name", ""), x.get("detail", "")] for x in changes]
    if not rows:
        rows = [["Keine Änderung", "", "", "", "", "Seit dem letzten Snapshot wurden keine neuen, entfallenen oder geänderten Buchungen gefunden."]]
    intro = "Erster Vergleichslauf; der aktuelle Stand wurde als Basis gespeichert." if not previous.get("rows") else "Vergleich mit dem vorherigen lokal gespeicherten Snapshot."
    return f"""
<section class="hero"><h1>Änderungen seit letztem Lauf</h1><p class="lead">{esc(intro)}</p><p><button id="onlyChanged" class="filter-btn" type="button">Nur Änderungen seit letztem Lauf anzeigen</button></p></section>
<section class="panel">{table([('Status','Neu, geändert, entfallen oder Basis beim ersten Lauf.','text'),('Abfahrt','Abfahrtsdatum und Uhrzeit der Buchung.','text'),('Route','Abfahrtshafen und Zielhafen.','text'),('Buchungsnummer','Buchungsnummer aus PDF oder Dateiname.','number'),('PDF','PDF-Dateiname der Fahrkarte.','text'),('Details','Konkrete Änderung gegenüber dem vorherigen Snapshot.','text')], rows)}</section>
<script>document.getElementById('onlyChanged')?.addEventListener('click',()=>{{document.querySelectorAll('tbody tr').forEach(tr=>{{const s=tr.cells[0]?.innerText||''; if(s==='Basis'||s==='Keine Änderung') tr.style.display=tr.style.display==='none'?'':'none';}});}});</script>
"""


def ampel_body(rows: list[dict[str, Any]]) -> str:
    out = []
    counts = {"Grün": 0, "Gelb": 0, "Rot": 0}
    for row in rows:
        reasons = []
        status = "Grün"
        if row.get("storniert") or not row.get("endgueltig") or not row.get("gezaehlt"):
            status = "Rot"
            if row.get("storniert"):
                reasons.append("storniert")
            if not row.get("gezaehlt"):
                reasons.append("nicht gezählt")
            if not row.get("endgueltig"):
                reasons.append("ältere Dublette")
        if row.get("gezaehlt") and not row.get("storniert"):
            if not row.get("laenge"):
                reasons.append("Länge fehlt")
            if not row.get("kfz_kennzeichen") and "Auto" in str(row.get("fahrkarten", "")):
                reasons.append("Kennzeichen fehlt")
            if row.get("mwst_plausibel") is False:
                reasons.append("MwSt auffällig")
            if not row.get("mail_datum"):
                reasons.append("Mail fehlt")
            if reasons:
                status = "Gelb"
        counts[status] += 1
        if status != "Grün" or len(out) < 80:
            out.append([f'<span class="status-pill status-{status}">{status}</span>', row.get("abfahrt_datum", ""), row.get("buchungsnummer", ""), row.get("fahrkarten", ""), row.get("gesamtpreis", ""), ", ".join(reasons) or "vollständig"])
    return f"""
<section class="hero"><h1>Finanzamt-Ampel</h1><p class="lead">Schnelle Prüfansicht: grün ist vollständig gezählt und nicht storniert, gelb ist prüfbedürftig, rot wird nicht in Finanzamt-Summen gezählt.</p></section>
<div class="kpi"><div><span>Grün</span><strong class="ok">{counts['Grün']}</strong></div><div><span>Gelb</span><strong class="warn">{counts['Gelb']}</strong></div><div><span>Rot</span><strong class="bad">{counts['Rot']}</strong></div></div>
<section class="panel">{table([('Status','Ampelfarbe für Finanzamt- und Belegprüfung.','text'),('Abfahrt','Abfahrtsdatum der Buchung.','text'),('Buchungsnummer','Buchungsnummer aus PDF oder Dateiname.','number'),('Fahrkarten','Erkannte Fahrkartenarten.','text'),('Gesamtpreis','Gesamtpreis der Buchung in Euro.','money'),('Prüfhinweis','Grund für gelbe oder rote Bewertung.','text')], out)}</section>
"""


def pdfcheck_body() -> str:
    return """
<section class="hero"><h1>PDF-Ordner im Browser ansehen</h1><p class="lead">Diese Unterseite liest ausgewählte PDF-Dateien nur lokal im Browser. Es wird nichts hochgeladen. Für vollständige OCR-/Storno-/Mail-Auswertung bitte die Windows-App benutzen.</p></section>
<section class="panel pdf-reader"><div class="grid"><article class="card"><h2>Ordner mit PDF auswählen</h2><p>Für Jahresbelege oder einen ganzen Belegordner.</p><label class="button" for="pdfFolder">Ordner mit PDF auswählen</label><input id="pdfFolder" type="file" accept="application/pdf,.pdf" webkitdirectory directory multiple onchange="showFiles(this.files)"></article><article class="card"><h2>PDF-Dateien auswählen</h2><p>Für einzelne Fahrkarten ohne ganzen Ordner.</p><label class="button" for="pdfFiles">PDF-Dateien auswählen</label><input id="pdfFiles" type="file" accept="application/pdf,.pdf" multiple onchange="showFiles(this.files)"></article></div><p id="pdfInfo" class="hint">Noch keine PDF-Dateien ausgewählt.</p><table id="pdfTable" class="site-table"><thead><tr><th title="Dateiname oder relativer Ordnerpfad der lokal ausgewählten PDF-Datei.">Datei</th><th title="Aus Dateiname oder lesbarem Rohtext erkannte Buchungsnummer.">Buchung</th><th title="Im Rohtext gefundene Datumsangaben, soweit der Browser sie lesen kann.">Datum im Rohtext</th><th title="Im Rohtext gefundene Route, soweit der Browser sie lesen kann.">Route im Rohtext</th><th title="Dateigröße der lokalen PDF-Datei.">Größe</th><th title="Änderungsdatum der lokalen Datei laut Browser.">Geändert</th><th title="Hinweis zur lokalen Browser-Erkennung.">Hinweis</th></tr></thead><tbody></tbody></table></section>
<script>
const tbody=document.querySelector('#pdfTable tbody');const info=document.getElementById('pdfInfo');function sizeText(bytes){return bytes>1024*1024?(bytes/1024/1024).toLocaleString('de-DE',{maximumFractionDigits:1})+' MB':(bytes/1024).toLocaleString('de-DE',{maximumFractionDigits:0})+' KB'}function e(v){return String(v||'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}function extract(raw,file){const name=file.webkitRelativePath||file.name||'';const text=(name+' '+String(raw||'')).replace(/\\u0000/g,' ');const booking=(name.match(/(?:ETICKET|eticket)[_-](\\d{7,10})(?:[_-]|\\b)/i)||name.match(/_(\\d{7,10})(?:_|\\.pdf$)/i)||text.match(/(?:Buchung|Buchungsnummer)\\D{0,30}(\\d{7,10})/i)||['',''])[1];const dates=Array.from(text.matchAll(/\\b\\d{1,2}\\.\\d{1,2}\\.(?:\\d{2}|\\d{4})(?:\\s+\\d{1,2}[:.]\\d{2})?/g)).slice(0,2).map(m=>m[0]).join(' / ');const route=(text.match(/(Wittd.n|Dageb.ll|Amrum|Wyk)[^\\n\\r]{0,100}(Wittd.n|Dageb.ll|Amrum|Wyk)/i)||[''])[0].replace(/\\s+/g,' ').trim();return{booking,dates,route,likely:/WDR|Wyker Dampfschiffs|Fahrkarte|Buchung|ETICKET|eticket/i.test(text)}}async function showFiles(fileList){tbody.textContent='';const files=Array.from(fileList||[]).filter(f=>f.name.toLowerCase().endsWith('.pdf'));const folder=files.find(f=>f.webkitRelativePath)?.webkitRelativePath?.split('/')[0]||'';info.textContent=files.length?files.length+' PDF-Dateien lokal gelesen'+(folder?' aus Ordner: '+folder:'')+'. Es wurde nichts hochgeladen.':'Keine PDF-Dateien ausgewählt.';for(const file of files){const tr=document.createElement('tr');tr.innerHTML='<td>'+e(file.webkitRelativePath||file.name)+'</td><td></td><td></td><td></td><td>'+sizeText(file.size)+'</td><td>'+e(new Date(file.lastModified).toLocaleString('de-DE'))+'</td><td>Datei gelistet; Rohtext wird geprüft...</td>';tbody.appendChild(tr);const base=extract('',file);tr.cells[1].textContent=base.booking||'';tr.cells[6].textContent=base.likely?'Dateiname erkannt; PDF bleibt lokal':'Datei gelistet; für Vollauswertung bitte App nutzen';try{const raw=await file.slice(0,Math.min(file.size,2*1024*1024)).text();const d=extract(raw,file);tr.cells[1].textContent=d.booking||base.booking||'';tr.cells[2].textContent=d.dates||'';tr.cells[3].textContent=d.route||'';tr.cells[6].textContent=d.likely?'vermutlich WDR-PDF; Text nur soweit im Browser lesbar':'Datei gelistet; vollständige PDF-Auswertung bitte mit App erzeugen'}catch(err){tr.cells[6].textContent='Datei gelistet; Browser konnte keinen Rohtext lesen'}}}window.showFiles=showFiles;
</script>
"""


def help_body() -> str:
    return """
<section class="hero"><h1>Hilfe</h1><p class="lead">Die WDR eTicket-Werkstatt erzeugt lokale, nachvollziehbare Auswertungen aus Fahrkarten-PDFs und Maildaten.</p></section>
<section class="panel"><h2>Einlesen</h2><p><strong>Thunderbird/MBOX</strong> ist die vollständigste Quelle, weil Fahrkarten-PDFs, Zustelltexte und Storno-Mails gemeinsam vorliegen. Nach „MBOX-Daten suchen“ werden gefundene Quellen als Liste angezeigt und können angekreuzt werden.</p><p><strong>PDF-Ordner</strong> ist ideal für Jahresbelege, etwa für das Finanzamt. Neue PDFs müssen nicht vorsortiert werden; die App erkennt Fahrkarten und trennt erkennbare WDR-Fehlerfahrkarten aus. Reine PDF-Ordner kennen aber keine Storno-Mails.</p><p><strong>EML-/MSG-Ordner</strong> ist für exportierte Maildateien gedacht. <strong>Outlook direkt</strong> nutzt ein installiertes Outlook über COM; PST/OST-Dateien werden nicht roh gelesen.</p><h2>Exporte</h2><p>JSON ist für Programme am vollständigsten, CSV für Excel/LibreOffice, TXT für einfache Prüfungen. Tabelle und Kalender lassen sich zusätzlich über den PDF-Knopf des Browsers als PDF speichern. Das Finanzamt-Paket enthält Jahreslisten und kopierte Fahrkarten-PDFs.</p><h2>Stornos</h2><p>Storno wird nur erkannt, wenn „Storno“ im Betreff steht. Storno-Mails enthalten meist kein PDF, sondern nur Text und Nummern; deshalb werden sie vor der zugehörigen Fahrkarte eingeordnet und die Fahrkarte rot markiert.</p></section>
"""


def faq_body() -> str:
    return """
<section class="hero"><h1>FAQ</h1><p class="lead">Häufige Fragen zu PDF-Ordnern, Stornos, Finanzamt-Auswertung, Updates und Sicherheit.</p></section>
<div class="grid"><article class="card"><h2>Muss ich PDFs aussortieren?</h2><p>Normalerweise nein. Die App erkennt WDR-Fahrkarten automatisch. Aussortieren ist nur sinnvoll, wenn ausschließlich PDFs ohne Storno-Mails vorliegen und bekannte Stornos entfernt werden sollen.</p></article><article class="card"><h2>Warum sind Storno-Mails schwierig?</h2><p>Storno-Mails haben meistens kein neues PDF. Die Zuordnung läuft über Betreff und Buchungsnummer. Wenn die Nummer fehlt, darf die App nicht raten.</p></article><article class="card"><h2>Was passiert ohne Stornos?</h2><p>Dann werden stornierte Buchungen wie echte Fahrten gezählt. Summen, Reisedauer und Kalender können zu hoch sein. Die Storno-Simulation zeigt diesen Fall bewusst an.</p></article><article class="card"><h2>Warum warnt SmartScreen?</h2><p>Neue oder selten heruntergeladene Windows-Programme können warnen, auch wenn sie korrekt gebaut wurden. Hashes und Signaturhinweise stehen auf der Signaturseite.</p></article><article class="card"><h2>Wo liegen Daten?</h2><p>Standardmäßig im gewählten Arbeitsordner, hier <code>E:\codex\WDR-Ticket</code>. Einstellungen und Profile können zusätzlich unter <code>%LOCALAPPDATA%\AmrumSoftware\WDRTicket</code> liegen.</p></article><article class="card"><h2>Kostet die App etwas?</h2><p>Für diese lokale Werkstatt ist hier keine Bezahllizenz vorgesehen. Falls sich das später ändert, werden Lizenzseite, Setup und Datenschutz vorher angepasst.</p></article></div>
<section class="panel"><h2>Musterseiten</h2><p>Die Musterseiten verwenden nur die letzten 20 gezählten, nicht stornierten Einträge.</p><p><a class="button" href="eticket_zeiten_muster20.html">Musterauswertung</a> <a class="button" href="eticket_kalender_muster20.html">Musterkalender</a></p></section>
"""


def info_body() -> str:
    return f"""
<section class="hero"><h1>Info</h1><p class="lead">{APP_NAME} wurde lokal in Codex entwickelt und dient der nachvollziehbaren privaten Beleg- und Fahrkarten-Auswertung.</p></section>
<section class="panel"><p>Rechtlicher Anbieter: Jost Jahn. Amrum Software ist eine Projekt- und Sammelbezeichnung, keine eigene Rechtseinheit.</p><p>Kontakt: <a href="mailto:{MAIL}">{MAIL}</a></p><p>Öffentlicher Produktpfad, falls veröffentlicht: <a href="{PUBLIC_BASE}" target="_blank" rel="noopener">{PUBLIC_BASE}</a></p></section>
"""


def signature_body(hashes: dict[str, str]) -> str:
    rows = [[name, digest, "VirusTotal-Prüfung steht lokal noch aus; Hash ist für manuelle Prüfung dokumentiert."] for name, digest in hashes.items()]
    return f"""
<section class="hero"><h1>Signatur und SmartScreen</h1><p class="lead">Hinweise für normale Anwender, wenn Windows oder ein Virenscanner bei einer neuen lokalen Version nachfragt.</p></section>
<section class="panel"><h2>Warum kann eine Warnung erscheinen?</h2><p>Windows SmartScreen bewertet neben technischer Signatur auch Verbreitung und Reputation. Eine neue oder selten genutzte EXE kann deshalb eine Warnung anzeigen.</p><h2>Was prüfen?</h2><p>Quelle, Dateiname, Herausgeber, SHA256 und die Versionsseite. Ein selbstsigniertes Zertifikat wäre keine öffentliche Vertrauenslösung.</p>{table([('Datei','Download- oder Build-Artefakt.','text'),('SHA256','Hash zur manuellen Integritätsprüfung.','text'),('Status','Aktueller Prüfstatus.','text')], rows)}</section>
"""


def impressum_body() -> str:
    return f"""
<section class="hero"><h1>Impressum</h1><p class="lead">Anbieterkennzeichnung für die WDR eTicket-Werkstatt.</p></section>
<section class="panel"><p><strong>Angaben gemäß § 5 TMG</strong></p><p>Jost Jahn<br>Smäswai 4<br>25946 Nebel<br>Deutschland</p><p>E-Mail: <a href="mailto:{MAIL}">{MAIL}</a></p><p>Amrum Software ist eine Projekt- und Sammelbezeichnung der Softwareangebote von Jost Jahn und keine eigene Rechtseinheit.</p><p>Verantwortlich für diese lokale Software- und Webseitenzusammenstellung: Jost Jahn.</p></section>
"""


def datenschutz_body() -> str:
    return f"""
<section class="hero"><h1>Datenschutz</h1><p class="lead">Datenschutzhinweise für lokale Nutzung und eine spätere öffentliche Produktwebseite.</p></section>
<section class="panel"><h2>1. Verantwortlicher</h2><p>Jost Jahn, Smäswai 4, 25946 Nebel, Deutschland, E-Mail: <a href="mailto:{MAIL}">{MAIL}</a>.</p><h2>2. Zweck</h2><p>Die App liest WDR-eTicket-PDFs, Mailtexte, Stornomeldungen und daraus abgeleitete Metadaten, um lokale Tabellen, Kalender, Finanzamt-Pakete und Exporte zu erzeugen.</p><h2>3. Lokal verarbeitete Daten</h2><p>Verarbeitet werden können Buchungsnummern, interne Ticketnummern, Kennzeichen, Fahrzeuglängen, Reisezeiten, Zustellzeiten, Mailbetreffs, Mailtexte, Stornotexte, PDF-Dateinamen, Preise, MwSt-Beträge und Hashwerte.</p><h2>4. Quellen</h2><p>Unterstützt werden PDF-Ordner, Thunderbird-MBOX, EML-/MSG-Dateien und Outlook über eine lokale Outlook-Installation. PST/OST-Dateien werden nicht direkt roh gelesen.</p><h2>5. Speicherorte</h2><p>Die Standardausgabe liegt im gewählten Arbeitsordner, typischerweise <code>E:\codex\WDR-Ticket</code>. Einstellungen oder Profile können unter <code>%LOCALAPPDATA%\AmrumSoftware\WDRTicket</code> gespeichert werden.</p><h2>6. Keine automatische Übertragung</h2><p>Die lokale Auswertung überträgt keine Ticketdaten automatisch an externe Server. Eine Weitergabe erfolgt nur, wenn Anwender Dateien selbst kopieren, versenden oder veröffentlichen.</p><h2>7. Exporte</h2><p>JSON, CSV, TXT, HTML, ZIP und PDF-Berichte können personenbezogene Daten enthalten. Vor Weitergabe sollten Kennzeichen, Buchungsnummern und Mailtexte geprüft oder anonymisiert werden.</p><h2>8. Öffentliche Webseite und Server-Logfiles</h2><p>Bei öffentlicher Bereitstellung verarbeitet der Webserver technische Zugriffsdaten wie IP-Adresse, Zeitpunkt, angeforderte Datei, Browserkennung, Referrer und übertragene Datenmenge. Zweck sind Bereitstellung, Sicherheit und Fehleranalyse.</p><h2>9. Cookies und Tracking</h2><p>Diese statische Produktwebseite verwendet keine Analyse-, Werbe- oder Trackingdienste und benötigt keine Cookies.</p><h2>10. Löschung</h2><p>Lokale Auswertungen werden gelöscht, indem die erzeugten Ordner und Dateien entfernt werden. Original-Mails und PDFs bleiben in den jeweiligen Quellprogrammen, bis sie dort gelöscht werden.</p><h2>11. Rechte</h2><p>Betroffene Personen haben nach Maßgabe der DSGVO Rechte auf Auskunft, Berichtigung, Löschung, Einschränkung, Widerspruch und Datenübertragbarkeit.</p><h2>12. Beschwerderecht</h2><p>Eine Beschwerde ist bei einer Datenschutz-Aufsichtsbehörde möglich, insbesondere beim Unabhängigen Landeszentrum für Datenschutz Schleswig-Holstein.</p></section>
"""


def version_body(hashes: dict[str, str]) -> str:
    history_file = ROOT / "version_history.json"
    try:
        history = json.loads(history_file.read_text(encoding="utf-8")) if history_file.exists() else []
    except Exception:
        history = []
    if not isinstance(history, list):
        history = []
    current = {
        "version": VERSION,
        "date": datetime.now().strftime("%d.%m.%Y %H:%M"),
        "text": "Generator wiederhergestellt; Tabellenlogik, Synchronisierung, Signaturen und Prüfsummen repariert.",
    }
    if not history or history[0].get("version") != VERSION:
        history.insert(0, current)
    history = history[:80]
    write_text(history_file, json.dumps(history, ensure_ascii=False, indent=2))
    history_html = "\n".join(
        f'<p><strong>{esc(item.get("version", ""))}</strong> vom {esc(item.get("date", ""))}: {esc(item.get("text", ""))}</p>'
        for item in history
    )
    hash_rows = [[name, digest] for name, digest in hashes.items()]
    return f"""
<section class="hero"><h1>Versionen</h1><p class="lead">Aktuelle Version, vergangene Versionen, Downloadnamen und Hashes.</p></section>
<section class="panel"><h2>Aktuelle Version</h2><p><strong>{VERSION}</strong> vom {datetime.now().strftime('%d.%m.%Y %H:%M')}.</p>{table([('Datei','Stabiler Downloadname.','text'),('SHA256','Prüfsumme des Artefakts.','text')], hash_rows)}</section>
<section class="panel"><h2>Vergangene Versionen</h2>{history_html}</section>
"""

def write_versions_txt(hashes: dict[str, str]) -> None:
    lines = [
        f"version={VERSION}",
        f"date={datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
        f"website={PUBLIC_BASE}",
        f"setup_url={PUBLIC_BASE}downloads/WDRTicketSetup.exe",
        f"portable_url={PUBLIC_BASE}downloads/WDRTicketPortable.zip",
    ]
    for name, digest in sorted(hashes.items()):
        key = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_")
        lines.append(f"{key}_sha256={digest}")
    write_text(SITE / "versions.txt", "\n".join(lines) + "\n")


def release_payload(summary: dict[str, Any], hashes: dict[str, str]) -> dict[str, Any]:
    return {
        "app": APP_NAME,
        "version": VERSION,
        "created": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        "public_base": PUBLIC_BASE,
        "downloads": {
            "setup": {"file": "downloads/WDRTicketSetup.exe", "sha256": hashes.get("WDRTicketSetup.exe", "")},
            "portable": {"file": "downloads/WDRTicketPortable.zip", "sha256": hashes.get("WDRTicketPortable.zip", "")},
        },
        "summary": {
            "pdf_zeilen": summary.get("pdf_zeilen"),
            "endgueltige_fahrkarten": summary.get("endgueltige_fahrkarten"),
            "storniert": summary.get("storniert"),
            "gezaehlt": summary.get("gezaehlt"),
        },
        "privacy_note": "Lokale Echtdaten liegen nur in der lokalen Website. website_public enthält keine Fahrkarten-PDFs, Mailtexte oder Echtdaten-Auswertung.",
    }


def write_release_json(summary: dict[str, Any], hashes: dict[str, str]) -> None:
    payload = release_payload(summary, hashes)
    write_text(SITE / "release.json", json.dumps(payload, ensure_ascii=False, indent=2))


def anonymize(value: Any) -> str:
    text = str(value or "")
    if not text:
        return ""
    return "■" * min(max(len(text), 3), 10)


def public_demo_body(rows: list[dict[str, Any]]) -> str:
    demo_rows = []
    counted = [r for r in rows if r.get("gezaehlt") and not r.get("storniert")]
    for row in counted[-20:]:
        demo_rows.append([
            row.get("abfahrt_datum", ""),
            row.get("abfahrtshafen", ""),
            row.get("zielhafen", ""),
            anonymize(row.get("buchungsnummer", "")),
            anonymize(", ".join(row.get("kfz_kennzeichen", []) if isinstance(row.get("kfz_kennzeichen"), list) else [])),
            row.get("fahrkarten", ""),
            row.get("gesamtpreis", "") + " €" if row.get("gesamtpreis") else "",
        ])
    return f"""
<section class="hero"><h1>Öffentliche Demo</h1><p class="lead">Anonymisierte Musteransicht mit den letzten 20 gezählten, nicht stornierten Fahrkarten. Keine PDFs, keine Mailtexte, keine echten Buchungsnummern und keine echten Kennzeichen.</p></section>
<section class="panel">{table([('Abfahrt','Abfahrtsdatum der anonymisierten Musterzeile.','text'),('Von','Abfahrtshafen.','text'),('Nach','Zielhafen.','text'),('Buchung','Anonymisierte Buchungsnummer.','text'),('Kennzeichen','Anonymisierte Kennzeichen.','text'),('Fahrkarten','Erkannte Fahrkartenarten.','text'),('Gesamtpreis','Preis aus Musterdaten; vor Veröffentlichung ggf. entfernen.','money')], demo_rows)}</section>
"""


def websitecheck_body() -> str:
    checks = [
        ["Padding", "maximal 2px in Website-CSS und Tabellenbericht vorgesehen"],
        ["Navigation", "linke Navigation sichtbar; alter Einklappschlüssel wird verworfen"],
        ["Links", "lokale Website-Berichte werden relativ verlinkt; file:/// wird beim Einbetten entfernt"],
        ["Downloads", "Setup und Portable-ZIP liegen unter website/downloads"],
        ["Update", "versions.txt und release.json werden erzeugt"],
        ["Finanzamt", "finanzamt_paket.zip enthält MANIFEST-SHA256.txt"],
        ["Öffentliche Fassung", "website_public wird ohne PDFs, Mails und Echtdaten-Bericht erzeugt"],
    ]
    return f"""
<section class="hero"><h1>WEBSITECHECK</h1><p class="lead">Automatisch erzeugte Prüfansicht für die wichtigsten E:\\codex-Webseitenregeln.</p></section>
<section class="panel">{table([('Prüfpunkt','Geprüfter Bereich der Website.','text'),('Ergebnis','Aktueller Erzeugungsstand.','text')], checks)}</section>
"""


def write_public_site(summary: dict[str, Any], rows: list[dict[str, Any]], hashes: dict[str, str]) -> None:
    public = ROOT / "website_public"
    if public.exists():
        shutil.rmtree(public)
    public.mkdir(parents=True, exist_ok=True)
    (public / "assets").mkdir(parents=True, exist_ok=True)
    (public / "downloads").mkdir(parents=True, exist_ok=True)
    shutil.copy2(SITE / "site.css", public / "site.css")
    logo = ASSETS / "wdr_ticket_logo.svg"
    if logo.exists():
        shutil.copy2(logo, public / "assets" / "wdr_ticket_logo.svg")
    for filename in ("WDRTicketSetup.exe", "WDRTicketPortable.zip"):
        src = DOWNLOADS / filename
        if src.exists():
            shutil.copy2(src, public / "downloads" / filename)
    payload = release_payload(summary, hashes)
    write_text(public / "release.json", json.dumps(payload, ensure_ascii=False, indent=2))
    write_text(public / "versions.txt", read_text(SITE / "versions.txt"))
    public_summary = dict(summary)
    public_summary["wdr_fehlerfahrkarten"] = 0
    public_pages = {
        "index.html": page("Start", "index.html", index_body(public_summary), public_summary),
        "auswertung.html": page("Auswertung", "auswertung.html", '<section class="hero"><h1>Auswertung</h1><p class="lead">Die öffentliche Fassung enthält keine Echtdaten-Auswertung. Bitte die lokale App nutzen oder die anonymisierte Demo öffnen.</p><p><a class="button" href="public-demo.html">Demo öffnen</a></p></section>', public_summary),
        "kalender.html": page("Kalender", "kalender.html", '<section class="hero"><h1>Kalender</h1><p class="lead">Der echte Kalender wird nur lokal aus den eigenen Fahrkarten erzeugt.</p></section>', public_summary),
        "preisgrafik.html": page("Preisgrafik", "preisgrafik.html", '<section class="hero"><h1>Preisgrafik</h1><p class="lead">Die echte Preisgrafik wird nur lokal aus den eigenen Fahrkarten erzeugt.</p></section>', public_summary),
        "storno-risiko.html": page("Storno-Risiko", "storno-risiko.html", '<section class="hero"><h1>Storno-Risiko</h1><p class="lead">Die echte Storno-Prüfung wird nur lokal aus den eigenen Maildaten erzeugt.</p></section>', public_summary),
        "pruefansicht.html": page("Prüfansicht", "pruefansicht.html", '<section class="hero"><h1>Prüfansicht</h1><p class="lead">Die echte Prüfansicht bleibt lokal, weil sie Buchungsnummern, Kennzeichen und PDF-Kontext enthalten kann.</p></section>', public_summary),
        "aenderungen.html": page("Änderungen", "aenderungen.html", '<section class="hero"><h1>Änderungen</h1><p class="lead">Änderungsvergleiche werden nur lokal gespeichert.</p></section>', public_summary),
        "lauf.html": page("Letzter Lauf", "lauf.html", '<section class="hero"><h1>Letzter Lauf</h1><p class="lead">Laufdetails mit lokalen Pfaden werden in der öffentlichen Fassung nicht veröffentlicht.</p></section>', public_summary),
        "ampel.html": page("Finanzamt-Ampel", "ampel.html", '<section class="hero"><h1>Finanzamt-Ampel</h1><p class="lead">Die Finanzamt-Ampel wird lokal aus den eigenen Belegen erzeugt.</p></section>', public_summary),
        "pdfcheck.html": page("PDF-Ordner", "pdfcheck.html", pdfcheck_body(), public_summary),
        "exports.html": page("Exporte", "exports.html", downloads_body(), public_summary),
        "public-demo.html": page("Öffentliche Demo", "public-demo.html", public_demo_body(rows), public_summary),
        "websitecheck.html": page("WEBSITECHECK", "websitecheck.html", websitecheck_body(), public_summary),
        "finanzamt.html": page("Finanzamt", "finanzamt.html", '<section class="hero"><h1>Finanzamt</h1><p class="lead">Finanzamt-Pakete werden lokal erzeugt und enthalten eigene Belege. Die öffentliche Fassung enthält kein echtes Paket.</p></section>', public_summary),
        "hilfe.html": page("Hilfe", "hilfe.html", help_body(), public_summary),
        "faq.html": page("FAQ", "faq.html", faq_body(), public_summary),
        "info.html": page("Info", "info.html", info_body(), public_summary),
        "versionen.html": page("Versionen", "versionen.html", version_body(hashes), public_summary),
        "signatur.html": page("Signatur/SmartScreen", "signatur.html", signature_body(hashes), public_summary),
        "impressum.html": page("Impressum", "impressum.html", impressum_body(), public_summary),
        "datenschutz.html": page("Datenschutz", "datenschutz.html", datenschutz_body(), public_summary),
    }
    for name, html in public_pages.items():
        write_text(public / name, html)


def build() -> None:
    SITE.mkdir(parents=True, exist_ok=True)
    ASSETS.mkdir(parents=True, exist_ok=True)
    DOWNLOADS.mkdir(parents=True, exist_ok=True)
    STATE.mkdir(parents=True, exist_ok=True)
    logo = ROOT / "wdr_ticket_logo.svg"
    if logo.exists():
        shutil.copy2(logo, ASSETS / "wdr_ticket_logo.svg")
    write_css()
    update_finance_zip_manifest()
    summary, rows = data()
    previous = load_previous_snapshot()
    changes = compare_rows(rows, previous)
    copy_reports_and_data()
    hashes = copy_downloads_and_exports()
    write_versions_txt(hashes)
    write_release_json(summary, hashes)

    report_pages: dict[str, tuple[str, str, str]] = {}
    for page_name, report_name, title in [
        ("auswertung.html", "eticket_zeiten_tabelle.html", "Auswertung"),
        ("kalender.html", "eticket_kalender.html", "Kalender"),
        ("preisgrafik.html", "eticket_preis_scatter.html", "Preisgrafik"),
        ("storno-risiko.html", "eticket_storno_risiko.html", "Storno-Risiko"),
        ("pruefansicht.html", "eticket_pruefansicht.html", "Prüfansicht"),
        ("falschmeldung.html", "eticket_wdr_fehler.html", "WDR-Falschmeldung"),
    ]:
        extra, body = report_inline(report_name)
        report_pages[page_name] = (title, extra, body)

    finance_extra, finance_report = report_inline("exports/finanzamt_jahresbelege.html") if (SITE / "exports" / "finanzamt_jahresbelege.html").exists() else ("", "")
    if not finance_report:
        finance_report = '<section class="panel"><p class="bad">Finanzamt-Bericht fehlt.</p></section>'

    pages: dict[str, str] = {
        "index.html": page("Start", "index.html", index_body(summary), summary),
        "pdfcheck.html": page("PDF-Ordner", "pdfcheck.html", pdfcheck_body(), summary),
        "exports.html": page("Exporte", "exports.html", downloads_body(), summary),
        "public-demo.html": page("Öffentliche Demo", "public-demo.html", public_demo_body(rows), summary),
        "websitecheck.html": page("WEBSITECHECK", "websitecheck.html", websitecheck_body(), summary),
        "lauf.html": page("Letzter Lauf", "lauf.html", lauf_body(summary, rows), summary),
        "aenderungen.html": page("Änderungen", "aenderungen.html", changes_body(changes, previous), summary),
        "ampel.html": page("Finanzamt-Ampel", "ampel.html", ampel_body(rows), summary),
        "finanzamt.html": page("Finanzamt", "finanzamt.html", f'<section class="hero"><h1>Finanzamt-Jahresbelege</h1><p class="lead">Jahresweise gruppierte Fahrkarten mit Summen und PDF-Paket.</p></section>{finance_report}', summary, finance_extra),
        "hilfe.html": page("Hilfe", "hilfe.html", help_body(), summary),
        "faq.html": page("FAQ", "faq.html", faq_body(), summary),
        "info.html": page("Info", "info.html", info_body(), summary),
        "handbuch.html": page("Handbuch", "handbuch.html", help_body() + faq_body(), summary),
        "versionen.html": page("Versionen", "versionen.html", version_body(hashes), summary),
        "signatur.html": page("Signatur/SmartScreen", "signatur.html", signature_body(hashes), summary),
        "impressum.html": page("Impressum", "impressum.html", impressum_body(), summary),
        "datenschutz.html": page("Datenschutz", "datenschutz.html", datenschutz_body(), summary),
    }
    for page_name, (title, extra, body) in report_pages.items():
        if page_name == "falschmeldung.html" and int(summary.get("wdr_fehlerfahrkarten") or 0) <= 0:
            continue
        if page_name == "storno-risiko.html" and int(summary.get("storniert") or 0) <= 0:
            continue
        pages[page_name] = page(title, page_name, f'<section class="hero"><h1>{esc(title)}</h1><p class="lead">Direkt in die Webseite eingebetteter lokaler Bericht ohne iframe und ohne eigenen waagerechten Tabellenslider.</p></section>{body}', summary, extra)
    for name, html in pages.items():
        write_text(SITE / name, html)
    write_public_site(summary, rows, hashes)
    # Die öffentliche Website wird anschließend vollständig aus einer separaten,
    # datenfreien Produktbeschreibung neu erzeugt. So können weder Kennzahlen noch
    # Musterzeilen aus der privaten Auswertung in die Veröffentlichung gelangen.
    import build_wdr_ticket_public_website
    build_wdr_ticket_public_website.build()
    write_current_snapshot(rows, summary)
    print(f"Webseite nach E:\\codex-Anweisung erstellt: {SITE / 'index.html'}")
    print(f"Version: {VERSION}")
    print(f"Seiten: {len(pages)}")


if __name__ == "__main__":
    build()
