<?php

declare(strict_types=1);

/**
 * Stadionzeitung-Ausgabe hinzufügen/aktualisieren: baut eine Ausgabe nach der
 * festen Vorlage (siehe CLAUDE.md → Stadionzeitung) aus Canva-Bildern +
 * drei automatischen Live-Seiten (Tabelle/Termine/News). Manuell im Chat
 * ausgelöst, kein Cron.
 *
 * Aufruf: php bin/stadionzeitung-add.php <ordner> "<Gegner>" <YYYY-MM-DD> <team-key> [slug]
 *   <team-key>  Team-Key aus data/matchcenter.json (z. B. erste-mannschaft, damen) —
 *               bestimmt, wessen Tabelle auf der Live-Seite „Tabelle" erscheint.
 *
 * Erwarteter Ordnerinhalt:
 *   <ordner>/vorwort.*           optional, Fallback-Bild — nur genutzt, solange für diese
 *                                 Ausgabe noch kein Vorwort-Text hinterlegt ist (siehe unten)
 *   <ordner>/rueckseite.*        optional
 *   <ordner>/spieler.*           optional, freigestelltes Spielerfoto (PNG mit Transparenz)
 *                                 fürs Cover — wird unverändert kopiert, nie über GD neu kodiert
 *   <ordner>/verfasser.*         optional, Porträtfoto für die Vorwort-Signatur
 *   <ordner>/anzeigen/*          Sponsoren-Seiten (fertige Vollseiten — ob im Sponsoring
 *                                 halbe oder ganze Seite verkauft wurde, ist hier egal,
 *                                 geliefert wird immer eine fertige Seite), natürlich sortiert
 *
 * Feste Vorlage (Interview-Beschluss 29.07.2026): Cover (live) → Vorwort →
 * Ergebnis-Rückblick (live) → Anzeigen A → je Mannschaft eine Tabelle (live,
 * Reihenfolge = data/matchcenter.json) → Vorschau (live) → Anzeigen B →
 * Termine (live) → Anzeigen C → News (live) → Ansprechpartner (live) →
 * Anzeigen D → Rückseite. Das Cover wird automatisch generiert (siehe
 * app/components/stadionzeitung-cover.php) — kein Canva-Bild mehr nötig. Der
 * gesamte Anzeigen-Pool (in gelieferter Reihenfolge) wird zu möglichst
 * gleichen Teilen auf die vier Gruppen verteilt.
 *
 * Neue Ausgaben entstehen als ENTWURF (status: entwurf): unsichtbar in
 * Archiv/Sitemap, Live-Seiten rechnen bei jedem Aufruf frisch. Erst
 * bin/stadionzeitung-publish.php friert Tabellen/News/Ergebnisse/Vorschau als
 * snapshot ein und schaltet die Ausgabe sichtbar. Fürs gefaltete A5-Heft
 * (Druckfassung ?druck=1, Broschüren-Reihenfolge) sollte die Seitenzahl ein
 * Vielfaches von 4 sein — das Skript meldet die Lücke, aufgefüllt wird beim
 * Erzeugen gemeinsam (weiße Füllseiten in der Druckfassung sind der Notnagel).
 *
 * Vorwort-Text ist KEIN Skript-Input: er wird (wie data/events.json/news.json) im
 * Chat gepflegt — Felix schickt den Text, Claude trägt ihn manuell ins `vorwort`-Feld
 * der Ausgabe in data/stadionzeitung.json ein (heading/paragraphs/closing/signee_name/
 * signee_role). Damit ein erneuter Skript-Lauf (z. B. um eine Anzeige zu korrigieren)
 * dieses Feld nicht wegwirft, liest das Skript den bisherigen Wert vor dem Ersetzen
 * aus und schreibt ihn unverändert zurück. Ist ein `vorwort`-Feld vorhanden, rendert
 * die Ausgabe die Live-Seite app/components/stadionzeitung-vorwort.php statt des
 * Fallback-Bilds `vorwort.*`.
 *
 * Erneuter Aufruf mit demselben Slug ersetzt die Ausgabe vollständig (alte
 * Seiten-Bilder werden vorher gelöscht) — idempotent für Korrekturen.
 */
