<?php

declare(strict_types=1);

/**
 * Matchcenter-Sync (BFV-Widget-Ersatz): holt Spielplan, Ergebnisse und Tabelle der
 * drei Aktivenmannschaften aus der öffentlichen BFV-Widget-JSON-API, lädt die
 * Vereinswappen lokal nach public/assets/img/crests/ und schreibt data/matchcenter.json
 * atomar. Bei JEDEM Fehler bleibt der letzte gute Cache unangetastet — die Website
 * degradiert nur, sie bricht nie. Reverse-engineert aus dem BFV-Widget (das alte
 * <BFVWidget.HTML5…>-Embed lud genau diese Endpunkte client-seitig nach).
 *
 * Aufruf:  php bin/matchcenter-sync.php [-v] [--dump]
 * Cron:    37 7,19 * * * cd /pfad/zur/site && /usr/bin/php bin/matchcenter-sync.php >> storage/logs/matchcenter.log 2>&1
 *          (zusätzlich am Wochenende häufiger, siehe config.example.php)
 *
 * Datenquelle (Host https://widget-prod.bfv.de, liefert sauberes JSON, kein HTML-Scraping):
 *   - Spiele/Ergebnisse:  /api/service/widget/v1/team/{teamPermanentId}/matches
 *   - Liga-Tabelle:       /api/service/widget/v1/competition/{compoundId}/table
 *   - Vereinswappen:      https://app.bfv.de/export.media/-/action/getLogo/format/0/id/{clubId}  (PNG)
 * Ohne Browser-typischen User-Agent antwortet der Host mit HTTP 418 — der $fetch-Helfer
 * setzt ihn (wie bin/instagram-sync.php).
 */
// Nur CLI — oder der authentifizierte Cron-Endpoint public/cron.php, der CRON_HTTP
// setzt (der Tarif kennt keine Cronjobs, siehe CLAUDE.md: Ausnahme zu Regel 3).
if (PHP_SAPI !== 'cli' && !defined('CRON_HTTP')) {
    exit(1);
}

// require_once, weil cron.php den Bootstrap schon geladen hat: app/bootstrap.php
// definiert Konstanten und lädt helpers.php, ein zweiter Durchlauf wäre ein Fatal.
require_once dirname(__DIR__) . '/app/bootstrap.php';

$verbose = in_array('-v', $argv ?? [], true);
$dump    = in_array('--dump', $argv ?? [], true);

$apiHost   = 'https://widget-prod.bfv.de';
$crestBase = 'https://app.bfv.de/export.media/-/action/getLogo/format/0/id/';
$referer   = (string) config('matchcenter.referer', 'https://widget-prod.bfv.de/');
$maxUpcoming = (int) config('matchcenter.max_upcoming', 5);
$maxResults  = (int) config('matchcenter.max_results', 5);
$teamsCfg    = (array) config('matchcenter.teams', []);

$cacheFile = DATA_PATH . '/matchcenter.json';
$crestDir  = PUBLIC_PATH . '/assets/img/crests';

// Anzeigename/Liga/Slug kommen aus der Single Source of Truth (data/teams.json),
// nicht aus der API — der Scraper erfindet keine kuratierten Namen.
$teamsData = json_load('teams');
$slugMap = [
    'erste-mannschaft'  => 'fussball/erste-mannschaft',
    'zweite-mannschaft' => 'fussball/zweite-mannschaft',
    'damen'             => 'fussball/damen',
];

$tz = new DateTimeZone('Europe/Berlin');
$today = (new DateTimeImmutable('now', $tz))->setTime(0, 0);
$weekdaysDe = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
// Wettbewerbsart → kurzes, sprechendes Badge-Label.
$competitionLabels = [
    'Meisterschaften'      => 'Liga',
    'Freundschaftsspiele'  => 'Freundschaft',
    'Pokalspiele'          => 'Pokal',
    'Pokal'                => 'Pokal',
    'Hallenrunde'          => 'Halle',
];

// Schreibt über die zentrale log_write() (helpers.php) nach storage/logs/matchcenter.log;
// ERROR landet zusätzlich in error.log. Mit -v zusätzlich auf stdout.
$log = static function (string $msg, string $level = 'INFO') use ($verbose): void {
    log_write('matchcenter', $level, $msg);
    if ($verbose) {
        echo '[' . date('c') . "] {$level}  {$msg}\n";
    }
};

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

