<?php
declare(strict_types=1);

header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store, max-age=0');
header('Access-Control-Allow-Origin: *');

$demo = filter_input(INPUT_GET, 'demo', FILTER_VALIDATE_BOOL);
$root = dirname(__DIR__, 3);
$configFile = $root . '/config.php';

if ($demo) {
    respond(demoPayload());
}

if (!is_file($configFile)) {
    errorResponse(500, 'Server configuration is missing. Use ?demo=1 for demo data.');
}

$config = require $configFile;
$required = ['sportsdb_api_key', 'sportsdb_base_url', 'league_id', 'cache_seconds', 'timezone'];
foreach ($required as $key) {
    if (!isset($config[$key]) || $config[$key] === '') {
        errorResponse(500, 'Server configuration is incomplete.');
    }
}
$showFinalGames = (bool) ($config['show_final_games'] ?? false);
$finalScoreCardSeconds = max(0, (int) ($config['final_score_card_seconds'] ?? 300));
$finalScoreTickerSeconds = max(0, (int) ($config['final_score_ticker_seconds'] ?? 3600));
$finalScoreMaxEventAgeSeconds = max(3600, (int) ($config['final_score_max_event_age_seconds'] ?? 28800));

$cacheDir = dirname(__DIR__) . '/cache';
if (!is_dir($cacheDir) && !mkdir($cacheDir, 0775, true) && !is_dir($cacheDir)) {
    errorResponse(500, 'Cache directory could not be created.');
}

// Keep only compact daily totals. No IP addresses, device IDs, request
// headers, or individual request records are stored by this counter.
$endpointUsageStats = recordEndpointUsage($cacheDir, (string) $config['timezone']);

$cacheFile = $cacheDir . '/nfl-live.json';
$lockFile = $cacheDir . '/nfl-live.lock';
$cached = readJsonFile($cacheFile);
$cacheAge = is_file($cacheFile) ? time() - (int) filemtime($cacheFile) : PHP_INT_MAX;

if ($cached !== null && $cacheAge < (int) $config['cache_seconds']) {
    $cached['cache']['hit'] = true;
    $cached['cache']['age_seconds'] = max(0, $cacheAge);
    $cached = addVenueArtworkSafely($cached, $config, $cacheDir, 'fresh_cache');
    try {
        $cached = addFeaturedPlayers($cached, $config, $cacheDir);
    } catch (Throwable $featureException) {
        $cached['featured_players'] = [];
        $cached['player_data'] = playerEnrichmentFailure($featureException, 'fresh_cache');
    }
    $cached = addWeatherDataSafely($cached, $config, $cacheDir, 'fresh_cache');
    $cached = addOddsDataSafely($cached, $config, $cacheDir, 'fresh_cache');
    $cached = addRecentGamesSafely($cached, $config, $cacheDir, 'fresh_cache');
    respond(applyPresentationFilters($cached, $showFinalGames, $finalScoreCardSeconds));
}

$lock = fopen($lockFile, 'c');
if ($lock === false) {
    errorResponse(500, 'Cache lock could not be opened.');
}

if (!flock($lock, LOCK_EX)) {
    fclose($lock);
    errorResponse(500, 'Cache lock could not be acquired.');
}

clearstatcache(true, $cacheFile);
$cached = readJsonFile($cacheFile);
$cacheAge = is_file($cacheFile) ? time() - (int) filemtime($cacheFile) : PHP_INT_MAX;
if ($cached !== null && $cacheAge < (int) $config['cache_seconds']) {
    flock($lock, LOCK_UN);
    fclose($lock);
    $cached['cache']['hit'] = true;
    $cached['cache']['age_seconds'] = max(0, $cacheAge);
    $cached = addVenueArtworkSafely($cached, $config, $cacheDir, 'locked_cache');
    try {
        $cached = addFeaturedPlayers($cached, $config, $cacheDir);
    } catch (Throwable $featureException) {
        $cached['featured_players'] = [];
        $cached['player_data'] = playerEnrichmentFailure($featureException, 'locked_cache');
    }
    $cached = addWeatherDataSafely($cached, $config, $cacheDir, 'locked_cache');
    $cached = addOddsDataSafely($cached, $config, $cacheDir, 'locked_cache');
    $cached = addRecentGamesSafely($cached, $config, $cacheDir, 'locked_cache');
    respond(applyPresentationFilters($cached, $showFinalGames, $finalScoreCardSeconds));
}

try {
    $upstream = fetchLivescores($config);
    $payload = normalizeLivescores($upstream, (string) $config['timezone'], (int) $config['cache_seconds']);
    $payload = updateFinalScoreTicker(
        $payload,
        $cacheDir . '/nfl-final-history.json',
        $finalScoreTickerSeconds,
        $finalScoreMaxEventAgeSeconds
    );
    $payload = addScheduleFallback($payload, $config, $cacheDir);
    $payload = addRecentGamesSafely($payload, $config, $cacheDir, 'provider_payload');
    $payload = addTeamRecords($payload, $config, $cacheDir);
    $payload = addVenueArtworkSafely($payload, $config, $cacheDir, 'provider_payload');
    $payload = addFeaturedPlayers($payload, $config, $cacheDir);
    $payload = addWeatherDataSafely($payload, $config, $cacheDir, 'provider_payload');
    $payload = addOddsDataSafely($payload, $config, $cacheDir, 'provider_payload');
    $payload = addAdvertising($payload, $config, $cacheDir);
    $encoded = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
    $temporary = $cacheFile . '.tmp.' . getmypid();
    if (file_put_contents($temporary, $encoded, LOCK_EX) === false || !rename($temporary, $cacheFile)) {
        @unlink($temporary);
        throw new RuntimeException('Could not save the score cache.');
    }
    flock($lock, LOCK_UN);
    fclose($lock);
    respond(applyPresentationFilters($payload, $showFinalGames, $finalScoreCardSeconds));
} catch (Throwable $exception) {
    flock($lock, LOCK_UN);
    fclose($lock);
    if ($cached !== null) {
        // A cache written by an older API build may remain usable while the
        // live-score provider is unavailable. Upgrade it before responding so
        // new presentation features are not hidden by the stale payload.
        $cached['schema_version'] = 6;
        $cached['stale'] = true;
        $cached['warning'] = 'Live provider temporarily unavailable.';
        $cached['cache']['hit'] = true;
        $cached['cache']['age_seconds'] = max(0, $cacheAge);
        $cached = addVenueArtworkSafely($cached, $config, $cacheDir, 'stale_cache');
        try {
            $cached = addFeaturedPlayers($cached, $config, $cacheDir);
        } catch (Throwable $featureException) {
            // Player enrichment must never take down the score endpoint.
            $cached['featured_players'] = [];
            $cached['player_data'] = playerEnrichmentFailure($featureException, 'stale_cache');
        }
        $cached = addWeatherDataSafely($cached, $config, $cacheDir, 'stale_cache');
        $cached = addOddsDataSafely($cached, $config, $cacheDir, 'stale_cache');
        $cached = addRecentGamesSafely($cached, $config, $cacheDir, 'stale_cache');
        $cached = addAdvertising($cached, $config, $cacheDir);
        try {
            writeJsonFile($cacheFile, $cached);
        } catch (Throwable $cacheException) {
            // The upgraded response remains usable even if it cannot be saved.
        }
        respond(applyPresentationFilters($cached, $showFinalGames, $finalScoreCardSeconds));
    }
    errorResponse(502, 'Live scores are temporarily unavailable.');
}

function fetchLivescores(array $config): array
{
    $url = rtrim((string) $config['sportsdb_base_url'], '/') . '/livescore/' . rawurlencode((string) $config['league_id']);
    return fetchSportsDbUrl($url, (string) $config['sportsdb_api_key']);
}

function fetchSportsDbUrl(string $url, string $apiKey, int $timeoutSeconds = 8, int $connectTimeoutSeconds = 4): array
{
    $curl = curl_init($url);
    if ($curl === false) {
        throw new RuntimeException('Could not initialize HTTP client.');
    }
    $headers = ['Accept: application/json'];
    if ($apiKey !== '') {
        $headers[] = 'X-API-KEY: ' . $apiKey;
    }
    curl_setopt_array($curl, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => $connectTimeoutSeconds,
        CURLOPT_TIMEOUT => $timeoutSeconds,
        CURLOPT_HTTPHEADER => $headers,
        CURLOPT_USERAGENT => 'TVScoreboard.cc/1.0',
    ]);
    $body = curl_exec($curl);
    $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
    $error = curl_error($curl);
    curl_close($curl);
    if (!is_string($body) || $body === '' || $status < 200 || $status >= 300) {
        throw new RuntimeException('Sports provider request failed: ' . ($error !== '' ? $error : 'HTTP ' . $status));
    }
    return json_decode($body, true, 512, JSON_THROW_ON_ERROR);
}

function addAdvertising(array $payload, array $config, string $cacheDir): array
{
    $feedUrl = trim((string)($config['ad_feed_url'] ?? 'https://admin.tvscoreboard.cc/display-feed.php'));
    $cacheSeconds = max(5, (int)($config['ad_cache_seconds'] ?? 15));
    $cacheFile = $cacheDir . '/active-ad.json';
    $cached = readJsonFile($cacheFile);
    $cacheAge = is_file($cacheFile) ? time() - (int)filemtime($cacheFile) : PHP_INT_MAX;

    $feed = null;
    if ($cached !== null && $cacheAge < $cacheSeconds) {
        $feed = $cached;
    } elseif ($feedUrl !== '') {
        try {
            $feed = fetchSportsDbUrl($feedUrl, '');
            writeJsonFile($cacheFile, $feed);
        } catch (Throwable $exception) {
            $feed = $cached;
        }
    }

    $primary = is_array($feed['advertising']['primary'] ?? null)
        ? $feed['advertising']['primary']
        : ['active' => false];

    $mediaType = strtolower(trim((string)($primary['media_type'] ?? '')));
    $url = secureUrl(trim((string)($primary['url'] ?? '')));
    $active = ($primary['active'] ?? false) === true
        && in_array($mediaType, ['video', 'image'], true)
        && str_starts_with($url, 'https://');

    $payload['advertising'] = [
        'primary' => [
            'active' => $active,
            'id' => $active ? (int)($primary['id'] ?? 0) : 0,
            'name' => $active ? (string)($primary['name'] ?? '') : '',
            'media_type' => $active ? $mediaType : '',
            'url' => $active ? $url : '',
            'starts_at' => $active ? ($primary['starts_at'] ?? null) : null,
            'ends_at' => $active ? ($primary['ends_at'] ?? null) : null,
        ],
    ];
    return $payload;
}

function normalizeLivescores(array $upstream, string $timezone, int $cacheSeconds): array
{
    $rows = $upstream['events'] ?? $upstream['livescore'] ?? $upstream['livescores'] ?? [];
    if (!is_array($rows)) {
        $rows = [];
    }
    $games = [];
    foreach ($rows as $row) {
        if (!is_array($row)) continue;
        $status = strtoupper(trim((string) ($row['strStatus'] ?? '')));
        $progress = trim((string) ($row['strProgress'] ?? ''));
        $state = gameState($status, $progress);
        $commenceTimestamp = eventStartTimestamp($row);
        $games[] = [
            'id' => (string) ($row['idEvent'] ?? $row['idLiveScore'] ?? ''),
            'away' => team((string) ($row['strAwayTeam'] ?? 'Away Team'), (string) ($row['idAwayTeam'] ?? '')),
            'home' => team((string) ($row['strHomeTeam'] ?? 'Home Team'), (string) ($row['idHomeTeam'] ?? '')),
            'away_score' => nullableInt($row['intAwayScore'] ?? null),
            'home_score' => nullableInt($row['intHomeScore'] ?? null),
            'away_badge' => secureUrl((string) ($row['strAwayTeamBadge'] ?? '')),
            'home_badge' => secureUrl((string) ($row['strHomeTeamBadge'] ?? '')),
            'status' => $status,
            'status_text' => statusText($status, $progress, (string) ($row['strEventTime'] ?? '')),
            'state' => $state,
            'commence_at' => $commenceTimestamp !== null ? gmdate(DATE_ATOM, $commenceTimestamp) : null,
        ];
    }
    usort($games, static fn(array $a, array $b): int => stateOrder($a['state']) <=> stateOrder($b['state']));
    return [
        'ok' => true,
        'schema_version' => 6,
        'sport' => 'American Football',
        'league' => 'NFL',
        'league_key' => 'nfl',
        'mode' => hasState($games, 'live') ? 'live' : 'schedule',
        'generated_at' => gmdate(DATE_ATOM),
        'display_timezone' => $timezone,
        'stale' => false,
        'games' => $games,
        'cache' => ['hit' => false, 'age_seconds' => 0, 'ttl_seconds' => $cacheSeconds],
    ];
}