if (PHP_SAPI !== 'cli') {
    exit(1);
}

require dirname(__DIR__) . '/app/bootstrap.php';

$args = array_values(array_filter($argv, static fn (string $a): bool => $a !== '-v'));
$verbose = in_array('-v', $argv, true);
array_shift($args); // Skriptname

if (count($args) < 4) {
    fwrite(STDERR, "Nutzung: php bin/stadionzeitung-add.php <ordner> \"<Gegner>\" <YYYY-MM-DD> <team-key> [slug]\n");
    exit(1);
}

[$sourceDir, $opponent, $date, $teamKey] = $args;
$slugArg = $args[4] ?? '';

$log = static function (string $msg, string $level = 'INFO') use ($verbose): void {
    log_write('stadionzeitung', $level, $msg);
    if ($verbose || $level === 'ERROR' || $level === 'WARN') {
        echo '[' . date('c') . "] {$level}  {$msg}\n";
    }
};

$fail = static function (string $msg) use ($log): never {
    $log("{$msg} — bestehender Cache bleibt unangetastet.", 'ERROR');
    exit(1);
};

// --- Eingaben validieren ---
if (!is_dir($sourceDir)) {
    $fail("Ordner nicht gefunden: {$sourceDir}");
}

$parsedDate = DateTimeImmutable::createFromFormat('Y-m-d', $date);
if ($parsedDate === false || $parsedDate->format('Y-m-d') !== $date) {
    $fail("Ungültiges Datum (erwartet YYYY-MM-DD): {$date}");
}

$opponent = trim($opponent);
if ($opponent === '') {
    $fail('Gegner darf nicht leer sein.');
}

$mc = json_load('matchcenter');
$knownTeamKeys = array_column($mc['teams'] ?? [], 'key');
if (!in_array($teamKey, $knownTeamKeys, true)) {
    $fail("Unbekannter team-key '{$teamKey}'. Bekannt: " . implode(', ', $knownTeamKeys));
}

/**
 * Kleine, lokale Slugify-Funktion (nur dieser Aufrufer) — Umlaute
 * transliterieren, Kleinschreibung, alles außer a-z0-9 zu Bindestrichen.
 */
$slugify = static function (string $text): string {
    $text = strtr($text, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'Ä' => 'Ae', 'Ö' => 'Oe', 'Ü' => 'Ue', 'ß' => 'ss']);
    $text = strtolower($text);
    $text = preg_replace('/[^a-z0-9]+/', '-', $text) ?? '';
    return trim($text, '-');
};

$slug = $slugArg !== '' ? $slugify($slugArg) : $date . '-' . $slugify($opponent);
if ($slug === '') {
    $fail('Konnte keinen gültigen Slug bilden.');
}

$exts = 'jpg,jpeg,png,webp,JPG,JPEG,PNG,WEBP';
$findOne = static function (string $baseName) use ($sourceDir, $exts): ?string {
    $matches = glob(rtrim($sourceDir, '/') . "/{$baseName}.{{$exts}}", GLOB_BRACE) ?: [];
    return $matches[0] ?? null;
};
$findAll = static function (string $subdir) use ($sourceDir, $exts): array {
    $dir = rtrim($sourceDir, '/') . "/{$subdir}";
    if (!is_dir($dir)) {
        return [];
    }
    $files = glob("{$dir}/*.{{$exts}}", GLOB_BRACE) ?: [];
    natsort($files);
    return array_values($files);
};

