<?php

declare(strict_types=1);

/**
 * Config-Wert holen: config('smtp.host') oder config() für das ganze Array.
 */
function config(?string $key = null, mixed $default = null): mixed
{
    $value = $GLOBALS['__config'];
    if ($key === null) {
        return $value;
    }
    foreach (explode('.', $key) as $part) {
        if (!is_array($value) || !array_key_exists($part, $value)) {
            return $default;
        }
        $value = $value[$part];
    }
    return $value;
}

/**
 * Deploy-Selbstprüfung: harte Konfigurationsprobleme, die den Betrieb kaputt oder
 * unsicher machen. Leeres Array = alles gesetzt. Genutzt von bin/preflight.php und
 * als Not-Aus in den Formular-Actions: fehlt config/config.php, läuft bootstrap.php
 * auf config.example.php weiter — deren app_secret steht öffentlich im Repo, die
 * Time-Trap wäre also wertlos und der Mailversand würde ohnehin scheitern.
 */
function config_problems(): array
{
    $problems = [];

    if (!is_file(ROOT_PATH . '/config/config.php')) {
        $problems[] = 'config/config.php fehlt — die Seite läuft auf der Vorlage config.example.php.';
    }

    $secret = (string) config('app_secret');
    if ($secret === '' || $secret === 'CHANGE_ME' || strlen($secret) < 32) {
        $problems[] = 'app_secret fehlt oder ist zu kurz/Platzhalter (neu: php -r "echo bin2hex(random_bytes(32));").';
    }

    $smtp = (array) config('smtp', []);
    foreach (['host', 'port', 'username', 'password', 'from', 'to'] as $key) {
        if (empty($smtp[$key])) {
            $problems[] = "smtp.{$key} ist nicht gesetzt.";
        }
    }
    if (in_array((string) ($smtp['password'] ?? ''), ['POSTFACH_PASSWORT', 'BREVO_SMTP_KEY', 'CHANGE_ME'], true)) {
        $problems[] = 'smtp.password ist noch der Platzhalter aus der Vorlage.';
    }

    return $problems;
}

/**
 * HTML-Escaping — für JEDE dynamische Ausgabe verwenden.
 */
function e(string|int|float|null $value): string
{
    return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}

/**
 * Interner Link aus Slug: url('fussball') → '/fussball', url('') → '/'.
 */
function url(string $slug = ''): string
{
    return '/' . trim($slug, '/');
}

/**
 * Absolute URL für Canonical, OG und Sitemap.
 */
function abs_url(string $slug = ''): string
{
    return rtrim((string) config('base_url'), '/') . url($slug);
}

/**
 * Asset-Pfad mit Cache-Busting über filemtime: asset('css/tokens.css').
 *
 * $bust = false gibt den Pfad ohne ?v= zurück. Nötig für Ressourcen, die auch
 * aus statischem CSS heraus angefordert werden (Fonts via @font-face in
 * base.css): eine abweichende Query macht daraus für den Browser eine zweite
 * URL, der Preload passt dann nicht zum @font-face-Request und läuft ins Leere
 * („preloaded but not used"). Geänderte Font-Dateien bekommen deshalb einen
 * neuen Dateinamen statt einer neuen Version.
 */
function asset(string $path, bool $bust = true): string
{
    $path = ltrim($path, '/');
    if (!$bust) {
        return '/assets/' . $path;
    }
    $file = PUBLIC_PATH . '/assets/' . $path;
    $version = is_file($file) ? (string) filemtime($file) : '0';
    return '/assets/' . $path . '?v=' . $version;
}

/**
 * Bootstrap-Icon als Inline-SVG ausgeben (lokal aus public/assets/icons/<name>.svg).
 * Standard: dekorativ (aria-hidden). Mit $label wird es als beschriftetes Bild
 * (role="img") ausgegeben. Größe/Farbe steuert CSS über die Klasse .icon.
 * Quelle: Bootstrap Icons (MIT) — neue Icons via bin/icons-add.php hinzufügen.
 */
function icon(string $name, string $class = '', ?string $label = null): string
{
    static $cache = [];
    if (!array_key_exists($name, $cache)) {
        $file = PUBLIC_PATH . '/assets/icons/' . basename($name) . '.svg';
        $cache[$name] = is_file($file) ? trim((string) file_get_contents($file)) : '';
    }
    if ($cache[$name] === '') {
        return '';
    }
    $attrs = 'class="icon' . ($class !== '' ? ' ' . e($class) : '') . '"';
    $attrs .= $label !== null && $label !== ''
        ? ' role="img" aria-label="' . e($label) . '"'
        : ' aria-hidden="true" focusable="false"';

    return preg_replace('/<svg\b/', '<svg ' . $attrs, $cache[$name], 1);
}

/**
 * JSON-Datendatei laden (data/<name>.json) mit Request-weitem Cache.
 * Wirft bei kaputtem JSON — Datenfehler sollen laut scheitern, nicht leise.
 */
function json_load(string $name): array
{
    static $cache = [];
    if (!array_key_exists($name, $cache)) {
        $file = DATA_PATH . '/' . $name . '.json';
        if (!is_file($file)) {
            return [];
        }
        $cache[$name] = json_decode((string) file_get_contents($file), true, 512, JSON_THROW_ON_ERROR);
    }
    return $cache[$name];
}

/**
 * Intrinsische Bildmaße als ' width="…" height="…"' für CLS-freie <img>-Tags.
 * $rel = Pfad relativ zu public/assets/. PNG/JPG via getimagesize, SVG via
 * viewBox (Fallback: width/height-Attribute, Einheiten werden ignoriert —
 * fürs Seitenverhältnis reicht die Zahl). Liefert '' wenn nicht bestimmbar.
 */
function img_intrinsic_attrs(string $rel): string
{
    static $cache = [];
    if (!isset($cache[$rel])) {
        $file = PUBLIC_PATH . '/assets/' . ltrim($rel, '/');
        $w = $h = 0;
        if (is_file($file)) {
            if (str_ends_with(strtolower($file), '.svg')) {
                $svg = (string) file_get_contents($file);
                if (preg_match('/viewBox="\s*[\d.-]+[\s,]+[\d.-]+[\s,]+([\d.]+)[\s,]+([\d.]+)/', $svg, $m)) {
                    [$w, $h] = [(int) round((float) $m[1]), (int) round((float) $m[2])];
                } elseif (preg_match('/<svg\b[^>]*\bwidth="([\d.]+)[a-z%]*"[^>]*\bheight="([\d.]+)[a-z%]*"/s', $svg, $m)) {
                    [$w, $h] = [(int) round((float) $m[1]), (int) round((float) $m[2])];
                }
            } else {
                [$w, $h] = (getimagesize($file) ?: [0, 0]);
            }
        }
        $cache[$rel] = ($w > 0 && $h > 0) ? ' width="' . $w . '" height="' . $h . '"' : '';
    }
    return $cache[$rel];
}

/**
 * Responsive Bild-Varianten erzeugen (GD, läuft auch auf Shared Hosting) —
 * schreibt <destBase>-<breite>.jpg je Breite, skaliert nie hoch. Geteilte
 * Kernlogik von bin/img-resize.php (CLI-Wrapper) und bin/stadionzeitung-add.php
 * (ruft das direkt im Loop auf, ohne 22× einen Subprozess zu starten).
 * Rückgabe: [['width' => int, 'height' => int, 'file' => string, 'bytes' => int], …].
 */
function resize_image_variants(string $src, string $destBase, array $widths, int $quality = 78): array
{
    $image = match (strtolower(pathinfo($src, PATHINFO_EXTENSION))) {
        'jpg', 'jpeg' => imagecreatefromjpeg($src),
        'png'         => imagecreatefrompng($src),
        'webp'        => imagecreatefromwebp($src),
        default       => null,
    };
    if (!$image) {
        throw new RuntimeException("Kann Quelle nicht lesen: {$src}");
    }

    $srcW = imagesx($image);
    $srcH = imagesy($image);

    $out = [];
    foreach ($widths as $width) {
        $w = min((int) $width, $srcW); // nie hochskalieren
        $h = (int) round($srcH * $w / $srcW);
        $frame = imagecreatetruecolor($w, $h);
        imagecopyresampled($frame, $image, 0, 0, 0, 0, $w, $h, $srcW, $srcH);
        $file = "{$destBase}-{$w}.jpg";
        imagejpeg($frame, $file, $quality);
        $out[] = ['width' => $w, 'height' => $h, 'file' => $file, 'bytes' => (int) filesize($file)];
    }

    return $out;
}

/**
 * Freigestelltes PNG auf den Inhalt zuschneiden (Alpha-Bounding-Box + kleiner
 * Rand) — Stadionzeitung-Cover: Export-Tools lassen um eine Freistellung oft
 * viel leeren, transparenten Rand stehen (z. B. 17% oben, ~18–20% seitlich),
 * dadurch wirkt das Spielerfoto auf dem Cover kleiner als nötig. $padding =
 * Rand um die erkannte Fläche, Anteil der jeweiligen Kantenlänge (0.02 = 2%).
 * Liefert false bei Fehlern (kein lesbares PNG, komplett transparent) statt
 * zu werfen — das Spielerfoto ist ein "nice to have" fürs Cover, ein
 * Fehlschlag darf die Ausgabe nicht scheitern lassen.
 */