function addScheduleFallback(array $payload, array $config, string $cacheDir): array
{
    $games = $payload['games'] ?? [];
    if (is_array($games) && (hasState($games, 'live') || hasState($games, 'scheduled'))) {
        return $payload;
    }

    $scheduleCacheFile = $cacheDir . '/nfl-schedule-v3.json';
    $scheduleCacheSeconds = max(300, (int) ($config['schedule_cache_seconds'] ?? 900));
    $cachedSchedule = readJsonFile($scheduleCacheFile);
    $scheduleAge = is_file($scheduleCacheFile) ? time() - (int) filemtime($scheduleCacheFile) : PHP_INT_MAX;

    if ($cachedSchedule !== null && $scheduleAge < $scheduleCacheSeconds) {
        $scheduleGames = $cachedSchedule['games'] ?? [];
    } else {
        try {
            $upstream = fetchSchedule($config);
            $scheduleGames = normalizeSchedule($upstream, (string) $config['timezone']);
            writeJsonFile($scheduleCacheFile, ['games' => $scheduleGames, 'generated_at' => gmdate(DATE_ATOM)]);
        } catch (Throwable $exception) {
            $scheduleGames = is_array($cachedSchedule['games'] ?? null) ? $cachedSchedule['games'] : [];
        }
    }

    $payload['games'] = array_slice($scheduleGames, 0, 20);
    $payload['mode'] = 'schedule';
    return $payload;
}


function addRecentGamesSafely(array $payload, array $config, string $cacheDir, string $stage): array
{
    try {
        return addRecentGames($payload, $config, $cacheDir);
    } catch (Throwable $exception) {
        $message = preg_replace('/\s+/', ' ', trim($exception->getMessage())) ?? 'Unknown recent-games error';
        error_log(
            'TV Scoreboard recent-games enrichment failed [' . $stage . '] '
            . get_class($exception) . ': ' . substr($message, 0, 180)
        );

        if (!is_array($payload['recent_games'] ?? null)) {
            $payload['recent_games'] = [];
        }

        $payload['recent_games_meta'] = [
            'available' => $payload['recent_games'] !== [],
            'reason' => 'enrichment_error',
            'cache_hit' => false,
            'cache_age_seconds' => null,
            'cache_ttl_seconds' => max(300, (int)($config['recent_games_cache_seconds'] ?? 900)),
            'stale' => false,
            'last_updated' => null,
            'provider' => 'TheSportsDB',
        ];

        return $payload;
    }
}

function addRecentGames(array $payload, array $config, string $cacheDir): array
{
    $cacheFile = $cacheDir . '/nfl-recent-games-v1.json';
    $cacheSeconds = max(300, (int)($config['recent_games_cache_seconds'] ?? 900));
    $cached = readJsonFile($cacheFile);
    $cacheAge = is_file($cacheFile) ? time() - (int)filemtime($cacheFile) : PHP_INT_MAX;

    $games = [];
    $cacheHit = false;
    $stale = false;
    $lastUpdated = null;

    if ($cached !== null && $cacheAge < $cacheSeconds) {
        $games = is_array($cached['games'] ?? null) ? $cached['games'] : [];
        $cacheHit = true;
        $lastUpdated = $cached['generated_at'] ?? null;
    } else {
        try {
            $upstream = fetchRecentGames($config);
            $games = normalizeRecentGames($upstream);

            $stored = [
                'games' => $games,
                'generated_at' => gmdate(DATE_ATOM),
            ];

            writeJsonFile($cacheFile, $stored);
            $lastUpdated = $stored['generated_at'];
            $cacheAge = 0;
        } catch (Throwable $exception) {
            if ($cached === null) {
                throw $exception;
            }

            $games = is_array($cached['games'] ?? null) ? $cached['games'] : [];
            $cacheHit = true;
            $stale = true;
            $lastUpdated = $cached['generated_at'] ?? null;
        }
    }

    $payload['recent_games'] = array_slice(array_values($games), 0, 20);
    $payload['recent_games_meta'] = [
        'available' => $payload['recent_games'] !== [],
        'reason' => $payload['recent_games'] !== [] ? 'ok' : 'no_recent_games',
        'cache_hit' => $cacheHit,
        'cache_age_seconds' => $cacheHit ? max(0, $cacheAge) : 0,
        'cache_ttl_seconds' => $cacheSeconds,
        'stale' => $stale,
        'last_updated' => $lastUpdated,
        'provider' => 'TheSportsDB',
    ];

    return $payload;
}

function fetchRecentGames(array $config): array
{
    $url = 'https://www.thesportsdb.com/api/v1/json/'
        . rawurlencode((string)$config['sportsdb_api_key'])
        . '/eventspastleague.php?id='
        . rawurlencode((string)$config['league_id']);

    return fetchSportsDbUrl($url, '');
}

function normalizeRecentGames(array $upstream): array
{
    $rows = $upstream['events'] ?? $upstream['schedule'] ?? [];
    if (!is_array($rows)) {
        return [];
    }

    $games = [];
    $finalStatuses = ['FT', 'AOT', 'AET', 'FINAL', 'FIN', 'FINISHED'];

    foreach ($rows as $row) {
        if (!is_array($row)) continue;

        $awayScore = nullableInt($row['intAwayScore'] ?? null);
        $homeScore = nullableInt($row['intHomeScore'] ?? null);
        if ($awayScore === null || $homeScore === null) continue;

        $status = strtoupper(trim((string)($row['strStatus'] ?? '')));
        $progress = trim((string)($row['strProgress'] ?? ''));

        $isFinal = in_array($status, $finalStatuses, true)
            || preg_match('/^FINAL(?:\b|\/)/i', $progress) === 1;

        // eventspastleague.php contains completed league events. Some provider
        // rows omit strStatus, so a scored event with a blank status is valid.
        if (!$isFinal && $status !== '') continue;

        $timestamp = eventStartTimestamp($row);
        $statusText = in_array($status, ['AOT', 'AET'], true)
            || preg_match('/OT|OVERTIME/i', $progress) === 1
            ? 'FINAL/OT'
            : 'FINAL';

        $games[] = [
            'id' => (string)($row['idEvent'] ?? ''),
            'away' => team(
                (string)($row['strAwayTeam'] ?? 'Away Team'),
                (string)($row['idAwayTeam'] ?? '')
            ),
            'home' => team(
                (string)($row['strHomeTeam'] ?? 'Home Team'),
                (string)($row['idHomeTeam'] ?? '')
            ),
            'away_score' => $awayScore,
            'home_score' => $homeScore,
            'away_badge' => secureUrl((string)($row['strAwayTeamBadge'] ?? '')),
            'home_badge' => secureUrl((string)($row['strHomeTeamBadge'] ?? '')),
            'status' => $status !== '' ? $status : 'FT',
            'status_text' => $statusText,
            'state' => 'final',
            'commence_at' => $timestamp !== null ? gmdate(DATE_ATOM, $timestamp) : null,
            'round' => isset($row['intRound']) ? (int)$row['intRound'] : null,
            'season' => trim((string)($row['strSeason'] ?? '')),
        ];
    }

    usort($games, static function (array $a, array $b): int {
        $aTime = isset($a['commence_at']) ? strtotime((string)$a['commence_at']) : false;
        $bTime = isset($b['commence_at']) ? strtotime((string)$b['commence_at']) : false;
        return (int)($bTime ?: 0) <=> (int)($aTime ?: 0);
    });

    return $games;
}

function fetchSchedule(array $config): array
{
    // The V2 next-league route is capped at 10 events. The premium V1 route
    // returns up to 20, which covers a complete NFL week including late games.
    $url = 'https://www.thesportsdb.com/api/v1/json/'
        . rawurlencode((string) $config['sportsdb_api_key'])
        . '/eventsnextleague.php?id='
        . rawurlencode((string) $config['league_id']);
    return fetchSportsDbUrl($url, '');
}

function normalizeSchedule(array $upstream, string $timezone): array
{
    $rows = $upstream['events'] ?? $upstream['schedule'] ?? [];
    if (!is_array($rows)) {
        return [];
    }

    $games = [];
    foreach ($rows as $row) {
        if (!is_array($row)) continue;

        $games[] = [
            'id' => (string) ($row['idEvent'] ?? ''),
            'away' => team((string) ($row['strAwayTeam'] ?? 'Away Team'), (string) ($row['idAwayTeam'] ?? '')),
            'home' => team((string) ($row['strHomeTeam'] ?? 'Home Team'), (string) ($row['idHomeTeam'] ?? '')),
            'away_score' => null,
            'home_score' => null,
            'away_badge' => secureUrl((string) ($row['strAwayTeamBadge'] ?? '')),
            'home_badge' => secureUrl((string) ($row['strHomeTeamBadge'] ?? '')),
            'status' => 'NS',
            'status_text' => scheduleTime($row, $timezone),
            'state' => 'scheduled',
            'commence_at' => (($timestamp = eventStartTimestamp($row)) !== null) ? gmdate(DATE_ATOM, $timestamp) : null,
        ];
    }

    return $games;
}

function scheduleTime(array $row, string $timezone): string
{
    try {
        $zone = new DateTimeZone($timezone);
        $timestamp = trim((string) ($row['strTimestamp'] ?? ''));

        if ($timestamp !== '') {
            return (new DateTimeImmutable($timestamp))->setTimezone($zone)->format('l n/j • g:i A');
        }

        $date = trim((string) ($row['dateEvent'] ?? ''));
        $time = trim((string) ($row['strTime'] ?? $row['strEventTime'] ?? ''));
        if ($date !== '' && $time !== '') {
            $utc = new DateTimeZone('UTC');
            return (new DateTimeImmutable($date . ' ' . $time, $utc))->setTimezone($zone)->format('l n/j • g:i A');
        }
        if ($date !== '') {
            return (new DateTimeImmutable($date, $zone))->format('l n/j');
        }
    } catch (Throwable $exception) {
        // Fall through to a safe generic label when provider time data is malformed.
    }

    return 'UPCOMING';
}

function addTeamRecords(array $payload, array $config, string $cacheDir): array
{
    $recordData = loadSeasonRecords($config, $cacheDir);
    $records = $recordData['records'] ?? [];
    $available = (bool) ($recordData['available'] ?? false);

    if ($available && is_array($records)) {
        foreach (($payload['games'] ?? []) as $index => $game) {
            if (!is_array($game)) continue;

            foreach (['away', 'home'] as $side) {
                $teamName = (string) ($game[$side]['name'] ?? '');
                $key = recordKey($teamName);
                if ($key === '' || !isset($records[$key]) || !is_array($records[$key])) continue;

                $record = $records[$key];
                $payload['games'][$index][$side]['wins'] = (int) ($record['wins'] ?? 0);
                $payload['games'][$index][$side]['losses'] = (int) ($record['losses'] ?? 0);
                $payload['games'][$index][$side]['ties'] = (int) ($record['ties'] ?? 0);
                $payload['games'][$index][$side]['record'] = formatRecord($record);
            }
        }
    }

    $payload['standings'] = [
        'available' => $available,
        'season' => (string) ($recordData['season'] ?? ''),
        'generated_at' => $recordData['generated_at'] ?? null,
    ];
    return $payload;
}

function addWeatherDataSafely(array $payload, array $config, string $cacheDir, string $stage): array
{
    try {
        return addWeatherData($payload, $config, $cacheDir);
    } catch (Throwable $exception) {
        $message = preg_replace('/\s+/', ' ', trim($exception->getMessage())) ?? 'Unknown weather error';
        error_log('TV Scoreboard weather enrichment failed [' . $stage . '] ' . get_class($exception) . ': ' . substr($message, 0, 180));
        $payload['weather_data'] = [
            'enabled' => trim((string)($config['weatherapi_key'] ?? '')) !== '',
            'available' => false,
            'reason' => 'enrichment_error',
            'provider' => 'WeatherAPI.com',
            'attribution' => 'Powered by WeatherAPI.com',
        ];
        return $payload;
    }
}

