<?php

declare(strict_types=1);

final class MyronPublicPageCacheWarmer
{
    private const DEFAULT_LIMIT = 25;
    private const DEFAULT_TIMEOUT_SECONDS = 5;

    /**
     * @param callable(string, int): array{ok:bool,status:int,error:string,cacheStatus?:string} | null $fetcher
     */
    public function __construct(
        private readonly string $projectRoot,
        private $fetcher = null
    ) {
    }

    public static function fromProjectRoot(string $projectRoot): self
    {
        return new self(rtrim($projectRoot, DIRECTORY_SEPARATOR));
    }

    /**
     * @return list<string>
     */
    public function discoverRoutes(int $limit = self::DEFAULT_LIMIT): array
    {
        $limit = max(1, $limit);
        $routes = ['/'];

        try {
            foreach (myronContentRepository()->listPublishedPages() as $page) {
                $path = $this->normalizeRoute((string) ($page['path'] ?? ''));
                if ($path !== '') {
                    $routes[] = $path;
                }
            }
        } catch (Throwable) {
        }

        try {
            foreach (myronQuizDefinitionRepository()->listAll() as $quiz) {
                if ((string) ($quiz['status'] ?? '') !== 'published') {
                    continue;
                }
                $slug = myronNormalizeSlug((string) ($quiz['slug'] ?? ''));
                if ($slug === '' || !is_array(myronQuizDefinitionRepository()->currentPublishedVersion((string) ($quiz['id'] ?? '')))) {
                    continue;
                }
                $routes[] = '/quiz/' . $slug;
            }
        } catch (Throwable) {
        }

        $unique = [];
        foreach ($routes as $route) {
            $normalized = $this->normalizeRoute($route);
            if ($normalized === '') {
                continue;
            }
            $unique[$normalized] = true;
            if (count($unique) >= $limit) {
                break;
            }
        }

        return array_keys($unique);
    }

    /**
     * @param list<string>|null $routes
     * @return array{ok:bool,attempted:int,warmed:int,failed:int,routes:list<array{route:string,ok:bool,status:int,error:string}>}
     */
    public function warm(?array $routes = null, int $limit = self::DEFAULT_LIMIT, int $timeoutSeconds = self::DEFAULT_TIMEOUT_SECONDS): array
    {
        $limit = max(1, $limit);
        $timeoutSeconds = max(1, $timeoutSeconds);
        $routes = $routes === null ? $this->discoverRoutes($limit) : array_slice($routes, 0, $limit);
        $results = [];
        $warmed = 0;
        $failed = 0;

        foreach ($routes as $route) {
            $normalized = $this->normalizeRoute((string) $route);
            if ($normalized === '') {
                continue;
            }

            $result = $this->warmRoute($normalized, $timeoutSeconds);
            $results[] = ['route' => $normalized] + $result;
            if ($result['ok']) {
                $warmed++;
            } else {
                $failed++;
            }
        }

        return [
            'ok' => $failed === 0,
            'attempted' => count($results),
            'warmed' => $warmed,
            'failed' => $failed,
            'routes' => $results,
        ];
    }

    /**
     * @return array{ok:bool,status:int,error:string}
     */
    private function warmRoute(string $route, int $timeoutSeconds): array
    {
        $url = $this->urlForRoute($route);
        if ($url === '') {
            return ['ok' => false, 'status' => 0, 'error' => 'Unable to resolve public base URL.'];
        }

        try {
            if (is_callable($this->fetcher)) {
                $result = ($this->fetcher)($url, $timeoutSeconds);
                $cacheStatus = strtoupper(trim((string) ($result['cacheStatus'] ?? '')));
                $cacheConfirmed = in_array($cacheStatus, ['HIT', 'MISS'], true);
                return [
                    'ok' => (bool) ($result['ok'] ?? false) && $cacheConfirmed,
                    'status' => (int) ($result['status'] ?? 0),
                    'error' => $cacheConfirmed
                        ? (string) ($result['error'] ?? '')
                        : $this->cacheVerificationError($cacheStatus),
                ];
            }

            return $this->fetchUrl($url, $timeoutSeconds);
        } catch (Throwable $e) {
            return ['ok' => false, 'status' => 0, 'error' => $e->getMessage()];
        }
    }