function crop_transparent_png_to_content(string $src, string $dest, float $padding = 0.02): bool
{
    $im = @imagecreatefrompng($src);
    if (!$im) {
        return false;
    }
    imagealphablending($im, false);
    imagesavealpha($im, true);
    $w = imagesx($im);
    $h = imagesy($im);

    $minX = $w;
    $minY = $h;
    $maxX = 0;
    $maxY = 0;
    $found = false;
    for ($y = 0; $y < $h; $y++) {
        for ($x = 0; $x < $w; $x++) {
            $alpha = (imagecolorat($im, $x, $y) >> 24) & 0x7F; // 0=opak, 127=transparent
            if ($alpha < 110) {
                $found = true;
                $minX = min($minX, $x);
                $maxX = max($maxX, $x);
                $minY = min($minY, $y);
                $maxY = max($maxY, $y);
            }
        }
    }
    if (!$found) {
        return false;
    }

    $padX = (int) round(($maxX - $minX) * $padding);
    $padY = (int) round(($maxY - $minY) * $padding);
    $cropX = max(0, $minX - $padX);
    $cropY = max(0, $minY - $padY);
    $cropW = min($w - $cropX, $maxX - $minX + 1 + 2 * $padX);
    $cropH = min($h - $cropY, $maxY - $minY + 1 + 2 * $padY);

    $out = imagecreatetruecolor($cropW, $cropH);
    imagealphablending($out, false);
    imagesavealpha($out, true);
    $transparent = imagecolorallocatealpha($out, 0, 0, 0, 127);
    imagefill($out, 0, 0, $transparent);
    imagecopy($out, $im, 0, 0, $cropX, $cropY, $cropW, $cropH);

    return imagepng($out, $dest) && is_file($dest);
}

/**
 * QR-Code als PNG erzeugen (Stadionzeitung-Cover: Link zur digitalen Ausgabe).
 * Nutzt die manuell vendorte "PHP QR Code"-Bibliothek (vendor-manual/phpqrcode,
 * GD-basiert, kein neues PHP-Modul, kein Composer) statt eines Laufzeit-Diensts.
 * Die Bibliothek ist alter PHP-5-Code und meldet auf PHP 8 pro Aufruf ein paar
 * Deprecation-Notices zur Parameter-Reihenfolge — schon beim `require` (nicht
 * erst beim Aufruf), deshalb error_reporting() um beides herum runtergesetzt.
 * Genau dasselbe Muster wie bei der vendorten Files-Gallery (siehe
 * public/dateien/_filesconfig.php, dort `display_errors = 0`).
 * QR ist ein "nice to have" auf dem Cover: Rückgabe false statt Exception,
 * ein Fehlschlag darf die Ausgabe nicht scheitern lassen.
 *
 * $scale = Pixel pro QR-Modul. Default 6 ergibt gut 200px — richtig für die
 * Stadionzeitungs-Seiten, viel zu klein für Druckmaterial. Für Plakate und
 * Aushänge deutlich höher gehen (siehe bin/qr.php).
 */
function generate_qr_png(string $data, string $destPath, int $scale = 6): bool
{
    $lib = ROOT_PATH . '/vendor-manual/phpqrcode/phpqrcode.php';
    if (!is_file($lib)) {
        return false;
    }
    $previous = error_reporting(E_ALL & ~E_DEPRECATED);
    try {
        require_once $lib;
        QRcode::png($data, $destPath, QR_ECLEVEL_M, max(1, $scale), 2);
    } catch (Throwable) {
        return false;
    } finally {
        error_reporting($previous);
    }
    return is_file($destPath);
}

/**
 * Anzeigewerte zu einem Anstoß. $kickoff = Zeit ohne Zonenangabe aus
 * data/matchcenter.json (z. B. 2026-08-01T14:00), gelesen als Europe/Berlin —
 * PHP läuft hier global auf UTC, deshalb explizit. Liefert:
 *   iso        maschinenlesbar mit Zone (für <time datetime> und den JS-Ticker)
 *   countdown  „heute, 14:00 Uhr" | „morgen, 14:00 Uhr" | „in 6 Tagen" | „in 3 Wochen"
 * Unlesbarer Wert → beide Felder leer; bereits angestoßenes Spiel → countdown leer.
 *
 * $with_time = false lässt die Uhrzeit im Countdown weg („heute" statt
 * „heute, 00:00 Uhr") — für ganztägige Vereins-Events aus data/events.json, die
 * keine time-Angabe haben (siehe event-feature-Komponente). Spiele haben immer
 * einen Anstoß, für sie bleibt der Default.
 */
function match_when(string $kickoff, bool $with_time = true): array
{
    if ($kickoff === '') {
        return ['iso' => '', 'countdown' => ''];
    }
    $tz = new DateTimeZone('Europe/Berlin');
    try {
        $start = new DateTimeImmutable($kickoff, $tz);
    } catch (Exception) {
        return ['iso' => '', 'countdown' => ''];
    }
    $now = new DateTimeImmutable('now', $tz);
    if ($start <= $now) {
        return ['iso' => $start->format('c'), 'countdown' => ''];
    }
    // Kalendertage zählen, nicht 24-Stunden-Blöcke: ein Anstoß morgen um 14 Uhr
    // ist „morgen", auch wenn es nur 20 Stunden hin sind.
    $days = (int) $now->setTime(0, 0)->diff($start->setTime(0, 0))->format('%a');
    $time = $with_time ? ', ' . $start->format('H:i') . ' Uhr' : '';
    return [
        'iso'       => $start->format('c'),
        'countdown' => match (true) {
            $days === 0 => 'heute' . $time,
            $days === 1 => 'morgen' . $time,
            $days < 14  => 'in ' . $days . ' Tagen',
            default     => 'in ' . (int) round($days / 7) . ' Wochen',
        },
    ];
}

/**
 * Deutsches Langdatum aus einem ISO-Datum (YYYY-MM-DD), z. B. "13. September 2025".
 * Kein IntlDateFormatter im Projekt (kein ext-intl-Vorbild) — feste Monatsnamen
 * reichen für einsprachiges Deutsch. Unlesbares Datum → Rohwert zurück.
 */
function german_date(string $isoDate): string
{
    static $months = [
        1 => 'Januar', 2 => 'Februar', 3 => 'März', 4 => 'April', 5 => 'Mai', 6 => 'Juni',
        7 => 'Juli', 8 => 'August', 9 => 'September', 10 => 'Oktober', 11 => 'November', 12 => 'Dezember',
    ];
    $date = DateTimeImmutable::createFromFormat('Y-m-d', $isoDate);
    if ($date === false) {
        return $isoDate;
    }
    return (int) $date->format('j') . '. ' . $months[(int) $date->format('n')] . ' ' . $date->format('Y');
}

/**
 * Wie german_date(), aber für mehrtägige Events (data/events.json: date_end).
 * Gleicher Monat/Jahr → "4.–6. September 2026"; unterschiedlicher Monat →
 * "28. August – 3. September 2026"; unterschiedliches Jahr → volles Datum beidseitig.
 * $end === null oder === $start → einfaches german_date().
 */
function german_date_range(string $start, ?string $end): string
{
    if ($end === null || $end === '' || $end === $start) {
        return german_date($start);
    }
    $startDate = DateTimeImmutable::createFromFormat('Y-m-d', $start);
    $endDate = DateTimeImmutable::createFromFormat('Y-m-d', $end);
    if ($startDate === false || $endDate === false) {
        return german_date($start);
    }
    if ($startDate->format('Y') !== $endDate->format('Y')) {
        return german_date($start) . ' – ' . german_date($end);
    }
    if ($startDate->format('n') !== $endDate->format('n')) {
        static $months = [
            1 => 'Januar', 2 => 'Februar', 3 => 'März', 4 => 'April', 5 => 'Mai', 6 => 'Juni',
            7 => 'Juli', 8 => 'August', 9 => 'September', 10 => 'Oktober', 11 => 'November', 12 => 'Dezember',
        ];
        return (int) $startDate->format('j') . '. ' . $months[(int) $startDate->format('n')]
            . ' – ' . german_date($end);
    }
    return (int) $startDate->format('j') . '.–' . german_date($end);
}

/**
 * Kompaktes Datums-Chip fürs termine-list (Tag groß + Monat kurz), z. B. für
 * den 4. September: ['day' => '4', 'month' => 'Sep']. Unlesbares Datum → leere
 * Strings (Aufrufer zeigt dann einfach nichts an, kein Fataler Fehler).
 */
function german_date_chip(string $isoDate): array
{
    static $months = [
        1 => 'Jan', 2 => 'Feb', 3 => 'Mär', 4 => 'Apr', 5 => 'Mai', 6 => 'Jun',
        7 => 'Jul', 8 => 'Aug', 9 => 'Sep', 10 => 'Okt', 11 => 'Nov', 12 => 'Dez',
    ];
    $date = DateTimeImmutable::createFromFormat('Y-m-d', $isoDate);
    if ($date === false) {
        return ['day' => '', 'month' => ''];
    }
    return ['day' => $date->format('j'), 'month' => $months[(int) $date->format('n')]];
}