function addWeatherData(array $payload, array $config, string $cacheDir): array
{
    $apiKey = trim((string)($config['weatherapi_key'] ?? ''));
    if ($apiKey === '') {
        $payload['weather_data'] = [
            'enabled' => false,
            'available' => false,
            'reason' => 'missing_api_key',
            'provider' => 'WeatherAPI.com',
            'attribution' => 'Powered by WeatherAPI.com',
        ];
        return $payload;
    }

    $games = is_array($payload['games'] ?? null) ? $payload['games'] : [];
    $externalBudget = max(1, min(8, (int)($config['weather_max_new_requests'] ?? 2)));
    $liveCacheSeconds = max(600, (int)($config['weather_live_cache_seconds'] ?? 900));
    $forecastCacheSeconds = max(1800, (int)($config['weather_forecast_cache_seconds'] ?? 10800));
    $available = 0;
    $pending = 0;
    $indoor = 0;

    // Live games are enriched first so every visible scorecard gets weather
    // before schedule-only feature cards consume the cold-cache request budget.
    uasort($games, static function (array $a, array $b): int {
        return (($a['state'] ?? '') === 'live' ? 0 : 1) <=> (($b['state'] ?? '') === 'live' ? 0 : 1);
    });

    foreach ($games as $index => $game) {
        if (!is_array($game)) continue;
        $state = (string)($game['state'] ?? '');
        if (!in_array($state, ['live', 'scheduled'], true)) continue;

        $homeName = trim((string)($game['home']['name'] ?? ''));
        $venue = nflVenue($homeName);
        if ($venue === null) {
            $payload['games'][$index]['weather'] = ['available' => false, 'reason' => 'unknown_venue'];
            $pending++;
            continue;
        }

        if (($venue['indoor'] ?? false) === true) {
            $payload['games'][$index]['weather'] = [
                'available' => false,
                'indoor' => true,
                'reason' => 'indoor_venue',
                'venue' => (string)$venue['venue'],
                'location' => (string)$venue['location'],
                'condition' => 'Indoor stadium',
                'icon_kind' => 'indoor',
                'provider' => 'WeatherAPI.com',
            ];
            $indoor++;
            continue;
        }

        $cacheSeconds = $state === 'live' ? $liveCacheSeconds : $forecastCacheSeconds;
        $weatherResponse = loadVenueWeather($venue, $config, $cacheDir, $cacheSeconds, $externalBudget);
        if ($weatherResponse === null) {
            $payload['games'][$index]['weather'] = [
                'available' => false,
                'reason' => $externalBudget <= 0 ? 'warming_cache' : 'provider_unavailable',
                'venue' => (string)$venue['venue'],
                'location' => (string)$venue['location'],
                'provider' => 'WeatherAPI.com',
            ];
            $pending++;
            continue;
        }

        $gameWeather = normalizeGameWeather($weatherResponse, $game, $venue);
        $payload['games'][$index]['weather'] = $gameWeather;
        if (($gameWeather['available'] ?? false) === true) {
            $available++;
        } else {
            $pending++;
        }
    }

    // Restore the provider's display ordering after prioritizing enrichment.
    ksort($payload['games']);
    $payload['games'] = array_values($payload['games']);
    $payload['weather_data'] = [
        'enabled' => true,
        'available' => $available > 0,
        'reason' => $available > 0 ? 'ok' : ($pending > 0 ? 'warming_or_unavailable' : 'no_outdoor_games'),
        'available_games' => $available,
        'pending_games' => $pending,
        'indoor_games' => $indoor,
        'provider' => 'WeatherAPI.com',
        'attribution' => 'Powered by WeatherAPI.com',
        'source_url' => 'https://www.weatherapi.com/',
    ];
    return $payload;
}

function loadVenueWeather(
    array $venue,
    array $config,
    string $cacheDir,
    int $cacheSeconds,
    int &$externalBudget
): ?array {
    $key = recordKey((string)$venue['location']);
    $cacheFile = $cacheDir . '/weatherapi-' . hash('sha256', $key) . '.json';
    $cached = readJsonFile($cacheFile);
    $cacheAge = is_file($cacheFile) ? time() - (int)filemtime($cacheFile) : PHP_INT_MAX;
    $failed = is_array($cached) && ($cached['failed'] ?? false) === true;
    $effectiveCacheSeconds = $failed ? 120 : $cacheSeconds;

    if ($cached !== null && $cacheAge < $effectiveCacheSeconds) {
        return $failed ? null : $cached;
    }
    if ($externalBudget <= 0) {
        return ($cached !== null && !$failed) ? $cached : null;
    }
    $externalBudget--;

    try {
        $baseUrl = rtrim((string)($config['weatherapi_base_url'] ?? 'https://api.weatherapi.com/v1'), '/');
        $url = $baseUrl . '/forecast.json?key=' . rawurlencode((string)$config['weatherapi_key'])
            . '&q=' . rawurlencode((string)$venue['query'])
            . '&days=3&aqi=no&alerts=no';
        $response = fetchSportsDbUrl($url, '', 6, 2);
        $response['_tvscoreboard_cached_at'] = gmdate(DATE_ATOM);
        try {
            writeJsonFile($cacheFile, $response);
        } catch (Throwable $cacheException) {
            // Weather remains usable even if shared hosting blocks the cache.
        }
        return $response;
    } catch (Throwable $exception) {
        if ($cached !== null && !$failed) return $cached;
        try {
            writeJsonFile($cacheFile, ['failed' => true, 'generated_at' => gmdate(DATE_ATOM)]);
        } catch (Throwable $cacheException) {
            // A negative cache is optional.
        }
        return null;
    }
}

function normalizeGameWeather(array $response, array $game, array $venue): array
{
    $state = (string)($game['state'] ?? '');
    $targetTimestamp = strtotime((string)($game['commence_at'] ?? ''));
    $observation = null;
    $forecastFor = null;

    if ($state === 'live' && is_array($response['current'] ?? null)) {
        $observation = $response['current'];
        $forecastFor = (string)($observation['last_updated'] ?? 'Current conditions');
    } elseif ($targetTimestamp !== false) {
        $nearestDifference = PHP_INT_MAX;
        foreach (($response['forecast']['forecastday'] ?? []) as $day) {
            if (!is_array($day)) continue;
            foreach (($day['hour'] ?? []) as $hour) {
                if (!is_array($hour)) continue;
                $hourTimestamp = (int)($hour['time_epoch'] ?? 0);
                if ($hourTimestamp <= 0) continue;
                $difference = abs($hourTimestamp - $targetTimestamp);
                if ($difference < $nearestDifference) {
                    $nearestDifference = $difference;
                    $observation = $hour;
                    $forecastFor = (string)($hour['time'] ?? 'Game time');
                }
            }
        }
        // Never label a forecast from the wrong day as game-time weather.
        if ($nearestDifference > 43200) $observation = null;
    }

    if (!is_array($observation)) {
        return [
            'available' => false,
            'reason' => 'outside_forecast_window',
            'venue' => (string)$venue['venue'],
            'location' => (string)$venue['location'],
            'provider' => 'WeatherAPI.com',
        ];
    }

    $condition = is_array($observation['condition'] ?? null) ? $observation['condition'] : [];
    $conditionText = trim((string)($condition['text'] ?? 'Weather available'));
    $conditionCode = (int)($condition['code'] ?? 0);
    $iconUrl = trim((string)($condition['icon'] ?? ''));
    if (str_starts_with($iconUrl, '//')) $iconUrl = 'https:' . $iconUrl;
    $chanceOfRain = isset($observation['chance_of_rain']) ? (int)$observation['chance_of_rain'] : null;
    $chanceOfSnow = isset($observation['chance_of_snow']) ? (int)$observation['chance_of_snow'] : null;
    $precipChance = max((int)($chanceOfRain ?? 0), (int)($chanceOfSnow ?? 0));

    return [
        'available' => true,
        'indoor' => false,
        'venue' => (string)$venue['venue'],
        'location' => (string)$venue['location'],
        'forecast_for' => $forecastFor,
        'temp_f' => isset($observation['temp_f']) ? (int)round((float)$observation['temp_f']) : null,
        'feelslike_f' => isset($observation['feelslike_f']) ? (int)round((float)$observation['feelslike_f']) : null,
        'condition' => $conditionText,
        'condition_code' => $conditionCode,
        'icon_url' => secureUrl($iconUrl),
        'icon_kind' => weatherIconKind($conditionCode, $conditionText),
        'wind_mph' => isset($observation['wind_mph']) ? (int)round((float)$observation['wind_mph']) : null,
        'wind_dir' => trim((string)($observation['wind_dir'] ?? '')),
        'precip_chance' => $precipChance,
        'humidity' => isset($observation['humidity']) ? (int)$observation['humidity'] : null,
        'provider' => 'WeatherAPI.com',
        'attribution' => 'Powered by WeatherAPI.com',
    ];
}

function weatherIconKind(int $code, string $condition): string
{
    $text = strtolower($condition);
    if (str_contains($text, 'thunder')) return 'storm';
    if (str_contains($text, 'snow') || str_contains($text, 'sleet') || str_contains($text, 'ice')) return 'snow';
    if (str_contains($text, 'rain') || str_contains($text, 'drizzle')) return 'rain';
    if (str_contains($text, 'fog') || str_contains($text, 'mist')) return 'fog';
    if ($code === 1000) return 'clear';
    if ($code === 1003) return 'partly_cloudy';
    return 'cloudy';
}

function addOddsDataSafely(array $payload, array $config, string $cacheDir, string $stage): array
{
    try {
        return addOddsData($payload, $config, $cacheDir);
    } catch (Throwable $exception) {
        $message = preg_replace('/\s+/', ' ', trim($exception->getMessage())) ?? 'Unknown odds error';
        error_log('TV Scoreboard odds enrichment failed [' . $stage . '] ' . get_class($exception) . ': ' . substr($message, 0, 180));
        $payload['odds_data'] = [
            'enabled' => trim((string)($config['odds_api_key'] ?? '')) !== '',
            'available' => false,
            'reason' => 'enrichment_error',
            'provider' => 'The Odds API',
        ];
        return $payload;
    }
}

function addOddsData(array $payload, array $config, string $cacheDir): array
{
    $apiKey = trim((string)($config['odds_api_key'] ?? ''));
    if ($apiKey === '') {
        $payload['odds_data'] = [
            'enabled' => false,
            'available' => false,
            'reason' => 'missing_api_key',
            'provider' => 'The Odds API',
        ];
        return $payload;
    }

    $games = is_array($payload['games'] ?? null) ? $payload['games'] : [];
    $scheduled = array_values(array_filter(
        $games,
        static fn(mixed $game): bool => is_array($game) && ($game['state'] ?? '') === 'scheduled'
    ));
    if ($scheduled === []) {
        $payload['odds_data'] = [
            'enabled' => true,
            'available' => false,
            'reason' => 'no_scheduled_games',
            'provider' => 'The Odds API',
        ];
        return $payload;
    }

    $cacheFile = $cacheDir . '/the-odds-api-nfl.json';
    $cacheSeconds = max(3600, (int)($config['odds_cache_seconds'] ?? 43200));
    $cached = readJsonFile($cacheFile);
    $cacheAge = is_file($cacheFile) ? time() - (int)filemtime($cacheFile) : PHP_INT_MAX;
    $oddsPayload = null;
    $cacheHit = false;
    $stale = false;

    if ($cached !== null && $cacheAge < $cacheSeconds) {
        $oddsPayload = $cached;
        $cacheHit = true;
    } else {
        try {
            $oddsPayload = fetchNflOdds($config);
            writeJsonFile($cacheFile, $oddsPayload);
        } catch (Throwable $exception) {
            if ($cached !== null) {
                $oddsPayload = $cached;
                $cacheHit = true;
                $stale = true;
            } else {
                throw $exception;
            }
        }
    }

    $events = is_array($oddsPayload['events'] ?? null) ? $oddsPayload['events'] : [];
    $byMatchup = [];
    foreach ($events as $event) {
        if (!is_array($event)) continue;
        $awayKey = oddsTeamKey((string)($event['away_team'] ?? ''));
        $homeKey = oddsTeamKey((string)($event['home_team'] ?? ''));
        if ($awayKey === '' || $homeKey === '') continue;
        $byMatchup[$awayKey . '|' . $homeKey] = normalizeOddsEvent($event);
    }

    $availableGames = 0;
    foreach ($games as $index => $game) {
        if (!is_array($game) || ($game['state'] ?? '') !== 'scheduled') continue;
        $awayKey = oddsTeamKey((string)($game['away']['name'] ?? ''));
        $homeKey = oddsTeamKey((string)($game['home']['name'] ?? ''));
        $odds = $byMatchup[$awayKey . '|' . $homeKey] ?? null;
        if (!is_array($odds) || ($odds['available'] ?? false) !== true) continue;

        $odds['display_line'] = compactOddsLine($odds, $game);
        $payload['games'][$index]['odds'] = $odds;
        $availableGames++;
    }

    $quota = is_array($oddsPayload['quota'] ?? null) ? $oddsPayload['quota'] : [];
    $payload['odds_data'] = [
        'enabled' => true,
        'available' => $availableGames > 0,
        'reason' => $availableGames > 0 ? 'ok' : 'no_matching_odds',
        'available_games' => $availableGames,
        'cache_hit' => $cacheHit,
        'cache_age_seconds' => $cacheHit ? max(0, $cacheAge) : 0,
        'cache_ttl_seconds' => $cacheSeconds,
        'stale' => $stale,
        'last_updated' => $oddsPayload['fetched_at'] ?? null,
        'requests_used' => isset($quota['used']) ? (int)$quota['used'] : null,
        'requests_remaining' => isset($quota['remaining']) ? (int)$quota['remaining'] : null,
        'request_cost' => isset($quota['last']) ? (int)$quota['last'] : null,
        'provider' => 'The Odds API',
        'source_url' => 'https://the-odds-api.com/',
    ];
    return $payload;
}