if ($teamsCfg === []) {
    $fail('Keine Teams in config.matchcenter.teams konfiguriert');
}

// --- Lock gegen parallele Läufe ---
$lock = fopen(STORAGE_PATH . '/cache/matchcenter.lock', 'c');
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
    // Kein ERROR: ein überlappender Cron-Lauf ist kein Ausfall, der nächste Takt holt es nach.
    $log('Läuft bereits (Lock belegt) — Lauf übersprungen.', 'WARN');
    exit(1);
}

// --- HTTP-Helfer mit Browser-typischen Headern (gegen HTTP 418) ---
$fetch = static function (string $url, bool $json = true) use ($referer): string|false {
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_MAXREDIRS      => 5,
        CURLOPT_TIMEOUT        => 20,
        CURLOPT_ENCODING       => '',
        CURLOPT_USERAGENT      => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
        CURLOPT_HTTPHEADER     => [
            'Accept: ' . ($json ? 'application/json, text/plain, */*' : '*/*'),
            'Accept-Language: de-DE,de;q=0.9',
            'Referer: ' . $referer,
            'Sec-Fetch-Dest: ' . ($json ? 'empty' : 'image'),
            'Sec-Fetch-Mode: ' . ($json ? 'cors' : 'no-cors'),
            'Sec-Fetch-Site: cross-site',
        ],
    ]);
    $body = curl_exec($ch);
    $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    return ($body !== false && $status === 200) ? $body : false;
};

/** JSON-Endpoint holen und data-Knoten zurückgeben (oder null). */
$fetchJson = static function (string $url) use ($fetch, $dump, $log): ?array {
    $body = $fetch($url, true);
    if ($body === false) {
        return null;
    }
    if ($dump) {
        echo "DUMP {$url}\n" . substr($body, 0, 4000) . "\n";
    }
    $data = json_decode($body, true);
    return is_array($data) ? $data : null;
};

// --- Wappen-Download: idempotent, normalisiert auf max 96px PNG (Transparenz erhalten) ---
if (!is_dir($crestDir)) {
    mkdir($crestDir, 0755, true);
}
$crestKeep = [];
$downloadCrest = static function (?string $clubId) use ($fetch, $crestBase, $crestDir, &$crestKeep, $log): ?string {
    $clubId = trim((string) $clubId);
    if ($clubId === '' || !preg_match('/^[A-Z0-9-]+$/i', $clubId)) {
        return null;
    }
    $rel = "img/crests/{$clubId}.png";
    $file = "{$crestDir}/{$clubId}.png";
    $crestKeep["{$clubId}.png"] = true;
    if (is_file($file)) {
        return $rel;
    }
    $bin = $fetch($crestBase . $clubId, false);
    if ($bin === false) {
        $log("Wappen-Download fehlgeschlagen: {$clubId}", 'WARN');
        return null;
    }
    $src = @imagecreatefromstring($bin);
    if ($src === false) {
        $log("Wappen unlesbar: {$clubId}", 'WARN');
        return null;
    }
    $w = imagesx($src);
    $h = imagesy($src);
    $scale = min(96 / $w, 96 / $h, 1.0);
    $nw = max(1, (int) round($w * $scale));
    $nh = max(1, (int) round($h * $scale));
    $out = imagecreatetruecolor($nw, $nh);
    imagealphablending($out, false);
    imagesavealpha($out, true);
    imagefilledrectangle($out, 0, 0, $nw, $nh, imagecolorallocatealpha($out, 0, 0, 0, 127));
    imagecopyresampled($out, $src, 0, 0, 0, 0, $nw, $nh, $w, $h);
    imagepng($out, $file);
    return $rel;
};

// --- Datums-/Zeit-Helfer (deutsche Quellformate, Europe/Berlin) ---
$parseKickoff = static function (string $date, string $time) use ($tz): ?DateTimeImmutable {
    $date = trim($date);
    $time = trim($time) !== '' ? trim($time) : '00:00';
    if ($date === '') {
        return null;
    }
    $dt = DateTimeImmutable::createFromFormat('d.m.Y H:i', "{$date} {$time}", $tz);
    return $dt ?: null;
};
$dateDisplay = static function (?DateTimeImmutable $dt, string $raw) use ($weekdaysDe): string {
    if ($dt === null) {
        return $raw; // Roh-String behalten — Anzeige bricht nie weg.
    }
    return $weekdaysDe[(int) $dt->format('w')] . ', ' . $dt->format('d.m.');
};