/**
 * Deutscher Wochentag aus einem ISO-Datum, z. B. "Freitag". Ergänzt
 * german_date()/german_date_chip() um die eine Angabe, die ein Tagesprogramm
 * braucht (Veranstaltungs-Programm: „Freitag, 4. September"). Kein
 * IntlDateFormatter im Projekt, deshalb feste Namen wie bei den Monaten.
 * Unlesbares Datum → leerer String, der Aufrufer lässt es dann weg.
 */
function german_weekday(string $isoDate): string
{
    static $days = [
        1 => 'Montag', 2 => 'Dienstag', 3 => 'Mittwoch', 4 => 'Donnerstag',
        5 => 'Freitag', 6 => 'Samstag', 7 => 'Sonntag',
    ];
    $date = DateTimeImmutable::createFromFormat('Y-m-d', $isoDate);
    if ($date === false) {
        return '';
    }
    return $days[(int) $date->format('N')];
}

/**
 * Anzeigename eines Wettbewerbs aus data/matchcenter.json. Die BFV-API kennt nur
 * „Liga" und „Freundschaft": „Liga" wird durch den echten Liganamen ersetzt — ohne
 * $league bleibt sie leer, damit Ligaspiele in den Mannschafts-Sektionen nicht
 * redundant beschriftet werden (die Sektion nennt die Liga schon). „Freundschaft"
 * wird ausgeschrieben, damit erkennbar ist, warum ein Testspiel nicht in der
 * Tabelle zählt.
 */
function match_competition_label(string $competition, string $league = ''): string
{
    return match ($competition) {
        'Liga'         => $league,
        'Freundschaft' => 'Freundschaftsspiel',
        default        => $competition,
    };
}

/**
 * Alle last_results über sämtliche Teams gemergt, mit team_key + team_name
 * angereichert (last_results[] hat das anders als upcoming[] nicht schon
 * eingebacken). Genutzt von /termine (Ergebnis-Zeilen) und /news
 * (Ergebnis-Karten) — derselbe Datenschnitt, zwei Darstellungen, nie doppelt
 * gepflegt. $mc = json_load('matchcenter').
 */
function matchcenter_recent_results(array $mc): array
{
    $results = [];
    foreach ($mc['teams'] ?? [] as $team) {
        foreach ($team['last_results'] ?? [] as $result) {
            $result['team_key'] = $team['key'] ?? '';
            $result['team_name'] = $team['name'] ?? '';
            $results[] = $result;
        }
    }
    return $results;
}

/**
 * Anzeigename eines Vereinsbereichs (department-Schlüssel aus data/events.json
 * bzw. der normalisierten Termine-Form). Genutzt von der termine-list-Komponente
 * (Badge) und vom Hero-Aufmacher (Slide-Beschriftung) — dieselben Labels, eine
 * Stelle. Unbekannter Schlüssel → Rohwert, damit ein neuer Bereich sichtbar
 * bleibt statt zu verschwinden.
 */
function department_label(string $department): string
{
    return match ($department) {
        'fussball' => 'Fußball',
        'turnen'   => 'Turnen',
        'verein'   => 'Verein',
        default    => $department,
    };
}

/**
 * Ist eine Veranstaltung veröffentlicht? Gleiche Semantik wie
 * stadionzeitung_is_published(): ein fehlendes status-Feld gilt als
 * veröffentlicht, damit ein Eintrag ohne das Feld nie unsichtbar wird.
 */
function veranstaltung_is_published(array $veranstaltung): bool
{
    return ($veranstaltung['status'] ?? 'veroeffentlicht') !== 'entwurf';
}

/**
 * Alle veröffentlichten Veranstaltungen (data/veranstaltungen.json), neueste
 * zuerst. EINZIGE Ladestelle für die Übersicht /veranstaltungen, den
 * Termine-Merge und die Sitemap — so fallen Entwürfe an einer Stelle heraus
 * statt an drei. Die Detailseite (app/pages/veranstaltung.php) lädt bewusst
 * ungefiltert: ein Entwurf muss unter seiner direkten URL lesbar bleiben,
 * sonst könnte ihn niemand gegenlesen.
 */
function veranstaltungen_load(): array
{
    $items = array_values(array_filter(
        json_load('veranstaltungen')['veranstaltungen'] ?? [],
        'veranstaltung_is_published'
    ));
    usort(
        $items,
        static fn (array $a, array $b): int => (string) ($b['date_start'] ?? '') <=> (string) ($a['date_start'] ?? '')
    );
    return $items;
}

/**
 * Steht ein Termin zum Bezugsdatum noch an? Maßgeblich ist das ENDE, nicht der
 * Beginn: ein mehrtägiges Fest (Sportfest, Freitag bis Sonntag) gehört am
 * Samstag noch zu den kommenden Terminen und nicht schon zu den vergangenen.
 * Gemeinsame Regel für events.json, veranstaltungen.json und die Entscheidung
 * „Ankündigung oder Rückblick" auf der Veranstaltungs-Detailseite.
 */
function termin_is_upcoming(array $entry, string $today): bool
{
    $end = (string) ($entry['date_end'] ?? '');
    $reference = $end !== '' ? $end : (string) ($entry['date_start'] ?? '');
    return $reference >= $today;
}

/**
 * Baut die zusammengeführten Termine-Listen (Fußball-Spiele + manuelle
 * Vereins-Events + Veranstaltungen mit eigener Detailseite) — gemeinsame
 * Grundlage für app/pages/termine.php UND die Stadionzeitungs-Live-Seite
 * „termine" (Phase 3), damit nie zwei Merge-Implementierungen auseinanderlaufen.
 *
 * DREI Quellen, jede genau einmal (siehe CLAUDE.md): Spiele aus
 * data/matchcenter.json, Termine ohne eigene Seite aus data/events.json,
 * Termine MIT eigener Seite aus data/veranstaltungen.json. Ein Fest darf
 * niemals in beiden JSON-Dateien stehen, sonst steht es doppelt in der Liste.
 * Rückgabe:
 *   upcoming          normalisierte Termine-Form, aufsteigend sortiert
 *   past              normalisierte Termine-Form, absteigend sortiert
 *   upcoming_matches  rohe $mc['upcoming'] (für sportsevent_nodes())
 *   events            rohe data/events.json (für event_schema())
 *   veranstaltungen   rohe data/veranstaltungen.json (für event_schema() mit
 *                     Slug, damit der Knoten seine eigene URL trägt)
 *
 * $asOf (YYYY-MM-DD, leer = heute): Bezugsdatum für „kommend"/„vergangen".
 * Für die Website immer heute (Default). Die Stadionzeitung braucht dagegen
 * einen FESTEN Stand zum Zeitpunkt des Ausgaben-Anlegens (bin/stadionzeitung-
 * add.php ruft mit dem Spieltag auf und friert das Ergebnis in
 * `termine_snapshot` ein) — eine spätere, viel später aufgerufene Ausgabe
 * soll weiter zeigen, was am Spieltag anstand, nicht was heute ansteht.
 */