function fetchNflOdds(array $config): array
{
    $baseUrl = rtrim((string)($config['odds_api_base_url'] ?? 'https://api.the-odds-api.com/v4'), '/');
    $sport = trim((string)($config['odds_sport_key'] ?? 'americanfootball_nfl'));
    $markets = trim((string)($config['odds_markets'] ?? 'h2h,spreads,totals'));
    $url = $baseUrl . '/sports/' . rawurlencode($sport) . '/odds/'
        . '?apiKey=' . rawurlencode((string)$config['odds_api_key'])
        . '&regions=us&markets=' . rawurlencode($markets)
        . '&oddsFormat=american&dateFormat=iso';

    $curl = curl_init($url);
    if ($curl === false) throw new RuntimeException('Could not initialize odds HTTP client.');
    curl_setopt_array($curl, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 3,
        CURLOPT_TIMEOUT => 8,
        CURLOPT_HTTPHEADER => ['Accept: application/json'],
        CURLOPT_HEADER => true,
        CURLOPT_USERAGENT => 'TVScoreboard.cc/1.0',
    ]);
    $response = curl_exec($curl);
    $status = (int)curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
    $headerSize = (int)curl_getinfo($curl, CURLINFO_HEADER_SIZE);
    $error = curl_error($curl);
    curl_close($curl);
    if (!is_string($response) || $response === '' || $status < 200 || $status >= 300) {
        throw new RuntimeException('Odds provider request failed: ' . ($error !== '' ? $error : 'HTTP ' . $status));
    }

    $headers = substr($response, 0, $headerSize);
    $body = substr($response, $headerSize);
    $events = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
    if (!is_array($events)) $events = [];

    return [
        'events' => $events,
        'fetched_at' => gmdate(DATE_ATOM),
        'quota' => [
            'used' => oddsHeaderInt($headers, 'x-requests-used'),
            'remaining' => oddsHeaderInt($headers, 'x-requests-remaining'),
            'last' => oddsHeaderInt($headers, 'x-requests-last'),
        ],
    ];
}

function oddsHeaderInt(string $headers, string $name): ?int
{
    if (preg_match('/^' . preg_quote($name, '/') . ':\s*(\d+)/mi', $headers, $match) !== 1) return null;
    return (int)$match[1];
}

function normalizeOddsEvent(array $event): array
{
    $awayName = trim((string)($event['away_team'] ?? ''));
    $homeName = trim((string)($event['home_team'] ?? ''));
    $awayMoneylines = [];
    $homeMoneylines = [];
    $awaySpreads = [];
    $homeSpreads = [];
    $totals = [];
    $bookmakers = 0;

    foreach (($event['bookmakers'] ?? []) as $bookmaker) {
        if (!is_array($bookmaker)) continue;
        $bookmakers++;
        foreach (($bookmaker['markets'] ?? []) as $market) {
            if (!is_array($market)) continue;
            $key = (string)($market['key'] ?? '');
            foreach (($market['outcomes'] ?? []) as $outcome) {
                if (!is_array($outcome)) continue;
                $name = trim((string)($outcome['name'] ?? ''));
                if ($key === 'h2h' && isset($outcome['price'])) {
                    if (oddsTeamKey($name) === oddsTeamKey($awayName)) $awayMoneylines[] = (float)$outcome['price'];
                    if (oddsTeamKey($name) === oddsTeamKey($homeName)) $homeMoneylines[] = (float)$outcome['price'];
                } elseif ($key === 'spreads' && isset($outcome['point'])) {
                    if (oddsTeamKey($name) === oddsTeamKey($awayName)) $awaySpreads[] = (float)$outcome['point'];
                    if (oddsTeamKey($name) === oddsTeamKey($homeName)) $homeSpreads[] = (float)$outcome['point'];
                } elseif ($key === 'totals' && strcasecmp($name, 'Over') === 0 && isset($outcome['point'])) {
                    $totals[] = (float)$outcome['point'];
                }
            }
        }
    }

    $awayMoneyline = oddsMedian($awayMoneylines);
    $homeMoneyline = oddsMedian($homeMoneylines);
    $awaySpread = oddsMedian($awaySpreads);
    $homeSpread = oddsMedian($homeSpreads);
    $total = oddsMedian($totals);
    $available = $awayMoneyline !== null || $homeMoneyline !== null || $awaySpread !== null || $homeSpread !== null || $total !== null;

    return [
        'available' => $available,
        'event_id' => (string)($event['id'] ?? ''),
        'commence_at' => (string)($event['commence_time'] ?? ''),
        'away_moneyline' => $awayMoneyline !== null ? (int)round($awayMoneyline) : null,
        'home_moneyline' => $homeMoneyline !== null ? (int)round($homeMoneyline) : null,
        'away_spread' => $awaySpread,
        'home_spread' => $homeSpread,
        'total' => $total,
        'bookmakers_count' => $bookmakers,
        'provider' => 'The Odds API',
    ];
}

function oddsMedian(array $values): ?float
{
    if ($values === []) return null;
    sort($values, SORT_NUMERIC);
    $count = count($values);
    $middle = intdiv($count, 2);
    return $count % 2 === 1
        ? round((float)$values[$middle], 1)
        : round(((float)$values[$middle - 1] + (float)$values[$middle]) / 2, 1);
}

function compactOddsLine(array $odds, array $game): string
{
    $awayShort = oddsTeamAbbreviation((string)($game['away']['name'] ?? $game['away']['short'] ?? 'AWAY'));
    $homeShort = oddsTeamAbbreviation((string)($game['home']['name'] ?? $game['home']['short'] ?? 'HOME'));
    $awaySpread = $odds['away_spread'] ?? null;
    $homeSpread = $odds['home_spread'] ?? null;
    $parts = [];
    if (is_numeric($awaySpread) && (float)$awaySpread < 0) {
        $parts[] = $awayShort . ' ' . signedOddsNumber((float)$awaySpread);
    } elseif (is_numeric($homeSpread) && (float)$homeSpread < 0) {
        $parts[] = $homeShort . ' ' . signedOddsNumber((float)$homeSpread);
    }
    if (is_numeric($odds['total'] ?? null)) $parts[] = 'O/U ' . plainOddsNumber((float)$odds['total']);
    return implode('  |  ', $parts);
}

function oddsTeamAbbreviation(string $teamName): string
{
    $codes = [
        'arizona cardinals'=>'ARI','atlanta falcons'=>'ATL','baltimore ravens'=>'BAL','buffalo bills'=>'BUF',
        'carolina panthers'=>'CAR','chicago bears'=>'CHI','cincinnati bengals'=>'CIN','cleveland browns'=>'CLE',
        'dallas cowboys'=>'DAL','denver broncos'=>'DEN','detroit lions'=>'DET','green bay packers'=>'GB',
        'houston texans'=>'HOU','indianapolis colts'=>'IND','jacksonville jaguars'=>'JAX','kansas city chiefs'=>'KC',
        'las vegas raiders'=>'LV','los angeles chargers'=>'LAC','los angeles rams'=>'LAR','miami dolphins'=>'MIA',
        'minnesota vikings'=>'MIN','new england patriots'=>'NE','new orleans saints'=>'NO','new york giants'=>'NYG',
        'new york jets'=>'NYJ','philadelphia eagles'=>'PHI','pittsburgh steelers'=>'PIT','san francisco 49ers'=>'SF',
        'seattle seahawks'=>'SEA','tampa bay buccaneers'=>'TB','tennessee titans'=>'TEN','washington commanders'=>'WAS',
    ];
    $key = oddsTeamKey($teamName);
    if (isset($codes[$key])) return $codes[$key];
    $parts = preg_split('/\s+/', strtoupper(trim($teamName))) ?: [];
    return substr((string)end($parts), 0, 3);
}

function signedOddsNumber(float $value): string
{
    return ($value > 0 ? '+' : '') . plainOddsNumber($value);
}

function plainOddsNumber(float $value): string
{
    return abs($value - round($value)) < 0.001 ? (string)(int)round($value) : number_format($value, 1, '.', '');
}

function oddsTeamKey(string $name): string
{
    return recordKey(str_replace(['Washington Football Team', 'Oakland Raiders'], ['Washington Commanders', 'Las Vegas Raiders'], $name));
}

function nflVenue(string $homeTeam): ?array
{
    $venues = [
        'arizona cardinals' => ['venue' => 'State Farm Stadium', 'location' => 'Glendale, AZ', 'query' => 'Glendale, Arizona', 'indoor' => true],
        'atlanta falcons' => ['venue' => 'Mercedes-Benz Stadium', 'location' => 'Atlanta, GA', 'query' => 'Atlanta, Georgia', 'indoor' => true],
        'baltimore ravens' => ['venue' => 'M&T Bank Stadium', 'location' => 'Baltimore, MD', 'query' => 'Baltimore, Maryland', 'indoor' => false],
        'buffalo bills' => ['venue' => 'Highmark Stadium', 'location' => 'Orchard Park, NY', 'query' => 'Orchard Park, New York', 'indoor' => false],
        'carolina panthers' => ['venue' => 'Bank of America Stadium', 'location' => 'Charlotte, NC', 'query' => 'Charlotte, North Carolina', 'indoor' => false],
        'chicago bears' => ['venue' => 'Soldier Field', 'location' => 'Chicago, IL', 'query' => 'Chicago, Illinois', 'indoor' => false],
        'cincinnati bengals' => ['venue' => 'Paycor Stadium', 'location' => 'Cincinnati, OH', 'query' => 'Cincinnati, Ohio', 'indoor' => false],
        'cleveland browns' => ['venue' => 'Huntington Bank Field', 'location' => 'Cleveland, OH', 'query' => 'Cleveland, Ohio', 'indoor' => false],
        'dallas cowboys' => ['venue' => 'AT&T Stadium', 'location' => 'Arlington, TX', 'query' => 'Arlington, Texas', 'indoor' => true],
        'denver broncos' => ['venue' => 'Empower Field at Mile High', 'location' => 'Denver, CO', 'query' => 'Denver, Colorado', 'indoor' => false],
        'detroit lions' => ['venue' => 'Ford Field', 'location' => 'Detroit, MI', 'query' => 'Detroit, Michigan', 'indoor' => true],
        'green bay packers' => ['venue' => 'Lambeau Field', 'location' => 'Green Bay, WI', 'query' => 'Green Bay, Wisconsin', 'indoor' => false],
        'houston texans' => ['venue' => 'NRG Stadium', 'location' => 'Houston, TX', 'query' => 'Houston, Texas', 'indoor' => true],
        'indianapolis colts' => ['venue' => 'Lucas Oil Stadium', 'location' => 'Indianapolis, IN', 'query' => 'Indianapolis, Indiana', 'indoor' => true],
        'jacksonville jaguars' => ['venue' => 'EverBank Stadium', 'location' => 'Jacksonville, FL', 'query' => 'Jacksonville, Florida', 'indoor' => false],
        'kansas city chiefs' => ['venue' => 'GEHA Field at Arrowhead Stadium', 'location' => 'Kansas City, MO', 'query' => 'Kansas City, Missouri', 'indoor' => false],
        'las vegas raiders' => ['venue' => 'Allegiant Stadium', 'location' => 'Las Vegas, NV', 'query' => 'Las Vegas, Nevada', 'indoor' => true],
        'los angeles chargers' => ['venue' => 'SoFi Stadium', 'location' => 'Inglewood, CA', 'query' => 'Inglewood, California', 'indoor' => true],
        'los angeles rams' => ['venue' => 'SoFi Stadium', 'location' => 'Inglewood, CA', 'query' => 'Inglewood, California', 'indoor' => true],
        'miami dolphins' => ['venue' => 'Hard Rock Stadium', 'location' => 'Miami Gardens, FL', 'query' => 'Miami Gardens, Florida', 'indoor' => false],
        'minnesota vikings' => ['venue' => 'U.S. Bank Stadium', 'location' => 'Minneapolis, MN', 'query' => 'Minneapolis, Minnesota', 'indoor' => true],
        'new england patriots' => ['venue' => 'Gillette Stadium', 'location' => 'Foxborough, MA', 'query' => 'Foxborough, Massachusetts', 'indoor' => false],
        'new orleans saints' => ['venue' => 'Caesars Superdome', 'location' => 'New Orleans, LA', 'query' => 'New Orleans, Louisiana', 'indoor' => true],
        'new york giants' => ['venue' => 'MetLife Stadium', 'location' => 'East Rutherford, NJ', 'query' => 'East Rutherford, New Jersey', 'indoor' => false],
        'new york jets' => ['venue' => 'MetLife Stadium', 'location' => 'East Rutherford, NJ', 'query' => 'East Rutherford, New Jersey', 'indoor' => false],
        'philadelphia eagles' => ['venue' => 'Lincoln Financial Field', 'location' => 'Philadelphia, PA', 'query' => 'Philadelphia, Pennsylvania', 'indoor' => false],
        'pittsburgh steelers' => ['venue' => 'Acrisure Stadium', 'location' => 'Pittsburgh, PA', 'query' => 'Pittsburgh, Pennsylvania', 'indoor' => false],
        'san francisco 49ers' => ['venue' => "Levi's Stadium", 'location' => 'Santa Clara, CA', 'query' => 'Santa Clara, California', 'indoor' => false],
        'seattle seahawks' => ['venue' => 'Lumen Field', 'location' => 'Seattle, WA', 'query' => 'Seattle, Washington', 'indoor' => false],
        'tampa bay buccaneers' => ['venue' => 'Raymond James Stadium', 'location' => 'Tampa, FL', 'query' => 'Tampa, Florida', 'indoor' => false],
        'tennessee titans' => ['venue' => 'Nissan Stadium', 'location' => 'Nashville, TN', 'query' => 'Nashville, Tennessee', 'indoor' => false],
        'washington commanders' => ['venue' => 'Northwest Stadium', 'location' => 'Landover, MD', 'query' => 'Landover, Maryland', 'indoor' => false],
    ];
    $key = recordKey($homeTeam);
    return isset($venues[$key]) ? $venues[$key] : null;
}

