from __future__ import annotations

from calendar import monthrange
from collections import Counter
from datetime import datetime, timedelta
from email import policy
from email.parser import BytesParser
from email.utils import parsedate_to_datetime
from html import escape as html_escape
from html import unescape as html_unescape
from pathlib import Path
from zoneinfo import ZoneInfo
import argparse
import csv
import json
import logging
import re
import shutil
import subprocess
import zipfile

from PIL import Image
from pypdf import PdfReader


WORK_DIR = Path(r"E:\codex\WDR-Ticket")
MBOX_ETICKET_DIR = WORK_DIR / "etickets_aus_mbox"
INPUT_DIR = MBOX_ETICKET_DIR / "pdfs"
MBOX_INDEX = MBOX_ETICKET_DIR / "index.json"
OUTPUT = WORK_DIR / "eticket_zeiten_tabelle.html"
CALENDAR_OUTPUT = WORK_DIR / "eticket_kalender.html"
ERROR_OUTPUT = WORK_DIR / "eticket_wdr_fehler.html"
SAMPLE_OUTPUT = WORK_DIR / "eticket_zeiten_muster20.html"
CALENDAR_SAMPLE_OUTPUT = WORK_DIR / "eticket_kalender_muster20.html"
SCATTER_OUTPUT = WORK_DIR / "eticket_preis_scatter.html"
RISK_OUTPUT = WORK_DIR / "eticket_storno_risiko.html"
AUDIT_OUTPUT = WORK_DIR / "eticket_pruefansicht.html"
PREVIEW_DIR = WORK_DIR / "previews"
MAILTEXT_DIR = WORK_DIR / "mailtexte"
EXPORT_DIR = WORK_DIR / "exports"
TABLE_PDF_OUTPUT = EXPORT_DIR / "etickets_wdr_auswertung.pdf"
CALENDAR_PDF_OUTPUT = EXPORT_DIR / "etickets_wdr_kalender.pdf"
LOGO = WORK_DIR / "wdr_ticket_logo.svg"
LOCAL_TZ = ZoneInfo("Europe/Berlin")
WDR_MBOXES = [
    Path(r"E:\TB-Data\momj80ku.Jost Jahn\Mail\Local Folders\Archiv.sbd\!Reisen.sbd\WDR"),
    Path(r"E:\TB-Data\t5ttgr59.IETimport\Mail\Local Folders\Orders.sbd\Orders-O-Z.sbd\WDR"),
    Path(r"E:\TB-Data\t5ttgr59.IETimport\Mail\Local Folders\Orders.sbd\Orders-O-Z.sbd\wyk-wdr"),
]

logging.getLogger("pypdf").setLevel(logging.ERROR)

DASH_RE = "[-\u2013\u2014\u2212]"
DATE_TIME_RE = re.compile(r"(\d{1,2}\.\d{1,2}\.(?:\d{2}|\d{4}))\s*(?:" + DASH_RE + r"\s*)?(\d{1,2}[:.]\d{2})(?:\s*Uhr)?", re.I)
DATE_ONLY_RE = re.compile(r"\b(\d{1,2}\.\d{1,2}\.(?:\d{2}|\d{4}))\b")
BOOKING_FROM_NAME_RE = re.compile(r"_(\d{7,9})(?:_|\.pdf$)", re.I)
DAY_WORDS = "Montag|Dienstag|Mittwoch|Donnerstag|Freitag|Samstag|Sonnabend|Sonntag"

CARD_TYPE_ORDER = ["auto", "anhaenger", "motorrad", "fahrrad", "person", "other"]
CARD_TYPE_LABELS = {
    "auto": "Auto",
    "anhaenger": "H&auml;nger",
    "motorrad": "Motorrad",
    "fahrrad": "Rad",
    "person": "Person",
    "other": "Weitere",
}
CARD_TYPE_TEXT_LABELS = {
    "auto": "Auto",
    "anhaenger": "Hänger",
    "motorrad": "Motorrad",
    "fahrrad": "Rad",
    "person": "Person",
    "other": "Weitere",
}



def fix_display_text(value: str) -> str:
    """Repair known mojibake in visible text without touching JavaScript syntax."""
    replacements = {
        "ÃƒÂ¤": "ä", "ÃƒÂ¶": "ö", "ÃƒÂ¼": "ü", "Ãƒâ€ž": "Ä", "Ãƒâ€“": "Ö", "ÃƒÅ“": "Ü", "ÃƒÅ¸": "ß",
        "Ã¤": "ä", "Ã¶": "ö", "Ã¼": "ü", "Ã„": "Ä", "Ã–": "Ö", "Ãœ": "Ü", "ÃŸ": "ß",
        "Ã¢â€šÂ¬": "€", "â‚¬": "€", "Ã‚Â©": "©", "Â©": "©", "Ã‚Â§": "§", "Â§": "§",
        "Ã¢ËœÂ°": "☰", "â˜°": "☰", "F?r": "Für", "f?r": "für", "Zur?ck": "Zurück",
        "R?ckfahrt": "Rückfahrt", "H?nger": "Hänger", "Fahrzeugl?nge": "Fahrzeuglänge",
        "Fahrzeugl?ngen": "Fahrzeuglängen", "gleichm??ig": "gleichmäßig",
        "Gez?hlte": "Gezählte", "gez?hlte": "gezählte", "ausgew?hlt": "ausgewählt",
        "gew?hlt": "gewählt", "k?nnen": "können", "Aufgef?hrt": "Aufgeführt",
        "Pr?fung": "Prüfung", "enth?lt": "enthält",
    }
    for old, new in replacements.items():
        value = value.replace(old, new)
    return value


def esc(value: str, quote: bool = False) -> str:
    return html_escape(str(value), quote=quote)


def lines_of(text: str) -> list[str]:
    return [line.strip() for line in text.replace("\xa0", " ").splitlines() if line.strip()]


def normalize_date(value: str) -> str:
    value = (value or "").strip()
    m = re.fullmatch(r"(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})", value)
    if not m:
        return value
    day, month, year = m.groups()
    if len(year) == 2:
        year = "20" + year
    return f"{int(day):02d}.{int(month):02d}.{year}"


def normalize_time(value: str) -> str:
    value = (value or "").strip().replace(".", ":")
    m = re.fullmatch(r"(\d{1,2}):(\d{2})", value)
    if not m:
        return value
    hour, minute = m.groups()
    return f"{int(hour)}:{minute}"


def first_ticket_area(text: str) -> str:
    low = text.lower()
    starts = [
        p
        for p in [
            low.find("fahrschein und buchungsbestätigung"),
            low.find("fahrkarte und buchungsbestätigung"),
            low.find("fahrkarten zur"),
            low.find("fahrkarte zur"),
        ]
        if p >= 0
    ]
    start = min(starts) if starts else 0
    chunk = text[start:start + 3200]
    sep = chunk.find("\nQ ")
    if sep >= 0:
        chunk = chunk[:sep]
    hinweise = chunk.lower().find("hinweise:")
    if hinweise >= 0:
        chunk = chunk[:hinweise]
    return chunk


def first_match(pattern: str, text: str) -> str:
    m = re.search(pattern, text, re.I | re.S)
    return m.group(1).strip() if m else ""


def extract_after_label(area: str, labels: tuple[str, ...], stop_words: tuple[str, ...]) -> str:
    lines = lines_of(area)
    for idx, line in enumerate(lines):
        low = line.lower()
        if any(label in low for label in labels):
            for candidate in lines[idx + 1:idx + 8]:
                cand_low = candidate.lower()
                if any(stop in cand_low for stop in stop_words):
                    if "buchung" not in cand_low:
                        break
                if "kfz-schein" in cand_low or cand_low in {"lt.", "lt. kfz-schein"}:
                    continue
                candidate = re.sub(r"\bBuchung\b.*$", "", candidate, flags=re.I).strip()
                candidate = re.sub(r"^(?:Anh(?:ä|ae|\?)nger|PKW|Kfz)\s*:\s*", "", candidate, flags=re.I).strip()
                m = re.match(r"([A-ZÄÖÜ]{1,3}\s*[A-ZÄÖÜ]{0,2}\s*\d{1,5})\b", candidate)
                if m:
                    return re.sub(r"\s+", "", m.group(1))
                if re.fullmatch(r"[A-ZÄÖÜ0-9 -]{2,18}", candidate):
                    return candidate.strip()
    return ""


def extract_plate(area: str) -> str:
    value = extract_after_label(
        area,
        ("kfz-kennzeichen", "kennzeichen"),
        ("buchung", "kfz-länge", "länge", "preis", "gesamtpreis", "mwst"),
    )
    if value:
        return value
    lines = lines_of(area)
    for idx, line in enumerate(lines):
        if "kennzeichen" not in line.lower():
            continue
        for candidate in lines[idx + 1:idx + 14]:
            candidate = re.sub(r"\s+", "", candidate.strip().upper())
            if re.fullmatch(r"[A-ZÄÖÜ]{1,4}\d{1,5}", candidate):
                return candidate
    return ""


def extract_trailer_plate(area: str) -> str:
    m = re.search(r"Anh(?:ä|ae|\?)nger\s*:\s*([A-ZÄÖÜ]{1,3}\s*[A-ZÄÖÜ]{0,2}\s*\d{1,5})", area, re.I)
    if m:
        return re.sub(r"\s+", "", m.group(1)).strip()
    return ""


def unique_plate_parts(parts: list[dict[str, str]]) -> list[dict[str, str]]:
    seen = set()
    result = []
    for part in parts:
        value = (part.get("value") or "").strip()
        if not value:
            continue
        key = value.upper().replace(" ", "")
        if key in seen:
            continue
        seen.add(key)
        result.append({"role": part.get("role") or "", "value": value})
    return result


def plate_values_from_parts(parts: list[dict[str, str]]) -> list[str]:
    return [part["value"] for part in parts if part.get("value")]


def fallback_dates(area: str) -> tuple[str, str]:
    lines = lines_of(area)
    dep = ""
    ret = ""
    for idx, line in enumerate(lines):
        low = line.lower()
        if not dep and "hinfahrt" in low:
            for candidate in lines[idx + 1:idx + 6]:
                m = DATE_ONLY_RE.search(candidate)
                if m:
                    dep = normalize_date(m.group(1))
                    break
        if not ret and "rückfahrt" in low:
            for candidate in lines[idx + 1:idx + 6]:
                if "preis" in candidate.lower() or "kennzeichen" in candidate.lower():
                    break
                m = DATE_ONLY_RE.search(candidate)
                if m:
                    ret = normalize_date(m.group(1))
                    break
    return dep, ret


def clean_port_name(value: str) -> str:
    value = re.sub(r"\s+\b(?:Montag|Dienstag|Mittwoch|Donnerstag|Freitag|Samstag|Sonnabend|Sonntag),?\s+den\b.*$", "", value, flags=re.I)
    value = re.sub(r"\s*\([^)]*\)", "", value)
    value = re.sub(r"\s*\([^)]*$", "", value)
    return value.strip()


def route_from_area(area: str) -> tuple[str, str]:
    for line in lines_of(area):
        if not re.search(r"\s+" + DASH_RE + r"\s+", line):
            continue
        cleaned = re.sub(rf"\s+(?:{DAY_WORDS}),?\s+den\b.*$", "", line, flags=re.I).strip()
        low = cleaned.lower()
        if any(stop in low for stop in ("preis", "buchung", "kfz", "mwst", "hinfahrt", "rückfahrt")):
            continue
        if not re.search(r"\s+" + DASH_RE + r"\s+", cleaned):
            continue
        dep_port, dest_port = re.split(r"\s+" + DASH_RE + r"\s+", cleaned, 1)
        dep_port = clean_port_name(dep_port)
        dest_port = clean_port_name(dest_port)
        if dep_port and dest_port:
            return dep_port, dest_port
    return "", ""


def split_ticket_sections(text: str) -> list[str]:
    low = text.lower()
    starts = [
        p
        for p in [
            low.find("fahrkarte und buchungsbestätigung"),
            low.find("fahrkarten zur"),
            low.find("fahrkarte zur"),
        ]
        if p >= 0
    ]
    chunk = text[min(starts):] if starts else text
    parts = re.split(r"(?m)^\s*Q\s*(?:\.\s*){8,}.*$", chunk)
    cleaned = []
    for part in parts:
        part = part.strip()
        if not part:
            continue
        hinweise = part.lower().find("hinweise:")
        if hinweise >= 0:
            part = part[:hinweise].strip()
        if part:
            cleaned.append(part)
    return cleaned


def clean_body_text(value: str) -> str:
    value = value.replace("\r\n", "\n").replace("\r", "\n")
    value = "\n".join(line.rstrip() for line in value.split("\n")).strip()
    return re.sub(r"\n{4,}", "\n\n\n", value)


def classify_ticket_sections(text: str) -> dict[str, list[str]]:
    result = {key: [] for key in CARD_TYPE_ORDER}
    for idx, section in enumerate(split_ticket_sections(text)):
        low = section.lower()
        has_ticket_content = re.search(
            r"\bbuchung\s+\d+|(?:wittdün|dagebüll|amrum|wyk)\s*[-–—−]\s*(?:wittdün|dagebüll|amrum|wyk)|\bpkw\b|anh(?:ä|ae|\?)nger|motorrad|fahrrad|erwachsene|erwachsener|gesamtsumme|gesamtpreis",
            low,
            re.I,
        )
        if not has_ticket_content:
            continue
        has_trailer = re.search(r"anh(?:ä|ae|\?)nger|wohnwagen|trailer", low, re.I)
        has_auto = any(token in low for token in ("kfz-kennzeichen", "kfz-länge", "kfz-l?nge", "pkw ", " pkw"))
        if has_trailer:
            result["anhaenger"].append(section)
        if has_auto:
            result["auto"].append(section)
        if has_trailer or has_auto:
            continue
        if re.search(r"motorrad|motorroller|moped|krad", low, re.I):
            result["motorrad"].append(section)
        elif re.search(r"fahrrad|e-bike|ebike|pedelec", low, re.I):
            result["fahrrad"].append(section)
        elif re.search(r"\b(erwachsene|erwachsener|kind|kinder|person|personen|fahrgast|fahrgäste|fahrg?ste|schüler|sch?ler)\b", low, re.I) or idx > 0:
            result["person"].append(section)
        else:
            result["other"].append(section)
    return result


def join_card_sections(sections: list[str]) -> str:
    return "\n\n---\n\n".join(clean_body_text(section) for section in sections if section.strip())


def money_matches(pattern: str, text: str) -> list[int]:
    values = []
    for match in re.finditer(pattern, text, re.I):
        values.append(euro_to_cents(match.group(1)))
    return values


def section_price_cents(section: str) -> int:
    values = money_matches(r"\b(?:Gesamtpreis|Preis)\s+(?:EUR|€)\s*([0-9.]+,[0-9]{2})", section)
    return sum(values)


def section_tax_cents(section: str) -> int:
    values = money_matches(r"MwSt\.\s*(?:EUR|€)?\s*€?\s*([0-9.]+,[0-9]{2})", section)
    return sum(values)


def tax_cents_from_text(text: str) -> int:
    for line in lines_of(text):
        if "mwst" not in line.lower():
            continue
        values = re.findall(r"([0-9.]+,[0-9]{2})", line)
        if values:
            return euro_to_cents(values[-1])
    lines = lines_of(text)
    for idx, line in enumerate(lines):
        if "mwst" not in line.lower():
            continue
        collected = []
        for candidate in lines[idx + 1:idx + 10]:
            collected.extend(re.findall(r"([0-9.]+,[0-9]{2})", candidate))
            if collected and candidate.lower() == "eur":
                break
        if collected:
            return euro_to_cents(collected[-1])
    return 0


def summary_and_tax_cents_from_text(text: str) -> tuple[int, int]:
    lines = lines_of(text)
    for idx, line in enumerate(lines):
        low = line.lower()
        if "gesamtsumme" not in low:
            continue
        window = lines[idx:idx + 14]
        if not any("mwst" in candidate.lower() for candidate in window):
            continue
        collected = []
        for candidate in window:
            collected.extend(re.findall(r"([0-9.]+,[0-9]{2})", candidate))
        if len(collected) >= 2:
            return euro_to_cents(collected[-2]), euro_to_cents(collected[-1])
    return 0, 0


def tax_rate_from_text(text: str) -> int:
    m = re.search(r"(\d{1,2})\s*%\s*MwSt", text, re.I)
    return int(m.group(1)) if m else 0


def tax_check(total_cents: int, tax_cents: int, tax_rate: int, *alternative_bases: int) -> tuple[int, bool]:
    if not total_cents or not tax_cents or not tax_rate:
        return 0, True
    bases = []
    for base in (total_cents, *alternative_bases):
        if base and base not in bases:
            bases.append(base)
    expected_values = [int(round(base * tax_rate / (100 + tax_rate))) for base in bases]
    expected = min(expected_values, key=lambda value: abs(value - tax_cents))
    return expected, abs(expected - tax_cents) <= 2


def extract_length_m(area: str) -> str:
    length = first_match(r"Kfz[-–—−]?Länge\s*\(lt\.\s*Kfz-Schein\)\s*([0-9]+[,.][0-9]+)\s*m?", area)
    if not length:
        length = first_match(r"\bLänge\b\s*([0-9]+[,.][0-9]+)\s*m", area)
    if length:
        return length
    lines = lines_of(area)
    for idx, line in enumerate(lines):
        if "länge" not in line.lower() or "kfz" not in line.lower():
            continue
        for candidate in lines[idx + 1:idx + 12]:
            if "anh" in candidate.lower():
                continue
            m = re.search(r"\b([0-9]{3,5})\s*mm\b", candidate, re.I)
            if m:
                return f"{int(m.group(1)) / 1000:.3f}".replace(".", ",")
    return ""


def extract_internal_number(text: str, booking: str) -> str:
    lines = lines_of(text)
    found = []
    seen = set()
    for line in lines:
        compact = re.sub(r"\s+", "", line)
        if not re.fullmatch(r"\d{10,14}", compact):
            continue
        if compact == booking or compact in seen:
            continue
        seen.add(compact)
        found.append(compact)
    if found:
        return "\n".join(found)
    for idx, line in enumerate(lines[:80]):
        if not re.fullmatch(r"\d{7,12}", line):
            continue
        if line == booking:
            continue
        nearby = " ".join(lines[max(0, idx - 6):idx + 6]).lower()
        if any(token in nearby for token in ("bereithalten", "smartphone", "tablet", "fahrkarte", "barcode", "ocr")):
            return line
    return ""


def unique_sections(sections: list[str]) -> list[str]:
    result = []
    seen = set()
    for section in sections:
        key = re.sub(r"\s+", " ", section).strip()
        if not key or key in seen:
            continue
        seen.add(key)
        result.append(section)
    return result


def sort_date(value: str) -> str:
    try:
        return datetime.strptime(value, "%d.%m.%Y").strftime("%Y-%m-%d")
    except ValueError:
        return "9999-99-99"


def sort_time(value: str) -> str:
    if not value or ":" not in value:
        return "9999"
    h, m = value.split(":", 1)
    return f"{int(h):02d}{int(m):02d}"


def display_time(value: str) -> str:
    if not value or ":" not in value:
        return ""
    h, m = value.split(":", 1)
    return f"{int(h)}:{int(m):02d}"


def weekday_abbr(day: str) -> str:
    try:
        return ("Mo", "Di", "Mi", "Do", "Fr", "Sa", "So")[datetime.strptime(day, "%d.%m.%Y").weekday()]
    except ValueError:
        return ""


def weekday_index(day: str) -> str:
    try:
        return str(datetime.strptime(day, "%d.%m.%Y").weekday())
    except ValueError:
        return "9"


def sort_decimal(value: str) -> str:
    if not value:
        return ""
    return value.replace(".", "").replace(",", ".")


def euro_to_cents(value: str) -> int:
    if not value:
        return 0
    cleaned = value.replace(".", "").replace(",", ".")
    try:
        return int(round(float(cleaned) * 100))
    except ValueError:
        return 0