function termine_build_lists(string $asOf = ''): array
{
    $events = json_load('events')['events'] ?? [];
    $veranstaltungen = veranstaltungen_load();
    $mc = json_load('matchcenter');
    $upcomingMatches = $mc['upcoming'] ?? [];
    $teams = $mc['teams'] ?? [];
    $today = $asOf !== '' ? $asOf : date('Y-m-d');
    // Bei einem Bezugsdatum in der Vergangenheit (Stadionzeitungs-Snapshot)
    // auch Spiele rausfiltern, die zu dem Zeitpunkt schon gelaufen wären —
    // $mc['upcoming'] selbst kennt nur den AKTUELLEN Sync-Stand, nicht den
    // historischen Stand am Spieltag der Ausgabe.
    if ($asOf !== '') {
        $upcomingMatches = array_values(array_filter(
            $upcomingMatches,
            static fn (array $m): bool => substr((string) ($m['kickoff'] ?? ''), 0, 10) >= $asOf
        ));
    }

    // team_key wird gebraucht, damit normalizeMatch() den Liganamen nachschlagen
    // kann (upcoming[]-Einträge haben das schon eingebacken, last_results[] nicht).
    $leagueByTeamKey = [];
    foreach ($teams as $team) {
        $leagueByTeamKey[$team['key'] ?? ''] = $team['league'] ?? '';
    }

    $pastMatches = matchcenter_recent_results($mc);

    $normalizeMatch = static function (array $m) use ($leagueByTeamKey): array {
        $isHome = !empty($m['is_home']);
        $league = $leagueByTeamKey[$m['team_key'] ?? ''] ?? '';
        $competition = match_competition_label((string) ($m['competition'] ?? ''), $league);
        $meta = trim(($isHome ? 'Heimspiel' : 'Auswärtsspiel') . ($competition !== '' ? ' · ' . $competition : ''));
        if (isset($m['goals_home'], $m['goals_away'])) {
            $meta .= ' · ' . (int) $m['goals_home'] . ':' . (int) $m['goals_away'];
        }
        return [
            'date'       => substr((string) ($m['kickoff'] ?? ''), 0, 10),
            'date_end'   => null,
            'title'      => trim(($m['home'] ?? '') . ' – ' . ($m['away'] ?? '')),
            'department' => 'fussball',
            'type'       => 'spiel',
            'meta'       => $meta,
            'href'       => url('matchcenter'),
            // Zusatzfelder für Darstellungen, die mehr als die Zeile brauchen
            // (Hero-Aufmacher rendert daraus die match-feature-Karte mit Wappen).
            // termine-list ignoriert sie.
            'kickoff'    => (string) ($m['kickoff'] ?? ''),
            'team_name'  => (string) ($m['team_name'] ?? ''),
            'league'     => $league,
            'raw'        => $m,
        ];
    };

    // $href bleibt für data/events.json leer (kein Ziel, die termine-list rendert
    // die Zeile dann als <div>). Veranstaltungen aus data/veranstaltungen.json
    // haben eine Detailseite und bekommen sie hier gesetzt — dieselbe
    // Normalisierung, nur mit Ziel.
    $normalizeEvent = static function (array $e, ?string $href = null): array {
        $meta = trim(
            (!empty($e['time']) ? $e['time'] . ' Uhr' : '')
            . (!empty($e['time']) && !empty($e['location']) ? ' · ' : '')
            . (string) ($e['location'] ?? '')
        );
        return [
            'date'       => (string) ($e['date_start'] ?? ''),
            'date_end'   => $e['date_end'] ?? null,
            'title'      => (string) ($e['title'] ?? ''),
            'department' => (string) ($e['department'] ?? 'verein'),
            'type'       => 'event',
            'meta'       => $meta,
            'href'       => $href,
            // s. Kommentar bei $normalizeMatch — Zusatzfelder für reichere
            // Darstellungen (Hero-Aufmacher), von termine-list ignoriert.
            'time'       => (string) ($e['time'] ?? ''),
            'location'   => (string) ($e['location'] ?? ''),
            'raw'        => $e,
        ];
    };

    $normalizeVeranstaltung = static fn (array $v): array => $normalizeEvent(
        $v,
        url('veranstaltungen/' . (string) ($v['slug'] ?? ''))
    );

    $isUpcoming = static fn (array $e): bool => termin_is_upcoming($e, $today);
    $isPast = static fn (array $e): bool => !termin_is_upcoming($e, $today);

    $upcoming = array_merge(
        array_map($normalizeMatch, $upcomingMatches),
        array_map($normalizeEvent, array_filter($events, $isUpcoming)),
        array_map($normalizeVeranstaltung, array_filter($veranstaltungen, $isUpcoming))
    );
    usort($upcoming, static fn (array $a, array $b): int => $a['date'] <=> $b['date']);

    $past = array_merge(
        array_map($normalizeMatch, $pastMatches),
        array_map($normalizeEvent, array_filter($events, $isPast)),
        array_map($normalizeVeranstaltung, array_filter($veranstaltungen, $isPast))
    );
    usort($past, static fn (array $a, array $b): int => $b['date'] <=> $a['date']);

    return [
        'upcoming'         => $upcoming,
        'past'             => $past,
        'upcoming_matches' => $upcomingMatches,
        // Roh-Quellen für event_schema(): getrennt, obwohl beide dieselben
        // Feldnamen tragen. Nur eine Veranstaltung hat eine eigene URL, und die
        // muss als @id/url in den Knoten — sonst beschreiben /termine und die
        // Detailseite dasselbe Fest als zwei unabhängige Events.
        'events'           => $events,
        'veranstaltungen'  => $veranstaltungen,
    ];
}

/**
 * Die nächsten Termine als Aufmacher für den Startseiten-Hero — Spiele und
 * Vereins-Events chronologisch gemischt, in genau der Reihenfolge, in der sie
 * anstehen. Baut auf termine_build_lists() auf, damit es weiterhin nur EINE
 * Merge-Logik für Termine gibt (siehe CLAUDE.md); hier kommt nur die
 * Aufbereitung für die Karte dazu:
 *   type       'spiel' | 'event' → bestimmt die Innenvariante der Karte
 *   label      Beschriftung des Slides („Nächstes Spiel · 1. Mannschaft").
 *              Nur der erste Slide sagt „nächstes", danach würde es bei zwei
 *              Spielen hintereinander schief klingen.
 *   href       Ziel des Slides: Spiel → /matchcenter, Veranstaltung mit eigener
 *              Seite → /veranstaltungen/<slug>, sonstiger Termin → /termine
 *              (data/events.json-Einträge haben keine Detailseite)
 *   link_text  visually-hidden Zusatz, damit der Linktext das Ziel nennt
 *   match      roher Matchcenter-Eintrag (für die match-feature-Komponente)
 *   event      roher events.json-Eintrag (für die event-feature-Komponente)
 * Keine Termine → leeres Array; der Hero rendert dann ohne Karte.
 */
function upcoming_highlights(int $limit = 3): array
{
    $items = [];
    foreach (array_slice(termine_build_lists()['upcoming'], 0, max(0, $limit)) as $i => $item) {
        $isMatch = ($item['type'] ?? '') === 'spiel';
        $lead = $isMatch
            ? ($i === 0 ? 'Nächstes Spiel' : 'Spiel')
            : ($i === 0 ? 'Nächster Termin' : 'Termin');
        $suffix = $isMatch
            ? (string) ($item['team_name'] ?? '')
            : department_label((string) ($item['department'] ?? 'verein'));

        // Termine mit eigener Detailseite bringen ihr Ziel schon aus
        // termine_build_lists() mit — dann zeigt die Karte dorthin statt auf die
        // Sammelseite. Spiele bleiben beim Matchcenter.
        $detailHref = !$isMatch ? (string) ($item['href'] ?? '') : '';

        $items[] = [
            'type'      => $isMatch ? 'spiel' : 'event',
            'label'     => $suffix !== '' ? $lead . ' · ' . $suffix : $lead,
            'href'      => $isMatch ? url('matchcenter') : ($detailHref !== '' ? $detailHref : url('termine')),
            'link_text' => $isMatch
                ? 'zum Matchcenter'
                : ($detailHref !== '' ? 'zur Veranstaltung' : 'zu allen Terminen'),
            'match'     => $isMatch ? ($item['raw'] ?? []) : null,
            'event'     => $isMatch ? null : ($item['raw'] ?? []),
            'team_name' => (string) ($item['team_name'] ?? ''),
            'league'    => (string) ($item['league'] ?? ''),
        ];
    }
    return $items;
}

/**
 * Ist eine Meldung veröffentlicht? Gleiche Semantik wie
 * veranstaltung_is_published()/stadionzeitung_is_published(): ein fehlendes
 * status-Feld gilt als veröffentlicht (Bestandsschutz).
 */
function news_is_published(array $item): bool
{
    return ($item['status'] ?? 'veroeffentlicht') !== 'entwurf';
}

/**
 * Hat eine Meldung eine eigene Detailseite (/news/<slug>)? DIE EINZIGE STELLE,
 * die das entscheidet — Feed-Verlinkung, Archiv, Sitemap und Preflight fragen
 * ausschließlich hier, damit die Antwort nicht an vier Orten auseinanderläuft.
 *
 * Maßgeblich ist der INHALT (`artikel`), nicht der `slug`: einen Slug schreibt
 * man beim Anlegen reflexartig mit, sonst entstünden versehentlich Seiten mit
 * zwei Sätzen Inhalt. Eine Kurzmeldung bleibt damit eine reine Feed-Zeile, so
 * wie es vor den Detailseiten für alle Meldungen war.
 */
function news_has_article(array $item): bool
{
    return !empty($item['slug']) && !empty($item['artikel']);
}

/**
 * Alle veröffentlichten Meldungen (data/news.json), neueste zuerst. EINZIGE
 * gefilterte Ladestelle — für den Feed, das Archiv und die Sitemap. Die
 * Detailseite (app/pages/news-artikel.php) lädt bewusst ungefiltert über
 * news_find(): ein Entwurf muss unter seiner direkten URL lesbar bleiben,
 * sonst könnte ihn niemand gegenlesen (wie bei veranstaltungen_load()).
 */
function news_load(): array
{
    $items = array_values(array_filter(
        json_load('news')['news'] ?? [],
        'news_is_published'
    ));
    usort($items, static fn (array $a, array $b): int => (string) ($b['date'] ?? '') <=> (string) ($a['date'] ?? ''));
    return $items;
}

/**
 * Eine Meldung über ihren Slug, UNGEFILTERT (Entwürfe eingeschlossen) — für die
 * Detailseite. Ohne Treffer null, der Aufrufer antwortet dann mit 404.
 */
function news_find(string $slug): ?array
{
    foreach (json_load('news')['news'] ?? [] as $item) {
        if (($item['slug'] ?? '') === $slug) {
            return $item;
        }
    }
    return null;
}

/**
 * Baut den gemischten News-Feed (redaktionelle Meldungen + Instagram-Posts +
 * Ergebnisse) — gemeinsame Grundlage für app/pages/news.php UND die
 * Stadionzeitungs-Live-Seite „news" (Phase 3). Rückgabe:
 *   items  normalisierte, absteigend sortierte Form. config('news.max_items')
 *          kappt NUR Instagram und Ergebnisse (die wachsen automatisch);
 *          redaktionelle Meldungen stehen immer alle drin
 *   news   veröffentlichte Meldungen aus data/news.json (für news_article_schema())
 *
 * Die Meldungen kommen über news_load(), also OHNE Entwürfe. Das gilt
 * absichtlich auch für den Stadionzeitungs-Snapshot
 * (stadionzeitung_build_snapshot()): der friert seinen Stand beim
 * Veröffentlichen ein und ist die einzige Stelle, die sich nicht mehr
 * zurücknehmen lässt — ein Entwurf darf dort nie landen.
 */