function addVenueArtworkSafely(array $payload, array $config, string $cacheDir, string $stage): array
{
    try {
        return addVenueArtwork($payload, $config, $cacheDir);
    } catch (Throwable $exception) {
        error_log(
            'TV Scoreboard venue artwork failed [' . $stage . '] '
            . get_class($exception) . ': ' . substr($exception->getMessage(), 0, 180)
        );
        $payload['venue_artwork'] = [
            'available' => false,
            'reason' => 'enrichment_error',
            'stage' => $stage,
        ];
        return $payload;
    }
}

function addVenueArtwork(array $payload, array $config, string $cacheDir): array
{
    $games = is_array($payload['games'] ?? null) ? $payload['games'] : [];
    $requestBudget = max(0, min(8, (int)($config['venue_artwork_request_budget'] ?? 4)));
    $resolved = 0;

    foreach ($games as $index => $game) {
        if (!is_array($game)) continue;
        $existing = secureUrl((string)($game['stadium_image'] ?? ''));
        if ($existing !== '') {
            $payload['games'][$index]['stadium_image'] = $existing;
            $resolved++;
            continue;
        }

        $home = is_array($game['home'] ?? null) ? $game['home'] : [];
        $teamId = trim((string)($home['id'] ?? ''));
        $teamName = trim((string)($home['name'] ?? ''));
        $knownVenue = nflVenue($teamName);
        $venueName = is_array($knownVenue) ? trim((string)($knownVenue['venue'] ?? '')) : '';
        $artwork = loadVenueArtwork($teamId, $teamName, $venueName, $config, $cacheDir, $requestBudget);
        $imageUrl = secureUrl((string)($artwork['image_url'] ?? ''));
        if ($imageUrl === '') continue;

        $payload['games'][$index]['stadium_image'] = $imageUrl;
        $payload['games'][$index]['venue_artwork'] = [
            'provider' => 'TheSportsDB',
            'venue' => (string)($artwork['venue'] ?? $venueName),
            'creative_commons' => (string)($artwork['creative_commons'] ?? ''),
        ];
        $resolved++;
    }

    $payload['venue_artwork'] = [
        'available' => $resolved > 0,
        'provider' => 'TheSportsDB',
        'resolved_games' => $resolved,
        'cache_seconds' => max(86400, (int)($config['venue_artwork_cache_seconds'] ?? 2592000)),
    ];
    return $payload;
}

function loadVenueArtwork(
    string $teamId,
    string $teamName,
    string $venueName,
    array $config,
    string $cacheDir,
    int &$requestBudget
): array {
    $identity = $teamId !== '' ? 'team-' . $teamId : 'venue-' . recordKey($venueName . '-' . $teamName);
    $cacheFile = $cacheDir . '/venue-artwork-' . hash('sha256', $identity) . '.json';
    $cached = readJsonFile($cacheFile);
    $cacheAge = is_file($cacheFile) ? time() - (int)filemtime($cacheFile) : PHP_INT_MAX;
    $positiveTtl = max(86400, (int)($config['venue_artwork_cache_seconds'] ?? 2592000));
    $negativeTtl = max(3600, (int)($config['venue_artwork_negative_cache_seconds'] ?? 604800));
    $ttl = ($cached['failed'] ?? false) === true ? $negativeTtl : $positiveTtl;
    if ($cached !== null && $cacheAge < $ttl) return $cached;
    if ($requestBudget <= 0) return ['image_url' => '', 'deferred' => true];

    $apiKey = rawurlencode((string)$config['sportsdb_api_key']);
    $imageUrl = '';
    $creativeCommons = '';

    if ($venueName === '' && $teamId !== '' && $requestBudget > 0) {
        $requestBudget--;
        $url = 'https://www.thesportsdb.com/api/v1/json/' . $apiKey
            . '/lookupteam.php?id=' . rawurlencode($teamId);
        $teamResponse = fetchSportsDbUrl($url, '', 4, 2);
        $teams = is_array($teamResponse['teams'] ?? null) ? $teamResponse['teams'] : [];
        $team = is_array($teams[0] ?? null) ? $teams[0] : [];
        $venueName = trim((string)($team['strStadium'] ?? ''));
        $imageUrl = secureUrl((string)($team['strStadiumThumb'] ?? ''));
    }

    if ($imageUrl === '' && $venueName !== '' && $requestBudget > 0) {
        $requestBudget--;
        $url = 'https://www.thesportsdb.com/api/v1/json/' . $apiKey
            . '/searchvenues.php?v=' . rawurlencode($venueName);
        $venueResponse = fetchSportsDbUrl($url, '', 4, 2);
        $venues = is_array($venueResponse['venues'] ?? null) ? $venueResponse['venues'] : [];
        $selected = selectVenueRecord($venues, $venueName);
        if ($selected !== []) {
            $venueName = trim((string)($selected['strVenue'] ?? $venueName));
            $imageUrl = venueArtworkUrl($selected);
            $creativeCommons = trim((string)($selected['strCreativeCommons'] ?? ''));
        }
    }

    if ($imageUrl === '' && $requestBudget <= 0) {
        return ['image_url' => '', 'venue' => $venueName, 'deferred' => true];
    }

    $result = [
        'image_url' => venueArtworkPreviewUrl($imageUrl),
        'venue' => $venueName,
        'team_id' => $teamId,
        'team_name' => $teamName,
        'creative_commons' => $creativeCommons,
        'failed' => $imageUrl === '',
        'generated_at' => gmdate(DATE_ATOM),
    ];
    try {
        writeJsonFile($cacheFile, $result);
    } catch (Throwable $cacheException) {
        // Artwork can still be used if the metadata cache cannot be written.
    }
    return $result;
}

function selectVenueRecord(array $venues, string $venueName): array
{
    $wanted = recordKey($venueName);
    $fallback = [];
    foreach ($venues as $venue) {
        if (!is_array($venue)) continue;
        if ($fallback === []) $fallback = $venue;
        if (recordKey((string)($venue['strVenue'] ?? '')) === $wanted) return $venue;
    }
    return $fallback;
}

function venueArtworkUrl(array $venue): string
{
    foreach (['strFanart1', 'strThumb', 'strFanart2', 'strFanart3', 'strFanart4'] as $field) {
        $url = secureUrl((string)($venue[$field] ?? ''));
        if ($url !== '') return $url;
    }
    return '';
}

function venueArtworkPreviewUrl(string $url): string
{
    $url = secureUrl($url);
    if ($url === '') return '';
    if (preg_match('#/(medium|small|tiny)$#', $url) === 1) return $url;
    return rtrim($url, '/') . '/medium';
}

function addFeaturedPlayers(array $payload, array $config, string $cacheDir): array
{
    $limit = max(0, min(8, (int)($config['feature_player_count'] ?? 4)));
    if ($limit === 0) {
        $payload['featured_players'] = [];
        $payload['player_data'] = ['available' => false, 'reason' => 'disabled'];
        return $payload;
    }

    $teams = [];
    foreach (($payload['games'] ?? []) as $gameIndex => $game) {
        if (!is_array($game)) continue;
        foreach (['away', 'home'] as $side) {
            $team = is_array($game[$side] ?? null) ? $game[$side] : [];
            $teamId = trim((string)($team['id'] ?? ''));
            $teamName = trim((string)($team['name'] ?? ''));

            // Older cached schedule payloads did not retain TheSportsDB team
            // IDs. Resolve those IDs by name so player cards can still be
            // built while the live-score endpoint is temporarily unavailable.
            if ($teamId === '' && $teamName !== '') {
                $teamId = resolveTeamIdByName($teamName, $config, $cacheDir);
                if ($teamId !== '' && isset($payload['games'][$gameIndex][$side])) {
                    $payload['games'][$gameIndex][$side]['id'] = $teamId;
                }
            }

            if ($teamId === '' || isset($teams[$teamId])) continue;
            $teams[$teamId] = [
                'name' => $teamName !== '' ? $teamName : 'NFL Team',
                'badge' => secureUrl((string)($game[$side . '_badge'] ?? '')),
            ];
            // One roster contains enough positions for several player cards.
            // Limiting enrichment to one roster keeps the score endpoint well
            // inside shared-host execution limits during provider outages.
            if (count($teams) >= 1) break 2;
        }
    }

    $featured = [];
    $selectedTeamId = '';
    $selectedTeamName = '';
    $rosterSource = 'unavailable';
    $rosterCount = 0;
    foreach ($teams as $teamId => $teamInfo) {
        $players = loadTeamPlayers(
            (string)$teamId,
            (string)$teamInfo['name'],
            (string)$teamInfo['badge'],
            $config,
            $cacheDir,
            $rosterSource
        );
        $selectedTeamId = (string)$teamId;
        $selectedTeamName = (string)$teamInfo['name'];
        $rosterCount = count($players);
        $featured = selectFeaturedPlayers($players, $limit);
        break;
    }

    $payload['featured_players'] = $featured;
    $payload['player_data'] = [
        'available' => $featured !== [],
        'reason' => $teams === [] ? 'missing_team_id' : ($featured === [] ? 'roster_unavailable' : 'ok'),
        'team_id' => $selectedTeamId,
        'team_name' => $selectedTeamName,
        'roster_source' => $rosterSource,
        'roster_count' => $rosterCount,
        'featured_count' => count($featured),
    ];
    return $payload;
}

/**
 * Keep the public endpoint available while making player-enrichment failures
 * diagnosable. No stack trace, filesystem path, API key, or request data is
 * returned. The short message is also written to the PHP error log.
 */
function playerEnrichmentFailure(Throwable $exception, string $stage): array
{
    $message = preg_replace('/\s+/', ' ', trim($exception->getMessage())) ?? 'Unknown enrichment error';
    $message = substr($message, 0, 180);
    error_log(
        'TV Scoreboard player enrichment failed [' . $stage . '] '
        . get_class($exception) . ': ' . $message
    );

    return [
        'available' => false,
        'reason' => 'enrichment_error',
        'stage' => $stage,
        'error_class' => get_class($exception),
        'error_message' => $message,
    ];
}