$vorwort = $findOne('vorwort');
$rueckseite = $findOne('rueckseite');
$spieler = $findOne('spieler');
$verfasser = $findOne('verfasser');
// Anzeigen kommen NICHT mehr aus dem Ablieferordner, sondern aus dem dauerhaften
// Bestand (data/anzeigen.json, gepflegt über bin/anzeige-add.php). Ein noch
// vorhandener anzeigen/-Unterordner wäre also wirkungslos — dann besser sagen,
// wo sie jetzt hingehören, als ihn stillschweigend zu ignorieren.
if ($findAll('anzeigen') !== []) {
    $log('Der Ordner enthält noch anzeigen/ — die werden NICHT mehr von hier gelesen. '
        . 'Anzeigen liegen im Bestand: php bin/anzeige-add.php <ordner>', 'WARN');
}

if ($rueckseite === null) {
    $log('Keine rueckseite.* im Ordner — das Heft endet mit dem Impressum.', 'WARN');
}

// --- Lock gegen parallele Läufe (billige Absicherung, kein echtes Cron-Risiko hier) ---
if (!is_dir(STORAGE_PATH . '/cache')) {
    mkdir(STORAGE_PATH . '/cache', 0755, true);
}
$lock = fopen(STORAGE_PATH . '/cache/stadionzeitung.lock', 'c');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
    $log('Läuft bereits (Lock belegt) — Lauf übersprungen.', 'WARN');
    exit(1);
}

// --- Bisherigen Eintrag lesen (falls vorhanden) — trägt u. a. das chat-gepflegte
// vorwort-Feld über einen erneuten Lauf hinweg weiter, siehe Kommentar oben. ---
$cacheFile = DATA_PATH . '/stadionzeitung.json';
$existing = is_file($cacheFile)
    ? (json_decode((string) file_get_contents($cacheFile), true) ?: ['issues' => []])
    : ['issues' => []];
$oldIssue = null;
foreach ($existing['issues'] ?? [] as $entry) {
    if (($entry['slug'] ?? '') === $slug) {
        $oldIssue = $entry;
        break;
    }
}
$oldVorwort = $oldIssue['vorwort'] ?? null;
// Interview wie das Vorwort chat-gepflegt — überlebt erneute Läufe ebenso.
$oldInterview = $oldIssue['interview'] ?? null;

// --- Ziel-Ordner vorbereiten: bei erneutem Lauf alte Seiten dieser Ausgabe wegräumen ---
$destDir = PUBLIC_PATH . "/assets/img/stadionzeitung/{$slug}";
if (!is_dir($destDir)) {
    mkdir($destDir, 0755, true);
} else {
    // Der Ordner wird KOMPLETT geleert, nicht nach Mustern aufgeräumt: alles
    // darin entsteht in diesem Lauf neu (Vorwort- und Rückseiten-Bild, Spieler,
    // Verfasser, QR). Ein Muster-Aufräumen hinterlässt sonst Waisen, sobald sich
    // Dateinamen ändern — genau das ist passiert: neben den `page-NN`-Resten des
    // alten Positions-Schemas lagen auch die `anzeige-*`-Kopien, die jetzt im
    // Bestand (public/assets/img/anzeigen/) leben. Die geteilten CTA-QR-Codes
    // liegen eine Ebene höher und sind davon nicht betroffen.
    foreach (glob("{$destDir}/*") ?: [] as $old) {
        if (is_file($old)) {
            unlink($old);
        }
    }
}

$widths = [480, 960, 1440];