def cents_to_euro(cents: int) -> str:
    euros = cents / 100
    raw = f"{euros:,.2f}"
    return raw.replace(",", "X").replace(".", ",").replace("X", ".")


def parse_dt(day: str, time_value: str) -> datetime | None:
    if not day or not time_value or ":" not in time_value:
        return None
    try:
        return datetime.strptime(day + " " + display_time(time_value), "%d.%m.%Y %H:%M")
    except ValueError:
        return None


def duration_text(dep_day: str, dep_time: str, ret_day: str, ret_time: str) -> tuple[str, str]:
    start = parse_dt(dep_day, dep_time)
    end = parse_dt(ret_day, ret_time)
    if not start or not end or end < start:
        return "", ""
    hours = int(round((end - start).total_seconds() / 3600))
    days, rem_hours = divmod(hours, 24)
    day_word = "Tag" if days == 1 else "Tage"
    if days:
        return f"{days} {day_word} {rem_hours} Stunden", str(hours)
    return f"{rem_hours} Stunden", str(hours)


def pdf_uri(path: Path) -> str:
    return path.absolute().as_uri()


def render_preview(pdf: Path) -> str:
    PREVIEW_DIR.mkdir(parents=True, exist_ok=True)
    out = PREVIEW_DIR / (pdf.stem + ".jpg")
    if out.exists() and out.stat().st_mtime >= pdf.stat().st_mtime:
        return "previews/" + out.name
    tool = shutil.which("pdftoppm") or r"C:\batches\pdftoppm.exe"
    if not Path(tool).exists():
        return ""
    prefix = PREVIEW_DIR / pdf.stem
    for old in PREVIEW_DIR.glob(pdf.stem + "*.ppm"):
        old.unlink(missing_ok=True)
    try:
        subprocess.run(
            [tool, "-f", "1", "-l", "1", "-r", "90", str(pdf), str(prefix)],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            check=True,
            timeout=45,
        )
        ppm_files = sorted(PREVIEW_DIR.glob(pdf.stem + "*.ppm"))
        if not ppm_files:
            return ""
        with Image.open(ppm_files[0]) as image:
            image.convert("RGB").save(out, "JPEG", quality=82, optimize=True)
        for ppm in ppm_files:
            ppm.unlink(missing_ok=True)
    except Exception:
        return ""
    return "previews/" + out.name if out.exists() else ""


def split_mbox_messages(data: bytes):
    starts = [0] if data.startswith(b"From ") else []
    starts.extend(m.start() + 1 for m in re.finditer(br"\nFrom ", data))
    for idx, start in enumerate(starts):
        end = starts[idx + 1] - 1 if idx + 1 < len(starts) else len(data)
        raw = data[start:end]
        if raw.startswith(b"From "):
            raw = raw.partition(b"\n")[2]
        if raw.strip():
            yield raw


def mail_delivery_dt(msg) -> datetime | None:
    x_delivery = (msg.get("X-Delivery-Time") or "").strip()
    if x_delivery.isdigit():
        return datetime.fromtimestamp(int(x_delivery), LOCAL_TZ)
    date_header = msg.get("Date")
    if not date_header:
        return None
    try:
        dt = parsedate_to_datetime(date_header)
    except (TypeError, ValueError):
        return None
    if dt.tzinfo is None:
        return dt.replace(tzinfo=LOCAL_TZ)
    return dt.astimezone(LOCAL_TZ)


def html_body_to_text(value: str) -> str:
    value = re.sub(r"(?is)<(script|style).*?</\1>", " ", value)
    value = re.sub(r"(?i)<br\s*/?>", "\n", value)
    value = re.sub(r"(?i)</(?:p|div|tr|li|h[1-6])\s*>", "\n", value)
    value = re.sub(r"(?s)<[^>]+>", " ", value)
    value = html_unescape(value)
    value = re.sub(r"[ \t]+", " ", value)
    value = re.sub(r" *\n *", "\n", value)
    return clean_body_text(value)


def mail_body(msg) -> str:
    plain_parts = []
    html_parts = []
    for part in msg.walk():
        if part.is_multipart():
            continue
        disposition = (part.get_content_disposition() or "").lower()
        filename = part.get_filename()
        if disposition == "attachment" or filename:
            continue
        try:
            content = part.get_content()
        except Exception:
            continue
        if not isinstance(content, str):
            continue
        ctype = part.get_content_type().lower()
        if ctype == "text/plain":
            plain_parts.append(content)
        elif ctype == "text/html":
            html_parts.append(content)
    text = clean_body_text("\n\n".join(plain_parts))
    if not text and html_parts:
        text = html_body_to_text(html_parts[0])
    return text


def attachment_names(msg) -> list[str]:
    names = []
    for part in msg.walk():
        name = part.get_filename()
        if name and name.lower().endswith(".pdf"):
            names.append(Path(name).name)
    return names


def build_mail_index() -> dict[str, dict[str, str]]:
    index = {"__stornos_by_booking__": {}}
    if not MBOX_INDEX.exists():
        return index
    try:
        data = json.loads(MBOX_INDEX.read_text(encoding="utf-8"))
    except Exception:
        return index
    for ticket in data.get("tickets", []):
        pdf_name = ticket.get("pdf_name", "")
        if not pdf_name:
            continue
        index[pdf_name.lower()] = {
            "day": ticket.get("mail_day", ""),
            "time": ticket.get("mail_time", ""),
            "href": ticket.get("mail_text_href", ""),
            "body": ticket.get("mail_body", ""),
            "subject": ticket.get("mail_subject", ""),
        }
    index["__stornos_by_booking__"] = data.get("stornos_by_booking", {})
    return index


def number_cell(value: str, active: bool, sort_value: str = "") -> str:
    if not sort_value:
        sort_value = value if active else "999999"
    if not active:
        return f'<td class="num-col" data-sort="{esc(sort_value, quote=True)}"></td>'
    return f'<td class="num-col" data-sort="{esc(sort_value, quote=True)}">{esc(value)}</td>'


def text_cell(value: str, sort_value: str | None = None) -> str:
    sv = value if sort_value is None else sort_value
    return f'<td data-sort="{esc(sv, quote=True)}">{esc(value)}</td>'


def multiline_cell(value: str, sort_value: str | None = None, class_name: str = "") -> str:
    sv = value if sort_value is None else sort_value
    cls = f' class="{esc(class_name, quote=True)}"' if class_name else ""
    lines = [line for line in str(value or "").splitlines() if line.strip()]
    content = "".join(f'<span class="cell-line">{esc(line)}</span>' for line in lines)
    return f'<td{cls} data-sort="{esc(sv, quote=True)}">{content}</td>'


def plate_color_class(value: str, plate_colors: dict[str, int]) -> str:
    return f"plate-c{plate_colors.get(value, 0) % 10}"


def plate_cell(parts: list[dict[str, str]], plate_colors: dict[str, int]) -> str:
    if not parts:
        return '<td class="plate-cell" data-sort=""></td>'
    lines = []
    for part in parts:
        value = part.get("value", "")
        role = part.get("role", "")
        color_class = plate_color_class(value, plate_colors)
        if role:
            lines.append(
                f'<span class="plate-line"><span class="plate-role">{esc(role)}</span>'
                f'<span class="plate-value {color_class}">{esc(value)}</span></span>'
            )
        else:
            lines.append(f'<span class="plate-line"><span class="plate-value {color_class}">{esc(value)}</span></span>')
    sort_value = " ".join(part.get("value", "") for part in parts)
    return f'<td class="plate-cell" data-sort="{esc(sort_value.lower(), quote=True)}">{"".join(lines)}</td>'


def currency_cell(value: str, sort_value: str | None = None) -> str:
    sv = value if sort_value is None else sort_value
    shown = f"{esc(value)}&nbsp;&euro;" if value else ""
    return f'<td class="money-col" data-sort="{esc(sv, quote=True)}">{shown}</td>'


def datetime_cell(day: str, time_value: str) -> str:
    sort_value = f"{sort_date(day)} {sort_time(time_value)}"
    attrs = (
        f'data-sort="{esc(sort_value, quote=True)}" '
        f'data-sort-date-time="{esc(sort_value, quote=True)}" '
        f'data-sort-date="{esc(sort_date(day), quote=True)}" '
        f'data-sort-time="{esc(sort_time(time_value), quote=True)}" '
        f'data-sort-week-time="{esc(weekday_index(day) + " " + sort_time(time_value), quote=True)}" '
        f'data-sort-week-date="{esc(weekday_index(day) + " " + sort_date(day), quote=True)}"'
    )
    if not day:
        return f'<td class="datetime-col" {attrs}></td>'
    wd = weekday_abbr(day)
    wd_html = f'<span class="weekday">{esc(wd)}</span> ' if wd else ""
    date_html = f'{wd_html}<span class="datepart">{esc(day)}</span>'
    shown = display_time(time_value)
    if not shown:
        return f'<td class="datetime-col" {attrs}>{date_html} <span class="timeval emptytime"></span></td>'
    spacer = '<span class="padzero">0</span>' if len(shown.split(":", 1)[0]) == 1 else ""
    return f'<td class="datetime-col" {attrs}>{date_html} <span class="timeval">{spacer}{esc(shown)}</span></td>'


def mail_cell(day: str, time_value: str, href: str, body: str) -> str:
    sort_value = f"{sort_date(day)} {sort_time(time_value)}"
    if not day and not time_value and not body:
        return f'<td class="datetime-col mail-cell" data-sort="{esc(sort_value, quote=True)}"></td>'
    shown = display_time(time_value)
    if shown:
        spacer = '<span class="padzero">0</span>' if len(shown.split(":", 1)[0]) == 1 else ""
        label = f'<span class="datepart">{esc(day)}</span> <span class="timeval">{spacer}{esc(shown)}</span>'
    else:
        label = esc(day)
    content = f'<a href="{esc(href, quote=True)}" target="_blank" rel="noopener">{label}</a>' if href else label
    if body:
        content += f'<span class="mail-preview"><pre>{esc(body)}</pre></span>'
    return f'<td class="datetime-col mail-cell" data-sort="{esc(sort_value, quote=True)}">{content}</td>'


def storno_badge_cell(info: dict) -> str:
    count = int(info.get("count") or 0)
    if not count:
        return '<td class="cards-cell storno-only" data-sort=""></td>'
    label = "Storno" if count == 1 else f"{count} Storno"
    body = info.get("body", "")
    preview = f'<span class="card-preview"><strong>Stornomeldung</strong><pre>{esc(body)}</pre></span>' if body else ""
    return f'<td class="cards-cell storno-only" data-sort="storno"><span class="card-badge card-storno storno-badge" tabindex="0">{esc(label)}{preview}</span></td>'


def duration_inner(total_hours: int) -> str:
    days, rem_hours = divmod(max(0, total_hours), 24)
    day_number = "" if days == 0 else str(days)
    day_word = "" if days == 0 else ("Tag" if days == 1 else "Tage")
    return (
        f'<span class="duration-value">'
        f'<span class="duration-days">{day_number}</span>'
        f'<span class="duration-day-word">{esc(day_word)}</span>'
        f'<span class="duration-hours">{rem_hours}</span>'
        f'<span class="duration-hour-word">Stunden</span>'
        f'</span>'
    )


def duration_cell(value: str, sort_value: str, cell_id: str = "") -> str:
    id_attr = f' id="{esc(cell_id, quote=True)}"' if cell_id else ""
    if not sort_value:
        return f'<td{id_attr} class="duration-col" data-sort=""></td>'
    try:
        total_hours = int(sort_value)
    except ValueError:
        return f'<td{id_attr} class="duration-col" data-sort="{esc(sort_value, quote=True)}">{esc(value)}</td>'
    return f'<td{id_attr} class="duration-col" data-sort="{esc(sort_value, quote=True)}">{duration_inner(total_hours)}</td>'


def cards_badge(kind: str, label: str, detail_title: str, detail_text: str) -> str:
    preview = ""
    if detail_text:
        preview = f'<span class="card-preview"><strong>{esc(detail_title)}</strong><pre>{esc(detail_text)}</pre></span>'
    return f'<span class="card-badge card-{kind}" tabindex="0">{label}{preview}</span>'


def card_display_label(card_type: str, count: int) -> str:
    if card_type == "person":
        return "1 Person" if count == 1 else f"{count} Personen"
    if card_type == "fahrrad":
        return "1 Fahrrad" if count == 1 else f"{count} Fahrräder"
    label_base = CARD_TYPE_LABELS.get(card_type, esc(card_type))
    return label_base if count == 1 else f"{count} {label_base}"


def card_text_display_label(card_type: str, count: int) -> str:
    if card_type == "person":
        return "1 Person" if count == 1 else f"{count} Personen"
    if card_type == "fahrrad":
        return "1 Fahrrad" if count == 1 else f"{count} Fahrräder"
    label_base = CARD_TYPE_TEXT_LABELS.get(card_type, card_type)
    return label_base if count == 1 else f"{count} {label_base}"


def empty_card_slot(kind: str) -> str:
    return f'<span class="card-badge card-{kind} empty-card" aria-hidden="true">&nbsp;</span>'


def cards_cell(card_counts: dict[str, int], card_texts: dict[str, str], present_card_types: list[str], storno_info: dict | None = None, reserve_storno: bool = False, card_widths: dict[str, str] | None = None) -> str:
    def count_for(kind: str) -> int:
        try:
            return int(card_counts.get(kind, 0) or 0)
        except Exception:
            return 0

    slots: list[str] = []
    sort_parts: list[str] = []
    data_counts: dict[str, int] = {}
    grid_columns: list[str] = []
    safe_width = re.compile(r"^(?:[0-9]+(?:\.[0-9]+)?(?:ch|px|em|rem)|max-content|min-content|auto)$")

    for card_type in present_card_types:
        count = count_for(card_type)
        data_counts[card_type] = count
        requested_width = str((card_widths or {}).get(card_type, "max-content"))
        grid_columns.append(requested_width if safe_width.fullmatch(requested_width) else "max-content")
        if count > 0:
            label = card_display_label(card_type, count)
            title = card_texts.get(card_type, "") or label
            slots.append(
                f'<span class="card-badge card-{card_type}" title="{esc(title, quote=True)}">{esc(label)}</span>'
            )
            sort_parts.append(label)
        else:
            slots.append(empty_card_slot(card_type))

    try:
        storno_count = int((storno_info or {}).get("count", 0) or 0)
    except Exception:
        storno_count = 0
    data_counts["storno"] = storno_count
    if reserve_storno or storno_count:
        requested_width = str((card_widths or {}).get("storno", "max-content"))
        grid_columns.append(requested_width if safe_width.fullmatch(requested_width) else "max-content")
        if storno_count:
            label = "Storno" if storno_count == 1 else f"{storno_count} Storno"
            title = (storno_info or {}).get("hover", "") or (storno_info or {}).get("body", "") or label
            slots.append(
                f'<span class="card-badge card-storno" title="{esc(title, quote=True)}">{esc(label)}</span>'
            )
            sort_parts.append(label)
        else:
            slots.append(empty_card_slot("storno"))

    attrs = "".join(
        f' data-card-{kind}="{count}"' for kind, count in data_counts.items()
    )
    sort_value = " ".join(sort_parts)
    if not slots:
        return f'<td class="cards-cell" data-value="{esc(sort_value, quote=True)}"{attrs}></td>'
    template = " ".join(grid_columns) or "max-content"
    return f'<td class="cards-cell" data-value="{esc(sort_value, quote=True)}"{attrs}><div class="cards-grid" style="--card-grid-template:{esc(template, quote=True)}">{"".join(slots)}</div></td>'

def booking_cell(booking: str, pdf: Path, preview: str) -> str:
    label = esc(booking or pdf.stem)
    href = esc(pdf_uri(pdf), quote=True)
    if preview:
        img = esc(preview, quote=True)
        content = f'<a href="{href}" target="_blank" rel="noopener">{label}</a><span class="preview"><img src="{img}" alt="Vorschau {label}"></span>'
    else:
        content = f'<a href="{href}" target="_blank" rel="noopener">{label}</a>'
    return f'<td class="booking-cell" data-sort="{label}">{content}</td>'


def booking_text_cell(booking: str) -> str:
    return f'<td class="booking-cell" data-sort="{esc(booking, quote=True)}">{esc(booking)}</td>'


def th(label: str, kind: str) -> str:
    title = f"{label}: klicken zum Sortieren; rechten Rand ziehen, um die Spaltenbreite zu ändern; Rechtsklick öffnet weitere Optionen."
    label_html = {
        "Nr.": "Nr.",
        "Abfahrtshafen": "Ab&shy;fahrts&shy;hafen",
        "Zielhafen": "Ziel&shy;hafen",
        "Abfahrt": "Abfahrt",
        "Rückfahrt": "R&uuml;ckfahrt",
        "Mailzustellung": "Mail<wbr>zustellung",
        "Reisedauer": "Reise<wbr>dauer",
        "KfZ-Kennzeichen": "KfZ-<wbr>Kennzeichen",
        "Buchungsnummer": "Buchungs<wbr>nummer",
        "Intern": "Intern",
        "Länge": "L&auml;nge",
        "Fahrkarten": "Fahr&shy;karten",
        "Fahrzeug": "Fahr&shy;zeug",
        "Personen": "Per&shy;so&shy;nen",
        "Gesamtpreis": "Gesamt<wbr>preis",
        "MwSt": "MwSt",
    }.get(label, esc(label))
    return f'<th data-kind="{kind}" data-label="{esc(label, quote=True)}" title="{esc(title, quote=True)}"><span class="head-label">{label_html}</span><span class="resizer" aria-hidden="true"></span></th>'


def write_logo() -> None:
    svg = '''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" role="img" aria-label="WDR Ticket">
  <defs>
    <linearGradient id="sea" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#0d5d7f"/><stop offset="1" stop-color="#63b6b0"/></linearGradient>
    <linearGradient id="paper" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fff7df"/><stop offset="1" stop-color="#ffd77a"/></linearGradient>
  </defs>
  <rect width="96" height="96" rx="22" fill="#17384f"/>
  <path d="M9 64c10 3 15-3 24 0s14 3 23 0 15-3 31 1v14H9z" fill="url(#sea)"/>
  <path d="M22 40h52l-7 17H29z" fill="#f7fbff"/>
  <path d="M34 28h25l15 12H24z" fill="#dbeef7"/>
  <path d="M39 33h7v7h-7zm12 0h7v7h-7z" fill="#17384f" opacity=".75"/>
  <path d="M22 57h51" stroke="#17384f" stroke-width="4" stroke-linecap="round"/>
  <path d="M16 18h37c3 0 5 2 5 5v22c-4 0-7 3-7 7s3 7 7 7v5c0 3-2 5-5 5H16c-3 0-5-2-5-5V23c0-3 2-5 5-5z" fill="url(#paper)" transform="rotate(-8 34.5 43.5)" opacity=".96"/>
  <path d="M22 31h22M21 40h27M20 49h20" stroke="#17384f" stroke-width="4" stroke-linecap="round" transform="rotate(-8 34.5 43.5)"/>
  <circle cx="73" cy="25" r="5" fill="none" stroke="#ffd77a" stroke-width="3"/>
  <circle cx="82" cy="33" r="5" fill="none" stroke="#ffd77a" stroke-width="3"/>
  <path d="M76 29l-13 14M78 30l-8 15" stroke="#ffd77a" stroke-width="3" stroke-linecap="round"/>
  <path d="M18 78c8-3 14-3 22 0s14 3 22 0 13-3 20 0" fill="none" stroke="#d9f3f0" stroke-width="4" stroke-linecap="round"/>
</svg>
'''
    LOGO.write_text(svg, encoding="utf-8")


def parse_day_only(day: str):
    try:
        return datetime.strptime(day, "%d.%m.%Y").date()
    except ValueError:
        return None