function resolveTeamIdByName(string $teamName, array $config, string $cacheDir): string
{
    $normalizedName = recordKey($teamName);
    if ($normalizedName === '') return '';

    // The locally known IDs avoid a second provider request for the teams at
    // the front of the current schedule. Unknown teams still use the cached,
    // exact-name lookup below.
    $knownTeamIds = [
        'arizona cardinals' => '134946',
        'atlanta falcons' => '134942',
        'baltimore ravens' => '134922',
        'buffalo bills' => '134918',
        'carolina panthers' => '134943',
        'chicago bears' => '134938',
        'cincinnati bengals' => '134923',
        'cleveland browns' => '134924',
        'dallas cowboys' => '134934',
        'denver broncos' => '134930',
        'detroit lions' => '134939',
        'green bay packers' => '134940',
        'houston texans' => '134926',
        'indianapolis colts' => '134927',
        'jacksonville jaguars' => '134928',
        'kansas city chiefs' => '134931',
        'las vegas raiders' => '134932',
        'los angeles chargers' => '134933',
        'los angeles rams' => '134947',
        'miami dolphins' => '134919',
        'minnesota vikings' => '134941',
        'new england patriots' => '134920',
        'new orleans saints' => '134944',
        'new york giants' => '134935',
        'new york jets' => '134921',
        'philadelphia eagles' => '134936',
        'pittsburgh steelers' => '134925',
        'san francisco 49ers' => '134948',
        'seattle seahawks' => '134949',
        'tampa bay buccaneers' => '134945',
        'tennessee titans' => '134929',
        'washington commanders' => '134937',
    ];
    if (isset($knownTeamIds[$normalizedName])) return $knownTeamIds[$normalizedName];

    $cacheFile = $cacheDir . '/nfl-team-id-' . hash('sha256', $normalizedName) . '.json';
    $cached = readJsonFile($cacheFile);
    $cacheAge = is_file($cacheFile) ? time() - (int)filemtime($cacheFile) : PHP_INT_MAX;
    $effectiveCacheSeconds = ($cached['failed'] ?? false) === true ? 900 : 2592000;
    if ($cached !== null && $cacheAge < $effectiveCacheSeconds) {
        return trim((string)($cached['team_id'] ?? ''));
    }

    try {
        $url = 'https://www.thesportsdb.com/api/v1/json/'
            . rawurlencode((string)$config['sportsdb_api_key'])
            . '/searchteams.php?t=' . rawurlencode($teamName);
        $upstream = fetchSportsDbUrl($url, '', 3, 1);
        $rows = is_array($upstream['teams'] ?? null) ? $upstream['teams'] : [];
        $teamId = '';

        foreach ($rows as $row) {
            if (!is_array($row)) continue;
            $candidateName = recordKey((string)($row['strTeam'] ?? ''));
            $candidateLeague = strtolower(trim((string)($row['strLeague'] ?? '')));
            $candidateSport = strtolower(trim((string)($row['strSport'] ?? '')));
            if ($candidateName !== $normalizedName) continue;
            if ($candidateLeague !== '' && $candidateLeague !== 'nfl') continue;
            if ($candidateSport !== '' && $candidateSport !== 'american football') continue;
            $teamId = trim((string)($row['idTeam'] ?? ''));
            if ($teamId !== '') break;
        }

        try {
            writeJsonFile($cacheFile, [
                'team_id' => $teamId,
                'team_name' => $teamName,
                'failed' => $teamId === '',
                'generated_at' => gmdate(DATE_ATOM),
            ]);
        } catch (Throwable $cacheException) {
            // A cache write failure must not hide a successful lookup.
        }
        return $teamId;
    } catch (Throwable $exception) {
        if (trim((string)($cached['team_id'] ?? '')) !== '') {
            return trim((string)$cached['team_id']);
        }
        try {
            writeJsonFile($cacheFile, [
                'team_id' => '',
                'team_name' => $teamName,
                'failed' => true,
                'generated_at' => gmdate(DATE_ATOM),
            ]);
        } catch (Throwable $cacheException) {
            // The endpoint can continue without a negative cache entry.
        }
        return '';
    }
}

function loadTeamPlayers(
    string $teamId,
    string $teamName,
    string $teamBadge,
    array $config,
    string $cacheDir,
    ?string &$rosterSource = null
): array
{
    // V4 refreshes rosters so provider portraits added by this release are
    // available immediately instead of waiting for an older cache to expire.
    $cacheFile = $cacheDir . '/nfl-players-v4-' . preg_replace('/[^0-9A-Za-z_-]/', '-', $teamId) . '.json';
    $cacheSeconds = max(3600, (int)($config['players_cache_seconds'] ?? 43200));
    $cached = readJsonFile($cacheFile);
    $cacheAge = is_file($cacheFile) ? time() - (int)filemtime($cacheFile) : PHP_INT_MAX;

    $effectiveCacheSeconds = ($cached['failed'] ?? false) === true ? 120 : $cacheSeconds;
    if ($cached !== null && $cacheAge < $effectiveCacheSeconds) {
        $rosterSource = (string)($cached['source'] ?? 'cache');
        return is_array($cached['players'] ?? null) ? $cached['players'] : [];
    }

    // V1 is used first because this endpoint already relies on V1 for full
    // NFL schedules and the provider documents roster listing for both free
    // and premium keys. V2 remains available as a fallback.
    try {
        $url = 'https://www.thesportsdb.com/api/v1/json/'
            . rawurlencode((string)$config['sportsdb_api_key'])
            . '/lookup_all_players.php?id=' . rawurlencode($teamId);
        $upstream = fetchSportsDbUrl($url, '', 5, 2);
        $players = normalizeTeamPlayers($upstream, $teamId, $teamName, $teamBadge);
        if ($players === []) {
            throw new RuntimeException('V1 roster returned no players.');
        }
        $rosterSource = 'sportsdb_v1';
        try {
            writeJsonFile($cacheFile, [
                'players' => $players,
                'source' => $rosterSource,
                'generated_at' => gmdate(DATE_ATOM),
            ]);
        } catch (Throwable $cacheException) {
            // Return the roster even if shared hosting blocks a new cache file.
        }
        return $players;
    } catch (Throwable $v1Exception) {
        try {
            $url = rtrim((string)$config['sportsdb_base_url'], '/')
                . '/list/players/' . rawurlencode($teamId);
            $upstream = fetchSportsDbUrl($url, (string)$config['sportsdb_api_key'], 5, 2);
            $players = normalizeTeamPlayers($upstream, $teamId, $teamName, $teamBadge);
            if ($players === []) {
                throw new RuntimeException('V2 roster returned no players.');
            }
            $rosterSource = 'sportsdb_v2';
            try {
                writeJsonFile($cacheFile, [
                    'players' => $players,
                    'source' => $rosterSource,
                    'generated_at' => gmdate(DATE_ATOM),
                ]);
            } catch (Throwable $cacheException) {
                // Return the roster even if shared hosting blocks a new cache file.
            }
            return $players;
        } catch (Throwable $v2Exception) {
            if (is_array($cached['players'] ?? null) && $cached['players'] !== []) {
                $rosterSource = (string)($cached['source'] ?? 'stale_cache');
                return $cached['players'];
            }
            $rosterSource = 'unavailable';
            try {
                writeJsonFile($cacheFile, [
                    'players' => [],
                    'source' => $rosterSource,
                    'failed' => true,
                    'generated_at' => gmdate(DATE_ATOM),
                ]);
            } catch (Throwable $cacheException) {
                // A negative cache is optional; the score response is not.
            }
            return [];
        }
    }
}

function normalizeTeamPlayers(array $upstream, string $teamId, string $teamName, string $teamBadge): array
{
    $rows = $upstream['list'] ?? $upstream['player'] ?? $upstream['players'] ?? [];
    if (!is_array($rows)) return [];

    $players = [];
    foreach ($rows as $row) {
        if (!is_array($row)) continue;

        $status = strtolower(trim((string)($row['strStatus'] ?? '')));
        $position = trim((string)($row['strPosition'] ?? ''));
        if ($status !== '' && $status !== 'active') continue;
        if ($position === '' || preg_match('/coach|manager|staff/i', $position) === 1) continue;

        // Prefer the provider's player portrait/cutout and retain its source
        // metadata for visible attribution on the television card.
        $creativeCommons = strtolower(trim((string)($row['strCreativeCommons'] ?? '')));
        $image = trim((string)($row['strThumb'] ?? ''));
        if ($image === '') $image = trim((string)($row['strCutout'] ?? ''));
        $image = previewImageUrl($image);

        $players[] = [
            'id' => (string)($row['idPlayer'] ?? ''),
            'team_id' => $teamId,
            'name' => trim((string)($row['strPlayer'] ?? '')),
            'team' => trim((string)($row['strTeam'] ?? $teamName)),
            'position' => $position,
            'number' => trim((string)($row['strNumber'] ?? '')),
            'height' => trim((string)($row['strHeight'] ?? '')),
            'weight' => trim((string)($row['strWeight'] ?? '')),
            'nationality' => trim((string)($row['strNationality'] ?? '')),
            'image_url' => $image,
            'fallback_badge' => $teamBadge,
            'creative_commons' => $creativeCommons === 'yes',
            'provider' => 'TheSportsDB',
            'source_url' => 'https://www.thesportsdb.com/player/' . rawurlencode((string)($row['idPlayer'] ?? '')),
        ];
    }

    return array_values(array_filter($players, static fn(array $player): bool => $player['name'] !== ''));
}

function selectFeaturedPlayer(array $players, int $slot): ?array
{
    if ($players === []) return null;

    $roleGroups = [
        ['quarterback'],
        ['running back', 'fullback'],
        ['wide receiver', 'tight end'],
        ['linebacker'],
        ['cornerback', 'safety', 'defensive back'],
        ['defensive end', 'defensive tackle'],
    ];
    $wanted = $roleGroups[$slot % count($roleGroups)];

    foreach ($players as $player) {
        $position = strtolower((string)($player['position'] ?? ''));
        foreach ($wanted as $role) {
            if (strpos($position, $role) !== false) return $player;
        }
    }

    return $players[$slot % count($players)];
}

function selectFeaturedPlayers(array $players, int $limit): array
{
    if ($players === [] || $limit <= 0) return [];

    // A team badge is useful branding, but it is not a player photograph.
    // Show fewer player cards when necessary instead of logo-only stand-ins.
    $players = array_values(array_filter(
        $players,
        static fn(mixed $player): bool => is_array($player)
            && trim((string)($player['image_url'] ?? '')) !== ''
    ));
    if ($players === []) return [];

    $selected = [];
    $used = [];
    for ($slot = 0; $slot < $limit; $slot++) {
        $player = selectFeaturedPlayer($players, $slot);
        if ($player === null) break;

        $key = trim((string)($player['id'] ?? ''));
        if ($key === '') $key = recordKey((string)($player['name'] ?? ''));
        if ($key !== '' && isset($used[$key])) {
            $player = null;
            foreach ($players as $candidate) {
                if (!is_array($candidate)) continue;
                $candidateKey = trim((string)($candidate['id'] ?? ''));
                if ($candidateKey === '') $candidateKey = recordKey((string)($candidate['name'] ?? ''));
                if ($candidateKey !== '' && !isset($used[$candidateKey])) {
                    $player = $candidate;
                    $key = $candidateKey;
                    break;
                }
            }
        }

        if ($player === null) break;
        if ($key !== '') $used[$key] = true;
        $selected[] = $player;
    }

    return $selected;
}

function previewImageUrl(string $url): string
{
    $url = secureUrl(trim($url));
    if ($url === '') return '';
    if (preg_match('#/(?:medium|small|tiny)$#', $url) === 1) return $url;
    return rtrim($url, '/') . '/medium';
}

function loadSeasonRecords(array $config, string $cacheDir): array
{
    $season = nflSeason($config);
    $cacheFile = $cacheDir . '/nfl-records-' . preg_replace('/[^0-9A-Za-z_-]/', '-', $season) . '.json';
    $cacheSeconds = max(300, (int) ($config['records_cache_seconds'] ?? 900));
    $cached = readJsonFile($cacheFile);
    $cacheAge = is_file($cacheFile) ? time() - (int) filemtime($cacheFile) : PHP_INT_MAX;

    if ($cached !== null && $cacheAge < $cacheSeconds) {
        $cached['available'] = true;
        return $cached;
    }

    try {
        $upstream = fetchSeasonEvents($config, $season);
        $records = calculateSeasonRecords($upstream);
        $recordData = [
            'available' => true,
            'season' => $season,
            'generated_at' => gmdate(DATE_ATOM),
            'records' => $records,
        ];
        writeJsonFile($cacheFile, $recordData);
        return $recordData;
    } catch (Throwable $exception) {
        if ($cached !== null) {
            $cached['available'] = true;
            return $cached;
        }
        return ['available' => false, 'season' => $season, 'generated_at' => null, 'records' => []];
    }
}

function fetchSeasonEvents(array $config, string $season): array
{
    $url = 'https://www.thesportsdb.com/api/v1/json/'
        . rawurlencode((string) $config['sportsdb_api_key'])
        . '/eventsseason.php?id='
        . rawurlencode((string) $config['league_id'])
        . '&s='
        . rawurlencode($season);
    return fetchSportsDbUrl($url, '');
}

function calculateSeasonRecords(array $upstream): array
{
    $rows = $upstream['events'] ?? [];
    if (!is_array($rows)) return [];

    $records = [];
    foreach ($rows as $row) {
        if (!is_array($row)) continue;

        // TheSportsDB uses round 500 for NFL preseason. Team records shown in
        // the app are regular-season records, so only weeks 1 through 18 count.
        $round = (int) ($row['intRound'] ?? 0);
        if ($round < 1 || $round > 18) continue;

        $awayName = trim((string) ($row['strAwayTeam'] ?? ''));
        $homeName = trim((string) ($row['strHomeTeam'] ?? ''));
        foreach ([$awayName, $homeName] as $teamName) {
            $key = recordKey($teamName);
            if ($key !== '' && !isset($records[$key])) {
                $records[$key] = ['team' => $teamName, 'wins' => 0, 'losses' => 0, 'ties' => 0];
            }
        }

        $status = strtoupper(trim((string) ($row['strStatus'] ?? '')));
        if (!in_array($status, ['FT', 'AOT', 'AET', 'FINAL', 'FIN', 'FINISHED'], true)) continue;
        if (($row['intAwayScore'] ?? null) === null || ($row['intHomeScore'] ?? null) === null) continue;

        $awayKey = recordKey($awayName);
        $homeKey = recordKey($homeName);
        if ($awayKey === '' || $homeKey === '') continue;

        $awayScore = (int) $row['intAwayScore'];
        $homeScore = (int) $row['intHomeScore'];
        if ($awayScore > $homeScore) {
            $records[$awayKey]['wins']++;
            $records[$homeKey]['losses']++;
        } elseif ($homeScore > $awayScore) {
            $records[$homeKey]['wins']++;
            $records[$awayKey]['losses']++;
        } else {
            $records[$awayKey]['ties']++;
            $records[$homeKey]['ties']++;
        }
    }

    return $records;
}