function news_build_feed(): array
{
    $news = news_load();
    $instagramPosts = json_load('instagram')['posts'] ?? [];
    $results = matchcenter_recent_results(json_load('matchcenter'));
    $maxItems = (int) config('news.max_items', 20);

    $normalizeNews = static function (array $n): array {
        // Nur Meldungen mit Artikeltext verlinken (news_has_article()) — eine
        // Kurzmeldung bleibt eine nicht verlinkte Zeile, news-feed.php rendert
        // sie ohne href als <div>. `link_text` liefert die sichtbare
        // Weiterlesen-Auszeichnung; gleiche Prop wie in upcoming_highlights(),
        // damit die Komponente die Quellen nicht kennen muss.
        $hasArticle = news_has_article($n);
        return [
            'date'      => (string) ($n['date'] ?? ''),
            'title'     => (string) ($n['title'] ?? ''),
            'text'      => (string) ($n['text'] ?? ''),
            'image'     => $n['image'] ?? null,
            'href'      => $hasArticle ? url('news/' . $n['slug']) : null,
            'link_text' => $hasArticle ? 'Artikel lesen' : null,
            'source'    => 'news',
        ];
    };

    $normalizeInstagram = static function (array $post): array {
        // Titel = erste Caption-Zeile, Text = Rest ab Zeile zwei. So steht die
        // erste Zeile nie doppelt da (Titel + kompletter Caption-Text waren
        // vorher beide sichtbar). Einzeilige Captions → Text leer, die
        // Anzeige lässt ihn dann weg.
        $caption = trim((string) ($post['caption'] ?? ''));
        [$firstLine, $rest] = array_pad(explode("\n", $caption, 2), 2, '');
        $firstLine = trim($firstLine);
        $title = $firstLine !== '' ? mb_substr($firstLine, 0, 120) : 'Instagram-Beitrag';
        if ($title !== $firstLine && $firstLine !== '') {
            $title .= '…'; // hart gekappt → nicht mitten im Wort enden lassen
        }
        return [
            'date'   => !empty($post['taken_at']) ? date('Y-m-d', (int) $post['taken_at']) : '',
            'title'  => $title,
            'text'   => trim($rest),
            'image'  => !empty($post['image'])
                ? ['src' => $post['image'], 'width' => 640, 'height' => 800, 'alt' => (string) ($post['alt'] ?? '')]
                : null,
            'href'   => (string) ($post['url'] ?? ''),
            'source' => 'instagram',
        ];
    };

    $normalizeResult = static function (array $r): array {
        $isHome = !empty($r['is_home']);
        $opponent = $isHome ? ($r['away'] ?? '') : ($r['home'] ?? '');
        $opponentCrest = $isHome ? ($r['away_crest'] ?? null) : ($r['home_crest'] ?? null);
        $outcomeLabel = match ($r['outcome'] ?? null) {
            'win'   => 'Sieg gegen',
            'loss'  => 'Niederlage gegen',
            'draw'  => 'Unentschieden gegen',
            default => 'Spiel gegen',
        };
        return [
            'date'   => substr((string) ($r['kickoff'] ?? ''), 0, 10),
            'title'  => trim("{$outcomeLabel} {$opponent}"),
            'text'   => trim(($r['team_name'] ?? '') . " · Endstand {$r['goals_home']}:{$r['goals_away']}"),
            'image'  => null,
            'crest'  => ['src' => $opponentCrest, 'name' => $opponent],
            'href'   => url('matchcenter'),
            'source' => 'ergebnis',
        ];
    };

    // Manche last_results-Einträge (z. B. Testspiele) haben nie ein erfasstes
    // Ergebnis — ohne Tore ist es keine News ("Ergebnis" ohne Ergebnis), raus damit.
    $resultsWithScore = array_filter(
        $results,
        static fn (array $r): bool => isset($r['goals_home'], $r['goals_away'])
    );

    // Gekappt werden NUR die maschinell gefüllten Quellen: Instagram und
    // Ergebnisse wachsen von allein weiter (Sync 2×/Tag, jedes Wochenende neue
    // Spiele) und dagegen schützt die Grenze. Redaktionelle Meldungen stehen
    // immer alle da — sie sind wenige, gewollt und sollen dauerhaft erreichbar
    // bleiben. Vorher fiel ein älterer Artikel irgendwann aus der Liste und war
    // von der Website aus gar nicht mehr verlinkt.
    $automatisch = array_merge(
        array_map($normalizeInstagram, $instagramPosts),
        array_map($normalizeResult, $resultsWithScore)
    );
    usort($automatisch, static fn (array $a, array $b): int => $b['date'] <=> $a['date']);

    $items = array_merge(
        array_map($normalizeNews, $news),
        array_slice($automatisch, 0, $maxItems)
    );
    usort($items, static fn (array $a, array $b): int => $b['date'] <=> $a['date']);

    return [
        'items' => $items,
        'news'  => $news,
    ];
}

/**
 * Ist eine Stadionzeitung-Ausgabe veröffentlicht? Ausgaben ohne status-Feld
 * (vor Einführung von Entwurf/Veröffentlicht) gelten als veröffentlicht —
 * sie waren es faktisch, das Feld kam später dazu.
 */
function stadionzeitung_is_published(array $issue): bool
{
    return ($issue['status'] ?? 'veroeffentlicht') !== 'entwurf';
}

/**
 * Die neueste VERÖFFENTLICHTE Ausgabe (oder null, wenn es keine gibt) — für die
 * Kurz-URL /stadionzeitung/aktuell, auf die gedruckte QR-Codes zeigen. Sortiert
 * selbst nach `date` und verlässt sich nicht auf die Reihenfolge in der Datei:
 * die stimmt zwar (bin/stadionzeitung-add.php sortiert absteigend), aber ein
 * gedruckter QR-Code darf nicht von einer Dateireihenfolge abhängen.
 */
function stadionzeitung_latest(): ?array
{
    $issues = array_values(array_filter(
        json_load('stadionzeitung')['issues'] ?? [],
        'stadionzeitung_is_published'
    ));
    if ($issues === []) {
        return null;
    }
    usort($issues, static fn (array $a, array $b): int => strcmp((string) ($b['date'] ?? ''), (string) ($a['date'] ?? '')));

    return $issues[0];
}

/**
 * Live-Daten einer Stadionzeitung-Ausgabe frisch berechnen: Tabellen aller
 * Mannschaften, letzte Ergebnisse, Vorschau (nächstes Spiel je Team nach dem
 * Spieltag) und News-Feed. Einzige Berechnungsstelle — der Viewer (Entwurf,
 * live bei jedem Aufruf) und bin/stadionzeitung-publish.php (friert genau
 * dieses Ergebnis als `snapshot`-Feld ein) rufen dieselbe Funktion auf,
 * damit Vorschau und eingefrorene Ausgabe nie auseinanderlaufen können.
 */
function stadionzeitung_build_snapshot(array $issue): array
{
    $date = (string) ($issue['date'] ?? '');
    $issueTeam = (string) ($issue['team_key'] ?? '');

    $tabellen = [];
    $ergebnisse = [];
    $vorschau = [];
    foreach (json_load('matchcenter')['teams'] ?? [] as $team) {
        $key = (string) ($team['key'] ?? '');
        if ($key === '') {
            continue;
        }
        $tabellen[$key] = [
            'name'   => (string) ($team['name'] ?? ''),
            'league' => (string) ($team['league'] ?? ''),
            'season' => (string) ($team['season'] ?? ''),
            'table'  => $team['table'] ?? [],
        ];

        // Nur Spiele mit erfasstem Ergebnis — ein Testspiel ohne Tore ist
        // kein Rückblick (dieselbe Regel wie im News-Feed).
        $withScore = array_values(array_filter(
            $team['last_results'] ?? [],
            static fn (array $r): bool => isset($r['goals_home'], $r['goals_away'])
        ));
        if ($withScore !== []) {
            $ergebnisse[] = [
                'team_key'  => $key,
                'team_name' => (string) ($team['name'] ?? ''),
                'league'    => (string) ($team['league'] ?? ''),
                // EIN letztes Ergebnis pro Team: mit drei Mannschaften plus
                // Matchcenter-Ticket passt mehr nicht zuverlässig auf die
                // A5-Seite (zwei pro Team liefen über die Blattkante).
                'matches'   => array_slice($withScore, 0, 1),
            ];
        }

        // Vorschau: das nächste Spiel dieser Mannschaft NACH dem Spieltag der
        // Ausgabe. Das Heft-Spiel selbst fliegt raus (das Cover zeigt es
        // schon); ein Spiel einer ANDEREN Mannschaft am selben Tag bleibt drin.
        foreach ($team['next_matches'] ?? [] as $match) {
            // Spielfreie Runden führt der BFV als Pseudo-Begegnung gegen
            // "SPIELFREI" — kein echtes Spiel, im Heft überspringen.
            if (($match['home'] ?? '') === 'SPIELFREI' || ($match['away'] ?? '') === 'SPIELFREI') {
                continue;
            }
            $matchDate = substr((string) ($match['kickoff'] ?? ''), 0, 10);
            if ($date !== '' && $matchDate < $date) {
                continue;
            }
            if ($matchDate === $date && $key === $issueTeam) {
                continue;
            }
            $vorschau[] = [
                'team_key'  => $key,
                'team_name' => (string) ($team['name'] ?? ''),
                'league'    => (string) ($team['league'] ?? ''),
                'match'     => $match,
            ];
            break;
        }
    }

    // Momente-Seite: die vier neuesten Instagram-Fotos (keine Videos — die
    // haben zwar ein Vorschaubild, aber ein Standbild ohne Kontext wirkt im
    // Heft wie ein kaputtes Foto).
    $momente = array_slice(array_values(array_filter(
        json_load('instagram')['posts'] ?? [],
        static fn (array $post): bool => empty($post['is_video']) && !empty($post['image'])
    )), 0, 4);

    return [
        'tabellen'   => $tabellen,
        'ergebnisse' => $ergebnisse,
        'vorschau'   => $vorschau,
        // Fünf statt sechs: mit den größeren Überschriften + CTA-Fuß braucht
        // die News-Seite den Platz, sonst klebt der CTA an der Blattkante.
        'news'       => array_slice(news_build_feed()['items'] ?? [], 0, 5),
        'momente'    => array_map(static fn (array $post): array => [
            'image' => (string) $post['image'],
            'alt'   => (string) ($post['alt'] ?? ''),
        ], $momente),
    ];
}