def count_summary(rows_for_count: list[dict], key: str) -> str:
    counts = Counter((row.get(key) or "(leer)") for row in rows_for_count)
    return ", ".join(f"{esc(name)}: {count}" for name, count in sorted(counts.items())) or "-"


def plate_count_summary(rows_for_count: list[dict]) -> str:
    values = []
    for row in rows_for_count:
        values.extend(row.get("plate_values") or [])
    counts = Counter(values)
    return ", ".join(f"{esc(name)}: {count}" for name, count in sorted(counts.items())) or "-"


def merge_range_pos(current: str, new: str) -> str:
    if not current:
        return new
    if current == new:
        return current
    if "mid" in (current, new):
        return "mid"
    if {current, new} == {"start", "end"}:
        return "mid"
    if "single" in (current, new):
        return new if current == "single" else current
    return "mid"


def calendar_card_marks(rows_for_calendar: list[dict], present_card_types: list[str]) -> dict:
    marks = {}
    for row in rows_for_calendar:
        card_types = [card_type for card_type in present_card_types if row["card_counts"].get(card_type, 0)]
        start = parse_day_only(row.get("dep_date", ""))
        end = parse_day_only(row.get("ret_date", ""))
        if not start and end:
            start = end
        if start and (not end or end < start):
            end = start
        if not start or not end or not card_types:
            continue
        total_days = (end - start).days
        for offset in range(total_days + 1):
            day = start + timedelta(days=offset)
            if total_days == 0:
                pos = "single"
            elif offset == 0:
                pos = "start"
            elif offset == total_days:
                pos = "end"
            else:
                pos = "mid"
            day_marks = marks.setdefault(day, {})
            for card_type in card_types:
                day_marks[card_type] = merge_range_pos(day_marks.get(card_type, ""), pos)
    return marks


def build_calendar_html(rows_for_calendar: list[dict], present_card_types: list[str]) -> str:
    marks = calendar_card_marks(rows_for_calendar, present_card_types)
    if not marks:
        return "<p>Keine Kalenderdaten erkannt.</p>"
    month_names = ["", "Januar", "Februar", "M&auml;rz", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]
    first_year = min(day.year for day in marks)
    last_year = max(day.year for day in marks)
    years = []
    for year in range(first_year, last_year + 1):
        months = []
        for month in range(1, 13):
            first_weekday, days_in_month = monthrange(year, month)
            cells = ['<div class="cal-empty"></div>' for _ in range(first_weekday)]
            for day_num in range(1, days_in_month + 1):
                day = datetime(year, month, day_num).date()
                day_marks = marks.get(day, {})
                card_types_here = [card_type for card_type in present_card_types if card_type in day_marks]
                range_bars = "".join(
                    f'<span class="cal-range card-{card_type} range-{day_marks[card_type]}" data-cal-card="{card_type}"></span>'
                    for card_type in card_types_here
                )
                bg_card = card_types_here[0] if card_types_here else ""
                day_num_html = f'<span class="day-num card-{bg_card}">{day_num}</span>' if bg_card else f'<span class="day-num">{day_num}</span>'
                class_bits = ["cal-day"]
                if card_types_here:
                    class_bits.append("marked")
                class_bits.extend(f"has-{card_type}" for card_type in card_types_here)
                cells.append(f'<div class="{" ".join(class_bits)}">{day_num_html}<div class="cal-ranges">{range_bars}</div></div>')
            months.append(f'<section class="cal-month"><h3>{month_names[month]}</h3><div class="cal-weekdays"><span>Mo</span><span>Di</span><span>Mi</span><span>Do</span><span>Fr</span><span>Sa</span><span>So</span></div><div class="cal-grid">{"".join(cells)}</div></section>')
        years.append(f'<section class="calendar-year"><h2>{year}</h2><div class="calendar-months">{"".join(months)}</div></section>')
    filters = ['<button class="cal-filter-btn active" type="button" data-cal-filter="all">Alle</button>']
    filters.extend(f'<button class="cal-filter-btn card-{card_type}" type="button" data-cal-filter="{card_type}">{CARD_TYPE_LABELS[card_type]}</button>' for card_type in present_card_types)
    filters.append('<a class="filter-btn calendar-link" href="eticket_zeiten_tabelle.html">Buchungen anzeigen</a>')
    legend = "".join(f'<span class="legend-item"><span class="cal-range card-{card_type} range-single"></span>{CARD_TYPE_LABELS[card_type]}</span>' for card_type in present_card_types)
    return f'<div class="calendar-filter">{"".join(filters)}</div><div class="calendar-legend">{legend}</div>{"".join(years)}'


def base_css() -> str:
    return r'''
:root { --bg:#f6f8fb; --panel:#ffffff; --ink:#172033; --muted:#647084; --line:#d9e1ec; --head:#234f6d; --head2:#183b55; --hover:#edf6f7; --focus:#d4a017; --num-col-width:calc(var(--num-col-ch, 3) * 1ch + 14px); --toolbar-height:42px; }
* { box-sizing:border-box; }
body { margin:0; font-family:"Segoe UI", Arial, sans-serif; color:var(--ink); background:linear-gradient(180deg,#eef4f7 0%,var(--bg) 38%,#fff 100%); overflow:auto; }
main { width:auto; max-width:none; min-height:100vh; margin:0; padding:2px; display:block; }
.brand { display:flex; align-items:center; gap:10px; margin:0 0 6px; }
.app-logo { width:42px; height:42px; flex:0 0 auto; filter:drop-shadow(0 4px 8px rgba(23,32,51,.18)); }
h1 { margin:0; font-size:26px; font-weight:700; letter-spacing:0; }
.meta { margin:0 0 8px; color:var(--muted); font-size:13px; }
.toolbar { position:sticky; top:0; z-index:25; display:flex; gap:2px; align-items:center; margin:0 0 2px; padding:2px 0; flex-wrap:wrap; width:min(100vw,100%); max-width:100vw; background:linear-gradient(180deg,#eef4f7 0%,#eef4f7 70%,rgba(238,244,247,.88) 100%); }
.search { min-width:220px; max-width:440px; flex:1 1 220px; padding:2px; border:1px solid var(--line); border-radius:4px; font-size:14px; background:#fff; }
.filter-btn { padding:2px; border:1px solid var(--head); border-radius:4px; background:#fff; color:var(--head2); font-size:14px; font-weight:650; cursor:pointer; }
.filter-btn:hover { background:#eef6f8; }
.filter-btn[aria-pressed="true"] { background:var(--head); color:#fff; box-shadow:inset 0 0 0 1px rgba(255,255,255,.3); }
.storno-filter-btn { border-color:#b00020; color:#b00020; background:#fff6f6; }
.storno-filter-btn[aria-pressed="true"] { background:#b00020; color:#fff; border-color:#7c0017; }
.sim-filter-btn { border-color:#9a5a00; color:#684000; background:#fff6df; }
.sim-filter-btn[aria-pressed="true"] { background:#9a5a00; color:#fff; border-color:#6f4100; }
.toolbar-warning { padding:2px; border:1px solid #d8a44a; border-radius:4px; background:#fff8df; color:#704b00; font-size:13px; font-weight:650; }
.column-wrap { position:relative; }
.column-panel { position:absolute; top:calc(100% + 2px); left:0; z-index:80; display:none; min-width:220px; max-height:420px; overflow:auto; padding:2px; border:1px solid var(--line); border-radius:6px; background:#fff; box-shadow:0 14px 34px rgba(23,32,51,.22); }
.column-panel.open { display:block; }
.column-panel label { display:flex; align-items:center; gap:2px; padding:2px; font-size:13px; white-space:nowrap; }
.column-panel input { margin:0; }
.plate-c0 { --plate-bg:#e7f3fb; --plate-fg:#17384f; --plate-border:#8bc4df; }
.plate-c1 { --plate-bg:#fff1c9; --plate-fg:#684a00; --plate-border:#ddb645; }
.plate-c2 { --plate-bg:#ddf7df; --plate-fg:#1f6830; --plate-border:#83cc90; }
.plate-c3 { --plate-bg:#ffe5dc; --plate-fg:#7a2d18; --plate-border:#e99b83; }
.plate-c4 { --plate-bg:#f1e8ff; --plate-fg:#4f2f7a; --plate-border:#b99add; }
.plate-c5 { --plate-bg:#e9eefb; --plate-fg:#273f78; --plate-border:#9aaddd; }
.plate-c6 { --plate-bg:#fce4f1; --plate-fg:#74335d; --plate-border:#d59abb; }
.plate-c7 { --plate-bg:#e3f6f2; --plate-fg:#1d6358; --plate-border:#89cfc1; }
.plate-c8 { --plate-bg:#f4eddf; --plate-fg:#6f5123; --plate-border:#d1b374; }
.plate-c9 { --plate-bg:#edf0f4; --plate-fg:#445064; --plate-border:#adb9c8; }
.plate-btn { padding:2px; border:1px solid var(--plate-border,#9fb4c4); border-radius:999px; background:var(--plate-bg,#fff); color:var(--plate-fg,var(--head2)); font-size:13px; font-weight:650; cursor:pointer; }
.plate-btn.active { box-shadow:0 0 0 2px rgba(23,32,51,.24), inset 0 0 0 1px rgba(255,255,255,.55); filter:saturate(1.2); }
tr.plate-highlight td { box-shadow:inset 0 0 0 999px rgba(255,217,104,.16); }
.table-wrap { width:max-content; max-width:none; border:1px solid var(--line); background:var(--panel); box-shadow:0 8px 22px rgba(34,63,93,.08); overflow:visible; }
table { width:auto; border-collapse:collapse; table-layout:auto; min-width:0; }
col.num-col-def { width:var(--num-col-width); }
th { position:sticky; top:var(--toolbar-height); z-index:3; background:var(--head); color:#fff; text-align:left; padding:2px; min-width:5ch; font-size:13px; white-space:normal; overflow-wrap:normal; word-break:normal; hyphens:manual; line-height:1.08; cursor:pointer; border-right:1px solid rgba(255,255,255,.18); overflow:visible; text-overflow:clip; vertical-align:top; }
.head-label { display:inline-block; line-height:1.02; white-space:nowrap; overflow-wrap:normal; hyphens:manual; max-width:none; }
th:hover { background:var(--head2); }
th.sorted-asc::after { content:" ▲"; color:var(--focus); }
th.sorted-desc::after { content:" ▼"; color:var(--focus); }
.resizer { position:absolute; top:0; right:0; width:7px; height:100%; cursor:col-resize; user-select:none; touch-action:none; }
th:first-child, td.num-col { width:var(--num-col-width); min-width:var(--num-col-width); max-width:var(--num-col-width); }
th:first-child { min-width:var(--num-col-width); max-width:var(--num-col-width); text-align:right; padding-right:2px; }
th:first-child .head-label { min-width:0; }
th:first-child .resizer { display:none; }
td { padding:2px; border-top:1px solid var(--line); font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; width:auto; vertical-align:top; }
tfoot td { position:sticky; bottom:0; z-index:2; background:#eef4f7; font-weight:700; border-top:2px solid var(--head); }
.sum-label { text-align:right; color:var(--head2); }
tr:nth-child(even) td { background:#fafcfe; }
tr:hover td { background:var(--hover); }
tfoot tr:hover td, tfoot td { background:#eef4f7; }
tr.inactive td { color:#8a94a3; background:#f0f3f6; }
tr.inactive a { color:#748194; font-weight:500; }
tr.inactive:hover td { background:#e7edf3; }
@keyframes emptyBookingPulse { 0%,100% { opacity:.46; } 50% { opacity:.78; } }
tr.empty-booking td { color:#a8b0bb; background:#f5f7f9; animation:emptyBookingPulse 1.7s ease-in-out infinite; }
tr.empty-booking a { color:#8d98a6; }
a { color:#174f78; text-decoration:none; font-weight:600; }
a:hover { text-decoration:underline; }
.num-col { font-variant-numeric:tabular-nums; text-align:right; color:var(--muted); }
.time-col, .datetime-col { font-variant-numeric:tabular-nums; text-align:right; }
.weekday { display:inline-block; width:2.2ch; text-align:left; color:var(--muted); font-family:"Cascadia Mono", Consolas, monospace; }
.timeval { display:inline-block; min-width:43px; text-align:right; }
.padzero { visibility:hidden; }
.duration-col { font-variant-numeric:tabular-nums; text-align:right; white-space:nowrap; }
.duration-value { display:inline-grid; grid-template-columns:3ch 4ch 3ch 7ch; column-gap:4px; align-items:baseline; font-family:"Cascadia Mono", Consolas, monospace; }
.duration-days, .duration-hours { text-align:right; }
.duration-day-word, .duration-hour-word { text-align:left; }
.money-col { font-variant-numeric:tabular-nums; text-align:right; }
.plate-cell { white-space:normal; line-height:1.18; max-width:16ch; }
.plate-line { display:flex; flex-wrap:wrap; gap:0 2px; align-items:baseline; }
.plate-role { display:inline-block; min-width:0; color:var(--muted); font-size:12px; }
.plate-value { display:inline-block; max-width:9ch; padding:0 2px; border:1px solid var(--plate-border,#9fb4c4); border-radius:999px; background:var(--plate-bg,#fff); color:var(--plate-fg,var(--head2)); font-weight:700; font-variant-numeric:tabular-nums; white-space:normal; overflow-wrap:anywhere; }
.head-label { white-space:nowrap; }
.wrap-head .head-label { white-space:normal; }
.cards-cell { position:relative; overflow:visible; white-space:normal; }
.cards-grid { display:grid; grid-template-columns:var(--card-grid-template, max-content); gap:0; align-items:start; justify-content:flex-start; width:max-content; }
.card-badge { display:inline-block; position:relative; min-width:0; margin-right:0; padding:1px 2px; border-radius:999px; font-size:12px; font-weight:700; line-height:1.35; text-align:center; cursor:help; white-space:nowrap; }
.card-auto { --plate-bg:#e7f3fb; --plate-fg:#17384f; --plate-border:#8bc4df; background:var(--plate-bg); color:var(--plate-fg); border:1px solid var(--plate-border); }
.card-anhaenger { --plate-bg:#f1e8ff; --plate-fg:#4f2f7a; --plate-border:#b99add; background:var(--plate-bg); color:var(--plate-fg); border:1px solid var(--plate-border); }
.card-motorrad { --plate-bg:#ffe5dc; --plate-fg:#7a2d18; --plate-border:#e99b83; background:var(--plate-bg); color:var(--plate-fg); border:1px solid var(--plate-border); }
.card-fahrrad { --plate-bg:#ddf7df; --plate-fg:#1f6830; --plate-border:#83cc90; background:var(--plate-bg); color:var(--plate-fg); border:1px solid var(--plate-border); }
.card-person { --plate-bg:#fff1c9; --plate-fg:#684a00; --plate-border:#ddb645; background:var(--plate-bg); color:var(--plate-fg); border:1px solid var(--plate-border); }
.card-other { --plate-bg:#edf0f4; --plate-fg:#445064; --plate-border:#adb9c8; background:var(--plate-bg); color:var(--plate-fg); border:1px solid var(--plate-border); }
.card-storno { --plate-bg:#ffe5e8; --plate-fg:#b00020; --plate-border:#e1959f; background:var(--plate-bg); color:var(--plate-fg); border:1px solid var(--plate-border); justify-self:end; }
.cards-cell.storno-only { text-align:right; }
.empty-card { visibility:hidden; display:inline-block; pointer-events:none; opacity:0; }
.card-preview { display:none; position:absolute; left:0; top:22px; width:480px; max-height:360px; overflow:auto; padding:2px; background:#fff; border:1px solid var(--line); box-shadow:0 14px 34px rgba(23,32,51,.22); z-index:45; text-align:left; white-space:normal; }
.card-preview strong { display:block; margin:0 0 5px; color:var(--head2); }
.card-preview pre { margin:0; white-space:pre-wrap; font-family:"Segoe UI", Arial, sans-serif; font-size:12px; line-height:1.35; color:var(--ink); font-weight:400; }
.card-badge:hover .card-preview, .card-badge:focus .card-preview, .card-badge:focus-within .card-preview { display:block; }
.mail-cell { position:relative; overflow:visible; }
.mail-preview { display:none; position:absolute; left:2px; top:24px; width:520px; max-height:360px; overflow:auto; padding:2px; background:#fff; border:1px solid var(--line); box-shadow:0 14px 34px rgba(23,32,51,.22); z-index:35; text-align:left; }
.mail-preview pre { margin:0; white-space:pre-wrap; font-family:"Segoe UI", Arial, sans-serif; font-size:12px; line-height:1.35; color:var(--ink); }
.mail-cell:hover .mail-preview, .mail-cell:focus-within .mail-preview { display:block; }
.booking-cell { position:relative; overflow:visible; text-align:right; font-variant-numeric:tabular-nums; white-space:normal; max-width:8ch; overflow-wrap:anywhere; word-break:break-word; }
.booking-cell a { overflow-wrap:anywhere; word-break:break-word; }
.cell-line { display:block; }
.internal-cell { font-variant-numeric:tabular-nums; text-align:right; white-space:normal; }
.preview { display:none; position:absolute; left:2px; top:24px; width:360px; padding:2px; background:#fff; border:1px solid var(--line); box-shadow:0 14px 34px rgba(23,32,51,.22); z-index:30; }
.preview img { display:block; width:100%; height:auto; }
.booking-cell:hover .preview, .booking-cell:focus-within .preview { display:block; }
.header-menu { position:fixed; display:none; z-index:1000; min-width:230px; padding:2px; background:#fff; border:1px solid var(--line); border-radius:6px; box-shadow:0 14px 34px rgba(23,32,51,.22); }
.header-menu button { width:100%; padding:2px; border:0; border-radius:4px; background:#eef4f7; color:var(--head2); font:inherit; font-weight:650; text-align:left; cursor:pointer; }
.header-menu button:hover { background:#dcebf0; }
.reset-toast { position:fixed; display:none; z-index:1001; padding:2px; border-radius:5px; background:var(--head2); color:#fff; font-size:12px; box-shadow:0 10px 24px rgba(23,32,51,.22); pointer-events:none; }
.after-summary { margin:10px 0 0; color:var(--muted); font-size:13px; }
.after-summary p { margin:4px 0; }
.calendar-link { margin-left:auto; }
.calendar-report { margin:14px 0 24px; }
.calendar-report h2 { margin:12px 0 8px; font-size:20px; }
.calendar-filter { position:sticky; top:0; z-index:20; display:flex; flex-wrap:wrap; gap:2px; padding:2px 0; margin:0 0 2px; background:linear-gradient(180deg,#eef4f7 0%,#eef4f7 70%,rgba(238,244,247,.9) 100%); }
.cal-filter-btn { padding:2px; border:1px solid var(--plate-border,#9fb4c4); border-radius:999px; background:var(--plate-bg,#fff); color:var(--plate-fg,var(--head2)); font-weight:700; cursor:pointer; }
.cal-filter-btn.active { box-shadow:0 0 0 2px rgba(23,32,51,.24), inset 0 0 0 1px rgba(255,255,255,.55); }
.calendar-legend { display:flex; flex-wrap:wrap; gap:10px; margin:0 0 10px; color:var(--muted); font-size:13px; }
.legend-item { display:inline-flex; align-items:center; gap:4px; }
.calendar-year { margin:14px 0 18px; }
.calendar-year h2 { margin:0 0 8px; color:var(--head2); }
.calendar-months { display:grid; grid-template-columns:repeat(4, minmax(190px, 1fr)); gap:2px; }
.cal-month { border:1px solid var(--line); background:#fff; padding:2px; border-radius:6px; }
.cal-month h3 { margin:0 0 5px; font-size:14px; color:var(--head2); }
.cal-weekdays, .cal-grid { display:grid; grid-template-columns:repeat(7, 1fr); gap:2px; }
.cal-weekdays span { color:var(--muted); font-size:11px; text-align:center; }
.cal-day, .cal-empty { min-height:31px; position:relative; border-radius:4px; }
.cal-day { padding:2px 1px 1px; text-align:center; overflow:visible; }
.day-num { position:relative; z-index:2; display:inline-flex; align-items:center; justify-content:center; min-width:22px; height:20px; padding:0 2px; border-radius:999px; color:var(--ink); font-size:12px; font-variant-numeric:tabular-nums; }
.cal-day.marked .day-num { background:var(--plate-bg,#dbeef7); color:var(--plate-fg,#17384f); border:1px solid var(--plate-border,#8bc4df); font-weight:800; }
.cal-ranges { position:absolute; left:0; right:0; bottom:3px; display:grid; gap:2px; padding:0 0; z-index:1; }
.cal-range { display:block; height:4px; background:var(--plate-border,#9fb4c4); opacity:.9; }
.cal-range.range-single { margin:0 8px; border-radius:999px; }
.cal-range.range-start { margin-left:50%; margin-right:-3px; border-radius:999px 0 0 999px; }
.cal-range.range-mid { margin-left:-3px; margin-right:-3px; border-radius:0; }
.cal-range.range-end { margin-left:-3px; margin-right:50%; border-radius:0 999px 999px 0; }
.cal-day.cal-muted { opacity:.22; }
.cal-day.cal-muted .day-num { background:transparent; border-color:transparent; color:var(--muted); font-weight:400; }
body.storno-sim tr.cancelled-ticket { color:var(--ink) !important; }
body.storno-sim tr.storno-entry { display:none !important; }
body.storno-sim .card-storno { display:none !important; }
@media (max-width:900px) { .calendar-months { grid-template-columns:repeat(2, minmax(180px, 1fr)); } }
@media (max-width:520px) { .calendar-months { grid-template-columns:1fr; } }
@media (max-width:700px) { main { padding:2px; } h1 { font-size:22px; } .search { min-width:100%; } td, th { padding:2px; font-size:13px; } .preview { width:280px; } .mail-preview, .card-preview { width:320px; } }
'''