function nflSeason(array $config): string
{
    $configured = trim((string) ($config['season'] ?? ''));
    if ($configured !== '') return $configured;

    $zone = new DateTimeZone((string) $config['timezone']);
    $now = new DateTimeImmutable('now', $zone);
    $year = (int) $now->format('Y');
    if ((int) $now->format('n') <= 2) $year--;
    return (string) $year;
}

function recordKey(string $teamName): string
{
    return strtolower(trim(preg_replace('/\s+/', ' ', $teamName) ?? $teamName));
}

function formatRecord(array $record): string
{
    $value = (int) ($record['wins'] ?? 0) . '-' . (int) ($record['losses'] ?? 0);
    $ties = (int) ($record['ties'] ?? 0);
    return $ties > 0 ? $value . '-' . $ties : $value;
}

function hasState(array $games, string $state): bool
{
    foreach ($games as $game) {
        if (is_array($game) && ($game['state'] ?? '') === $state) {
            return true;
        }
    }
    return false;
}

function applyPresentationFilters(array $payload, bool $showFinalGames, int $finalScoreCardSeconds): array
{
    $payload['schema_version'] = 6;
    $games = $payload['games'] ?? [];
    if (!is_array($games)) {
        $games = [];
    }

    $tickerItems = $payload['ticker']['items'] ?? [];
    if (!is_array($tickerItems)) {
        $tickerItems = [];
    }

    if (!$showFinalGames) {
        $now = time();
        $games = array_values(array_filter(
            $games,
            static fn(mixed $game): bool => is_array($game)
                && (($game['state'] ?? '') !== 'final'
                    || finalGameCardIsRecent($game, $tickerItems, $now, $finalScoreCardSeconds))
        ));
    }

    $payload['games'] = $games;
    $payload['display'] = [
        'show_final_games' => $showFinalGames,
        'final_score_card_seconds' => $finalScoreCardSeconds,
    ];
    $players = is_array($payload['featured_players'] ?? null) ? $payload['featured_players'] : [];
    $payload['features'] = buildFeatureCards($games, $players);

    $now = time();
    $tickerItems = array_values(array_filter(
        $tickerItems,
        static fn(mixed $item): bool => is_array($item) && (int) ($item['expires_at'] ?? 0) > $now
    ));
    $payload['ticker'] = buildTicker($tickerItems);

    $primaryAd = $payload['advertising']['primary'] ?? null;
    if (is_array($primaryAd) && ($primaryAd['active'] ?? false) === true) {
        $endsAt = trim((string)($primaryAd['ends_at'] ?? ''));
        $endsTimestamp = $endsAt !== '' ? strtotime($endsAt) : false;
        if ($endsTimestamp !== false && $endsTimestamp <= $now) {
            $payload['advertising']['primary'] = ['active' => false];
        }
    }

    return $payload;
}

function finalGameCardIsRecent(array $game, array $tickerItems, int $now, int $windowSeconds): bool
{
    if ($windowSeconds <= 0) return false;
    $gameId = (string) ($game['id'] ?? '');
    if ($gameId === '') return false;

    foreach ($tickerItems as $item) {
        if (!is_array($item)) continue;
        $itemGameId = (string) ($item['game_id'] ?? '');
        $itemId = (string) ($item['id'] ?? '');
        if ($itemGameId !== $gameId && $itemId !== 'nfl-final-' . $gameId) continue;
        $detectedAt = (int) ($item['detected_at'] ?? 0);
        return $detectedAt > 0 && ($now - $detectedAt) < $windowSeconds;
    }
    return false;
}

/**
 * Build presentation-neutral feature cards for the television clients.
 *
 * The Roku layout understands this small schema instead of knowing where the
 * data came from. Future weather, player, injury, or other-sport providers can
 * add new card types without changing the schedule rail or screen rotation.
 */
function buildFeatureCards(array $games, array $players = []): array
{
    $matchups = [];
    $weatherCards = [];
    $oddsCards = [];

    foreach (array_slice($games, 0, 8) as $index => $game) {
        if (!is_array($game) || ($game['state'] ?? '') !== 'scheduled') continue;

        $away = is_array($game['away'] ?? null) ? $game['away'] : [];
        $home = is_array($game['home'] ?? null) ? $game['home'] : [];
        $awayName = (string)($away['short'] ?? $away['name'] ?? 'Away');
        $homeName = (string)($home['short'] ?? $home['name'] ?? 'Home');
        $homeFullName = (string)($home['name'] ?? $homeName);
        $stadiumImage = secureUrl((string)($game['stadium_image'] ?? ''));
        $venueInfo = nflVenue($homeFullName);
        $venueName = is_array($venueInfo) ? (string)($venueInfo['venue'] ?? '') : '';

        $matchups[] = [
            'id' => 'matchup-' . (string)($game['id'] ?? $index),
            'type' => 'matchup',
            'eyebrow' => $index === 0 ? 'NEXT UP' : 'GAME SPOTLIGHT',
            'title' => $awayName . ' at ' . $homeName,
            'subtitle' => (string)($game['status_text'] ?? 'UPCOMING'),
            'away' => $away,
            'home' => $home,
            'away_badge' => secureUrl((string)($game['away_badge'] ?? '')),
            'home_badge' => secureUrl((string)($game['home_badge'] ?? '')),
            'league_key' => 'nfl',
            'stadium_image' => $stadiumImage,
            'venue' => $venueName,
            'footer' => $venueName !== '' ? $venueName : 'UPCOMING NFL MATCHUP',
        ];

        $weather = is_array($game['weather'] ?? null) ? $game['weather'] : [];
        if (($weather['available'] ?? false) === true && count($weatherCards) < 4) {
            $temp = $weather['temp_f'] ?? null;
            $weatherVenue = trim((string)($weather['venue'] ?? $venueName));
            if ($weatherVenue === '') $weatherVenue = 'STADIUM FORECAST';
            $weatherCards[] = [
                'id' => 'weather-' . (string)($game['id'] ?? $index),
                'type' => 'weather',
                'eyebrow' => 'GAME-DAY WEATHER',
                'title' => $awayName . ' at ' . $homeName,
                'subtitle' => (string)($weather['location'] ?? 'Stadium forecast'),
                'condition' => (string)($weather['condition'] ?? 'Forecast available'),
                'temp_f' => $temp,
                'feelslike_f' => $weather['feelslike_f'] ?? null,
                'wind_mph' => $weather['wind_mph'] ?? null,
                'wind_dir' => (string)($weather['wind_dir'] ?? ''),
                'precip_chance' => $weather['precip_chance'] ?? null,
                'humidity' => $weather['humidity'] ?? null,
                'icon_url' => (string)($weather['icon_url'] ?? ''),
                'icon_kind' => (string)($weather['icon_kind'] ?? 'cloudy'),
                'league_key' => 'nfl',
                'stadium_image' => $stadiumImage,
                'venue' => (string)($weather['venue'] ?? ''),
                'forecast_for' => (string)($weather['forecast_for'] ?? ''),
                'provider' => 'WeatherAPI.com',
                'source_url' => 'https://www.weatherapi.com/',
                'footer' => $weatherVenue,
            ];
        }

        $odds = is_array($game['odds'] ?? null) ? $game['odds'] : [];
        if (($odds['available'] ?? false) === true && count($oddsCards) < 6) {
            $oddsCards[] = [
                'id' => 'odds-' . (string)($game['id'] ?? $index),
                'type' => 'odds',
                'eyebrow' => 'PREGAME ODDS',
                'title' => $awayName . ' at ' . $homeName,
                'subtitle' => (string)($game['status_text'] ?? 'UPCOMING'),
                'away_name' => $awayName,
                'home_name' => $homeName,
                'away_moneyline' => $odds['away_moneyline'] ?? null,
                'home_moneyline' => $odds['home_moneyline'] ?? null,
                'away_spread' => $odds['away_spread'] ?? null,
                'home_spread' => $odds['home_spread'] ?? null,
                'total' => $odds['total'] ?? null,
                'bookmakers_count' => (int)($odds['bookmakers_count'] ?? 0),
                'league_key' => 'nfl',
                'stadium_image' => $stadiumImage,
                'venue' => $venueName,
                'footer' => $venueName !== '' ? $venueName : 'PREGAME ODDS',
            ];
        }
    }

    $playerCards = [];
    foreach ($players as $player) {
        if (!is_array($player) || trim((string)($player['name'] ?? '')) === '') continue;
        $number = trim((string)($player['number'] ?? ''));
        $position = trim((string)($player['position'] ?? 'NFL PLAYER'));
        $subtitle = trim((string)($player['team'] ?? 'NFL'));
        if ($number !== '') $subtitle .= '  •  #' . $number;
        $playerVenueInfo = nflVenue((string)($player['team'] ?? ''));
        $playerVenue = is_array($playerVenueInfo) ? (string)($playerVenueInfo['venue'] ?? '') : '';

        $playerCards[] = [
            'id' => 'player-' . (string)($player['id'] ?? count($playerCards)),
            'type' => 'player',
            'eyebrow' => 'PLAYER SPOTLIGHT',
            'title' => (string)$player['name'],
            'subtitle' => $subtitle,
            'image_url' => (string)($player['image_url'] ?? ''),
            'fallback_badge' => (string)($player['fallback_badge'] ?? ''),
            'team' => (string)($player['team'] ?? ''),
            'position' => $position,
            'number' => $number,
            'height' => (string)($player['height'] ?? ''),
            'weight' => (string)($player['weight'] ?? ''),
            'nationality' => (string)($player['nationality'] ?? ''),
            'provider' => (string)($player['provider'] ?? 'TheSportsDB'),
            'source_url' => (string)($player['source_url'] ?? ''),
            'creative_commons' => ($player['creative_commons'] ?? false) === true,
            'venue' => $playerVenue,
            'footer' => $playerVenue !== '' ? $playerVenue : 'NFL PLAYER SPOTLIGHT',
        ];
    }

    // Player, weather, odds, and matchup cards rotate without allowing any single
    // feature type to dominate the schedule rail.
    $features = [];
    $playerIndex = 0;
    $weatherIndex = 0;
    $oddsIndex = 0;
    $matchupIndex = 0;
    while (
        $playerIndex < count($playerCards)
        || $weatherIndex < count($weatherCards)
        || $oddsIndex < count($oddsCards)
        || $matchupIndex < count($matchups)
    ) {
        for ($slot = 0; $slot < 2 && $playerIndex < count($playerCards); $slot++) {
            $features[] = $playerCards[$playerIndex++];
        }
        if ($weatherIndex < count($weatherCards)) $features[] = $weatherCards[$weatherIndex++];
        if ($oddsIndex < count($oddsCards)) $features[] = $oddsCards[$oddsIndex++];
        if ($matchupIndex < count($matchups)) $features[] = $matchups[$matchupIndex++];
    }

    return $features;
}