// --- Anzeigen kommen aus dem DAUERHAFTEN Bestand (data/anzeigen.json), nicht
// mehr aus dem Ablieferordner: Sponsorenmotive ändern sich selten, das Heft
// erscheint oft. Gepflegt wird der Bestand über bin/anzeige-add.php (Bilder) und
// im Chat (Sponsorenname, Reihenfolge, aktiv). Hier wird nichts gerendert, die
// Bilder liegen schon. ---
$anzeigenSeiten = array_values(array_map(
    static function (array $a): array {
        $sponsor = trim((string) ($a['sponsor'] ?? ''));
        return [
            'type'   => 'image',
            'base'   => (string) $a['base'],
            'widths' => $a['widths'] ?? [],
            // Alt-Text aus dem Sponsorennamen abgeleitet, damit er nur an EINER
            // Stelle gepflegt wird. `alt` im Bestand überschreibt ihn (z. B.
            // „Eigenanzeige: …").
            'alt'    => (string) ($a['alt'] ?? ('Anzeige: ' . $sponsor)),
        ];
    },
    array_filter(
        json_load('anzeigen')['anzeigen'] ?? [],
        // `bis` gegen das DATUM DER AUSGABE prüfen, nicht gegen heute: ein Heft
        // zum Spiel am 1. August darf für ein Fest am 6. September werben, ein
        // später angelegtes Heft nicht mehr. Damit retirieren zeitgebundene
        // Eigenanzeigen (Sportfest-Plakat, Programm) von selbst, statt bis zum
        // nächsten Handgriff im Heft zu bleiben.
        static fn (array $a): bool => ($a['aktiv'] ?? true)
            && !empty($a['base'])
            && (empty($a['bis']) || (string) $a['bis'] >= $date)
    )
));

// --- Seiten-Bausteine: Live-Seiten brauchen kein Bild, die zwei verbliebenen
// Ausgaben-Bilder (Vorwort-Fallback, Rückseite) schon. Beide bekommen SPRECHENDE
// Dateinamen statt der früheren Positionsnummern (page-07) — die verschoben sich
// bei jeder Umverteilung. ---
$live = static function (string $page, ?string $team = null): array {
    $entry = ['type' => 'live', 'page' => $page];
    if ($team !== null) {
        $entry['team'] = $team;
    }
    return $entry;
};

$bild = static function (string $src, string $name, string $alt) use ($destDir, $widths, $slug): array {
    try {
        $variants = resize_image_variants($src, "{$destDir}/{$name}", $widths, 82);
    } catch (RuntimeException $e) {
        throw new RuntimeException("Seite '{$name}' ({$src}) fehlgeschlagen: " . $e->getMessage());
    }
    $generated = array_values(array_unique(array_column($variants, 'width')));
    sort($generated);

    return [
        'type'   => 'image',
        'base'   => "img/stadionzeitung/{$slug}/{$name}",
        'widths' => $generated,
        'alt'    => $alt,
    ];
};

/*
 * Inhalt als Liste von BLÖCKEN, Anzeigen kommen anschließend in die Lücken
 * dazwischen. Ein Block ist eine Folge von Seiten, die zusammenbleiben MUSS;
 * `anzeigen_danach` sagt, ob nach dem Block eine Lücke für Anzeigen entsteht.
 *
 * Drei Stellen sind bewusst anzeigenfrei:
 *  - nach dem Cover: der Leser schlägt auf Inhalt auf, nicht auf Werbung,
 *  - innerhalb des Aufschlags Vorwort+Interview (deshalb EIN Block),
 *  - innerhalb der Tabellen (deshalb EIN Block): die Tabellenseiten müssen im
 *    gefalteten Heft nebeneinander liegen, eine Anzeige dazwischen zerstört
 *    genau die Doppelseite, die die Paritätsrechnung unten herstellt,
 *  - zwischen Impressum und Rückseite: die Rückseite ist die Heft-Außenseite.
 */