def main_js() -> str:
    return r'''
const table = document.getElementById('tickets');
const q = document.getElementById('q');
const finalToggle = document.getElementById('finalToggle');
const stornoToggle = document.getElementById('stornoToggle');
const stornoSimulationToggle = document.getElementById('stornoSimulationToggle');
const columnsToggle = document.getElementById('columnsToggle');
const columnsPanel = document.getElementById('columnsPanel');
const headerMenu = document.getElementById('headerMenu');
const resetToast = document.getElementById('resetToast');
const toolbar = document.querySelector('.toolbar');
const footerDuration = document.getElementById('footerDuration');
const footerVehicle = document.getElementById('footerVehicle');
const footerPersons = document.getElementById('footerPersons');
const footerTotal = document.getElementById('footerTotal');
const footerTax = document.getElementById('footerTax');
const cardTypes = [
  {key:'auto', label:'Auto'},
  {key:'anhaenger', label:'Anh\u00e4nger'},
  {key:'motorrad', label:'Motorrad'},
  {key:'fahrrad', label:'Fahrrad'},
  {key:'person', label:'Person'},
  {key:'other', label:'Weitere'}
].filter(type => Array.from(table.tBodies[0].rows).some(row => Number(row.querySelector('.cards-cell')?.dataset['card' + camel(type.key)] || 0) > 0));
let currentSort = { index: -1, mode: '', dir: 'asc' };
let selectedPlates = new Set();
const columnStorageKey = 'wdrTicketHiddenColumns';
function readStoredArray(key, fallback) {
  try {
    const parsed = JSON.parse(localStorage.getItem(key) || 'null');
    return Array.isArray(parsed) ? parsed : fallback;
  } catch (_err) {
    localStorage.removeItem(key);
    return fallback;
  }
}
function readStoredObject(key) {
  try {
    const parsed = JSON.parse(localStorage.getItem(key) || '{}');
    return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
  } catch (_err) {
    localStorage.removeItem(key);
    return {};
  }
}
let hiddenColumns = new Set(readStoredArray(columnStorageKey, ['Intern']));
const columnWidthStorageKey = 'wdrTicketColumnWidths';
let savedColumnWidths = readStoredObject(columnWidthStorageKey);
function camel(key) { return key.split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1)).join(''); }
function syncToolbarHeight() {
  document.documentElement.style.setProperty('--toolbar-height', (toolbar ? toolbar.offsetHeight : 42) + 'px');
}
syncToolbarHeight();
if (window.ResizeObserver && toolbar) new ResizeObserver(syncToolbarHeight).observe(toolbar);
window.addEventListener('resize', syncToolbarHeight);
function money(cents) {
  return (cents / 100).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' \u20ac';
}
function durationHtml(hours) {
  const days = Math.floor(hours / 24);
  const rem = hours % 24;
  const dayNumber = days === 0 ? '' : String(days);
  const dayWord = days === 0 ? '' : (days === 1 ? 'Tag' : 'Tage');
  return '<span class="duration-value"><span class="duration-days">' + dayNumber + '</span><span class="duration-day-word">' + dayWord + '</span><span class="duration-hours">' + rem + '</span><span class="duration-hour-word">Stunden</span></span>';
}
function updateFooter() {
  let vehicle = 0, persons = 0, total = 0, tax = 0, hours = 0;
  const simulateNoStorno = stornoSimulationToggle && stornoSimulationToggle.getAttribute('aria-pressed') === 'true';
  Array.from(table.tBodies[0].rows).forEach(row => {
    if (row.hidden) return;
    const counted = row.dataset.counted === '1' || (simulateNoStorno && row.dataset.simCounted === '1');
    if (!counted) return;
    vehicle += Number(simulateNoStorno ? (row.dataset.simVehicleCents || 0) : (row.dataset.vehicleCents || 0));
    persons += Number(simulateNoStorno ? (row.dataset.simPersonCents || 0) : (row.dataset.personCents || 0));
    total += Number(simulateNoStorno ? (row.dataset.simTotalCents || 0) : (row.dataset.totalCents || 0));
    tax += Number(simulateNoStorno ? (row.dataset.simTaxCents || 0) : (row.dataset.taxCents || 0));
    hours += Number(simulateNoStorno ? (row.dataset.simDurationHours || 0) : (row.dataset.durationHours || 0));
  });
  if (footerDuration) {
    footerDuration.dataset.sort = String(hours);
    footerDuration.innerHTML = durationHtml(hours);
  }
  if (footerVehicle) {
    footerVehicle.dataset.sort = String(vehicle / 100);
    footerVehicle.textContent = money(vehicle);
  }
  if (footerPersons) {
    footerPersons.dataset.sort = String(persons / 100);
    footerPersons.textContent = money(persons);
  }
  if (footerTotal) {
    footerTotal.dataset.sort = String(total / 100);
    footerTotal.textContent = money(total);
  }
  if (footerTax) {
    footerTax.dataset.sort = String(tax / 100);
    footerTax.textContent = money(tax);
  }
}
function applyPlateHighlights() {
  Array.from(table.tBodies[0].rows).forEach(row => {
    const plates = (row.dataset.plates || '').split('|').filter(Boolean);
    row.classList.toggle('plate-highlight', plates.some(plate => selectedPlates.has(plate)));
  });
}
function applyFilters() {
  const needle = q.value.trim().toLowerCase();
  const onlyFinal = finalToggle.getAttribute('aria-pressed') === 'true';
  const hideStorno = stornoToggle && stornoToggle.getAttribute('aria-pressed') === 'true';
  const simulateNoStorno = stornoSimulationToggle && stornoSimulationToggle.getAttribute('aria-pressed') === 'true';
  document.body.classList.toggle('storno-sim', Boolean(simulateNoStorno));
  Array.from(table.tBodies[0].rows).forEach(row => {
    const rowText = row.dataset.search || '';
    const searchMiss = needle.length > 0 && !rowText.includes(needle);
    const notFinal = onlyFinal && row.dataset.final !== '1';
    const stornoHidden = (hideStorno && row.dataset.cancelled === '1') || (simulateNoStorno && row.classList.contains('storno-entry'));
    row.hidden = Boolean(searchMiss || notFinal || stornoHidden);
  });
  applyPlateHighlights();
  updateFooter();
}
function columnLabelAt(index) {
  const th = table.tHead.rows[0].cells[index];
  return (th && (th.dataset.label || th.textContent) || '').trim();
}
function saveHiddenColumns() {
  localStorage.setItem(columnStorageKey, JSON.stringify(Array.from(hiddenColumns)));
}
function saveColumnWidths() {
  localStorage.setItem(columnWidthStorageKey, JSON.stringify(savedColumnWidths));
}
function columnHasContent(index) {
  return Array.from(table.tBodies[0].rows).some(row => {
    const cell = row.cells[index];
    return cell && cell.textContent.trim().length > 0;
  });
}
function applyColumnVisibility() {
  const rows = [table.tHead.rows[0], ...Array.from(table.tBodies[0].rows), ...Array.from(table.tFoot.rows)];
  Array.from(table.tHead.rows[0].cells).forEach((th, index) => {
    const label = columnLabelAt(index);
    const empty = label !== 'Nr.' && !columnHasContent(index);
    const hide = hiddenColumns.has(label) || empty;
    rows.forEach(row => {
      if (row.cells[index]) row.cells[index].hidden = hide;
    });
    const col = table.querySelectorAll('colgroup col')[index];
    if (col) col.hidden = hide;
  });
  if (window.syncWdrHeaderWrapping) window.syncWdrHeaderWrapping();
  if (columnsPanel) {
    columnsPanel.querySelectorAll('input[type="checkbox"]').forEach(input => {
      input.checked = !hiddenColumns.has(input.value);
    });
  }
}
function renderColumnPanel() {
  if (!columnsPanel) return;
  columnsPanel.innerHTML = Array.from(table.tHead.rows[0].cells).map((th, index) => {
    const label = columnLabelAt(index);
    if (label !== 'Nr.' && !columnHasContent(index)) {
      hiddenColumns.add(label);
      return '<label class="disabled" title="Diese Spalte enthält in dieser Auswertung keine Werte und wird deshalb nicht angeboten."><input type="checkbox" disabled> ' + label + ' (leer)</label>';
    }
    const checked = hiddenColumns.has(label) ? '' : ' checked';
    return '<label><input type="checkbox" value="' + label.replace(/"/g, '&quot;') + '"' + checked + '> ' + label + '</label>';
  }).join('');
  saveHiddenColumns();
}
function applyColumnWidths() {
  const cols = table.querySelectorAll('colgroup col');
  Array.from(table.tHead.rows[0].cells).forEach((th, index) => {
    const label = columnLabelAt(index);
    const width = Number(savedColumnWidths[label] || 0);
    if (width > 0) {
      th.style.width = width + 'px';
      if (cols[index]) cols[index].style.width = width + 'px';
    }
  });
}
function getCellSortValue(row, index, mode) {
  const cell = row.cells[index];
  if (!cell) return '';
  if (mode === 'date') return cell.dataset.sortDate || '';
  if (mode === 'time') return cell.dataset.sortTime || '';
  if (mode === 'date-time') return cell.dataset.sortDateTime || cell.dataset.sort || '';
  if (mode === 'week-time') return cell.dataset.sortWeekTime || '';
  if (mode === 'week-date') return cell.dataset.sortWeekDate || '';
  if (mode && mode.startsWith('card:')) return Number(cell.dataset['card' + camel(mode.slice(5))] || 0);
  return cell.dataset.sort || cell.textContent.trim();
}
function cast(value, kind) {
  if (kind === 'number') {
    const n = Number(String(value).replace(',', '.'));
    return Number.isNaN(n) ? -Infinity : n;
  }
  return String(value).toLowerCase();
}
function sortRows(th, index, mode, forcedDir) {
  const kind = mode && mode.startsWith('card:') ? 'number' : (th.dataset.kind || 'text');
  const same = currentSort.index === index && currentSort.mode === mode;
  const dir = forcedDir || (same && currentSort.dir === 'asc' ? 'desc' : 'asc');
  currentSort = { index, mode, dir };
  table.querySelectorAll('th').forEach(x => x.classList.remove('sorted-asc', 'sorted-desc'));
  th.classList.add(dir === 'asc' ? 'sorted-asc' : 'sorted-desc');
  const rows = Array.from(table.tBodies[0].rows);
  rows.sort((a, b) => {
    const av = cast(getCellSortValue(a, index, mode), kind);
    const bv = cast(getCellSortValue(b, index, mode), kind);
    if (av < bv) return dir === 'asc' ? -1 : 1;
    if (av > bv) return dir === 'asc' ? 1 : -1;
    return 0;
  });
  table.tBodies[0].append(...rows);
}
function showMenu(x, y, items) {
  headerMenu.innerHTML = items.map(item => '<button type="button" data-action="' + item.action + '" data-index="' + (item.index ?? '') + '" data-mode="' + (item.mode || '') + '" data-dir="' + (item.dir || '') + '">' + item.label + '</button>').join('');
  headerMenu.style.left = Math.min(x, window.innerWidth - 250) + 'px';
  headerMenu.style.top = Math.min(y, window.innerHeight - 220) + 'px';
  headerMenu.style.display = 'block';
  headerMenu.setAttribute('aria-hidden', 'false');
}
function hideHeaderMenu() {
  headerMenu.style.display = 'none';
  headerMenu.setAttribute('aria-hidden', 'true');
}
function showResetToast(x, y) {
  resetToast.style.left = Math.min(x + 10, window.innerWidth - 210) + 'px';
  resetToast.style.top = Math.min(y + 10, window.innerHeight - 34) + 'px';
  resetToast.style.display = 'block';
  window.clearTimeout(showResetToast.timer);
  showResetToast.timer = window.setTimeout(() => { resetToast.style.display = 'none'; }, 900);
}
function resetColumnWidths(x, y) {
  savedColumnWidths = {};
  saveColumnWidths();
  table.querySelectorAll('col, th, td').forEach(cell => cell.removeAttribute('style'));
  table.removeAttribute('style');
  if (window.syncWdrHeaderWrapping) window.syncWdrHeaderWrapping();
  if (Number.isFinite(x) && Number.isFinite(y)) showResetToast(x, y);
}
function dateSortItems(index) {
  return [
    { action:'sort', index, mode:'week-time', label:'Nach Wochentag + Uhrzeit' },
    { action:'sort', index, mode:'week-date', label:'Nach Wochentag + Datum' },
    { action:'sort', index, mode:'date', label:'Nach Datum' },
    { action:'sort', index, mode:'date-time', label:'Nach Datum + Uhrzeit' },
    { action:'sort', index, mode:'time', label:'Nach Uhrzeit' }
  ];
}
function headerLabel(th) { return (th.dataset.label || th.textContent || '').trim(); }
table.querySelectorAll('th').forEach((th, index) => {
  th.addEventListener('click', event => {
    if (event.target.classList.contains('resizer')) return;
    const label = headerLabel(th);
    if (label === 'Fahrkarten') {
      const rect = th.getBoundingClientRect();
      showMenu(rect.left, rect.bottom + 4, cardTypes.map(type => ({ action:'sort', index, mode:'card:' + type.key, dir:'desc', label:type.label + ' zuerst' })));
      return;
    }
    sortRows(th, index, label === 'Abfahrt' || label === 'Rückfahrt' || label === 'R\u00fcckfahrt' ? 'date-time' : 'default');
  });
  th.addEventListener('contextmenu', event => {
    event.preventDefault();
    const label = headerLabel(th);
    const items = [{ action:'reset', label:'Spaltenbreiten zur\u00fccksetzen' }];
    if (label === 'Abfahrt' || label === 'Rückfahrt' || label === 'R\u00fcckfahrt') items.push(...dateSortItems(index));
    showMenu(event.clientX, event.clientY, items);
  });
});
headerMenu.addEventListener('click', event => {
  const button = event.target.closest('button');
  if (!button) return;
  const action = button.dataset.action;
  if (action === 'reset') resetColumnWidths(headerMenu.offsetLeft, headerMenu.offsetTop);
  if (action === 'sort') {
    const index = Number(button.dataset.index);
    const th = table.tHead.rows[0].cells[index];
    sortRows(th, index, button.dataset.mode, button.dataset.dir || undefined);
  }
  hideHeaderMenu();
});
document.addEventListener('click', event => { if (!headerMenu.contains(event.target)) hideHeaderMenu(); });
document.addEventListener('keydown', event => { if (event.key === 'Escape') hideHeaderMenu(); });
const tableCols = Array.from(table.querySelectorAll('colgroup col'));
table.querySelectorAll('.resizer').forEach((handle, index) => {
  handle.addEventListener('pointerdown', event => {
    if (event.button !== 0 || index === 0) return;
    event.preventDefault();
    event.stopPropagation();
    const th = handle.parentElement;
    const col = tableCols[index];
    const startX = event.clientX;
    const startWidth = th.getBoundingClientRect().width;
    handle.setPointerCapture(event.pointerId);
    function move(ev) {
      const width = Math.max(42, startWidth + ev.clientX - startX);
      th.style.width = width + 'px';
      if (col) col.style.width = width + 'px';
    }
    function up(ev) {
      savedColumnWidths[columnLabelAt(index)] = Math.round(th.getBoundingClientRect().width);
      saveColumnWidths();
      if (window.syncWdrHeaderWrapping) window.syncWdrHeaderWrapping();
      handle.releasePointerCapture(ev.pointerId);
      handle.removeEventListener('pointermove', move);
      handle.removeEventListener('pointerup', up);
    }
    handle.addEventListener('pointermove', move);
    handle.addEventListener('pointerup', up);
  });
});
q.addEventListener('input', applyFilters);
finalToggle.addEventListener('click', () => {
  const next = finalToggle.getAttribute('aria-pressed') !== 'true';
  finalToggle.setAttribute('aria-pressed', String(next));
  finalToggle.title = next
    ? 'Filter aktiv: Es werden nur endg\u00fcltige Fahrkarten angezeigt. Klicken, um alle Fahrkarten inklusive \u00e4lterer Dubletten zu zeigen.'
    : 'Nur endg\u00fcltige Fahrkarten anzeigen; \u00e4ltere Dubletten ausblenden.';
  applyFilters();
});
if (stornoToggle) {
  stornoToggle.addEventListener('click', () => {
    const next = stornoToggle.getAttribute('aria-pressed') !== 'true';
    stornoToggle.setAttribute('aria-pressed', String(next));
    stornoToggle.title = next
      ? 'Filter aktiv: Stornierte Buchungen und Stornomeldungen werden ausgeblendet.'
      : 'Stornierte Buchungen inklusive Stornomeldungen ausblenden.';
    applyFilters();
  });
}
if (stornoSimulationToggle) {
  stornoSimulationToggle.addEventListener('click', () => {
    const next = stornoSimulationToggle.getAttribute('aria-pressed') !== 'true';
    stornoSimulationToggle.setAttribute('aria-pressed', String(next));
    stornoSimulationToggle.title = next
      ? 'Simulation aktiv: Stornomeldungen werden ignoriert; stornierte Fahrkarten werden wie normale Fahrkarten gezählt.'
      : 'Simulation: so rechnen, als gäbe es keine Stornos.';
    applyFilters();
  });
}
renderColumnPanel();
applyColumnVisibility();
applyColumnWidths();
if (columnsToggle && columnsPanel) {
  columnsToggle.addEventListener('click', event => {
    event.stopPropagation();
    columnsPanel.classList.toggle('open');
    columnsToggle.setAttribute('aria-expanded', String(columnsPanel.classList.contains('open')));
  });
  columnsPanel.addEventListener('change', event => {
    const input = event.target.closest('input[type="checkbox"]');
    if (!input) return;
    if (input.checked) hiddenColumns.delete(input.value);
    else hiddenColumns.add(input.value);
    saveHiddenColumns();
    applyColumnVisibility();
  });
  document.addEventListener('click', event => {
    if (!columnsPanel.contains(event.target) && event.target !== columnsToggle) {
      columnsPanel.classList.remove('open');
      columnsToggle.setAttribute('aria-expanded', 'false');
    }
  });
}
document.querySelectorAll('.plate-btn').forEach(button => {
  button.addEventListener('click', () => {
    const plate = button.dataset.plate;
    if (selectedPlates.has(plate)) {
      selectedPlates.delete(plate);
      button.classList.remove('active');
    } else {
      selectedPlates.add(plate);
      button.classList.add('active');
    }
    applyPlateHighlights();
  });
});
if (window.syncWdrHeaderWrapping) window.syncWdrHeaderWrapping();
applyFilters();
'''