/**
 * Daten für die Live-Seiten einer Ausgabe: veröffentlichte Ausgaben rendern
 * aus ihrem eingefrorenen `snapshot` (eine gedruckte Publikation ändert sich
 * nicht mehr), Entwürfe rechnen bei jedem Aufruf frisch — so sieht Felix beim
 * Bauen immer den aktuellen Stand, und das Veröffentlichen friert genau
 * diesen Stand ein.
 */
function stadionzeitung_live_data(array $issue): array
{
    if (stadionzeitung_is_published($issue) && is_array($issue['snapshot'] ?? null)) {
        return $issue['snapshot'];
    }
    return stadionzeitung_build_snapshot($issue);
}

/**
 * Komponente rendern: component('hero', ['title' => …]).
 * Props werden als lokale Variablen extrahiert; Komponenten sind dumme Includes.
 */
function component(string $name, array $props = []): void
{
    extract($props, EXTR_SKIP);
    require APP_PATH . '/components/' . $name . '.php';
}

/**
 * Page-Datei ausführen: sie setzt $meta und emittiert ihren Body.
 * Rückgabe: [$meta, $html].
 */
function render_page(string $file): array
{
    $meta = [];
    ob_start();
    require $file;
    return [$meta, (string) ob_get_clean()];
}

/**
 * Signierten Zeitstempel für die Formular-Time-Trap erzeugen.
 */
function form_token(): string
{
    $ts = (string) time();
    return $ts . '.' . hash_hmac('sha256', $ts, (string) config('app_secret'));
}

/**
 * Time-Trap prüfen: Signatur gültig, älter als $min Sekunden, jünger als $max.
 * Obergrenze großzügig (24h), damit langsame oder lange offene Formulare nicht
 * grundlos abgewiesen werden; die Untergrenze fängt Sofort-Submits von Bots ab.
 */
function form_token_valid(string $token, int $min = 3, int $max = 86400): bool
{
    $parts = explode('.', $token);
    if (count($parts) !== 2) {
        return false;
    }
    [$ts, $sig] = $parts;
    if (!hash_equals(hash_hmac('sha256', $ts, (string) config('app_secret')), $sig)) {
        return false;
    }
    $age = time() - (int) $ts;
    return $age >= $min && $age <= $max;
}

/**
 * Client-IP für Rate-Limiting/Logging. Bewusst nur REMOTE_ADDR — X-Forwarded-For
 * ist ohne vertrauenswürdigen Proxy spoofbar und wird daher nicht ausgewertet.
 */
function client_ip(): string
{
    return (string) ($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
}

/**
 * Dateibasiertes Rate-Limit mit gleitendem Fenster (shared-hosting-sicher, kein
 * APCu/Redis nötig). Gibt true zurück und verbucht einen Treffer, solange in den
 * letzten $window Sekunden weniger als $max Treffer für $key gezählt wurden; sonst
 * false ohne Eintrag. Atomar via flock. Bei Datei-/IO-Fehler wird NICHT geblockt
 * (Verfügbarkeit vor Schutz). Verwaiste Zähler werden gelegentlich aufgeräumt.
 */
function rate_limit_ok(string $key, int $max, int $window): bool
{
    $dir = STORAGE_PATH . '/ratelimit';
    if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
        return true;
    }

    // Probabilistische GC: Zähler-Dateien, die seit >1 Tag nicht angefasst wurden, löschen.
    if (random_int(1, 100) === 1) {
        foreach (glob($dir . '/*.json') ?: [] as $stale) {
            if ((int) @filemtime($stale) < time() - 86400) {
                @unlink($stale);
            }
        }
    }

    $file = $dir . '/' . hash('sha256', $key) . '.json';
    $fh = @fopen($file, 'c+');
    if ($fh === false) {
        return true;
    }
    try {
        flock($fh, LOCK_EX);
        $raw = (string) stream_get_contents($fh);
        $hits = $raw !== '' ? (array) (json_decode($raw, true) ?: []) : [];
        $now = time();
        $hits = array_values(array_filter($hits, static fn ($t): bool => (int) $t > $now - $window));
        if (count($hits) >= $max) {
            return false;
        }
        $hits[] = $now;
        rewind($fh);
        ftruncate($fh, 0);
        fwrite($fh, (string) json_encode($hits));
        return true;
    } finally {
        flock($fh, LOCK_UN);
        fclose($fh);
    }
}

/**
 * EINZIGE Log-Schreibstelle des Projekts — Web wie CLI, alle Kanäle.
 *
 * $channel  Dateiname ohne Endung: mail | spam | instagram | matchcenter
 * $level    INFO (Normalbetrieb) | WARN (Auffälligkeit, Betrieb läuft weiter) |
 *           ERROR (Funktion ist ausgefallen)
 *
 * Format:  [2026-07-27T08:11:45+00:00] ERROR  Meldung
 *
 * Ein Ereignis = eine Zeile (Umbrüche werden ersetzt), damit die Logs grep- und
 * auswertbar bleiben und Meldungstexte keine Zeilen fälschen können. ERROR läuft
 * zusätzlich in eine gemeinsame `error.log` — eine Datei beantwortet die Frage
 * „ist gerade etwas kaputt?", die Kanal-Logs behalten den vollen Verlauf.
 *
 * Datensparsam: hier gehören keine Formularinhalte und keine Klartext-IPs hinein.
 * Übersicht über alle Kanäle: php bin/logs.php
 */
function log_write(string $channel, string $level, string $message): void
{
    $channel = preg_replace('/[^a-z0-9-]/', '', strtolower($channel)) ?: 'app';
    $level = strtoupper($level);
    if (!in_array($level, ['INFO', 'WARN', 'ERROR'], true)) {
        $level = 'INFO';
    }
    $message = trim((string) preg_replace('/\s*[\r\n]+\s*/', ' | ', $message));
    $stamp = date('c');

    $dir = STORAGE_PATH . '/logs';
    @file_put_contents($dir . '/' . $channel . '.log', "[{$stamp}] {$level}  {$message}\n", FILE_APPEND | LOCK_EX);

    if ($level === 'ERROR') {
        @file_put_contents($dir . '/error.log', "[{$stamp}] {$channel}  {$message}\n", FILE_APPEND | LOCK_EX);
    }
}

/**
 * Abgewiesenen Formular-Versuch protokollieren (storage/logs/spam.log) — reine
 * Beobachtbarkeit zum Tunen der Schwellen. Bewusst INFO: ein geblockter Bot ist
 * kein Fehler, sondern der Schutz bei der Arbeit. Datensparsam: nur ein gekürzter,
 * gesalzener IP-Hash, keine Klartext-IP/PII. $reason z. B. honeypot|token|ratelimit|links|daily-cap|replay.
 */
function log_spam(string $route, string $reason): void
{
    $ipHash = substr(hash_hmac('sha256', client_ip(), (string) config('app_secret')), 0, 12);
    log_write('spam', 'INFO', "{$route} {$reason} ip={$ipHash}");
}

/**
 * Not-Aus für die Formular-Actions: false, wenn die Konfiguration unvollständig ist
 * (dann liefe die Seite auf config.example.php — öffentliches app_secret, kein
 * SMTP-Passwort). Protokolliert den Grund einmal pro Versuch.
 */
function config_ready(string $route): bool
{
    $problems = config_problems();
    if ($problems === []) {
        return true;
    }
    log_write('mail', 'ERROR', "{$route}: Formular abgewiesen, Konfiguration unvollständig — " . implode(' ', $problems));
    return false;
}

/**
 * Formular-Mail über All-Inkl-SMTP verschicken — EINZIGE Versandstelle der Seite.
 * Alle Actions gehen hierdurch, damit Timeout, Verschlüsselung und Fehler-Logging
 * an genau einem Ort stehen.
 *
 * $opts: subject, body (Pflicht) · to, to_name (Default: smtp.to) ·
 *        reply_to ['email'=>…, 'name'=>…] · context (Präfix im Log).
 *
 * Gibt true/false zurück und wirft nie — Versandfehler landen in
 * storage/logs/mail.log und werden dem Besucher nie im Klartext gezeigt.
 * Timeout bewusst kurz (10 s): der Versand hängt im Request-Pfad, ein toter
 * SMTP-Server darf den Besucher nicht bis zur max_execution_time blockieren.
 */