$bloecke = [];
try {
    $bloecke[] = ['name' => 'cover', 'seiten' => [$live('cover')], 'anzeigen_danach' => false];

    $aufschlag = [];
    if ($oldVorwort !== null) {
        $aufschlag[] = $live('vorwort');
    } elseif ($vorwort !== null) {
        $aufschlag[] = $bild($vorwort, 'vorwort', 'Vorwort der Stadionzeitung');
    }
    // Interview-Seite nur, wenn die Ausgabe ein Interview trägt (chat-gepflegtes
    // Feld) — das Cover zeigt dann automatisch die Teaser-Zeile dazu.
    if ($oldInterview !== null) {
        $aufschlag[] = $live('interview');
    }
    if ($aufschlag !== []) {
        $bloecke[] = ['name' => 'aufschlag', 'seiten' => $aufschlag, 'anzeigen_danach' => true];
    }

    $bloecke[] = ['name' => 'ergebnisse', 'seiten' => [$live('ergebnisse')], 'anzeigen_danach' => true];

    // Eine Tabellen-Seite je Mannschaft, Reihenfolge wie in matchcenter.json
    // (= config 'matchcenter.teams'). Kommt eine Mannschaft dazu (z. B. die
    // SG der Zweiten), bekommt die nächste Ausgabe ihre Tabelle automatisch.
    $tabellen = [];
    foreach ($mc['teams'] ?? [] as $team) {
        if (!empty($team['key'])) {
            $tabellen[] = $live('tabelle', (string) $team['key']);
        }
    }
    if ($tabellen !== []) {
        $bloecke[] = ['name' => 'tabellen', 'seiten' => $tabellen, 'anzeigen_danach' => true];
    }

    foreach (['vorschau', 'termine', 'news', 'kontakt', 'mitglied', 'historie', 'sportheim', 'partner', 'momente'] as $seite) {
        $bloecke[] = ['name' => $seite, 'seiten' => [$live($seite)], 'anzeigen_danach' => true];
    }

    $bloecke[] = ['name' => 'impressum', 'seiten' => [$live('impressum')], 'anzeigen_danach' => false];

    if ($rueckseite !== null) {
        $bloecke[] = [
            'name'            => 'rueckseite',
            'seiten'          => [$bild($rueckseite, 'rueckseite', "Rückseite der Stadionzeitung gegen {$opponent}")],
            'anzeigen_danach' => false,
        ];
    }
} catch (RuntimeException $e) {
    $fail($e->getMessage());
}

// --- Anzeigen gleichmäßig auf die Lücken verteilen ---
$luecken = [];
foreach ($bloecke as $i => $b) {
    if ($b['anzeigen_danach']) {
        $luecken[] = $i;
    }
}
$luckCount = count($luecken);
$adCount = count($anzeigenSeiten);

if ($luckCount === 0) {
    $fail('Keine Lücke für Anzeigen — die Vorlage hat keine Inhaltsblöcke.');
}

// Grundmenge plus Restverteilung. Der Rest wird GESTREUT (nicht vorne gebündelt),
// sonst stünden alle Doppel-Lücken am Heftanfang.
$basis = intdiv($adCount, $luckCount);
$rest = $adCount % $luckCount;
$verteilung = [];
for ($g = 0; $g < $luckCount; $g++) {
    $verteilung[$g] = $basis + (intdiv(($g + 1) * $rest, $luckCount) - intdiv($g * $rest, $luckCount));
}

if ($adCount > 2 * $luckCount) {
    $log(sprintf(
        '%d Anzeigen auf %d Lücken — mehr als zwei hintereinander ist damit unvermeidbar (höchstens %d). Weniger Anzeigen oder eine Inhaltsseite mehr.',
        $adCount,
        $luckCount,
        max($verteilung)
    ), 'WARN');
}

/** 1-basierte Seitenzahl, auf der ein Block beginnt (null, wenn er fehlt). */
$blockSeite = static function (string $name) use ($bloecke, $verteilung): ?int {
    $seite = 0;
    $g = 0;
    foreach ($bloecke as $b) {
        if ($b['name'] === $name) {
            return $seite + 1;
        }
        $seite += count($b['seiten']);
        if ($b['anzeigen_danach']) {
            $seite += $verteilung[$g];
            $g++;
        }
    }
    return null;
};

/*
 * Doppelseiten-Regel: aufgeschlagen liegen die Seiten (2,3), (4,5), … nebeneinander,
 * die GERADE Seite links. Die erste Tabellenseite muss deshalb auf einer geraden
 * Seitenzahl liegen, damit die Tabellen im gefalteten Heft eine Doppelseite bilden.
 * Nur Anzeigen in Lücken VOR dem Tabellenblock verschieben dessen Parität — eine
 * Anzeige zwischen einer solchen Lücke und einer dahinter zu tauschen kippt sie
 * also, ohne die Gesamtseitenzahl zu ändern. Früher war das Handarbeit
 * („notfalls eine Anzeige verschieben"), jetzt rechnet es das Skript.
 */