// --- Saison-Label (Fußball-Saison läuft Jul–Jun): aus dem jüngsten Spiel ableiten,
//     ersatzweise (leere Saison) aus dem heutigen Datum → liefert immer ein Label. ---
$seasonLabel = static function (DateTimeImmutable $d): string {
    $y = (int) $d->format('Y');
    $start = (int) $d->format('n') >= 7 ? $y : $y - 1;
    return sprintf('Saison %d/%02d', $start, ($start + 1) % 100);
};
$seasonFromRows = static function (array ...$rowSets) use ($seasonLabel, $tz, $today): string {
    $latest = null;
    foreach ($rowSets as $rows) {
        foreach ($rows as $r) {
            if (empty($r['kickoff'])) {
                continue;
            }
            $dt = DateTimeImmutable::createFromFormat('Y-m-d\TH:i', (string) $r['kickoff'], $tz);
            if ($dt && ($latest === null || $dt > $latest)) {
                $latest = $dt;
            }
        }
    }
    return $seasonLabel($latest ?? $today);
};

/**
 * Ein Match aus der API in unser Anzeige-Format überführen.
 * @param array<string,mixed> $m  Rohes Match aus data.matches[]
 * @param string $teamCfgId  permanentId der eigenen Mannschaft (für is_home/outcome)
 */
$mapMatch = static function (array $m, string $teamCfgId) use ($parseKickoff, $dateDisplay, $downloadCrest, $competitionLabels, $tz): array {
    $rawDate = (string) ($m['kickoffDate'] ?? '');
    $rawTime = (string) ($m['kickoffTime'] ?? '');
    $dt = $parseKickoff($rawDate, $rawTime);
    $isHome = ($m['homeTeamPermanentId'] ?? null) === $teamCfgId;
    $type = (string) ($m['competitionType'] ?? '');

    $row = [
        'kickoff'      => $dt?->format('Y-m-d\TH:i'),
        'date_display' => $dateDisplay($dt, $rawDate),
        'time_display' => $rawTime !== '' ? $rawTime . ' Uhr' : '',
        'competition'  => $competitionLabels[$type] ?? ($type !== '' ? $type : 'Spiel'),
        'home'         => (string) ($m['homeTeamName'] ?? ''),
        'away'         => (string) ($m['guestTeamName'] ?? ''),
        'home_crest'   => $downloadCrest($m['homeClubId'] ?? null),
        'away_crest'   => $downloadCrest($m['guestClubId'] ?? null),
        'is_home'      => $isHome,
    ];

    $result = trim((string) ($m['result'] ?? ''));
    if ($result !== '' && preg_match('/^(\d+)\s*:\s*(\d+)/', $result, $mm)) {
        $gh = (int) $mm[1];
        $ga = (int) $mm[2];
        $own = $isHome ? $gh : $ga;
        $opp = $isHome ? $ga : $gh;
        $row['goals_home']  = $gh;
        $row['goals_away']  = $ga;
        $row['result']      = $result;
        $row['outcome']     = $own > $opp ? 'win' : ($own === $opp ? 'draw' : 'loss');
    }
    return $row;
};

// --- Vorheriger Cache: bei Teil-Ausfällen (BFV liefert 418/Fehler) behalten wir
//     die alten Daten dieses Teams bzw. Teils, statt die Sektion zu leeren. ---
$prev = [];
if (is_file($cacheFile)) {
    $decoded = json_decode((string) file_get_contents($cacheFile), true);
    if (is_array($decoded)) {
        $prev = $decoded;
    }
}
$prevByKey = [];
foreach ($prev['teams'] ?? [] as $pt) {
    if (!empty($pt['key'])) {
        $prevByKey[$pt['key']] = $pt;
    }
}
$cacheFallbackUsed = false;

// Wappen-Dateinamen aus (ggf. aus dem Cache übernommenen) Zeilen in die Keep-Liste
// aufnehmen, damit die GC unten sie nicht löscht.
$keepCrests = static function (array $rows) use (&$crestKeep): void {
    foreach ($rows as $r) {
        foreach (['home_crest', 'away_crest', 'crest'] as $k) {
            if (!empty($r[$k]) && is_string($r[$k])) {
                $crestKeep[basename($r[$k])] = true;
            }
        }
    }
};