    /**
     * @return array{ok:bool,status:int,error:string}
     */
    private function fetchUrl(string $url, int $timeoutSeconds): array
    {
        $context = stream_context_create([
            'http' => [
                'method' => 'GET',
                'timeout' => $timeoutSeconds,
                'ignore_errors' => true,
                'header' => "User-Agent: MyronCMS-Cache-Warmer\r\nAccept: text/html\r\n",
            ],
            'ssl' => [
                'verify_peer' => false,
                'verify_peer_name' => false,
            ],
        ]);

        $body = @file_get_contents($url, false, $context);
        $modernHeaderApiAvailable = function_exists('http_get_last_response_headers');
        $modernHeaders = $modernHeaderApiAvailable ? http_get_last_response_headers() : null;
        // PHP 8.3 and earlier expose stream response headers through this
        // request-scoped variable, so it must be captured in the same method
        // scope as file_get_contents().
        $legacyHeaders = $http_response_header ?? [];
        $headers = $this->selectResponseHeaders($modernHeaderApiAvailable, $modernHeaders, $legacyHeaders);
        $status = $this->statusFromHeaders($headers);
        $cacheStatus = $this->cacheStatusFromHeaders($headers);
        if (
            is_string($body)
            && $body !== ''
            && $status >= 200
            && $status < 400
            && in_array($cacheStatus, ['HIT', 'MISS'], true)
        ) {
            return ['ok' => true, 'status' => $status, 'error' => ''];
        }

        return [
            'ok' => false,
            'status' => $status,
            'error' => is_string($body) && $body !== '' && $status >= 200 && $status < 400
                ? $this->cacheVerificationError($cacheStatus)
                : 'Warm request failed.',
        ];
    }

    /**
     * @param list<string>|null $modernHeaders
     * @param list<string> $legacyHeaders
     * @return list<string>
     */
    private function selectResponseHeaders(
        bool $modernHeaderApiAvailable,
        ?array $modernHeaders,
        array $legacyHeaders
    ): array {
        return $modernHeaderApiAvailable ? ($modernHeaders ?? []) : $legacyHeaders;
    }

    /**
     * @param list<string> $headers
     */
    private function statusFromHeaders(array $headers): int
    {
        foreach ($headers as $header) {
            if (preg_match('#^HTTP/\S+\s+(\d{3})#i', $header, $matches) === 1) {
                return (int) $matches[1];
            }
        }

        return 0;
    }

    /**
     * @param list<string> $headers
     */
    private function cacheStatusFromHeaders(array $headers): string
    {
        foreach ($headers as $header) {
            if (preg_match('/^X-Myron-Public-Page-Cache:\s*(HIT|MISS|BYPASS)\s*$/i', trim($header), $matches) === 1) {
                return strtoupper($matches[1]);
            }
        }

        return '';
    }

    private function cacheVerificationError(string $cacheStatus): string
    {
        return $cacheStatus === 'BYPASS'
            ? 'Warm request bypassed the public page cache.'
            : 'Warm request did not confirm a public page cache HIT or MISS.';
    }

    private function urlForRoute(string $route): string
    {
        $base = trim((string) getenv('MYRON_PUBLIC_CACHE_WARMER_BASE_URL'));
        if ($base === '' && function_exists('myronAppUrl')) {
            $base = rtrim(myronAppUrl(), '/') . rtrim(myronPublicBasePath(), '/');
        }
        $base = rtrim($base, '/');
        if ($base === '') {
            return '';
        }

        return $base . $this->normalizeRoute($route);
    }

    private function normalizeRoute(string $route): string
    {
        $route = '/' . ltrim(trim($route), '/');
        $route = preg_replace('#/+#', '/', $route) ?: '/';
        return $route !== '/' ? rtrim($route, '/') : '/';
    }
}

if (!function_exists('myronPublicPageCacheWarmer')) {
    function myronPublicPageCacheWarmer(): MyronPublicPageCacheWarmer
    {
        static $warmer = null;
        if ($warmer instanceof MyronPublicPageCacheWarmer) {
            return $warmer;
        }

        $warmer = MyronPublicPageCacheWarmer::fromProjectRoot(dirname(__DIR__, 3));
        return $warmer;
    }
}

if (!function_exists('myronWarmPublicPageCacheFailSoft')) {
    /**
     * @return array{ok:bool,attempted:int,warmed:int,failed:int,routes:list<array{route:string,ok:bool,status:int,error:string}>}
     */
    function myronWarmPublicPageCacheFailSoft(int $limit = 25, int $timeoutSeconds = 5): array
    {
        try {
            if (function_exists('myronRunCoordinatedPublicPageCacheWarmFailSoft')) {
                return myronRunCoordinatedPublicPageCacheWarmFailSoft($limit, $timeoutSeconds, true);
            }
            return myronPublicPageCacheWarmer()->warm(null, $limit, $timeoutSeconds);
        } catch (Throwable $e) {
            return [
                'ok' => false,
                'attempted' => 0,
                'warmed' => 0,
                'failed' => 1,
                'routes' => [['route' => '', 'ok' => false, 'status' => 0, 'error' => $e->getMessage()]],
            ];
        }
    }
}