function updateFinalScoreTicker(
    array $payload,
    string $historyFile,
    int $retentionSeconds,
    int $maxEventAgeSeconds
): array
{
    $now = time();
    $history = readJsonFile($historyFile) ?? [];

    foreach (($payload['games'] ?? []) as $game) {
        if (!is_array($game)) continue;

        $gameId = (string) ($game['id'] ?? '');
        if ($gameId === '') {
            $gameId = hash('sha256', json_encode([$game['away'] ?? [], $game['home'] ?? []]));
        }

        $state = (string) ($game['state'] ?? '');
        if ($state === 'live') {
            if (!isset($history[$gameId]) || !is_array($history[$gameId])) {
                $history[$gameId] = [
                    'id' => 'nfl-final-' . $gameId,
                    'game_id' => $gameId,
                    'type' => 'final_score',
                    'priority' => 10,
                    'message' => '',
                    'state' => 'observed_live',
                    'detected_at' => $now,
                    'expires_at' => 0,
                ];
            }
            continue;
        }

        if ($state !== 'final') continue;

        if (!isset($history[$gameId]) || !is_array($history[$gameId])) {
            $commenceAt = isset($game['commence_at']) ? strtotime((string) $game['commence_at']) : false;
            $eventIsRecent = $commenceAt === false || ($now - $commenceAt) <= $maxEventAgeSeconds;
            $history[$gameId] = [
                'id' => 'nfl-final-' . $gameId,
                'game_id' => $gameId,
                'type' => 'final_score',
                'priority' => 10,
                'message' => finalScoreMessage($game),
                'state' => $eventIsRecent ? 'final' : 'suppressed_old_final',
                'detected_at' => $now,
                'expires_at' => $eventIsRecent ? $now + $retentionSeconds : 0,
            ];
        } elseif (($history[$gameId]['state'] ?? '') === 'observed_live') {
            // A game observed live is a genuine transition even if weather or
            // overtime made it unusually long.
            $history[$gameId]['state'] = 'final';
            $history[$gameId]['message'] = finalScoreMessage($game);
            $history[$gameId]['detected_at'] = $now;
            $history[$gameId]['expires_at'] = $now + $retentionSeconds;
        } else {
            // Scores may be corrected after the first final marker. Keep the
            // original expiration but refresh the displayed result.
            $history[$gameId]['message'] = finalScoreMessage($game);
        }
        $history[$gameId]['game_id'] = $gameId;
    }

    // Retain expired IDs for seven days so a provider that leaves a completed
    // game in its live feed cannot repeatedly re-add it to the ticker.
    $rememberUntil = $now - (7 * 24 * 60 * 60);
    $history = array_filter(
        $history,
        static fn(mixed $item): bool => is_array($item) && (int) ($item['detected_at'] ?? 0) >= $rememberUntil
    );

    $encoded = json_encode($history, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
    $temporary = $historyFile . '.tmp.' . getmypid();
    if (file_put_contents($temporary, $encoded, LOCK_EX) === false || !rename($temporary, $historyFile)) {
        @unlink($temporary);
        throw new RuntimeException('Could not save final-score history.');
    }

    $activeItems = array_values(array_filter(
        $history,
        static fn(array $item): bool => ($item['state'] ?? 'final') === 'final'
            && (int) ($item['expires_at'] ?? 0) > $now
    ));
    $payload['ticker'] = buildTicker($activeItems);
    return $payload;
}

function buildTicker(array $items): array
{
    if ($items === []) {
        return ['active' => false, 'priority' => null, 'category' => null, 'label' => '', 'message' => '', 'items' => []];
    }

    $highestPriority = max(array_map(static fn(array $item): int => (int) ($item['priority'] ?? 0), $items));
    $selected = array_values(array_filter(
        $items,
        static fn(array $item): bool => (int) ($item['priority'] ?? 0) === $highestPriority
    ));
    usort($selected, static fn(array $a, array $b): int => (int) ($b['detected_at'] ?? 0) <=> (int) ($a['detected_at'] ?? 0));

    $category = (string) ($selected[0]['type'] ?? 'message');
    $labels = [
        'final_score' => 'FINAL SCORES',
        'urgent_alert' => 'ALERT',
        'admin_message' => 'NOTICE',
        'league_update' => 'NFL UPDATE',
    ];

    return [
        'active' => true,
        'priority' => $highestPriority,
        'category' => $category,
        'label' => $labels[$category] ?? 'TV SCOREBOARD',
        'message' => implode('     |     ', array_map(static fn(array $item): string => (string) ($item['message'] ?? ''), $selected)),
        // Preserve lower-priority items so they can resume automatically when
        // a higher-priority alert or admin message expires.
        'items' => array_values($items),
    ];
}

function finalScoreMessage(array $game): string
{
    $away = (string) ($game['away']['short'] ?? $game['away']['name'] ?? 'Away');
    $home = (string) ($game['home']['short'] ?? $game['home']['name'] ?? 'Home');
    $awayScore = $game['away_score'] ?? '-';
    $homeScore = $game['home_score'] ?? '-';
    return 'FINAL  •  ' . $away . ' ' . $awayScore . ', ' . $home . ' ' . $homeScore;
}

function gameState(string $status, string $progress): string
{
    $finalStatuses = ['FT', 'AOT', 'AET', 'FINAL', 'FIN', 'FINISHED'];
    if (in_array($status, $finalStatuses, true) || preg_match('/^FINAL(?:\\b|\\/)/i', trim($progress)) === 1) return 'final';
    if (in_array($status, ['CANC', 'PST'], true)) return 'delayed';
    if ($status === 'NS' || ($status === '' && $progress === '')) return 'scheduled';
    return 'live';
}

function statusText(string $status, string $progress, string $eventTime): string
{
    if ($progress !== '') return strtoupper($progress);
    $labels = ['NS' => $eventTime !== '' ? $eventTime : 'UPCOMING', 'HT' => 'HALFTIME', 'FT' => 'FINAL', 'AOT' => 'FINAL/OT', 'PST' => 'POSTPONED', 'CANC' => 'CANCELLED'];
    return $labels[$status] ?? ($status !== '' ? $status : 'UPCOMING');
}

function stateOrder(string $state): int
{
    return ['live' => 0, 'scheduled' => 1, 'final' => 2, 'delayed' => 3][$state] ?? 4;
}

function team(string $name, string $id = ''): array
{
    $parts = preg_split('/\\s+/', trim($name)) ?: [];
    $short = count($parts) > 1 ? end($parts) : $name;
    return ['id' => $id, 'name' => $name, 'short' => (string) $short];
}

function nullableInt(mixed $value): ?int
{
    return $value === null || $value === '' ? null : (int) $value;
}

function secureUrl(string $url): string
{
    return str_starts_with($url, 'http://') ? 'https://' . substr($url, 7) : $url;
}

function eventStartTimestamp(array $row): ?int
{
    try {
        $utc = new DateTimeZone('UTC');
        $timestamp = trim((string) ($row['strTimestamp'] ?? ''));
        if ($timestamp !== '') {
            return (new DateTimeImmutable($timestamp, $utc))->getTimestamp();
        }

        $date = trim((string) ($row['dateEvent'] ?? ''));
        $time = trim((string) ($row['strTime'] ?? $row['strEventTime'] ?? ''));
        if ($date !== '' && $time !== '') {
            return (new DateTimeImmutable($date . ' ' . $time, $utc))->getTimestamp();
        }
        if ($date !== '') {
            return (new DateTimeImmutable($date, $utc))->getTimestamp();
        }
    } catch (Throwable $exception) {
        return null;
    }

    return null;
}

function readJsonFile(string $path): ?array
{
    if (!is_file($path)) return null;
    $contents = file_get_contents($path);
    if (!is_string($contents)) return null;
    $decoded = json_decode($contents, true);
    return is_array($decoded) ? $decoded : null;
}

function writeJsonFile(string $path, array $value): void
{
    $encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
    $temporary = $path . '.tmp.' . getmypid();
    if (file_put_contents($temporary, $encoded, LOCK_EX) === false || !rename($temporary, $path)) {
        @unlink($temporary);
        throw new RuntimeException('Could not save JSON cache.');
    }
}

function recordEndpointUsage(string $cacheDir, string $timezone): array
{
    $empty = [
        'available' => false,
        'today' => 0,
        'last_30_days' => 0,
        'tracking_started_at' => null,
    ];
    $path = $cacheDir . '/endpoint-usage-daily.json';
    $handle = @fopen($path, 'c+');
    if ($handle === false) {
        return $empty;
    }

    try {
        if (!flock($handle, LOCK_EX)) {
            fclose($handle);
            return $empty;
        }

        $contents = stream_get_contents($handle);
        $stored = is_string($contents) && $contents !== '' ? json_decode($contents, true) : null;
        if (!is_array($stored)) {
            $stored = [];
        }

        try {
            $zone = new DateTimeZone($timezone);
        } catch (Throwable $exception) {
            $zone = new DateTimeZone('UTC');
        }
        $now = new DateTimeImmutable('now', $zone);
        $today = $now->format('Y-m-d');
        $cutoff = $now->modify('-29 days')->format('Y-m-d');
        $pruneBefore = $now->modify('-45 days')->format('Y-m-d');

        $days = is_array($stored['days'] ?? null) ? $stored['days'] : [];
        $days[$today] = max(0, (int)($days[$today] ?? 0)) + 1;
        foreach (array_keys($days) as $date) {
            if (!is_string($date) || $date < $pruneBefore || $date > $today) {
                unset($days[$date]);
            }
        }
        ksort($days);

        $trackingStartedAt = is_string($stored['tracking_started_at'] ?? null)
            ? $stored['tracking_started_at']
            : gmdate(DATE_ATOM);
        $data = [
            'timezone' => $zone->getName(),
            'tracking_started_at' => $trackingStartedAt,
            'days' => $days,
        ];
        $encoded = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);

        rewind($handle);
        if (!ftruncate($handle, 0) || fwrite($handle, $encoded) === false) {
            flock($handle, LOCK_UN);
            fclose($handle);
            return $empty;
        }
        fflush($handle);
        flock($handle, LOCK_UN);
        fclose($handle);

        $last30Days = 0;
        foreach ($days as $date => $count) {
            if ($date >= $cutoff && $date <= $today) {
                $last30Days += max(0, (int)$count);
            }
        }

        return [
            'available' => true,
            'today' => (int)($days[$today] ?? 0),
            'last_30_days' => $last30Days,
            'tracking_started_at' => $trackingStartedAt,
        ];
    } catch (Throwable $exception) {
        if (is_resource($handle)) {
            @flock($handle, LOCK_UN);
            @fclose($handle);
        }
        error_log('TV Scoreboard endpoint usage counter failed: ' . $exception->getMessage());
        return $empty;
    }
}

function respond(array $payload, int $status = 200): never
{
    global $endpointUsageStats;
    if (isset($endpointUsageStats) && is_array($endpointUsageStats)) {
        $payload['endpoint_usage'] = $endpointUsageStats;
    }
    http_response_code($status);
    echo json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
    exit;
}

function errorResponse(int $status, string $message): never
{
    respond(['ok' => false, 'error' => $message, 'games' => []], $status);
}

function demoPayload(): array
{
    $now = time();
    return [
        'ok' => true, 'schema_version' => 6, 'sport' => 'American Football', 'league' => 'NFL', 'league_key' => 'nfl', 'mode' => 'live', 'generated_at' => gmdate(DATE_ATOM), 'display_timezone' => 'America/New_York', 'stale' => false,
        'games' => [
            ['id'=>'demo-1','away'=>['name'=>'Buffalo Bills','short'=>'Bills','record'=>'2-0'],'home'=>['name'=>'New York Jets','short'=>'Jets','record'=>'1-1'],'away_score'=>24,'home_score'=>17,'away_badge'=>'https://r2.thesportsdb.com/images/media/team/badge/6pb6f51355247187.png','home_badge'=>'https://r2.thesportsdb.com/images/media/team/badge/hz0v9m1744634622.png','status'=>'Q4','status_text'=>'06:42 - 4TH','state'=>'live'],
            ['id'=>'demo-2','away'=>['name'=>'Pittsburgh Steelers','short'=>'Steelers','record'=>'1-1'],'home'=>['name'=>'Baltimore Ravens','short'=>'Ravens','record'=>'2-0'],'away_score'=>13,'home_score'=>20,'away_badge'=>'','home_badge'=>'','status'=>'Q3','status_text'=>'02:17 - 3RD','state'=>'live'],
            ['id'=>'demo-4','away'=>['name'=>'Kansas City Chiefs','short'=>'Chiefs','record'=>'1-1'],'home'=>['name'=>'Denver Broncos','short'=>'Broncos','record'=>'0-2'],'away_score'=>null,'home_score'=>null,'away_badge'=>'','home_badge'=>'','status'=>'NS','status_text'=>'Sunday 9/20 • 4:25 PM','state'=>'scheduled'],
        ],
        'recent_games' => [
            ['id'=>'demo-final-1','away'=>['id'=>'134918','name'=>'Buffalo Bills','short'=>'Bills'],'home'=>['id'=>'134919','name'=>'Miami Dolphins','short'=>'Dolphins'],'away_score'=>31,'home_score'=>24,'away_badge'=>'','home_badge'=>'','status'=>'FT','status_text'=>'FINAL','state'=>'final','commence_at'=>gmdate(DATE_ATOM, $now - 86400),'round'=>3,'season'=>(string)gmdate('Y')],
        ],
        'recent_games_meta' => ['available'=>true,'reason'=>'demo','cache_hit'=>false,'cache_age_seconds'=>0,'cache_ttl_seconds'=>900,'stale'=>false,'last_updated'=>gmdate(DATE_ATOM),'provider'=>'TheSportsDB'],
        'standings' => ['available'=>true,'season'=>(string) gmdate('Y'),'generated_at'=>gmdate(DATE_ATOM)],
        'display' => ['show_final_games' => false],
        'advertising' => ['primary' => ['active' => false]],
        'ticker' => buildTicker([
            ['id'=>'nfl-final-demo-3','type'=>'final_score','priority'=>10,'message'=>'FINAL  •  Cowboys 27, Eagles 21','detected_at'=>$now,'expires_at'=>$now + 3600],
        ]),
        'cache' => ['hit'=>false,'age_seconds'=>0,'ttl_seconds'=>5],
    ];
}