$tabellenIndex = null;
foreach ($bloecke as $i => $b) {
    if ($b['name'] === 'tabellen') {
        $tabellenIndex = $i;
        break;
    }
}

if ($tabellenIndex !== null && ($seite = $blockSeite('tabellen')) !== null && $seite % 2 === 1) {
    $vor = [];
    $nach = [];
    foreach ($luecken as $g => $blockIdx) {
        if ($blockIdx < $tabellenIndex) {
            $vor[] = $g;
        } else {
            $nach[] = $g;
        }
    }

    $getauscht = false;
    // Erst versuchen: eine Anzeige nach vorne holen (Lücke vorne darf auf 2).
    foreach ($vor as $g) {
        if ($verteilung[$g] >= 2) {
            continue;
        }
        foreach (array_reverse($nach) as $h) {
            if ($verteilung[$h] > 0) {
                $verteilung[$g]++;
                $verteilung[$h]--;
                $getauscht = true;
                $log("Doppelseiten-Regel: eine Anzeige aus Lücke {$h} nach Lücke {$g} verschoben, damit die Tabellen nebeneinander liegen.");
                break 2;
            }
        }
    }
    // Sonst umgekehrt: eine Anzeige nach hinten abgeben.
    if (!$getauscht) {
        foreach ($vor as $g) {
            if ($verteilung[$g] < 1) {
                continue;
            }
            foreach ($nach as $h) {
                if ($verteilung[$h] < 2) {
                    $verteilung[$g]--;
                    $verteilung[$h]++;
                    $getauscht = true;
                    $log("Doppelseiten-Regel: eine Anzeige aus Lücke {$g} nach Lücke {$h} verschoben, damit die Tabellen nebeneinander liegen.");
                    break 2;
                }
            }
        }
    }
    if (!$getauscht) {
        $log('Doppelseiten-Regel NICHT herstellbar (keine Anzeige verschiebbar) — die Tabellen liegen im Heft nicht nebeneinander.', 'WARN');
    }
}

// --- Zusammensetzen: Block, dann die Anzeigen seiner Lücke ---
$images = [];
$g = 0;
$zeiger = 0;
foreach ($bloecke as $b) {
    foreach ($b['seiten'] as $seiteEintrag) {
        $images[] = $seiteEintrag;
    }
    if ($b['anzeigen_danach']) {
        for ($k = 0; $k < $verteilung[$g]; $k++) {
            $images[] = $anzeigenSeiten[$zeiger++];
        }
        $g++;
    }
}

$total = count($images);

// Layout einmal kompakt protokollieren (statt einer Zeile pro Seite) — daran
// sieht man auf einen Blick, ob die Verteilung stimmt.
$layout = implode(' ', array_map(
    static fn (array $e): string => ($e['type'] === 'live' ? strtoupper(substr($e['page'], 0, 3)) : '·'),
    $images
));
$log("Layout ({$total} Seiten, · = Anzeige): {$layout}");
$log(sprintf(
    '%d Anzeigen auf %d Lücken verteilt (%s), längster Block: %d.',
    $adCount,
    $luckCount,
    implode('/', $verteilung),
    max($verteilung ?: [0])
));

// Fürs gefaltete Heft (Sattelheftung) muss die Seitenzahl durch 4 teilbar
// sein — die Druckfassung füllt fehlende Seiten weiß auf, aber sauberer ist
// eine gemeinsame Lösung beim Erzeugen (weitere Anzeige, Füllseite mit
// Inhalt). Deshalb hier nur eine deutliche Meldung, kein Abbruch.
if ($total % 4 !== 0) {
    $log("Seitenzahl {$total} ist kein Vielfaches von 4 — die Druckfassung füllt mit " . (4 - $total % 4) . ' weißen Seiten auf. Besser gemeinsam lösen (z. B. eine Anzeige mehr).', 'WARN');
}