function send_mail(array $opts): bool
{
    $smtp = (array) config('smtp', []);
    $context = (string) ($opts['context'] ?? 'Mailversand');

    try {
        $mail = new PHPMailer\PHPMailer\PHPMailer(true);
        $mail->isSMTP();
        $mail->Host = (string) $smtp['host'];
        $mail->Port = (int) $smtp['port'];
        $mail->SMTPAuth = true;
        $mail->SMTPSecure = PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS;
        $mail->Username = (string) $smtp['username'];
        $mail->Password = (string) $smtp['password'];
        $mail->CharSet = PHPMailer\PHPMailer\PHPMailer::CHARSET_UTF8;
        $mail->Timeout = 10;
        $mail->XMailer = ' '; // kein X-Mailer-Header (verrät sonst die PHPMailer-Version)

        $mail->setFrom((string) $smtp['from'], (string) ($smtp['from_name'] ?? ''));
        $mail->addAddress((string) ($opts['to'] ?? $smtp['to']), (string) ($opts['to_name'] ?? ''));
        if (!empty($opts['reply_to']['email'])) {
            $mail->addReplyTo((string) $opts['reply_to']['email'], (string) ($opts['reply_to']['name'] ?? ''));
        }
        $mail->Subject = (string) $opts['subject'];
        $mail->Body = (string) $opts['body'];

        $mail->send();
        return true;
    } catch (Throwable $e) {
        log_write('mail', 'ERROR', "{$context} fehlgeschlagen: " . $e->getMessage());
        return false;
    }
}

/**
 * Formular-Zustand nach fehlgeschlagener Validierung (Eingaben + Feldfehler), damit
 * der 422-Re-Render die Werte zurückschreiben kann. Ohne Argumente = nur lesen.
 * $old: Feldname → Wert. $errors: Feld-ID (wie im Markup) → Fehlermeldung.
 */
function form_state(?array $old = null, ?array $errors = null): array
{
    static $state = ['old' => [], 'errors' => []];
    if ($old !== null) {
        $state = ['old' => $old, 'errors' => $errors ?? []];
    }
    return $state;
}

/**
 * Zuvor eingegebener Wert eines Feldes ('' wenn keiner) — für value/selected/checked.
 */
function form_old(string $name): string
{
    return (string) (form_state()['old'][$name] ?? '');
}

/**
 * Serverseitige Fehlermeldung zu einer Feld-ID ('' wenn das Feld in Ordnung war).
 */
function form_field_error(string $id): string
{
    return (string) (form_state()['errors'][$id] ?? '');
}

/**
 * True, wenn der aktuelle Request ein Formular mit Feldfehlern rendert.
 */
function form_has_errors(): bool
{
    return form_state()['errors'] !== [];
}

/**
 * Die Varianten des EINEN Kontaktformulars, adressiert über das Hidden-Feld
 * `department`. EINZIGE Quelle für Auswahlliste, Empfänger und Feldregeln —
 * `app/components/contact-form.php` und `app/actions/contact-submit.php` lesen
 * beide hier, damit die Whitelist nicht an zwei Stellen gepflegt werden muss
 * (das war sie vorher, mit einem Kommentar als einziger Absicherung).
 *
 * Ein unbekannter Wert fällt auf die allgemeine Variante zurück — ein
 * manipuliertes Feld kann damit weder einen fremden Empfänger erreichen noch
 * die Validierung umgehen.
 *
 * `to` leer heißt: Standardempfänger aus `config('smtp.to')`. Die
 * Turn-Variante geht an `club.email_turnen`, weil die Abteilung ihre Anfragen
 * selbst beantwortet (so war es auch auf der alten Seite).
 */
function contact_variant(string $department): array
{
    if ($department !== 'turnen') {
        return [
            'department'        => '',
            'interests'         => ['Herrenfußball', 'Frauenfußball', 'Jugendfußball', 'Turnen', 'Ehrenamtlich mithelfen', 'Sonstiges'],
            'interest_required' => false,
            'ask_subject'       => true,
            'message_required'  => true,
            'to'                => '',
            'context'           => 'Kontaktanfrage',
            'subject_prefix'    => 'Kontaktanfrage',
        ];
    }

    // Die Auswahl ist keine eigene Liste, sondern sind die Disziplinen aus
    // data/turnen.json — so heißen die Optionen immer genau wie die Seiten, auf
    // die sie sich beziehen, auch wenn eine Disziplin dazukommt oder wegfällt.
    $interests = [];
    foreach (json_load('turnen')['disciplines'] ?? [] as $discipline) {
        if (!empty($discipline['hero']['title'])) {
            $interests[] = (string) $discipline['hero']['title'];
        }
    }

    $club = json_load('club');
    return [
        'department'        => 'turnen',
        'interests'         => $interests,
        // Pflicht, anders als in der allgemeinen Variante: die Disziplin ist der
        // Kern der Anfrage, und sie ersetzt hier das Betreff-Feld.
        'interest_required' => true,
        'ask_subject'       => false,
        // Bewusst optional (wie auf der alten Seite): „Interessiere mich für
        // Trampolin, ruft mich an" ist eine vollständige Anfrage. Name, Mail und
        // Disziplin stehen ja schon da.
        'message_required'  => false,
        'to'                => (string) ($club['email_turnen'] ?? ''),
        'context'           => 'Anfrage Turnabteilung',
        'subject_prefix'    => 'Anfrage Turnen',
    ];
}

/**
 * Antwort auf eine fehlgeschlagene Formular-Validierung ohne JS: die absendende
 * Seite mit HTTP 422 direkt neu rendern — mit erhaltenen Eingaben und Feldfehlern.
 * Bewusst kein PRG-Redirect, weil die Werte sonst verloren gingen (und ein
 * Session-Cookie dafür unverhältnismäßig wäre). Die Statuszeile bekommt beim
 * Re-Render `autofocus`, damit der Fokus auch ohne JS in der Fehlermeldung landet.
 */
function render_form_invalid(string $slug, array $old, array $errors): never
{
    form_state($old, $errors);

    $routes = require APP_PATH . '/routes.php';
    $current = isset($routes[$slug]) ? $slug : '';

    http_response_code(422);
    header('Cache-Control: no-store');

    [$meta, $content] = render_page(APP_PATH . '/pages/' . $routes[$current]['file']);
    require APP_PATH . '/layout.php';
    exit;
}

/**
 * BreadcrumbList-Knoten: $items = [['name'=>…, 'slug'=>…], …] (Reihenfolge = Pfad).
 */
function breadcrumb_schema(array $items): array
{
    $list = [];
    foreach (array_values($items) as $i => $item) {
        $list[] = [
            '@type'    => 'ListItem',
            'position' => $i + 1,
            'name'     => $item['name'],
            'item'     => abs_url($item['slug']),
        ];
    }
    return ['@type' => 'BreadcrumbList', 'itemListElement' => $list];
}

/**
 * Redaktioneller Text aus data/*.json darf interne Deeplinks im Format
 * [Label](slug) tragen — slug wie in routes.php, optional mit #anker
 * ('mitmachen', 'fussball#kontakt') oder nur ein Anker auf derselben Seite
 * ('#kontakt'). Kein HTML im JSON: inline_links_html() rendert die Links als <a>
 * und escaped alles andere, inline_links_text() liefert den reinen Text (für
 * das FAQPage-Schema und jede andere HTML-freie Ausgabe).
 *
 * Genutzt von den FAQ-Antworten (beide FAQ-Komponenten, faq_schema()) und vom
 * Artikelkörper der News-Detailseite. Die Escaping-Garantie ist der Wert dieser
 * Funktion: sie ist die einzige Stelle, an der aus JSON HTML entsteht — jede
 * Erweiterung (Fettung, Listen, externe URLs) wäre eine neue Angriffsfläche.
 */
function inline_links_html(string $answer): string
{
    $parts = preg_split('/\[([^\]]+)\]\(([a-z0-9\/#_-]*)\)/i', $answer, -1, PREG_SPLIT_DELIM_CAPTURE);
    $html = '';
    foreach ($parts as $i => $part) {
        if ($i % 3 === 0) {          // Text zwischen den Links
            $html .= e($part);
        } elseif ($i % 3 === 1) {    // Label — der Slug folgt als nächstes Element
            $target = $parts[$i + 1];
            [$slug, $anchor] = array_pad(explode('#', $target, 2), 2, null);
            $href = ($slug === '' ? '' : url($slug)) . ($anchor !== null ? '#' . $anchor : '');
            $html .= '<a href="' . e($href) . '">' . e($part) . '</a>';
        }
    }
    return $html;
}

function inline_links_text(string $answer): string
{
    return preg_replace('/\[([^\]]+)\]\([a-z0-9\/#_-]*\)/i', '$1', $answer) ?? $answer;
}

/**
 * FAQPage-Knoten aus [['q'=>…, 'a'=>…], …]. Leere Paare werden übersprungen;
 * ohne Fragen wird ein leerer Array zurückgegeben (Aufrufer filtert das raus).
 */