def calendar_js() -> str:
    return r'''
const report = document.querySelector('.calendar-report');
const buttons = Array.from(document.querySelectorAll('.cal-filter-btn'));
const selectedCalendarKinds = new Set();
function activeKinds() { return selectedCalendarKinds.size ? selectedCalendarKinds : null; }
function setDayNumberClass(day, cardKind) {
  const num = day.querySelector('.day-num');
  if (!num) return;
  Array.from(num.classList).forEach(cls => {
    if (cls.startsWith('card-')) num.classList.remove(cls);
  });
  if (cardKind) num.classList.add('card-' + cardKind);
}
function applyCalendarFilter() {
  const kinds = activeKinds();
  buttons.forEach(button => {
    const kind = button.dataset.calFilter || 'all';
    button.classList.toggle('active', kind === 'all' ? !kinds : selectedCalendarKinds.has(kind));
  });
  document.querySelectorAll('.cal-range').forEach(mark => {
    const hidden = Boolean(kinds && !kinds.has(mark.dataset.calCard));
    mark.hidden = hidden;
    mark.style.display = hidden ? 'none' : '';
  });
  document.querySelectorAll('.cal-day').forEach(day => {
    const visibleMarks = Array.from(day.querySelectorAll('.cal-range')).filter(mark => !mark.hidden);
    day.classList.toggle('marked', visibleMarks.length > 0);
    setDayNumberClass(day, visibleMarks[0] ? visibleMarks[0].dataset.calCard : '');
  });
  if (report) report.dataset.filter = kinds ? Array.from(kinds).join(',') : 'all';
}
buttons.forEach(button => {
  button.addEventListener('click', () => {
    const kind = button.dataset.calFilter || 'all';
    if (kind === 'all') {
      selectedCalendarKinds.clear();
    } else if (selectedCalendarKinds.has(kind)) {
      selectedCalendarKinds.delete(kind);
    } else {
      selectedCalendarKinds.add(kind);
    }
    applyCalendarFilter();
  });
});
applyCalendarFilter();
'''


def html_page(title: str, body: str, extra_head: str = "") -> str:
    page_html = f'''<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{esc(title)}</title>
<link rel="icon" type="image/svg+xml" href="wdr_ticket_logo.svg">
<style>{base_css()}
/* WDR responsive soft hyphen headers */
th .head-label {{ hyphens: manual; overflow-wrap: normal; word-break: normal; white-space: normal; max-width:8ch; }}
body.wrapHeaders th .head-label {{ white-space: normal; }}
th[data-label="KfZ-Kennzeichen"] .head-label {{ white-space: normal; max-width:7ch; }}
body.wrapHeaders th[data-label="KfZ-Kennzeichen"] .head-label {{ white-space: normal; }}
.cards-grid {{ display: grid; grid-template-columns: var(--card-grid-template, max-content); gap: 2px; align-items: start; justify-content: start; }}
.card-badge {{ min-width: 0; justify-self: start; white-space: nowrap; }}
.empty-card {{ visibility: hidden; display: inline-block; pointer-events: none; opacity: 0; }}
body.anonymized td:nth-child(8), body.anonymized td:nth-child(9), body.anonymized td:nth-child(10) {{ background: #4b5563 !important; color: #4b5563 !important; text-shadow: none !important; }}
body.anonymized td:nth-child(8) *, body.anonymized td:nth-child(9) *, body.anonymized td:nth-child(10) * {{ background: #4b5563 !important; border-color: #4b5563 !important; box-shadow: none !important; color: transparent !important; text-shadow: none !important; }}
body.anonymized .mail-popover, body.anonymized .preview-popover {{ display: none !important; }}
.anon-filter-btn[aria-pressed="true"] {{ background: #1d5f75; color: #fff; border-color: #123f50; }}
.finance-year table {{ font-family: "Cascadia Mono", Consolas, "Courier New", monospace; font-variant-numeric: tabular-nums; }}
@media (max-width:1250px){{ body.wrapHeaders th .head-label{{white-space:normal;max-width:8ch;}} body.wrapHeaders th[data-label="KfZ-Kennzeichen"] .head-label, body.wrapHeaders th[data-label="Buchungsnummer"] .head-label{{max-width:7ch;}} body.wrapHeaders th[data-label="Gesamtpreis"] .head-label{{max-width:6ch;}} }}
</style>
{extra_head}
</head>
<body>
{body}
</body>
</html>
'''
    return fix_display_text(page_html)


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="WDR eTicket-Tabelle, Kalender und Exporte erzeugen.")
    parser.add_argument("--work-dir", default=str(WORK_DIR), help="Arbeitsordner fuer Ausgabe und Projektdateien.")
    parser.add_argument("--input-dir", default="", help="Ordner mit eticket*.pdf oder *.pdf Dateien.")
    parser.add_argument("--mbox-dir", default="", help="Ordnerstruktur aus der Thunderbird/MBOX-Extraktion.")
    parser.add_argument("--output", default="", help="Zieldatei fuer die HTML-Tabelle.")
    parser.add_argument("--calendar-output", default="", help="Zieldatei fuer den Kalender.")
    parser.add_argument("--error-output", default="", help="Zieldatei fuer WDR-Fehlerfahrkarten.")
    parser.add_argument("--json", default="", help="Zusaetzlicher JSON-Export in diese Datei.")
    parser.add_argument("--csv", default="", help="Zusaetzlicher CSV-Export in diese Datei.")
    parser.add_argument("--text", default="", help="Zusaetzlicher Text-Export in diese Datei.")
    parser.add_argument("--finance-mode", action="store_true", help="Zusaetzliche Jahresbeleg-/Finanzamt-Exporte ohne Mailtexte erzeugen.")
    return parser.parse_args(argv)


def configure_paths(args: argparse.Namespace) -> None:
    global WORK_DIR, MBOX_ETICKET_DIR, INPUT_DIR, MBOX_INDEX, OUTPUT, CALENDAR_OUTPUT, ERROR_OUTPUT, PREVIEW_DIR, MAILTEXT_DIR, EXPORT_DIR, LOGO
    WORK_DIR = Path(args.work_dir)
    MBOX_ETICKET_DIR = Path(args.mbox_dir) if args.mbox_dir else WORK_DIR / "etickets_aus_mbox"
    INPUT_DIR = Path(args.input_dir) if args.input_dir else MBOX_ETICKET_DIR / "pdfs"
    MBOX_INDEX = MBOX_ETICKET_DIR / "index.json"
    OUTPUT = Path(args.output) if args.output else WORK_DIR / "eticket_zeiten_tabelle.html"
    CALENDAR_OUTPUT = Path(args.calendar_output) if args.calendar_output else WORK_DIR / "eticket_kalender.html"
    ERROR_OUTPUT = Path(args.error_output) if args.error_output else WORK_DIR / "eticket_wdr_fehler.html"
    PREVIEW_DIR = WORK_DIR / "previews"
    MAILTEXT_DIR = WORK_DIR / "mailtexte"
    EXPORT_DIR = WORK_DIR / "exports"
    LOGO = WORK_DIR / "wdr_ticket_logo.svg"


def export_record(row: dict) -> dict:
    counted = bool(row.get("active") and not row.get("cancelled"))
    card_labels = []
    for card_type in CARD_TYPE_ORDER:
        count = (row.get("card_counts") or {}).get(card_type, 0)
        if count:
            card_labels.append(card_text_display_label(card_type, count))
    return {
        "nr": row.get("seq", ""),
        "abfahrtshafen": row.get("dep_port", ""),
        "zielhafen": row.get("dest_port", ""),
        "abfahrt_datum": row.get("dep_date", ""),
        "abfahrt_uhrzeit": row.get("dep_time", ""),
        "rueckfahrt_datum": row.get("ret_date", ""),
        "rueckfahrt_uhrzeit": row.get("ret_time", ""),
        "mail_datum": row.get("mail_day", ""),
        "mail_uhrzeit": row.get("mail_time", ""),
        "reisedauer": row.get("duration", ""),
        "reisedauer_stunden": int(row.get("duration_sort") or 0),
        "kfz_kennzeichen": [line for line in str(row.get("kfz", "")).splitlines() if line.strip()],
        "buchungsnummer": row.get("booking", ""),
        "intern": [line for line in str(row.get("internal", "")).splitlines() if line.strip()],
        "laenge": row.get("length", ""),
        "fahrkarten": ", ".join(card_labels),
        "fahrzeug": row.get("vehicle", ""),
        "personen": row.get("persons", ""),
        "gesamtpreis": row.get("total", ""),
        "mwst": row.get("tax", ""),
        "mwst_satz": row.get("tax_rate", ""),
        "mwst_erwartet": row.get("tax_expected", ""),
        "mwst_plausibel": bool(row.get("tax_ok", True)),
        "endgueltig": bool(row.get("active")),
        "gezaehlt": counted,
        "storniert": bool(row.get("cancelled")),
        "aeltere_dublette": bool(row.get("duplicate_old")),
        "pdf_datei": str(row.get("pdf", "")),
        "pdf_name": row.get("source", ""),
        "mail_link": row.get("mail_href", ""),
        "storno_link": row.get("storno_href", ""),
    }


def flat_export_record(record: dict) -> dict:
    result = {}
    for key, value in record.items():
        if isinstance(value, list):
            result[key] = " | ".join(value)
        elif isinstance(value, bool):
            result[key] = "ja" if value else "nein"
        else:
            result[key] = value
    return result


def write_exports(rows: list[dict], error_rows: list[dict], summary: dict, args: argparse.Namespace) -> None:
    EXPORT_DIR.mkdir(parents=True, exist_ok=True)
    records = [export_record(row) for row in rows]
    error_records = [export_record(row) for row in error_rows]
    payload = {
        "erstellt": summary.get("erstellt", ""),
        "quelle": str(INPUT_DIR),
        "zusammenfassung": summary,
        "zeilen": records,
        "wdr_fehlerfahrkarten": error_records,
    }
    json_targets = [EXPORT_DIR / "etickets_wdr.json"]
    csv_targets = [EXPORT_DIR / "etickets_wdr.csv"]
    text_targets = [EXPORT_DIR / "etickets_wdr.txt"]
    if args.json:
        json_targets.append(Path(args.json))
    if args.csv:
        csv_targets.append(Path(args.csv))
    if args.text:
        text_targets.append(Path(args.text))
    for target in dict.fromkeys(json_targets):
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
    flat_records = [flat_export_record(record) for record in records]
    fields = list(flat_records[0].keys()) if flat_records else list(flat_export_record(export_record({})).keys())
    for target in dict.fromkeys(csv_targets):
        target.parent.mkdir(parents=True, exist_ok=True)
        with target.open("w", encoding="utf-8-sig", newline="") as fh:
            writer = csv.DictWriter(fh, fieldnames=fields, delimiter=";")
            writer.writeheader()
            writer.writerows(flat_records)
    for target in dict.fromkeys(text_targets):
        target.parent.mkdir(parents=True, exist_ok=True)
        lines = [
            "WDR eTicket Export",
            f"Erstellt: {summary.get('erstellt', '')}",
            f"Quelle: {INPUT_DIR}",
            f"PDF-Zeilen: {summary.get('pdf_zeilen', 0)}",
            f"Endgueltige Fahrkarten: {summary.get('endgueltige_fahrkarten', 0)}",
            f"Storniert: {summary.get('storniert', 0)}",
            f"Gezaehlt: {summary.get('gezaehlt', 0)}",
            "",
            "\t".join(fields),
        ]
        for record in flat_records:
            lines.append("\t".join(str(record.get(field, "")) for field in fields))
        target.write_text("\n".join(lines) + "\n", encoding="utf-8")



def anonymize_js() -> str:
    return r"""
(function () {
  const table = document.getElementById('tickets');
  if (!table || !table.tHead || !table.tBodies.length) return;
  const probe = document.createElement('span');
  probe.style.cssText = 'position:fixed;left:-10000px;top:-10000px;visibility:hidden;white-space:nowrap;padding:0;margin:0;border:0;';
  document.body.appendChild(probe);

  function measuredWidth(text, reference) {
    const style = window.getComputedStyle(reference);
    probe.style.font = style.font;
    probe.style.fontWeight = style.fontWeight;
    probe.style.letterSpacing = style.letterSpacing;
    probe.textContent = text || '';
    return probe.getBoundingClientRect().width;
  }

  function longestValueWidth(index, reference) {
    let width = measuredWidth('00000', reference);
    Array.from(table.tBodies[0].rows).forEach(row => {
      const cell = row.cells[index];
      if (!cell || cell.hidden) return;
      const grid = cell.querySelector('.cards-grid');
      if (grid) {
        width = Math.max(width, grid.scrollWidth);
        return;
      }
      const lines = (cell.innerText || cell.textContent || '').split(/\r?\n/);
      lines.forEach(line => { width = Math.max(width, measuredWidth(line.trim(), cell)); });
    });
    return width;
  }

  function syncHeaderWrapping() {
    const headers = Array.from(table.tHead.rows[0].cells);
    headers.forEach(th => th.classList.remove('wrap-head'));
    const rect = table.getBoundingClientRect();
    const available = Math.max(0, document.documentElement.clientWidth - rect.left - 2);
    const overflows = table.scrollWidth > available + 1;
    headers.forEach((th, index) => {
      if (th.hidden) return;
      const label = th.querySelector('.head-label') || th;
      const headerWidth = measuredWidth((th.dataset.label || label.textContent || '').trim(), label);
      const valueWidth = longestValueWidth(index, th);
      th.classList.toggle('wrap-head', overflows && headerWidth > valueWidth + 1);
    });
  }

  window.syncWdrHeaderWrapping = syncHeaderWrapping;
  window.addEventListener('resize', syncHeaderWrapping);
  requestAnimationFrame(syncHeaderWrapping);
})();
"""


def sample_last20_js() -> str:
    return r'''
(() => {
  const tbody = document.querySelector('#tickets tbody');
  if (!tbody) return;
  const counted = Array.from(tbody.querySelectorAll('tr')).filter(row => row.dataset.counted === '1');
  const keep = new Set(counted.slice(-20));
  Array.from(tbody.querySelectorAll('tr')).forEach(row => {
    if (!keep.has(row)) row.remove();
  });
  const meta = document.querySelector('.meta');
  if (meta) meta.textContent = 'Musterauswertung: letzte 20 gezählte, nicht stornierte Fahrkarten aus dem lokalen Datenbestand.';
  const footer = {
    vehicle: document.getElementById('footerVehicle'),
    persons: document.getElementById('footerPersons'),
    total: document.getElementById('footerTotal'),
    tax: document.getElementById('footerTax'),
    duration: document.getElementById('footerDuration')
  };
  function euro(cents) {
    return (cents / 100).toLocaleString('de-DE', {minimumFractionDigits: 2, maximumFractionDigits: 2}) + '\u00a0€';
  }
  function duration(hours) {
    const d = Math.floor(hours / 24);
    const h = hours % 24;
    const dayWord = d === 1 ? 'Tag' : 'Tage';
    return (d ? d + ' ' + dayWord + ' ' : '') + h + ' Stunden';
  }
  const sums = counted.slice(-20).reduce((acc, row) => {
    acc.vehicle += Number(row.dataset.vehicleCents || 0);
    acc.persons += Number(row.dataset.personCents || 0);
    acc.total += Number(row.dataset.totalCents || 0);
    acc.tax += Number(row.dataset.taxCents || 0);
    acc.duration += Number(row.dataset.durationHours || 0);
    return acc;
  }, {vehicle: 0, persons: 0, total: 0, tax: 0, duration: 0});
  if (footer.vehicle) footer.vehicle.textContent = euro(sums.vehicle);
  if (footer.persons) footer.persons.textContent = euro(sums.persons);
  if (footer.total) footer.total.textContent = euro(sums.total);
  if (footer.tax) footer.tax.textContent = euro(sums.tax);
  if (footer.duration) footer.duration.textContent = duration(sums.duration);
})();
'''


def pdf_card_summary(row: dict) -> str:
    parts = []
    counts = row.get("card_counts") or {}
    for key in CARD_TYPE_ORDER:
        count = int(counts.get(key, 0) or 0)
        if not count:
            continue
        if key == "person":
            parts.append("1 Person" if count == 1 else f"{count} Personen")
        elif key == "fahrrad":
            parts.append("1 Fahrrad" if count == 1 else f"{count} Fahrr?der")
        elif key == "anhaenger":
            parts.append("1 Hänger" if count == 1 else f"{count} Hänger")
        elif key == "auto":
            parts.append("1 Auto" if count == 1 else f"{count} Autos")
        elif key == "motorrad":
            parts.append("1 Motorrad" if count == 1 else f"{count} Motorr?der")
        else:
            parts.append(f"{count} Weitere")
    return ", ".join(parts)


def pdf_dt(day: str, time_value: str) -> str:
    if not day:
        return ""
    wd = weekday_abbr(day)
    time_part = display_time(time_value)
    return f"{wd} {day}" + (f" {time_part}" if time_part else "")