// --- Spielerfoto fürs Cover: unverändert kopieren (kein resize_image_variants(),
// das würde über imagejpeg() die Transparenz zerstören — Felix liefert bereits
// ein web-taugliches Foto). Optional, kein Fehlschlag bei Fehlen. ---
$playerImage = null;
if ($spieler !== null) {
    $ext = strtolower(pathinfo($spieler, PATHINFO_EXTENSION));
    $playerDest = "{$destDir}/spieler.{$ext}";
    // PNG: auf den Inhalt zuschneiden (Freistellungen kommen oft mit viel
    // leerem Rand aus dem Export-Tool). Andere Formate (kein Alpha) unverändert.
    $copied = $ext === 'png'
        ? crop_transparent_png_to_content($spieler, $playerDest)
        : copy($spieler, $playerDest);
    if ($copied) {
        $playerImage = "img/stadionzeitung/{$slug}/spieler.{$ext}";
        $log('Spielerfoto übernommen' . ($ext === 'png' ? ' (auf Inhalt zugeschnitten).' : ' (unverändert kopiert).'));
    } else {
        $log("Spielerfoto konnte nicht kopiert werden: {$spieler}", 'WARN');
    }
}

// --- QR-Code zur digitalen Ausgabe fürs Cover: einmalig, Ziel-URL ändert sich nie.
// QR ist "nice to have" — schlägt die Erzeugung fehl, läuft die Ausgabe ohne weiter. ---
$qrImage = null;
$qrDest = "{$destDir}/qr.png";
if (generate_qr_png(abs_url('stadionzeitung/' . $slug), $qrDest)) {
    $qrImage = "img/stadionzeitung/{$slug}/qr.png";
    $log('QR-Code erzeugt.');
} else {
    $log('QR-Code konnte nicht erzeugt werden — Cover rendert ohne QR.', 'WARN');
}

// Geteilte QRs der CTA-Füße (Kontakt → /mitmachen, News → /news, Termine →
// /termine, Vorschau → /matchcenter, für alle Ausgaben gleich) — werden bei
// jedem Lauf mit der aktuellen base_url neu geschrieben, damit sie nach dem
// Produktivgang automatisch auf die echte Domain zeigen.
foreach (['mitmachen', 'news', 'termine', 'matchcenter', 'mitglied-werden', 'historie', 'sportheimbuchung', 'partner-werden'] as $ctaTarget) {
    if (generate_qr_png(abs_url($ctaTarget), PUBLIC_PATH . "/assets/img/stadionzeitung/qr-{$ctaTarget}.png")) {
        $log("QR-Code für /{$ctaTarget} erzeugt.");
    } else {
        $log("QR-Code für /{$ctaTarget} konnte nicht erzeugt werden — die Seite rendert ohne QR.", 'WARN');
    }
}

// Momente-Seite: QR aufs Instagram-Profil (externe URL aus club.json, die
// einzige Nicht-Website-Adresse unter den Heft-QRs — ein gedruckter Code,
// keine Einbindung, Regel 1 bleibt unberührt).
$instagramUrl = (string) (json_load('club')['social']['instagram'] ?? '');
if ($instagramUrl !== '' && generate_qr_png($instagramUrl, PUBLIC_PATH . '/assets/img/stadionzeitung/qr-instagram.png')) {
    $log('QR-Code fürs Instagram-Profil erzeugt.');
} else {
    $log('QR-Code fürs Instagram-Profil konnte nicht erzeugt werden — Momente-Seite rendert ohne QR.', 'WARN');
}