function faq_schema(array $faq): array
{
    $questions = [];
    foreach ($faq as $item) {
        if (empty($item['q']) || empty($item['a'])) {
            continue;
        }
        $questions[] = [
            '@type'          => 'Question',
            'name'           => $item['q'],
            'acceptedAnswer' => ['@type' => 'Answer', 'text' => inline_links_text($item['a'])],
        ];
    }
    return $questions === [] ? [] : ['@type' => 'FAQPage', 'mainEntity' => $questions];
}

/**
 * Seiten-Schema-Knoten zusammenstellen (für $meta['schema']):
 * Breadcrumb + optional FAQPage. Generisch für Übersichts-/Jugendseiten.
 */
function page_schema(array $breadcrumb, array $faq = []): array
{
    $nodes = [];
    if ($breadcrumb !== []) {
        $nodes[] = breadcrumb_schema($breadcrumb);
    }
    if ($faq !== [] && ($faqNode = faq_schema($faq)) !== []) {
        $nodes[] = $faqNode;
    }
    return $nodes;
}

/**
 * WebSite-Knoten (für die Startseite): definiert die Site als Entität und
 * verweist via publisher auf den Club (#club). Keine SearchAction — es gibt
 * keine Site-Suche.
 */
function website_schema(): array
{
    $club = json_load('club');
    return [
        '@type'      => 'WebSite',
        '@id'        => abs_url() . '#website',
        'url'        => abs_url(),
        'name'       => $club['name'],
        'inLanguage' => 'de-DE',
        'publisher'  => ['@id' => abs_url() . '#club'],
    ];
}

/**
 * Schema-Knoten für eine Mannschafts-Seite: SportsTeam (verweist auf den Club
 * via #club) + Breadcrump (Start → Fußball → Team) + optional FAQPage.
 * $team: Eintrag aus data/teams.json; $slug: voller Seiten-Slug.
 */
function team_schema(array $team, string $slug): array
{
    $teamNode = [
        '@type'    => 'SportsTeam',
        'name'     => $team['name'],
        'sport'    => 'Fußball',
        'url'      => abs_url($slug),
        'memberOf' => ['@id' => abs_url() . '#club'],
    ];
    if (!empty($team['hero']['text'])) {
        $teamNode['description'] = $team['hero']['text'];
    }

    $nodes = [$teamNode];
    $nodes = array_merge($nodes, page_schema(
        [
            ['name' => 'Startseite', 'slug' => ''],
            ['name' => 'Fußball', 'slug' => 'fussball'],
            ['name' => $team['name'], 'slug' => $slug],
        ],
        $team['faq'] ?? []
    ));
    return $nodes;
}

/**
 * SportsEvent-Knoten für anstehende Spiele (Matchcenter). $upcoming: Einträge aus
 * data/matchcenter.json → upcoming[] (home, away, kickoff, …). startDate nur, wenn
 * der Anstoß als ISO-Zeit vorliegt. Aufrufer hängt das Ergebnis an $meta['schema'].
 */
function sportsevent_nodes(array $upcoming): array
{
    $nodes = [];
    foreach ($upcoming as $m) {
        if (empty($m['home']) || empty($m['away'])) {
            continue;
        }
        $node = [
            '@type'       => 'SportsEvent',
            'name'        => $m['home'] . ' – ' . $m['away'],
            'sport'       => 'Fußball',
            'homeTeam'    => ['@type' => 'SportsTeam', 'name' => $m['home']],
            'awayTeam'    => ['@type' => 'SportsTeam', 'name' => $m['away']],
            'eventStatus' => 'https://schema.org/EventScheduled',
        ];
        if (!empty($m['kickoff'])) {
            $node['startDate'] = $m['kickoff'];
        }
        $nodes[] = $node;
    }
    return $nodes;
}

/**
 * Event-Knoten für ein Club-Event (data/events.json: title, date_start, date_end?,
 * location?, text?). Analog sportsevent_nodes(), nur für die manuell gepflegten
 * Vereins-Events statt Spieltermine. Fehlt title/date_start → null (Aufrufer filtert).
 *
 * $slug: interner Pfad der Detailseite, falls das Event eine hat
 * (data/veranstaltungen.json). Dann bekommt der Knoten @id und url — /termine
 * und die Detailseite beschreiben damit nachweislich DASSELBE Event und nicht
 * zwei zufällig gleich benannte. Events ohne eigene Seite lassen $slug leer.
 */
function event_schema(array $event, ?string $slug = null): ?array
{
    if (empty($event['title']) || empty($event['date_start'])) {
        return null;
    }
    $node = [
        '@type'               => 'Event',
        'name'                => $event['title'],
        'startDate'           => $event['date_start'] . (!empty($event['time']) ? 'T' . $event['time'] : ''),
        'eventStatus'         => 'https://schema.org/EventScheduled',
        'eventAttendanceMode' => 'https://schema.org/OfflineEventAttendanceMode',
    ];
    if (!empty($event['date_end'])) {
        $node['endDate'] = $event['date_end'];
    }
    if (!empty($event['location'])) {
        $node['location'] = ['@type' => 'Place', 'name' => $event['location']];
    }
    if (!empty($event['text'])) {
        $node['description'] = $event['text'];
    }
    if ($slug !== null && $slug !== '') {
        $node['@id'] = abs_url($slug) . '#event';
        $node['url'] = abs_url($slug);
    }
    // Hauptplakat als Event-Bild (nur Veranstaltungen haben eins) — gleiche
    // Ableitung wie in news_article_schema().
    $plakat = $event['plakate'][0] ?? null;
    if (!empty($plakat['base']) && !empty($plakat['widths'])) {
        $largest = max($plakat['widths']);
        $node['image'] = rtrim((string) config('base_url'), '/') . asset("{$plakat['base']}-{$largest}.jpg");
    }
    return $node;
}

/**
 * Article-Knoten für eine redaktionelle Meldung (data/news.json: title, date,
 * text, image?). Fehlt title/date → null (Aufrufer filtert). Verweist wie
 * team_schema()/job_posting_schema() via #club auf den Organisation-Knoten
 * (Autor und Herausgeber sind hier derselbe Verein, kein einzelner Redakteur).
 *
 * $pageSlug (z. B. 'news/neue-website-online') nur für Meldungen mit eigener
 * Detailseite: dann bekommt der Knoten @id, url und mainEntityOfPage, damit die
 * Feed-Seite /news und die Detailseite nachweislich DENSELBEN Article
 * beschreiben. Gleiches Muster und dieselbe Begründung wie event_schema().
 */
function news_article_schema(array $item, ?string $pageSlug = null): ?array
{
    if (empty($item['title']) || empty($item['date'])) {
        return null;
    }
    $node = [
        '@type'         => 'Article',
        'headline'      => $item['title'],
        'datePublished' => $item['date'],
        'author'        => ['@id' => abs_url() . '#club'],
        'publisher'     => ['@id' => abs_url() . '#club'],
    ];
    if ($pageSlug !== null) {
        $node['@id'] = abs_url($pageSlug) . '#article';
        $node['url'] = abs_url($pageSlug);
        $node['mainEntityOfPage'] = ['@type' => 'WebPage', '@id' => abs_url($pageSlug)];
    }
    if (!empty($item['text'])) {
        $node['description'] = $item['text'];
    }
    if (!empty($item['image']['base']) && !empty($item['image']['widths'])) {
        $largest = max($item['image']['widths']);
        $node['image'] = rtrim((string) config('base_url'), '/') . asset("{$item['image']['base']}-{$largest}.jpg");
    }
    return $node;
}

/**
 * JobPosting-Knoten für eine Ehrenamtsstelle (Seite /mitmachen). $job: Eintrag aus
 * data/mitmachen.json → positions[]; $pageSlug: Seiten-Slug für die Anker-URL.
 * employmentType VOLUNTEER; hiringOrganization verweist via #club auf den Org-Knoten;
 * jobLocation = Vereinsadresse aus club.json (Single Source). validThrough bewusst
 * optional — ein abgelaufenes Datum entfernt die Anzeige aktiv aus den Ergebnissen,
 * deshalb nur bei echt befristeten Stellen setzen. Ohne title → leerer Array (Aufrufer filtert).
 */
function job_posting_schema(array $job, string $pageSlug): array
{
    if (empty($job['title'])) {
        return [];
    }
    $club = json_load('club');
    $node = [
        '@type'              => 'JobPosting',
        'title'              => $job['title'],
        'description'        => $job['description'] ?? ($job['summary'] ?? $job['title']),
        'employmentType'     => 'VOLUNTEER',
        'hiringOrganization' => ['@id' => abs_url() . '#club'],
        'jobLocation'        => [
            '@type'   => 'Place',
            'address' => [
                '@type'           => 'PostalAddress',
                'streetAddress'   => $club['address']['street'],
                'postalCode'      => $club['address']['zip'],
                'addressLocality' => $club['address']['city'],
                'addressCountry'  => $club['address']['country'],
            ],
        ],
    ];
    if (!empty($job['id'])) {
        $node['identifier'] = [
            '@type' => 'PropertyValue',
            'name'  => $club['name'],
            'value' => $job['id'],
        ];
        $node['url'] = abs_url($pageSlug) . '#' . $job['id'];
    }
    if (!empty($job['posted'])) {
        $node['datePosted'] = $job['posted'];
    }
    if (!empty($job['valid_through'])) {
        $node['validThrough'] = $job['valid_through'];
    }
    return $node;
}