def write_pdf_exports(rows: list[dict], summary: dict) -> None:
    try:
        from reportlab.lib import colors
        from reportlab.lib.pagesizes import A4, landscape
        from reportlab.lib.styles import getSampleStyleSheet
        from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
        from reportlab.pdfbase import pdfmetrics
        from reportlab.pdfbase.ttfonts import TTFont
    except Exception as exc:
        note = EXPORT_DIR / "PDF_EXPORT_FEHLT.txt"
        note.write_text(f"PDF-Export konnte nicht erzeugt werden: {exc}\n", encoding="utf-8")
        return

    EXPORT_DIR.mkdir(parents=True, exist_ok=True)
    styles = getSampleStyleSheet()
    wdr_pdf_font_name = "Helvetica"
    for font_path in [Path(r"C:\Windows\Fonts\arial.ttf"), Path(r"C:\Windows\Fonts\segoeui.ttf")]:
        if font_path.exists():
            try:
                pdfmetrics.registerFont(TTFont("WDRSans", str(font_path)))
                wdr_pdf_font_name = "WDRSans"
                break
            except Exception:
                pass
    small = styles["BodyText"]
    small.fontName = wdr_pdf_font_name
    small.fontSize = 7
    small.leading = 8

    def p(value: str) -> Paragraph:
        return Paragraph(html_escape(str(value or "")), small)

    title_style = styles["Heading1"]
    title_style.fontName = wdr_pdf_font_name
    doc = SimpleDocTemplate(str(TABLE_PDF_OUTPUT), pagesize=landscape(A4), rightMargin=18, leftMargin=18, topMargin=18, bottomMargin=18, title="WDR eTicket-Auswertung")
    data = [[p("Nr."), p("Abfahrt"), p("Rückfahrt"), p("Strecke"), p("Buchung"), p("Kennzeichen"), p("Fahrkarten"), p("Fahrzeug"), p("Personen"), p("Gesamt"), p("MwSt")]]
    for row in rows:
        data.append([
            p(row.get("seq", "")),
            p(pdf_dt(row.get("dep_date", ""), row.get("dep_time", ""))),
            p(pdf_dt(row.get("ret_date", ""), row.get("ret_time", ""))),
            p(f'{row.get("dep_port", "")} - {row.get("dest_port", "")}'),
            p(row.get("booking", "")),
            p("\n".join(row.get("plate_values") or [])),
            p(pdf_card_summary(row)),
            p((row.get("vehicle") or "") + (" €" if row.get("vehicle") else "")),
            p((row.get("persons") or "") + (" €" if row.get("persons") else "")),
            p((row.get("total") or "") + (" €" if row.get("total") else "")),
            p((row.get("tax") or "") + (" €" if row.get("tax") else "")),
        ])
    table = Table(data, repeatRows=1, colWidths=[24, 58, 58, 88, 54, 66, 78, 48, 48, 48, 42])
    table.setStyle(TableStyle([
        ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#174f78")),
        ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
        ("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#d7e1ec")),
        ("VALIGN", (0, 0), (-1, -1), "TOP"),
        ("FONTSIZE", (0, 0), (-1, -1), 7),
        ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f4f8fb")]),
    ]))
    story = [Paragraph("WDR eTicket-Auswertung", title_style), Paragraph(f'Gezählte Fahrkarten: {summary.get("gezaehlt", len(rows))}; Gesamtpreis: {summary.get("summe_gesamtpreis", "")} €; MwSt: {summary.get("summe_mwst", "")} €', styles["BodyText"]), Spacer(1, 8), table]
    doc.build(story)

    cal_doc = SimpleDocTemplate(str(CALENDAR_PDF_OUTPUT), pagesize=A4, rightMargin=24, leftMargin=24, topMargin=24, bottomMargin=24, title="WDR eTicket-Kalender")
    cal_data = [[p("Abfahrt"), p("Rückfahrt"), p("Strecke"), p("Reisedauer"), p("Fahrkarten"), p("Buchung")]]
    for row in rows:
        cal_data.append([
            p(pdf_dt(row.get("dep_date", ""), row.get("dep_time", ""))),
            p(pdf_dt(row.get("ret_date", ""), row.get("ret_time", ""))),
            p(f'{row.get("dep_port", "")} - {row.get("dest_port", "")}'),
            p(row.get("duration", "")),
            p(pdf_card_summary(row)),
            p(row.get("booking", "")),
        ])
    cal_table = Table(cal_data, repeatRows=1, colWidths=[70, 70, 110, 70, 95, 60])
    cal_table.setStyle(TableStyle([
        ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#174f78")),
        ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
        ("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#d7e1ec")),
        ("VALIGN", (0, 0), (-1, -1), "TOP"),
        ("FONTSIZE", (0, 0), (-1, -1), 7),
        ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f4f8fb")]),
    ]))
    cal_doc.build([Paragraph("WDR eTicket-Kalenderexport", title_style), Paragraph("Listenform für Archiv, Druck und Weitergabe.", styles["BodyText"]), Spacer(1, 8), cal_table])


def _nice_step(max_value: float, target: int = 5) -> float:
    import math
    if max_value <= 0:
        return 1.0
    raw = max_value / max(target, 1)
    power = 10 ** math.floor(math.log10(raw))
    for factor in (1, 2, 5, 10):
        step = factor * power
        if raw <= step:
            return step
    return 10 * power


def _price_points(rows: list[dict]) -> list[dict]:
    pts = []
    for row in rows:
        try:
            length_mm = float((row.get('length') or '').replace(' m','').replace('.', '').replace(',', '.')) * 1000.0
            dep_dt = datetime.strptime(row.get('dep_date',''), '%d.%m.%Y')
        except Exception:
            continue
        if length_mm <= 0:
            continue
        cents = euro_to_cents(row.get('vehicle',''))
        if cents <= 0:
            continue
        counts = row.get('card_counts') or {}
        kinds = [k for k in ('auto','anhaenger') if counts.get(k)]
        if not kinds:
            continue
        split = cents / len(kinds)
        for kind in kinds:
            pts.append({'kind':'Hänger' if kind == 'anhaenger' else 'Auto','x':dep_dt.toordinal(),'date':dep_dt,'y':split/length_mm,'booking':row.get('booking',''),'length':row.get('length',''),'price':cents_to_euro(int(round(split)))})
    return pts