// --- Pro Team: Spiele + Tabelle holen, parsen, Wappen laden (isoliert) ---
$resultTeams = [];
$upcomingAll = [];

foreach ($teamsCfg as $cfg) {
    $key = (string) ($cfg['key'] ?? '');
    $teamCfgId = (string) ($cfg['team_id'] ?? '');
    $cfgCompoundId = (string) ($cfg['compound_id'] ?? '');
    if ($key === '' || $teamCfgId === '') {
        $log("Team-Eintrag ohne key/team_id übersprungen", 'WARN');
        continue;
    }
    $prevTeam = $prevByKey[$key] ?? null;

    try {
        $prevCompound = (string) ($prevTeam['compound_id'] ?? '');
        $previous = $prevTeam['previous'] ?? null;

        // Spiele zuerst — daraus leiten wir die aktuelle Wettbewerbs-ID (Tabelle) ab,
        // damit eine neue Saison automatisch übernommen wird (die team_id ist stabil,
        // die compound_id der Tabelle wechselt pro Saison).
        $matchesData = $fetchJson("{$apiHost}/api/service/widget/v1/team/{$teamCfgId}/matches");
        $apiCompound = (string) ($matchesData['data']['team']['compoundId'] ?? '');

        // Saisonwechsel: liefert die API eine NEUE compoundId, archivieren wir die eben
        // abgelaufene Saison einmalig (verschachteltes previous des Vorgängers wird nicht
        // mitkopiert → keine unbegrenzte Schachtelung).
        if ($apiCompound !== '' && $prevCompound !== '' && $apiCompound !== $prevCompound && $prevTeam !== null) {
            $previous = [
                'season'       => $prevTeam['season'] ?? null,
                'league'       => $prevTeam['league'] ?? null,
                'last_results' => $prevTeam['last_results'] ?? [],
                'table'        => $prevTeam['table'] ?? [],
            ];
        }

        // Beste bekannte compoundId: API > letzter Cache > Config-Fallback. So triggert ein
        // vorübergehender Fetch-Ausfall (apiCompound leer) keinen falschen Saisonwechsel.
        $compoundId = $apiCompound !== ''
            ? $apiCompound
            : ($prevCompound !== '' ? $prevCompound : $cfgCompoundId);
        $tableData = $compoundId !== '' ? $fetchJson("{$apiHost}/api/service/widget/v1/competition/{$compoundId}/table") : null;

        // Name/Liga bevorzugt aus teams.json (Single Source), sonst API/alter Cache.
        $apiCompetition = (string) ($matchesData['data']['team']['competitionName'] ?? '');
        $name = $teamsData[$key]['name'] ?? ($matchesData['data']['team']['name'] ?? ($prevTeam['name'] ?? $key));
        $league = $teamsData[$key]['league'] ?? null;
        if (!$league) {
            $league = $apiCompetition !== '' ? $apiCompetition : ($prevTeam['league'] ?? null);
        }

        // --- Spiele-Teil ---
        if ($matchesData !== null) {
            $matches = $matchesData['data']['matches'] ?? [];
            $upcoming = [];
            $results = [];
            foreach ($matches as $m) {
                // Spielfreie Runden führt der BFV als Pseudo-Begegnung gegen
                // "SPIELFREI" — kein echtes Spiel, gehört weder in "Nächste
                // Spiele" noch in Ergebnisse (tauchte sonst bis in den
                // Startseiten-Hero und die Stadionzeitung durch).
                if (($m['homeTeamName'] ?? '') === 'SPIELFREI' || ($m['guestTeamName'] ?? '') === 'SPIELFREI') {
                    continue;
                }
                $hasResult = trim((string) ($m['result'] ?? '')) !== '';
                $row = $mapMatch($m, $teamCfgId);
                if ($hasResult) {
                    $results[] = $row;
                } else {
                    // Ohne Ergebnis nur zeigen, wenn Anstoß heute/künftig.
                    $dt = $row['kickoff'] !== null ? new DateTimeImmutable($row['kickoff'], $tz) : null;
                    if ($dt === null || $dt->setTime(0, 0) >= $today) {
                        $upcoming[] = $row;
                    }
                }
            }
            usort($upcoming, static fn ($a, $b) => ($a['kickoff'] ?? '') <=> ($b['kickoff'] ?? ''));
            usort($results, static fn ($a, $b) => ($b['kickoff'] ?? '') <=> ($a['kickoff'] ?? ''));
            $upcoming = array_slice($upcoming, 0, $maxUpcoming);
            $results = array_slice($results, 0, $maxResults);

            // permanentId → clubId-Karte (für Wappen in der Tabelle).
            $clubIdByPermanent = [];
            foreach ($matches as $m) {
                if (!empty($m['homeTeamPermanentId']) && !empty($m['homeClubId'])) {
                    $clubIdByPermanent[$m['homeTeamPermanentId']] = $m['homeClubId'];
                }
                if (!empty($m['guestTeamPermanentId']) && !empty($m['guestClubId'])) {
                    $clubIdByPermanent[$m['guestTeamPermanentId']] = $m['guestClubId'];
                }
            }
        } else {
            // Spiele-Fetch fehlgeschlagen → alten Cache dieses Teams behalten.
            $upcoming = $prevTeam['next_matches'] ?? [];
            $results = $prevTeam['last_results'] ?? [];
            $clubIdByPermanent = [];
            $cacheFallbackUsed = true;
            $keepCrests($upcoming);
            $keepCrests($results);
            $log("Team {$key}: Spiele-Fetch fehlgeschlagen — alter Cache übernommen", 'WARN');
        }

        // --- Tabellen-Teil ---
        if ($tableData !== null) {
            $tableRows = $tableData['data']['table'] ?? [];
            $cfgZone = $tableData['data']['configuration'] ?? [];
            $rowCount = count($tableRows);
            $promo = (int) ($cfgZone['promotionTeamCount'] ?? 0);
            $promoPlayoff = (int) ($cfgZone['promotionPlayoffTeamCount'] ?? 0);
            $releg = (int) ($cfgZone['relegationTeamCount'] ?? 0);
            $relegPlayoff = (int) ($cfgZone['relegationPlayoffTeamCount'] ?? 0);

            $table = [];
            foreach ($tableRows as $r) {
                $pos = (int) ($r['position'] ?? 0);
                $permanentId = (string) ($r['team']['permanentId'] ?? '');
                $zone = null;
                if ($pos >= 1 && $pos <= $promo) {
                    $zone = 'promotion';
                } elseif ($pos <= $promo + $promoPlayoff) {
                    $zone = 'promotion-playoff';
                } elseif ($releg > 0 && $pos > $rowCount - $releg) {
                    $zone = 'relegation';
                } elseif ($relegPlayoff > 0 && $pos > $rowCount - $releg - $relegPlayoff) {
                    $zone = 'relegation-playoff';
                }
                $table[] = [
                    'rank'        => $pos,
                    'club'        => (string) ($r['team']['name'] ?? ''),
                    'crest'       => $downloadCrest($clubIdByPermanent[$permanentId] ?? null),
                    'played'      => (int) ($r['matches'] ?? 0),
                    'won'         => (int) ($r['matchesWon'] ?? 0),
                    'drawn'       => (int) ($r['matchesDrawn'] ?? 0),
                    'lost'        => (int) ($r['matchesLost'] ?? 0),
                    'goal_diff'   => (int) ($r['goalsDiff'] ?? 0),
                    'points'      => (int) ($r['points'] ?? 0),
                    'zone'        => $zone,
                    'is_own_club' => $permanentId === $teamCfgId,
                ];
            }
        } else {
            // Tabellen-Fetch fehlgeschlagen → alte Tabelle dieses Teams behalten.
            $table = $prevTeam['table'] ?? [];
            $cacheFallbackUsed = true;
            $keepCrests($table);
            $log("Team {$key}: Tabellen-Fetch fehlgeschlagen — alte Tabelle übernommen", 'WARN');
        }

        // Aktuelle Saison immer zeigen, solange der Spiele-Fetch erfolgreich war (auch wenn
        // sie noch leer ist) oder Daten/eine Vorsaison vorliegen. Nur überspringen, wenn der
        // Fetch fehlschlug UND weder Cache noch Vorsaison existieren.
        $hasCurrent = $upcoming !== [] || $results !== [] || $table !== [];
        $hasPrevious = $previous !== null
            && (($previous['last_results'] ?? []) !== [] || ($previous['table'] ?? []) !== []);
        if (!$hasCurrent && $matchesData === null && !$hasPrevious) {
            $log("Team {$key}: keine Daten (API leer, kein Cache) — übersprungen", 'WARN');
            continue;
        }

        $season = $seasonFromRows($upcoming, $results);

        $teamBlock = [
            'key'          => $key,
            'name'         => $name,
            'league'       => $league,
            'slug'         => $slugMap[$key] ?? '',
            'compound_id'  => $compoundId,
            'season'       => $season,
            'next_matches' => $upcoming,
            'last_results' => $results,
            'table'        => $table,
        ];
        if ($hasPrevious) {
            $teamBlock['previous'] = $previous;
            $keepCrests($previous['last_results'] ?? []);
            $keepCrests($previous['table'] ?? []);
        }
        $resultTeams[] = $teamBlock;

        foreach ($upcoming as $u) {
            $upcomingAll[] = array_merge($u, ['team_key' => $key, 'team_name' => $name]);
        }

        $log("Team {$key}: " . count($upcoming) . ' anstehend, ' . count($results) . ' Ergebnisse, ' . count($table) . ' Tabellenplätze'
            . ($hasPrevious ? ' (+ Vorsaison archiviert)' : ''));
    } catch (\Throwable $e) {
        // Ein fehlschlagendes Team darf die anderen nicht killen — alten Block behalten.
        $log("Team {$key} übersprungen: " . $e->getMessage(), 'WARN');
        if ($prevTeam !== null) {
            $resultTeams[] = $prevTeam;
            $cacheFallbackUsed = true;
            $keepCrests($prevTeam['next_matches'] ?? []);
            $keepCrests($prevTeam['last_results'] ?? []);
            $keepCrests($prevTeam['table'] ?? []);
            $keepCrests($prevTeam['previous']['last_results'] ?? []);
            $keepCrests($prevTeam['previous']['table'] ?? []);
            foreach ($prevTeam['next_matches'] ?? [] as $u) {
                $upcomingAll[] = array_merge($u, ['team_key' => $key, 'team_name' => $prevTeam['name'] ?? $key]);
            }
        }
    }
}