// --- Termine-Snapshot für die Live-Seite "termine": die 5 zum Spieltag
// nächsten Termine, EINMALIG eingefroren (termine_build_lists($date)) —
// nicht bei jedem Seitenaufruf neu berechnet. Eine Ausgabe ist eine gedruckte
// Publikation: wer sie Monate später liest, soll weiter sehen, was am
// Spieltag anstand, nicht was von heute aus gesehen ansteht. Das heutige
// Spiel selbst (das Cover zeigt es schon) fliegt raus — sonst listet
// "Kommende Termine" die eigene Ausgabe. ---
$termineUpcoming = array_filter(
    termine_build_lists($date)['upcoming'],
    static fn (array $item): bool => ($item['date'] ?? '') !== $date || ($item['type'] ?? '') !== 'spiel'
);
$termineSnapshot = array_slice(array_values($termineUpcoming), 0, 5);
$log(count($termineSnapshot) . ' Termine für die Live-Seite eingefroren (Stand ' . $date . ').');

// --- Porträtfoto für die Vorwort-Signatur: normales Foto (kein Alpha), daher
// über resize_image_variants() wie eine Bild-Seite — kleine Breiten reichen,
// es wird nur als kleiner Kreis neben der Signatur gezeigt. Optional. ---
$verfasserPhoto = null;
if ($verfasser !== null) {
    $verfasserDest = "{$destDir}/verfasser";
    try {
        $variants = resize_image_variants($verfasser, $verfasserDest, [120, 240], 82);
        $generatedWidths = array_values(array_unique(array_column($variants, 'width')));
        sort($generatedWidths);
        $verfasserPhoto = [
            'base'   => "img/stadionzeitung/{$slug}/verfasser",
            'widths' => $generatedWidths,
            'alt'    => '',
        ];
        $log('Verfasserfoto übernommen.');
    } catch (RuntimeException $e) {
        $log("Verfasserfoto konnte nicht verarbeitet werden: {$e->getMessage()}", 'WARN');
    }
}

// --- data/stadionzeitung.json atomar schreiben (bestehenden Slug ersetzen) ---
$issues = array_values(array_filter(
    $existing['issues'] ?? [],
    static fn (array $issue): bool => ($issue['slug'] ?? '') !== $slug
));
// Immer als Entwurf — auch beim erneuten Lauf über eine schon veröffentlichte
// Ausgabe: der Lauf ersetzt die Seiten, also muss der Stand neu geprüft und
// neu veröffentlicht werden (der alte snapshot passt nicht mehr und fliegt mit).
if ($oldIssue !== null && ($oldIssue['status'] ?? '') === 'veroeffentlicht') {
    $log("Ausgabe '{$slug}' war veröffentlicht — steht jetzt wieder auf Entwurf, nach Prüfung neu veröffentlichen (bin/stadionzeitung-publish.php).", 'WARN');
}
$issues[] = [
    'slug'             => $slug,
    'opponent'         => $opponent,
    'date'             => $date,
    'team_key'         => $teamKey,
    'status'           => 'entwurf',
    'pages'            => $total,
    'player_image'     => $playerImage,
    'qr_image'         => $qrImage,
    'vorwort'          => $oldVorwort,
    'interview'        => $oldInterview,
    'verfasser_photo'  => $verfasserPhoto,
    'termine_snapshot' => $termineSnapshot,
    'images'           => $images,
];
usort($issues, static fn (array $a, array $b): int => strcmp($b['date'], $a['date']));

$payload = json_encode(['issues' => $issues], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$tmp = $cacheFile . '.tmp';
file_put_contents($tmp, $payload);
rename($tmp, $cacheFile);

$log("OK: Ausgabe '{$slug}' ({$opponent}, {$date}, Team {$teamKey}) mit {$total} Seiten als Entwurf gespeichert.");
echo "Fertig (ENTWURF): /stadionzeitung/{$slug} ({$total} Seiten, davon {$adCount} Anzeigen aus dem Bestand)\n";
echo 'Anzeigen verteilt: ' . implode('/', $verteilung) . ' je Lücke, längster Block ' . max($verteilung ?: [0]) . ".\n";
echo "Veröffentlichen nach Prüfung: php bin/stadionzeitung-publish.php {$slug}\n";