def _moving_average(points: list[dict], window: int = 7) -> list[tuple[float, float]]:
    ordered = sorted(points, key=lambda p: p['x'])
    out = []
    for i, pnt in enumerate(ordered):
        sample = ordered[max(0, i-window//2):min(len(ordered), i+window//2+1)]
        out.append((pnt['x'], sum(p['y'] for p in sample)/len(sample)))
    return out


def _price_segments(points: list[dict]) -> list[dict]:
    segments = []
    for kind in ("Auto", "Hänger"):
        ordered = sorted([p for p in points if p["kind"] == kind], key=lambda p: (p["x"], p["booking"]))
        current = []
        current_key = None
        for point in ordered:
            key = round(point["y"], 4)
            if current and key != current_key:
                if len(current) >= 2 and current[-1]["x"] > current[0]["x"]:
                    segments.append({"kind": kind, "start": current[0], "end": current[-1], "value": current_key})
                current = []
            current.append(point)
            current_key = key
        if len(current) >= 2 and current[-1]["x"] > current[0]["x"]:
            segments.append({"kind": kind, "start": current[0], "end": current[-1], "value": current_key})
    return segments


def build_price_scatter_html(rows: list[dict]) -> str:
    points = _price_points(rows)
    if not points:
        return '<p class="note">Für die Preisgrafik wurden keine verwertbaren Fahrzeuglängen und Fahrzeugpreise gefunden.</p>'
    w,h,l,r,t,b = 1120,620,76,34,46,72
    xmin,xmax = min(p['x'] for p in points), max(p['x'] for p in points)
    ymax_raw = max(p['y'] for p in points)*1.14
    ystep = _nice_step(ymax_raw, 5)
    import math
    ymax = max(ystep, math.ceil(ymax_raw/ystep)*ystep)
    def sx(x): return l+(x-xmin)/(xmax-xmin or 1)*(w-l-r)
    def sy(y): return h-b-(y/ymax)*(h-t-b)
    colors = {'Auto':'#2496d2','Hänger':'#83bd4f'}
    edge = {'Auto':'#12384d','Hänger':'#315018'}
    parts = [f'<svg class="scatter-svg" viewBox="0 0 {w} {h}" role="img" aria-label="Cent pro Millimeter nach Reisezeitpunkt">','<rect x="0" y="0" width="1120" height="620" fill="#fbfdff"/>']
    tick = 0.0
    while tick <= ymax + 1e-9:
        y=sy(tick)
        parts.append(f'<line x1="{l}" y1="{y:.1f}" x2="{w-r}" y2="{y:.1f}" stroke="#d9e4ee"/>')
        parts.append(f'<text x="{l-10}" y="{y+4:.1f}" text-anchor="end" font-size="13" fill="#425466">{tick:.1f}</text>')
        tick += ystep
    for year in range(datetime.fromordinal(xmin).year, datetime.fromordinal(xmax).year+1):
        xo = datetime(year,1,1).toordinal()
        if xmin <= xo <= xmax:
            x=sx(xo)
            parts.append(f'<line x1="{x:.1f}" y1="{t}" x2="{x:.1f}" y2="{h-b}" stroke="#edf2f7"/>')
            parts.append(f'<text x="{x:.1f}" y="{h-b+24}" text-anchor="middle" font-size="13" fill="#425466">{year}</text>')
    parts.append(f'<line x1="{l}" y1="{h-b}" x2="{w-r}" y2="{h-b}" stroke="#20384f" stroke-width="1.6"/>')
    parts.append(f'<line x1="{l}" y1="{t}" x2="{l}" y2="{h-b}" stroke="#20384f" stroke-width="1.6"/>')
    parts.append(f'<text x="{l}" y="24" font-size="22" font-weight="800" fill="#16384f">Millimeterpreis je Fahrzeugart</text>')
    parts.append(f'<text x="{l}" y="44" font-size="13" fill="#536579">Y-Achse: Cent pro Millimeter Fahrzeuglänge; Punkte: einzelne Buchungen; Linien: Abschnitte gleicher Wertstufe</text>')
    parts.append(f'<text x="{l}" y="{h-20}" font-size="13" fill="#425466">Reisejahr</text>')
    parts.append(f'<text x="22" y="{t+120}" transform="rotate(-90 22 {t+120})" font-size="13" fill="#425466">Cent pro Millimeter</text>')
    for seg in _price_segments(points):
        kind = seg['kind']; start = seg['start']; end = seg['end']; value = seg['value']
        x1,x2,y = sx(start['x']), sx(end['x']), sy(value)
        parts.append(f'<line x1="{x1:.1f}" y1="{y:.1f}" x2="{x2:.1f}" y2="{y:.1f}" stroke="{colors[kind]}" stroke-width="2.4" opacity=".78"><title>{kind}: {value:.2f} Cent/mm</title></line>')
        parts.append(f'<text x="{min(x2 + 4, w-r-42):.1f}" y="{y-5:.1f}" font-size="10" fill="{edge[kind]}">{value:.2f}</text>')
    for pnt in sorted(points, key=lambda p:p['date']):
        title=f'{pnt["kind"]} | {pnt["date"].strftime("%d.%m.%Y")} | Buchung {pnt["booking"]} | {pnt["price"]} € | {pnt["length"]} | {pnt["y"]:.2f} Cent/mm'
        x,y=sx(pnt['x']),sy(pnt['y'])
        if pnt['kind']=='Auto':
            parts.append(f'<circle cx="{x:.1f}" cy="{y:.1f}" r="2.1" fill="{colors[pnt["kind"]]}" stroke="{edge[pnt["kind"]]}" stroke-width=".7"><title>{esc(title)}</title></circle>')
        else:
            parts.append(f'<rect x="{x-2:.1f}" y="{y-2:.1f}" width="4" height="4" rx="1" fill="{colors[pnt["kind"]]}" stroke="{edge[pnt["kind"]]}" stroke-width=".7"><title>{esc(title)}</title></rect>')
    for i,kind in enumerate(('Auto','Hänger')):
        yy=t+8+i*24
        parts.append(f'<rect x="{w-210}" y="{yy-11}" width="16" height="16" rx="3" fill="{colors[kind]}"/><text x="{w-186}" y="{yy+2}" font-size="14" fill="#16384f">{kind}</text>')
    parts.append('</svg>')
    return '<p class="meta">Hinweis: Bei kombinierten Auto-/Hänger-Buchungen wird der Fahrzeugpreis gleichmäßig auf die erkannten Fahrzeugarten verteilt, wenn im PDF kein sauber getrennter Einzelpreis erkennbar ist.</p>\n' + '\n'.join(parts)


def finance_year(row: dict) -> str:
    day = row.get("dep_date") or ""
    return day[-4:] if re.fullmatch(r"\d{2}\.\d{2}\.\d{4}", day) else "ohne_jahr"


def finance_record(row: dict) -> dict:
    return {
        "Abfahrt": pdf_dt(row.get("dep_date", ""), row.get("dep_time", "")),
        "Rückfahrt": pdf_dt(row.get("ret_date", ""), row.get("ret_time", "")),
        "Abfahrtshafen": row.get("dep_port", ""),
        "Zielhafen": row.get("dest_port", ""),
        "Buchungsnummer": row.get("booking", ""),
        "Fahrkarten": pdf_card_summary(row),
        "Fahrzeug": row.get("vehicle", ""),
        "Personen": row.get("persons", ""),
        "Gesamtpreis": row.get("total", ""),
        "MwSt": row.get("tax", ""),
        "PDF": row.get("source", ""),
    }


def finance_sum(records: list[dict], field: str) -> int:
    return sum(euro_to_cents(record.get(field, "")) for record in records)


def write_finance_pdf(target: Path, title: str, records: list[dict]) -> None:
    try:
        from reportlab.lib import colors
        from reportlab.lib.pagesizes import A4, landscape
        from reportlab.lib.styles import getSampleStyleSheet
        from reportlab.lib.units import mm
        from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
        from reportlab.pdfbase import pdfmetrics
        from reportlab.pdfbase.ttfonts import TTFont
    except Exception as exc:
        target.with_suffix(".txt").write_text(f"PDF konnte nicht erzeugt werden: {exc}", encoding="utf-8")
        return
    font_name = "Courier"
    for font_path in [Path(r"C:\Windows\Fonts\consola.ttf"), Path(r"C:\Windows\Fonts\lucon.ttf"), Path(r"C:\Windows\Fonts\arial.ttf")]:
        if font_path.exists():
            try:
                pdfmetrics.registerFont(TTFont("WDRMonoFinance", str(font_path)))
                font_name = "WDRMonoFinance"
                break
            except Exception:
                pass
    styles = getSampleStyleSheet()
    body_style = styles["BodyText"]
    body_style.fontName = font_name
    body_style.fontSize = 6.7
    body_style.leading = 7.6
    title_style = styles["Heading1"]
    title_style.fontName = font_name
    def para(value: str) -> Paragraph:
        return Paragraph(html_escape(str(value or "")), body_style)
    fields = ["Abfahrt", "Rückfahrt", "Abfahrtshafen", "Zielhafen", "Buchungsnummer", "Fahrkarten", "Fahrzeug", "Personen", "Gesamtpreis", "MwSt"]
    data = [[para(field) for field in fields]]
    for rec in records:
        data.append([para(rec[field] + (" €" if field in {"Fahrzeug", "Personen", "Gesamtpreis", "MwSt"} and rec[field] else "")) for field in fields])
    sum_row = {field: "" for field in fields}
    sum_row["Abfahrt"] = "Summe"
    for field in ("Fahrzeug", "Personen", "Gesamtpreis", "MwSt"):
        sum_row[field] = cents_to_euro(finance_sum(records, field)) + " €"
    data.append([para(sum_row[field]) for field in fields])
    doc = SimpleDocTemplate(str(target), pagesize=landscape(A4), rightMargin=10*mm, leftMargin=10*mm, topMargin=10*mm, bottomMargin=10*mm, title=title)
    table = Table(data, repeatRows=1, colWidths=[55, 55, 50, 48, 54, 82, 42, 42, 48, 38])
    table.setStyle(TableStyle([
        ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#174f78")),
        ("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
        ("BACKGROUND", (0, -1), (-1, -1), colors.HexColor("#eaf2f7")),
        ("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#d7e1ec")),
        ("VALIGN", (0, 0), (-1, -1), "TOP"),
        ("FONTSIZE", (0, 0), (-1, -1), 6.7),
        ("ROWBACKGROUNDS", (0, 1), (-1, -2), [colors.white, colors.HexColor("#f4f8fb")]),
        ("ALIGN", (6, 1), (-1, -1), "RIGHT"),
    ]))
    doc.build([Paragraph(title, title_style), Paragraph("Jahresweise gruppierte, gezählte und nicht stornierte WDR-Fahrkarten.", body_style), Spacer(1, 7), table])


def write_finance_exports(rows: list[dict], summary: dict) -> None:
    import zipfile
    EXPORT_DIR.mkdir(parents=True, exist_ok=True)
    package_root = EXPORT_DIR / "finanzamt_paket"
    if package_root.exists():
        shutil.rmtree(package_root)
    package_root.mkdir(parents=True, exist_ok=True)
    fields = ["Abfahrt", "Rückfahrt", "Abfahrtshafen", "Zielhafen", "Buchungsnummer", "Fahrkarten", "Fahrzeug", "Personen", "Gesamtpreis", "MwSt", "PDF"]
    by_year: dict[str, list[dict]] = {}
    source_by_record: dict[str, Path] = {}
    for row in rows:
        rec = finance_record(row)
        year = finance_year(row)
        by_year.setdefault(year, []).append(rec)
        if isinstance(row.get("pdf"), Path):
            source_by_record[rec["PDF"]] = row["pdf"]
    all_records = [rec for year in sorted(by_year) for rec in by_year[year]]
    with (EXPORT_DIR / "finanzamt_jahresbelege.csv").open("w", newline="", encoding="utf-8-sig") as fh:
        writer = csv.DictWriter(fh, fieldnames=fields, delimiter=";")
        writer.writeheader()
        writer.writerows(all_records)
    with (EXPORT_DIR / "finanzamt_jahresbelege.txt").open("w", encoding="utf-8") as fh:
        fh.write("WDR eTicket Jahresbelege / Finanzamt\n")
        fh.write(f"Gesamtpreis: {summary.get('summe_gesamtpreis', '')} €\nMwSt: {summary.get('summe_mwst', '')} €\n\n")
        for year in sorted(by_year):
            records = by_year[year]
            fh.write(f"== {year} ==\n")
            for record in records:
                fh.write(f"{record['Abfahrt']}\t{record['Abfahrtshafen']} - {record['Zielhafen']}\t{record['Buchungsnummer']}\t{record['Gesamtpreis']} €\tMwSt {record['MwSt']} €\n")
            fh.write(f"Summe {year}: {cents_to_euro(finance_sum(records, 'Gesamtpreis'))} €; MwSt {cents_to_euro(finance_sum(records, 'MwSt'))} €\n\n")
    sections = []
    for year in sorted(by_year):
        records = by_year[year]
        year_dir = package_root / year
        pdf_dir = year_dir / "fahrkarten_pdfs"
        pdf_dir.mkdir(parents=True, exist_ok=True)
        for record in records:
            source_pdf = source_by_record.get(record["PDF"])
            if source_pdf and source_pdf.exists():
                safe_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", f"{record['Abfahrt']}_{record['Buchungsnummer']}_{source_pdf.name}")[:170]
                shutil.copy2(source_pdf, pdf_dir / safe_name)
        year_csv = year_dir / f"finanzamt_jahresbelege_{year}.csv"
        with year_csv.open("w", newline="", encoding="utf-8-sig") as fh:
            writer = csv.DictWriter(fh, fieldnames=fields, delimiter=";")
            writer.writeheader()
            writer.writerows(records)
        year_txt = year_dir / f"kurzbericht_{year}.txt"
        year_txt.write_text(
            f"WDR eTicket Jahresbelege {year}\n"
            f"Fahrkarten: {len(records)}\n"
            f"Fahrzeug: {cents_to_euro(finance_sum(records, 'Fahrzeug'))} €\n"
            f"Personen: {cents_to_euro(finance_sum(records, 'Personen'))} €\n"
            f"Gesamtpreis: {cents_to_euro(finance_sum(records, 'Gesamtpreis'))} €\n"
            f"MwSt: {cents_to_euro(finance_sum(records, 'MwSt'))} €\n",
            encoding="utf-8",
        )
        year_pdf = year_dir / f"finanzamt_jahresbelege_{year}.pdf"
        write_finance_pdf(year_pdf, f"Finanzamt-Jahresbelege WDR {year}", records)
        row_html = []
        for rec in records:
            row_html.append("<tr>" + "".join([
                f"<td>{esc(rec['Abfahrt'])}</td>",
                f"<td>{esc(rec['Rückfahrt'])}</td>",
                f"<td>{esc(rec['Abfahrtshafen'])}</td>",
                f"<td>{esc(rec['Zielhafen'])}</td>",
                f"<td class='booking-cell'>{esc(rec['Buchungsnummer'])}</td>",
                f"<td>{esc(rec['Fahrkarten'])}</td>",
                f"<td class='money-col'>{esc(rec['Fahrzeug'])}&nbsp;€</td>",
                f"<td class='money-col'>{esc(rec['Personen'])}&nbsp;€</td>",
                f"<td class='money-col'>{esc(rec['Gesamtpreis'])}&nbsp;€</td>",
                f"<td class='money-col'>{esc(rec['MwSt'])}&nbsp;€</td>",
            ]) + "</tr>")
        total_html = "".join([
            "<td>Summe</td><td></td><td></td><td></td><td></td><td></td>",
            f"<td class='money-col'>{esc(cents_to_euro(finance_sum(records, 'Fahrzeug')))}&nbsp;€</td>",
            f"<td class='money-col'>{esc(cents_to_euro(finance_sum(records, 'Personen')))}&nbsp;€</td>",
            f"<td class='money-col'>{esc(cents_to_euro(finance_sum(records, 'Gesamtpreis')))}&nbsp;€</td>",
            f"<td class='money-col'>{esc(cents_to_euro(finance_sum(records, 'MwSt')))}&nbsp;€</td>",
        ])
        sections.append(f'''<section class="finance-year"><h2>{esc(year)}</h2><div class="table-wrap"><table><thead><tr>{''.join(th(field, 'number' if field in {'Fahrzeug','Personen','Gesamtpreis','MwSt'} else 'text') for field in fields[:-1])}</tr></thead><tbody>{''.join(row_html)}</tbody><tfoot><tr>{total_html}</tr></tfoot></table></div></section>''')
    write_finance_pdf(EXPORT_DIR / "finanzamt_jahresbelege.pdf", "Finanzamt-Jahresbelege WDR", all_records)
    finance_body = f'''<main class="finance-report">
<div class="brand"><img class="app-logo" src="../wdr_ticket_logo.svg" alt=""><h1>Finanzamt-Jahresbelege WDR</h1></div>
<p class="meta">Gezählte, nicht stornierte Fahrkarten, jahresweise gruppiert. Quelle: {esc(str(INPUT_DIR))}. Gesamtpreis: {esc(summary.get('summe_gesamtpreis', ''))}&nbsp;€; MwSt: {esc(summary.get('summe_mwst', ''))}&nbsp;€.</p>
{''.join(sections)}
</main>'''
    (EXPORT_DIR / "finanzamt_jahresbelege.html").write_text(html_page("Finanzamt-Jahresbelege WDR", finance_body, '<style>.finance-report, .finance-report table { font-family:"Cascadia Mono", Consolas, "Courier New", monospace; } .finance-year h2 { margin-top:18px; } .finance-report tfoot td { font-weight:800; background:#eaf2f7; }</style>'), encoding="utf-8")
    kurzbericht = package_root / "kurzbericht_finanzamt.txt"
    kurzbericht.write_text(
        f"WDR eTicket Finanzamt-Paket\nErstellt: {summary.get('erstellt','')}\nGezählte Fahrkarten: {summary.get('gezaehlt', len(rows))}\nGesamtpreis: {summary.get('summe_gesamtpreis','')} €\nMwSt: {summary.get('summe_mwst','')} €\nJahre: {', '.join(sorted(by_year))}\n",
        encoding="utf-8",
    )
    for source in [
        EXPORT_DIR / "finanzamt_jahresbelege.csv",
        EXPORT_DIR / "finanzamt_jahresbelege.txt",
        EXPORT_DIR / "finanzamt_jahresbelege.html",
        EXPORT_DIR / "finanzamt_jahresbelege.pdf",
    ]:
        if source.exists():
            shutil.copy2(source, package_root / source.name)
    zip_path = EXPORT_DIR / "finanzamt_paket.zip"
    if zip_path.exists():
        zip_path.unlink()
    with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
        for item in sorted(package_root.rglob("*")):
            if item.is_file():
                zf.write(item, item.relative_to(package_root))


def audit_issue_rows(rows: list[dict]) -> list[dict]:
    issues = []
    for row in rows:
        reasons = []
        if row.get("tax_rate") and not row.get("tax_ok", True):
            reasons.append(f"MwSt-Prüfung auffällig: Satz {row.get('tax_rate')}%, erwartet {row.get('tax_expected')}, gelesen {row.get('tax')}")
        if row.get("cancelled"):
            reasons.append("Storno vorhanden")
        if row.get("active") and not row.get("cancelled") and not row.get("length"):
            reasons.append("fehlende Länge")
        if row.get("active") and not row.get("cancelled") and row.get("card_counts", {}).get("auto") and not (row.get("plate_values") or []):
            reasons.append("fehlendes Kennzeichen")
        if reasons:
            copy = dict(row)
            copy["audit_reasons"] = reasons
            issues.append(copy)
    return issues


def build_audit_html(rows: list[dict]) -> str:
    issues = audit_issue_rows(rows)
    if not issues:
        return '<p class="note">Keine auffälligen Buchungen nach den aktuellen Prüfregeln gefunden.</p>'
    body = []
    for row in issues:
        pdf_link = f'<a href="{esc(pdf_uri(row["pdf"]), quote=True)}" target="_blank" rel="noopener">{esc(row.get("source", ""))}</a>' if row.get("pdf") else ""
        body.append("<tr>" + "".join([
            text_cell(", ".join(row["audit_reasons"]), ""),
            text_cell(row.get("dep_date", ""), sort_date(row.get("dep_date", ""))),
            text_cell(row.get("booking", ""), row.get("booking", "")),
            plate_cell(row.get("kfz_parts") or [], {}),
            text_cell(row.get("length", ""), sort_decimal((row.get("length", "") or "").replace(" m", ""))),
            currency_cell(row.get("total", ""), sort_decimal(row.get("total", ""))),
            currency_cell(row.get("tax", ""), sort_decimal(row.get("tax", ""))),
            f"<td>{pdf_link}</td>",
        ]) + "</tr>")
    return f'''<p class="meta">{len(issues)} auffällige Buchungen: MwSt-Abweichungen, Stornos, fehlende Länge oder fehlende Kennzeichen.</p>
<div class="table-wrap"><table><thead><tr>{th("Prüfung", "text")}{th("Abfahrt", "text")}{th("Buchungsnummer", "text")}{th("KfZ-Kennzeichen", "text")}{th("Länge", "number")}{th("Gesamtpreis", "number")}{th("MwSt", "number")}<th>PDF</th></tr></thead><tbody>{''.join(body)}</tbody></table></div>'''


def build_risk_html(rows: list[dict], has_mail_index: bool) -> str:
    risk = [row for row in rows if not row.get('mail_day')] if has_mail_index else list(rows)
    intro = 'MBOX-/Maildaten sind vorhanden. Aufgeführt werden gezählte Fahrkarten ohne zugeordnete Mailzustellung.' if has_mail_index else 'Es liegen keine Mail-/Stornodaten vor. Alle reinen PDF-Buchungen können stornorelevant sein, wenn Storno-Mails nicht mit eingelesen wurden.'
    if not risk: return f'<p class="note">{esc(intro)} Keine konkreten Risikotreffer.</p>'
    body=[]
    for row in risk:
        body.append('<tr>'+''.join([text_cell(row.get('dep_date',''),sort_date(row.get('dep_date',''))),text_cell(row.get('dep_port',''),row.get('dep_port','').lower()),text_cell(row.get('dest_port',''),row.get('dest_port','').lower()),booking_text_cell(row.get('booking','')),currency_cell(row.get('total',''),sort_decimal(row.get('total',''))),text_cell('keine Mailzustellung' if has_mail_index else 'keine Stornodatenquelle','')])+'</tr>')
    return f'<p class="meta">{esc(intro)}</p><div class="table-wrap"><table><thead><tr>{th("Abfahrt","text")}{th("Abfahrtshafen","text")}{th("Zielhafen","text")}{th("Buchungsnummer","text")}{th("Gesamtpreis","number")}<th>Risiko</th></tr></thead><tbody>{"".join(body)}</tbody></table></div>'

def main(argv: list[str] | None = None) -> None:
    args = parse_args(argv)
    configure_paths(args)
    WORK_DIR.mkdir(parents=True, exist_ok=True)
    write_logo()
    mail_index = build_mail_index()
    stornos_by_booking = mail_index.pop("__stornos_by_booking__", {})

    rows = []
    pdf_files = sorted(INPUT_DIR.glob("eticket*.pdf"))
    if not pdf_files:
        pdf_files = sorted(INPUT_DIR.glob("*.pdf"))
    for pdf in pdf_files:
        try:
            reader = PdfReader(str(pdf))
            text = "\n".join(page.extract_text() or "" for page in reader.pages)
            ticket_text = text.replace("\xa0", " ")
            area = first_ticket_area(ticket_text)
            error = ""
        except Exception as exc:
            ticket_text = ""
            area = ""
            error = str(exc)

        date_times = DATE_TIME_RE.findall(area)
        dep_date, dep_time = date_times[0] if len(date_times) > 0 else ("", "")
        ret_date, ret_time = date_times[1] if len(date_times) > 1 else ("", "")
        dep_date, dep_time = normalize_date(dep_date), normalize_time(dep_time)
        ret_date, ret_time = normalize_date(ret_date), normalize_time(ret_time)
        if not dep_date:
            dep_date, ret_date = fallback_dates(area)

        dep_port, dest_port = route_from_area(area)
        card_sections = classify_ticket_sections(ticket_text)
        main_plate = extract_plate(area)
        main_plate_role = "Auto" if card_sections.get("auto") else ("Motorrad" if card_sections.get("motorrad") else "Kfz")
        plate_parts = [{"role": main_plate_role, "value": main_plate}] if main_plate else []
        for trailer_section in card_sections.get("anhaenger", []):
            trailer_plate = extract_trailer_plate(trailer_section) or extract_plate(trailer_section)
            if trailer_plate:
                plate_parts.append({"role": "Anhänger", "value": trailer_plate})
        plate_parts = unique_plate_parts(plate_parts)
        plate_values = plate_values_from_parts(plate_parts)
        kfz_display = "\n".join(f"{part['role']}: {part['value']}" if part.get("role") else part["value"] for part in plate_parts)

        length = extract_length_m(area)

        summary_total_cents, summary_tax_cents = summary_and_tax_cents_from_text(area)
        total = cents_to_euro(summary_total_cents) if summary_total_cents else ""
        if not total:
            total = first_match(r"Gesamtpreis\s+EUR\s*([0-9.]+,[0-9]{2})", area)
        if not total:
            total = first_match(r"Gesamtsumme\s+EUR\s*([0-9.]+,[0-9]{2})", area)
        if not total:
            total = first_match(r"\bPreis\b\s*(?:EUR|€)\s*([0-9.]+,[0-9]{2})", area)

        extracted_tax_cents = summary_tax_cents or tax_cents_from_text(area)
        tax = cents_to_euro(extracted_tax_cents) if extracted_tax_cents else ""
        booking = first_match(r"Buchung\s+(\d+)", area)
        if not booking:
            name_match = BOOKING_FROM_NAME_RE.search(pdf.name)
            booking = name_match.group(1) if name_match else ""
        internal_no = extract_internal_number(ticket_text, booking)

        duration, duration_sort = duration_text(dep_date, dep_time, ret_date, ret_time)
        mail = mail_index.get(pdf.name.lower(), {})
        storno_info = stornos_by_booking.get(booking, {}) if booking else {}
        vehicle_sections = []
        for card_type in ("auto", "anhaenger", "motorrad", "fahrrad"):
            vehicle_sections.extend(card_sections.get(card_type, []))
        vehicle_sections = unique_sections(vehicle_sections)
        person_sections = card_sections.get("person", [])
        has_vehicle = any(card_sections.get(card_type) for card_type in ("auto", "anhaenger", "motorrad", "fahrrad"))
        vehicle_cents = sum(section_price_cents(section) for section in vehicle_sections)
        person_cents = sum(section_price_cents(section) for section in person_sections)
        vehicle_tax_cents = sum(section_tax_cents(section) for section in vehicle_sections)
        person_tax_cents = sum(section_tax_cents(section) for section in person_sections)
        extracted_total_cents = euro_to_cents(total)
        tax_rate = tax_rate_from_text(area)
        if has_vehicle and vehicle_cents == 0:
            vehicle_cents = extracted_total_cents
        if not has_vehicle and person_cents == 0:
            person_cents = extracted_total_cents
        grand_total_cents = vehicle_cents + person_cents
        grand_tax_cents = extracted_tax_cents or (vehicle_tax_cents + person_tax_cents)
        if grand_total_cents == 0:
            grand_total_cents = extracted_total_cents
        if grand_total_cents and grand_tax_cents > int(grand_total_cents * 0.25):
            grand_tax_cents = 0
        expected_tax_cents, tax_ok = tax_check(grand_total_cents, grand_tax_cents, tax_rate, vehicle_cents, person_cents, extracted_total_cents)
        rows.append({
            "dep_port": dep_port,
            "dest_port": dest_port,
            "dep_date": dep_date,
            "dep_time": dep_time,
            "ret_date": ret_date,
            "ret_time": ret_time,
            "mail_day": mail.get("day", ""),
            "mail_time": mail.get("time", ""),
            "mail_href": mail.get("href", ""),
            "mail_body": mail.get("body", ""),
            "storno_count": int(storno_info.get("count") or 0),
            "storno_day": storno_info.get("day", ""),
            "storno_time": storno_info.get("time", ""),
            "storno_href": storno_info.get("href", ""),
            "storno_body": storno_info.get("body", ""),
            "storno_subjects": storno_info.get("subjects", ""),
            "duration": duration,
            "duration_sort": duration_sort,
            "kfz": kfz_display,
            "kfz_parts": plate_parts,
            "plate_values": plate_values,
            "booking": booking,
            "internal": internal_no,
            "length": (length + " m") if length else "",
            "card_counts": {key: len(value) for key, value in card_sections.items()},
            "card_texts": {key: join_card_sections(value) for key, value in card_sections.items()},
            "vehicle": cents_to_euro(vehicle_cents) if vehicle_cents else "",
            "persons": cents_to_euro(person_cents) if person_cents else "",
            "total": cents_to_euro(grand_total_cents) if grand_total_cents else "",
            "tax": cents_to_euro(grand_tax_cents) if grand_tax_cents else "",
            "tax_rate": str(tax_rate) if tax_rate else "",
            "tax_expected": cents_to_euro(expected_tax_cents) if expected_tax_cents else "",
            "tax_ok": tax_ok,
            "source": pdf.name,
            "pdf": pdf,
            "preview": render_preview(pdf),
            "error": error,
        })

    wdr_error_rows = [r for r in rows if not r.get("dep_date")]
    rows = [r for r in rows if r.get("dep_date")]
    rows.sort(key=lambda r: (sort_date(r["dep_date"]), sort_time(r["dep_time"]), r["booking"], r["source"]))

    booking_groups: dict[str, list[dict]] = {}
    for row in rows:
        row["active"] = True
        row["duplicate_old"] = False
        row["cancelled"] = bool(row.get("storno_count"))
        booking_key = (row["booking"] or "").strip()
        if booking_key:
            booking_groups.setdefault(booking_key, []).append(row)

    def newest_mail_key(row: dict) -> tuple[str, str, str]:
        if not row.get("mail_day") and not row.get("mail_time"):
            return ("0000-00-00", "0000", row["source"])
        return (sort_date(row.get("mail_day", "")), sort_time(row.get("mail_time", "")), row["source"])

    booking_keepers = {}
    for booking_key, group in booking_groups.items():
        if len(group) < 2:
            continue
        keeper = max(group, key=newest_mail_key)
        booking_keepers[booking_key] = keeper
        for row in group:
            if row is not keeper:
                row["active"] = False
                row["duplicate_old"] = True

    active_no = 0
    for row in rows:
        if row["active"]:
            active_no += 1
            row["seq"] = str(active_no)
            row["seq_sort"] = f"{active_no:06d}"
        else:
            row["seq"] = ""
            row["seq_sort"] = "999999"

    for booking_key, keeper in booking_keepers.items():
        keeper_sort = keeper.get("seq_sort", "999999")
        for row in booking_groups.get(booking_key, []):
            row["seq_sort"] = keeper_sort

    active_rows = [r for r in rows if r["active"]]
    counted_rows = [r for r in active_rows if not r.get("cancelled")]
    duplicate_old_count = sum(1 for r in rows if r["duplicate_old"])
    cancelled_count = sum(1 for r in active_rows if r.get("cancelled"))
    present_card_types = [card_type for card_type in CARD_TYPE_ORDER if any(row["card_counts"].get(card_type, 0) for row in rows)]
    card_column_widths: dict[str, str] = {}
    for card_type in present_card_types:
        labels = [card_display_label(card_type, row["card_counts"].get(card_type, 0)) for row in rows if row["card_counts"].get(card_type, 0)]
        labels.append(CARD_TYPE_LABELS.get(card_type, card_type))
        max_chars = max(len(label) for label in labels if label)
        card_column_widths[card_type] = f"{max(4, min(11, max_chars + 1))}ch"
    if cancelled_count:
        max_storno = max(len("Storno"), len(f"{max((r.get('storno_count', 0) or 0) for r in rows)} Storno"))
        card_column_widths["storno"] = f"{max(6, min(10, max_storno + 1))}ch"
    vehicle_cents = sum(euro_to_cents(r["vehicle"]) for r in counted_rows)
    person_cents = sum(euro_to_cents(r["persons"]) for r in counted_rows)
    total_cents = sum(euro_to_cents(r["total"]) for r in counted_rows)
    tax_cents = sum(euro_to_cents(r["tax"]) for r in counted_rows)
    total_duration_hours = sum(int(r["duration_sort"] or 0) for r in counted_rows)
    missing_date = sum(1 for r in counted_rows if not r["dep_date"])
    missing_time = sum(1 for r in counted_rows if r["dep_date"] and not r["dep_time"])
    preview_count = sum(1 for r in rows if r["preview"])
    mail_count = sum(1 for r in active_rows if r["mail_day"] or r["mail_time"])
    num_col_chars = max(len("Nr."), len(str(max(len(active_rows), 1))))
    now = datetime.now().strftime("%d.%m.%Y %H:%M")
    plate_values = sorted({plate for row in active_rows for plate in (row.get("plate_values") or [])})
    plate_colors = {plate: idx for idx, plate in enumerate(plate_values)}

    headers = "".join([
        th("Nr.", "number"),
        th("Abfahrtshafen", "text"),
        th("Zielhafen", "text"),
        th("Abfahrt", "text"),
        th("Rückfahrt", "text"),
        th("Mailzustellung", "text"),
        th("Reisedauer", "number"),
        th("KfZ-Kennzeichen", "text"),
        th("Buchungsnummer", "text"),
        th("Intern", "text"),
        th("Länge", "number"),
        th("Fahrkarten", "text"),
        th("Fahrzeug", "number"),
        th("Personen", "number"),
        th("Gesamtpreis", "number"),
        th("MwSt", "number"),
    ])
    colgroup = "<colgroup>" + "".join('<col class="num-col-def">' if i == 0 else "<col>" for i in range(headers.count("<th"))) + "</colgroup>"

    body_rows = []
    for r in rows:
        title = "Quelle: " + r["source"]
        if r["duplicate_old"]:
            title += " | ältere Dublette dieser Buchungsnummer; nicht summiert und nicht gezählt"
        if r.get("cancelled"):
            title += " | storniert; nicht summiert und nicht gezählt"
        class_names = []
        if r["duplicate_old"]:
            class_names.append("inactive")
        if r.get("cancelled"):
            class_names.append("cancelled-ticket")
        if not r.get("booking") or not r.get("dep_date"):
            class_names.append("empty-booking")
            title += " | PDF enthält keine vollständigen Fahrkartendaten"
        row_class = f' class="{" ".join(class_names)}"' if class_names else ""
        row_style = ' style="color:#b00020;"' if r.get("cancelled") and not r["duplicate_old"] else ""
        counted = r["active"] and not r.get("cancelled")
        sim_counted = r["active"]
        plate_data = "|".join(r.get("plate_values") or [])
        visible_cards = []
        for card_type in present_card_types:
            count = r["card_counts"].get(card_type, 0)
            if count:
                visible_cards.append(card_text_display_label(card_type, count))
        row_search = " ".join([
            r["seq"],
            r["dep_port"],
            r["dest_port"],
            weekday_abbr(r["dep_date"]),
            r["dep_date"],
            display_time(r["dep_time"]),
            weekday_abbr(r["ret_date"]),
            r["ret_date"],
            display_time(r["ret_time"]),
            r["mail_day"],
            display_time(r["mail_time"]),
            r["duration"],
            r["kfz"],
            r["booking"],
            r["internal"],
            r["length"],
            " ".join(visible_cards),
            r["vehicle"],
            r["persons"],
            r["total"],
            r["tax"],
            "Storno" if r.get("cancelled") else "",
        ])
        if r.get("tax_rate") and not r.get("tax_ok", True):
            title += f' | MwSt-Prüfung: Satz {r["tax_rate"]}%, erwartet {r["tax_expected"]} €, gelesen {r["tax"]} €'
        row_attrs = (
            f'{row_class}{row_style} data-final="{1 if r["active"] else 0}" '
            f'data-counted="{1 if counted else 0}" '
            f'data-cancelled="{1 if r.get("cancelled") else 0}" '
            f'data-plates="{esc(plate_data, quote=True)}" '
            f'data-search="{esc(row_search.lower(), quote=True)}" '
            f'data-vehicle-cents="{euro_to_cents(r["vehicle"]) if counted else 0}" '
            f'data-person-cents="{euro_to_cents(r["persons"]) if counted else 0}" '
            f'data-total-cents="{euro_to_cents(r["total"]) if counted else 0}" '
            f'data-tax-cents="{euro_to_cents(r["tax"]) if counted else 0}" '
            f'data-duration-hours="{int(r["duration_sort"] or 0) if counted else 0}" '
            f'data-sim-counted="{1 if sim_counted else 0}" '
            f'data-sim-vehicle-cents="{euro_to_cents(r["vehicle"]) if sim_counted else 0}" '
            f'data-sim-person-cents="{euro_to_cents(r["persons"]) if sim_counted else 0}" '
            f'data-sim-total-cents="{euro_to_cents(r["total"]) if sim_counted else 0}" '
            f'data-sim-tax-cents="{euro_to_cents(r["tax"]) if sim_counted else 0}" '
            f'data-sim-duration-hours="{int(r["duration_sort"] or 0) if sim_counted else 0}" '
            f'data-tax-rate="{esc(r.get("tax_rate", ""), quote=True)}" '
            f'data-tax-expected="{esc(r.get("tax_expected", ""), quote=True)}" '
            f'data-tax-ok="{1 if r.get("tax_ok", True) else 0}" '
            f'title="{esc(title, quote=True)}"'
        )
        if r["active"] and r.get("cancelled"):
            storno_info = {
                "count": r.get("storno_count", 0),
                "body": r.get("storno_body", ""),
            }
            storno_search = " ".join([
                r["dep_port"],
                r["dest_port"],
                weekday_abbr(r["dep_date"]),
                r["dep_date"],
                display_time(r["dep_time"]),
                weekday_abbr(r["ret_date"]),
                r["ret_date"],
                display_time(r["ret_time"]),
                r["storno_day"],
                display_time(r["storno_time"]),
                r["kfz"],
                r["booking"],
                "Storno",
            ])
            storno_attrs = (
                f'class="storno-entry" style="color:#b00020;background:#fff1f1;" data-final="1" '
                f'data-counted="0" data-cancelled="1" '
                f'data-plates="{esc(plate_data, quote=True)}" '
                f'data-search="{esc(storno_search.lower(), quote=True)}" '
                f'data-vehicle-cents="0" data-person-cents="0" data-total-cents="0" '
                f'data-tax-cents="0" data-duration-hours="0" '
                f'data-sim-counted="0" data-sim-vehicle-cents="0" data-sim-person-cents="0" data-sim-total-cents="0" data-sim-tax-cents="0" data-sim-duration-hours="0" '
                f'title="{esc("Stornomeldung zu Buchung " + r["booking"], quote=True)}"'
            )
            body_rows.append(f"<tr {storno_attrs}>" + "".join([
                number_cell("", False, r["seq_sort"]),
                text_cell(r["dep_port"], r["dep_port"].lower()),
                text_cell(r["dest_port"], r["dest_port"].lower()),
                datetime_cell(r["dep_date"], r["dep_time"]),
                datetime_cell(r["ret_date"], r["ret_time"]),
                mail_cell(r["storno_day"], r["storno_time"], r["storno_href"], r["storno_body"]),
                duration_cell("", ""),
                plate_cell(r.get("kfz_parts") or [], plate_colors),
                booking_text_cell(r["booking"]),
                text_cell("", ""),
                text_cell("", ""),
                cards_cell({card_type: 0 for card_type in CARD_TYPE_ORDER}, {}, present_card_types, storno_info, True, card_column_widths),
                currency_cell("", ""),
                currency_cell("", ""),
                currency_cell("", ""),
                currency_cell("", ""),
            ]) + "</tr>")
        body_rows.append(f"<tr {row_attrs}>" + "".join([
            number_cell(r["seq"], r["active"], r["seq_sort"]),
            text_cell(r["dep_port"], r["dep_port"].lower()),
            text_cell(r["dest_port"], r["dest_port"].lower()),
            datetime_cell(r["dep_date"], r["dep_time"]),
            datetime_cell(r["ret_date"], r["ret_time"]),
            mail_cell(r["mail_day"], r["mail_time"], r["mail_href"], r["mail_body"]),
            duration_cell(r["duration"], r["duration_sort"]),
            plate_cell(r.get("kfz_parts") or [], plate_colors),
            booking_cell(r["booking"], r["pdf"], r["preview"]),
            multiline_cell(r["internal"], r["internal"], "internal-cell"),
            text_cell(r["length"], sort_decimal(r["length"].replace(" m", ""))),
            cards_cell(r["card_counts"], r["card_texts"], present_card_types, {"count": r.get("storno_count", 0), "body": r.get("storno_body", "")} if r.get("cancelled") else None, cancelled_count > 0, card_column_widths),
            currency_cell(r["vehicle"], sort_decimal(r["vehicle"])),
            currency_cell(r["persons"], sort_decimal(r["persons"])),
            currency_cell(r["total"], sort_decimal(r["total"])),
            currency_cell(r["tax"], sort_decimal(r["tax"])),
        ]) + "</tr>")

    footer = "<tr>" + "".join([
        '<td class="sum-label">Summe</td>',
        '<td></td>',
        '<td></td>',
        '<td></td>',
        '<td></td>',
        '<td></td>',
        duration_cell("", str(total_duration_hours), "footerDuration"),
        '<td></td>',
        '<td></td>',
        '<td></td>',
        '<td></td>',
        '<td></td>',
        f'<td id="footerVehicle" class="money-col" data-sort="{vehicle_cents / 100:.2f}">{esc(cents_to_euro(vehicle_cents))}&nbsp;&euro;</td>',
        f'<td id="footerPersons" class="money-col" data-sort="{person_cents / 100:.2f}">{esc(cents_to_euro(person_cents))}&nbsp;&euro;</td>',
        f'<td id="footerTotal" class="money-col" data-sort="{total_cents / 100:.2f}">{esc(cents_to_euro(total_cents))}&nbsp;&euro;</td>',
        f'<td id="footerTax" class="money-col" data-sort="{tax_cents / 100:.2f}">{esc(cents_to_euro(tax_cents))}&nbsp;&euro;</td>',
    ]) + "</tr>"

    plate_buttons_html = ""
    if len(plate_values) > 1:
        plate_buttons_html = "".join(
            f'<button class="plate-btn {plate_color_class(plate, plate_colors)}" type="button" data-plate="{esc(plate, quote=True)}" title="Kennzeichen {esc(plate, quote=True)} markieren">{esc(plate)}</button>'
            for plate in plate_values
        )
    error_link_html = ""
    if cancelled_count:
        storno_controls_html = '<button id="stornoSimulationToggle" class="filter-btn sim-filter-btn" type="button" aria-pressed="false" title="Simulation: so rechnen, als gäbe es keine Stornos.">Storno-Simulation</button>'
    else:
        storno_note = "Keine Stornos bekannt; bei reiner PDF-Auswertung liegen keine Stornomails vor." if not MBOX_INDEX.exists() else "Keine Stornos bekannt und keine Stornomails gefunden."
        storno_controls_html = f'<span class="toolbar-warning" title="{esc(storno_note, quote=True)}">{esc(storno_note)}</span>'

    stats_summary = (
        f"Tabelle insgesamt: {len(rows)} PDF-Zeilen. "
        f"Davon endg&uuml;ltige Fahrkarten: {len(active_rows)}. "
        f"Stornierte endg&uuml;ltige Fahrkarten: {cancelled_count}. "
        f"Gez&auml;hlte Fahrkarten: {len(counted_rows)}. "
        f"Ausgegraute &auml;ltere Dubletten: {duplicate_old_count}."
    )
    ports_summary = (
        "Abfahrtsh&auml;fen: " + count_summary(counted_rows, "dep_port") + "<br>" +
        "Zielh&auml;fen: " + count_summary(counted_rows, "dest_port")
    )
    plates_summary = "Kennzeichen: " + plate_count_summary(counted_rows)
    summary_paras = f"<p>{stats_summary}</p><p>{ports_summary}</p><p>{plates_summary}</p>"
    calendar_html = build_calendar_html(counted_rows, present_card_types)

    body_html = "\n".join(body_rows)
    main_body = f'''<main style="--num-col-ch:{num_col_chars}">
<div class="brand"><img class="app-logo" src="wdr_ticket_logo.svg" alt=""><h1>WDR eTicket-Zeiten</h1></div>
<p class="meta">{len(rows)} PDF-Dateien ausgewertet. {len(active_rows)} endg&uuml;ltige Fahrkarten, davon {cancelled_count} storniert und {len(counted_rows)} gez&auml;hlt; {duplicate_old_count} &auml;ltere Dubletten ausgegraut. {len(wdr_error_rows)} WDR-Fehlerfahrkarten ohne Reisedaten ausgelagert. {missing_time} gez&auml;hlte Zeilen mit Datum aber ohne Uhrzeit im PDF-Text. {preview_count} JPG-Vorschauen erzeugt. {mail_count} Mailzustellungen endg&uuml;ltiger Zeilen zugeordnet. Erstellt am {esc(now)}.</p>
<div class="toolbar"><span class="column-wrap"><button id="columnsToggle" class="filter-btn" type="button" aria-expanded="false" title="Spalten ein- und ausblenden.">Spalten</button><button id="compactToggle" class="filter-btn" type="button" onclick="document.body.classList.toggle('compact'); if(window.syncWdrHeaderWrapping){{window.syncWdrHeaderWrapping();}}" title="Kompakte Ansicht ohne Zusatzspalten umschalten.">Kompakt</button><button id="layoutReset" class="filter-btn" type="button" onclick="localStorage.removeItem('wdrTicketColumnWidths'); localStorage.removeItem('wdrTicketHiddenColumns'); localStorage.removeItem('wdrTicketSort'); document.querySelectorAll('col,th,td,table').forEach(function(e){{e.style.width='';e.style.minWidth='';e.style.maxWidth='';}}); if(window.syncWdrHeaderWrapping){{window.syncWdrHeaderWrapping();}}" title="Spaltenbreiten, sichtbare Spalten und Sortierung zurücksetzen.">Layout zurücksetzen</button><span id="columnsPanel" class="column-panel" role="menu" aria-label="Spaltenauswahl"></span></span><input id="q" class="search" type="search" placeholder="Suchen" aria-label="Tabelle filtern"><button id="anonToggle" class="filter-btn anon-filter-btn" type="button" aria-pressed="false" title="Kennzeichen, Buchungsnummern und interne Nummern für Bildschirm, Screenshot und PDF-Druck dunkel abdecken.">Anonymisieren</button><button id="finalToggle" class="filter-btn" type="button" aria-pressed="false" title="Nur endg&uuml;ltige Fahrkarten anzeigen; &auml;ltere Dubletten ausblenden.">Endg&uuml;ltige Fahrkarten anzeigen</button>{storno_controls_html}{plate_buttons_html}<button class="filter-btn" type="button" onclick="window.print()" title="Auswertung über den Browser als PDF drucken oder speichern.">PDF</button><a class="filter-btn" href="website/exports.html" target="_blank" rel="noopener" title="Exportseite mit verlinkten Dateien öffnen.">Exporte</a><a class="filter-btn calendar-link" href="eticket_kalender.html" target="_blank" rel="noopener">Kalender anzeigen</a></div>
<div class="table-wrap">
<table id="tickets">
{colgroup}
<thead><tr>{headers}</tr></thead>
<tbody>{body_html}</tbody>
<tfoot>{footer}</tfoot>
</table>
</div>
<div class="after-summary">{summary_paras}</div>
<div id="headerMenu" class="header-menu" role="menu" aria-hidden="true"></div>
<div id="resetToast" class="reset-toast" aria-hidden="true">Spaltenbreiten zur&uuml;ckgesetzt</div>
</main>
<script>{main_js()}</script><script>{anonymize_js()}</script>'''
    OUTPUT.write_text(html_page("WDR eTicket-Zeiten", main_body), encoding="utf-8")
    SAMPLE_OUTPUT.write_text(html_page("WDR eTicket-Musterauswertung", main_body + f"<script>{sample_last20_js()}</script>"), encoding="utf-8")

    calendar_body = f'''<main>
<div class="brand"><img class="app-logo" src="wdr_ticket_logo.svg" alt=""><h1>WDR eTicket-Kalender</h1></div>
<p class="meta">Markiert sind Abfahrts- und R&uuml;ckfahrtstage der gez&auml;hlten, nicht stornierten Fahrkarten. <a href="eticket_zeiten_tabelle.html">Zur&uuml;ck zur Tabelle</a> <button class="filter-btn" type="button" onclick="window.print()" title="Kalender über den Browser als PDF drucken oder speichern.">PDF</button></p>
<section class="calendar-report">{calendar_html}</section>
<script>{calendar_js()}</script>
</main>'''
    CALENDAR_OUTPUT.write_text(html_page("WDR eTicket-Kalender", calendar_body), encoding="utf-8")
    sample_calendar_html = build_calendar_html(counted_rows[-20:], present_card_types)
    sample_calendar_body = f'''<main>
<div class="brand"><img class="app-logo" src="wdr_ticket_logo.svg" alt=""><h1>WDR eTicket-Musterkalender</h1></div>
<p class="meta">Musterkalender: letzte 20 gezählte, nicht stornierte Fahrkarten. <a href="eticket_zeiten_muster20.html">Musterauswertung anzeigen</a> <button class="filter-btn" type="button" onclick="window.print()">PDF</button></p>
<section class="calendar-report">{sample_calendar_html}</section>
<script>{calendar_js()}</script>
</main>'''
    CALENDAR_SAMPLE_OUTPUT.write_text(html_page("WDR eTicket-Musterkalender", sample_calendar_body), encoding="utf-8")

    error_rows_html = ""
    for r in wdr_error_rows:
        pdf_link = f'<a href="{esc(pdf_uri(r["pdf"]), quote=True)}" target="_blank" rel="noopener">{esc(r["source"])}</a>'
        error_rows_html += "<tr>" + "".join([
            text_cell(r.get("booking", ""), r.get("booking", "")),
            mail_cell(r.get("mail_day", ""), r.get("mail_time", ""), r.get("mail_href", ""), r.get("mail_body", "")),
            text_cell(r.get("dep_port", ""), r.get("dep_port", "").lower()),
            text_cell(r.get("dest_port", ""), r.get("dest_port", "").lower()),
            f'<td>{pdf_link}</td>',
            currency_cell(r.get("total", ""), sort_decimal(r.get("total", ""))),
            currency_cell(r.get("tax", ""), sort_decimal(r.get("tax", ""))),
        ]) + "</tr>"
    error_body = f'''<main>
<div class="brand"><img class="app-logo" src="wdr_ticket_logo.svg" alt=""><h1>WDR-Fehlerfahrkarten</h1></div>
<p class="meta">Diese Fahrkarten enthalten im PDF keine Reisedaten, z.B. nur &bdquo;&minus; , den&ldquo; und &bdquo;&minus; Uhr&ldquo;. Sie werden nicht gez&auml;hlt und nicht in der Haupttabelle angezeigt. <a href="eticket_zeiten_tabelle.html">Zur&uuml;ck zur Tabelle</a></p>
<div class="table-wrap"><table><thead><tr>{th("Buchungsnummer", "text")}{th("Mailzustellung", "text")}{th("Abfahrtshafen", "text")}{th("Zielhafen", "text")}<th>PDF</th>{th("Gesamtpreis", "number")}{th("MwSt", "number")}</tr></thead><tbody>{error_rows_html}</tbody></table></div>
</main>'''
    ERROR_OUTPUT.write_text(html_page("WDR-Fehlerfahrkarten", error_body), encoding="utf-8")

    scatter_body = f'''<main>
<div class="brand"><img class="app-logo" src="wdr_ticket_logo.svg" alt=""><h1>WDR eTicket-Preisgrafik</h1></div>
<p class="meta"><a href="eticket_zeiten_tabelle.html">Zurück zur Tabelle</a></p>
<section class="chart-panel">{build_price_scatter_html(counted_rows)}</section>
</main>'''
    SCATTER_OUTPUT.write_text(html_page("WDR eTicket-Preisgrafik", scatter_body), encoding="utf-8")

    risk_body = f'''<main>
<div class="brand"><img class="app-logo" src="wdr_ticket_logo.svg" alt=""><h1>WDR eTicket-Storno-Risiko</h1></div>
<p class="meta"><a href="eticket_zeiten_tabelle.html">Zurück zur Tabelle</a></p>
{build_risk_html(counted_rows, MBOX_INDEX.exists())}
</main>'''
    RISK_OUTPUT.write_text(html_page("WDR eTicket-Storno-Risiko", risk_body), encoding="utf-8")

    export_summary = {
        "erstellt": now,
        "pdf_zeilen": len(rows),
        "wdr_fehlerfahrkarten": len(wdr_error_rows),
        "endgueltige_fahrkarten": len(active_rows),
        "storniert": cancelled_count,
        "gezaehlt": len(counted_rows),
        "aeltere_dubletten": duplicate_old_count,
        "mit_datum_ohne_uhrzeit": missing_time,
        "summe_gesamtpreis": cents_to_euro(total_cents),
        "summe_mwst": cents_to_euro(tax_cents),
        "quelle_pdf_ordner": str(INPUT_DIR),
        "quelle_mbox_index": str(MBOX_INDEX) if MBOX_INDEX.exists() else "",
    }
    write_exports(rows, wdr_error_rows, export_summary, args)
    write_finance_exports(counted_rows, export_summary)
    audit_body = f'''<main><div class="brand"><img class="app-logo" src="wdr_ticket_logo.svg" alt=""><h1>WDR eTicket-Prüfansicht</h1></div>{build_audit_html(rows)}</main>'''
    AUDIT_OUTPUT.write_text(html_page("WDR eTicket-Prüfansicht", audit_body), encoding="utf-8")
    write_pdf_exports(counted_rows, export_summary)

    print(f"HTML erstellt: {OUTPUT}")
    print(f"Kalender erstellt: {CALENDAR_OUTPUT}")
    print(f"WDR-Fehlerseite erstellt: {ERROR_OUTPUT}")
    print(f"Exporte erstellt: {EXPORT_DIR}")
    print(f"PDF-Auswertung erstellt: {TABLE_PDF_OUTPUT}")
    print(f"PDF-Kalender erstellt: {CALENDAR_PDF_OUTPUT}")
    print(f"Preisgrafik erstellt: {SCATTER_OUTPUT}")
    print(f"Storno-Risiko erstellt: {RISK_OUTPUT}")
    print(f"Prüfansicht erstellt: {AUDIT_OUTPUT}")
    print(f"PDF-Dateien: {len(rows)}")
    print(f"Endgültige Fahrkarten: {len(active_rows)}")
    print(f"Stornierte endgültige Fahrkarten: {cancelled_count}")
    print(f"Aktiv gezählte Buchungen: {len(counted_rows)}")
    print(f"Ausgegraute ältere Dubletten: {duplicate_old_count}")
    print(f"JPG-Vorschauen: {preview_count}")
    print(f"Mailzustellungen aktiver Zeilen: {mail_count}")
    print(f"Ohne Abfahrtsdatum: {missing_date}")
    print(f"Mit Datum ohne Uhrzeit: {missing_time}")
    print(f"Summe Gesamtpreis: {cents_to_euro(total_cents)}")
    print(f"Summe MwSt: {cents_to_euro(tax_cents)}")


if __name__ == "__main__":
    main()