if ($resultTeams === []) {
    $fail('Kein einziges Team lieferte Daten (API blockt evtl. die Server-IP oder Struktur geändert)');
}

// Globaler „nächste Spiele"-Slider: teamübergreifend, nach Anstoß sortiert.
usort($upcomingAll, static fn ($a, $b) => ($a['kickoff'] ?? '') <=> ($b['kickoff'] ?? ''));
$upcomingAll = array_slice($upcomingAll, 0, $maxUpcoming);

// --- Nicht mehr referenzierte Wappen aufräumen. Nur, wenn ALLE Teams frisch
//     geladen wurden (sonst könnte ein transienter Ausfall ein noch genutztes
//     Wappen löschen) und die Keep-Liste nicht leer ist. ---
if (!$cacheFallbackUsed && $crestKeep !== []) {
    foreach (glob("{$crestDir}/*.png") ?: [] as $existing) {
        if (!isset($crestKeep[basename($existing)])) {
            unlink($existing);
        }
    }
}

// --- Cache atomar schreiben (tmp + rename). Jeder Schritt ist abgesichert: ein
//     defektes UTF-8-Zeichen aus der API (json_encode → Exception) oder ein
//     Schreib-/Rename-Fehler darf den bestehenden guten Cache NICHT leerschreiben. ---
try {
    $payload = json_encode([
        'fetched_at' => time(),
        'stale'      => $cacheFallbackUsed, // true = mind. ein Teil kam aus dem alten Cache
        'teams'      => $resultTeams,
        'upcoming'   => $upcomingAll,
    ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
} catch (\Throwable $e) {
    $fail('Cache-Encode fehlgeschlagen: ' . $e->getMessage());
}

$tmp = $cacheFile . '.tmp';
if (file_put_contents($tmp, $payload) === false) {
    $fail("Cache-Schreiben fehlgeschlagen (tmp: {$tmp})");
}
if (!rename($tmp, $cacheFile)) {
    @unlink($tmp);
    $fail("Cache-Rename fehlgeschlagen ({$tmp} → {$cacheFile})");
}

$log('OK: ' . count($resultTeams) . ' Teams gecached, ' . count($crestKeep) . ' Wappen lokal in assets/img/crests/');
