<?php

declare(strict_types=1);

final class MyronPublicPageCache
{
    private const CACHE_SCHEMA = 1;
    private const MAX_ENTRY_BYTES = 2_500_000;
    private const PUBLIC_HTML_CACHE_CONTROL = 'public, max-age=0, must-revalidate';
    private const ALLOWED_QUERY_KEYS = ['author', 'category', 'page', 'tag'];
    private const BYPASS_QUERY_KEYS = [
        'fbclid',
            'gclid',
            'mc_cid',
            'mc_eid',
            'utm_campaign',
        'utm_content',
        'utm_medium',
        'utm_source',
        'utm_term',
    ];

    public function __construct(
        private readonly string $projectRoot,
        private readonly string $cacheRoot
    ) {
    }

    public static function fromProjectRoot(string $projectRoot): self
    {
        $root = rtrim($projectRoot, DIRECTORY_SEPARATOR);
        return new self($root, $root . '/app/storage/cache/public-pages');
    }

    /**
     * @param array<string, mixed> $server
     * @param array<string, mixed> $cookies
     */
    public function tryServeFromRequest(array $server, array $cookies): bool
    {
        $request = $this->requestContext($server, $cookies, false);
        if ($request === null) {
            return false;
        }

        $paths = $this->entryPaths($request['key']);
        if (!is_file($paths['meta']) || !is_file($paths['html'])) {
            return false;
        }

        try {
            $metadata = $this->readJsonFile($paths['meta']);
            $state = $this->state();
        } catch (Throwable) {
            return false;
        }

        if (
            (int) ($metadata['schema'] ?? 0) !== self::CACHE_SCHEMA
            || (string) ($metadata['generation'] ?? '') !== (string) ($state['generation'] ?? '')
            || (string) ($metadata['key'] ?? '') !== $request['key']
        ) {
            return false;
        }

        $size = filesize($paths['html']);
        if (!is_int($size) || $size <= 0 || $size > self::MAX_ENTRY_BYTES) {
            return false;
        }

        $etag = (string) ($metadata['etag'] ?? '');
        if ($etag !== '' && $this->requestHasMatchingEtag($server, $etag)) {
            http_response_code(304);
            header('X-Myron-Public-Page-Cache: HIT');
            header('ETag: ' . $etag);
            $lastModified = (string) ($metadata['lastModified'] ?? '');
            if ($lastModified !== '') {
                header('Last-Modified: ' . $lastModified);
            }
            $this->sendPublicHtmlCacheControl();
            return true;
        }

        $html = file_get_contents($paths['html']);
        if (!is_string($html) || $html === '') {
            return false;
        }

        http_response_code(200);
        header('Content-Type: text/html; charset=utf-8');
        header('X-Myron-Public-Page-Cache: HIT');
        $this->sendPublicHtmlCacheControl();
        if ($etag !== '') {
            header('ETag: ' . $etag);
        }
        $lastModified = (string) ($metadata['lastModified'] ?? '');
        if ($lastModified !== '') {
            header('Last-Modified: ' . $lastModified);
        }

        if (strtoupper((string) ($server['REQUEST_METHOD'] ?? 'GET')) !== 'HEAD') {
            echo $html;
        }

        return true;
    }

    /**
     * @param array<string, mixed> $server
     * @param array<string, mixed> $cookies
     */
    public function shouldCaptureRequest(array $server, array $cookies): bool
    {
        return $this->requestContext($server, $cookies, true) !== null;
    }

    /**
     * @param array<string, mixed> $server
     * @param array<string, mixed> $cookies
     * @param list<string> $headers
     */
    public function storeResponseFromRequest(array $server, array $cookies, string $html, int $statusCode, array $headers): bool
    {
        $request = $this->requestContext($server, $cookies, true);
        if ($request === null || !$this->responseIsCacheable($html, $statusCode, $headers)) {
            if (!headers_sent()) {
                header('X-Myron-Public-Page-Cache: BYPASS');
            }
            return false;
        }

        try {
            $this->ensureCacheRoot();
            $state = $this->state();
            $paths = $this->entryPaths($request['key']);
            $etag = '"' . hash('sha256', $html) . '"';
            $lastModified = gmdate('D, d M Y H:i:s') . ' GMT';
            $metadata = [
                'schema' => self::CACHE_SCHEMA,
                'key' => $request['key'],
                'path' => $request['path'],
                'query' => $request['query'],
                'host' => $request['host'],
                'generation' => (string) ($state['generation'] ?? ''),
                'etag' => $etag,
                'lastModified' => $lastModified,
                'storedAt' => gmdate('c'),
                'bytes' => strlen($html),
            ];

            $this->writeAtomic($paths['html'], $html);
            $this->writeAtomic($paths['meta'], json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");

            if (!headers_sent()) {
                header('X-Myron-Public-Page-Cache: MISS');
                header('ETag: ' . $etag);
                header('Last-Modified: ' . $lastModified);
                $this->sendPublicHtmlCacheControl();
            }
            return true;
        } catch (Throwable) {
            if (!headers_sent()) {
                header('X-Myron-Public-Page-Cache: BYPASS');
            }
            return false;
        }
    }

    public function invalidateAll(string $reason = ''): void
    {
        $invalidated = false;
        try {
            $this->ensureCacheRoot();
            foreach (glob($this->cacheRoot . '/*.{html,json,tmp}', GLOB_BRACE) ?: [] as $file) {
                if (is_file($file) && basename($file) !== 'state.json') {
                    @unlink($file);
                }
            }
            $state = [
                'schema' => self::CACHE_SCHEMA,
                'generation' => bin2hex(random_bytes(12)),
                'invalidatedAt' => gmdate('c'),
                'reason' => trim($reason),
            ];
            $this->writeAtomic($this->statePath(), json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
            $invalidated = true;
        } catch (Throwable) {
            // Cache invalidation must never block the authoring operation.
        }

        if ($invalidated && class_exists(MyronPublicPageCacheWarmCoordinator::class)) {
            try {
                $coordinator = MyronPublicPageCacheWarmCoordinator::fromProjectRoot($this->projectRoot);
                $coordinator->request($reason);
                $coordinator->schedulePostResponseWarm();
            } catch (Throwable) {
                // Cache warming coordination must never block the write path.
            }
        }
    }

    public static function publicHtmlCacheControl(): string
    {
        return self::PUBLIC_HTML_CACHE_CONTROL;
    }

    private function sendPublicHtmlCacheControl(): void
    {
        header_remove('Cache-Control');
        header('Cache-Control: ' . self::PUBLIC_HTML_CACHE_CONTROL);
    }

    /**
     * @param array<string, mixed> $server
     * @param array<string, mixed> $cookies
     * @return array{key:string,path:string,query:string,host:string}|null
     */
    private function requestContext(array $server, array $cookies, bool $forStore): ?array
    {
        $method = strtoupper(trim((string) ($server['REQUEST_METHOD'] ?? 'GET')));
        if ($forStore && $method !== 'GET') {
            return null;
        }
        if (!$forStore && !in_array($method, ['GET', 'HEAD'], true)) {
            return null;
        }
        if ($this->hasSessionCookie($cookies)) {
            return null;
        }

        $requestUri = (string) ($server['REQUEST_URI'] ?? '/');
        $rawPath = parse_url($requestUri, PHP_URL_PATH);
        $rawPath = is_string($rawPath) && $rawPath !== '' ? $rawPath : '/';
        if ($this->rawPathIsBypassed($rawPath)) {
            return null;
        }

        $path = function_exists('myronResolvePublicRequestPath')
            ? myronResolvePublicRequestPath($requestUri)
            : '/' . ltrim($rawPath, '/');
        $path = $this->normalizePublicPath($path);
        if ($this->publicPathIsBypassed($path)) {
            return null;
        }

        $query = $this->normalizedQuery($requestUri);
        if ($query === null) {
            return null;
        }

        $host = strtolower(trim((string) ($server['HTTP_HOST'] ?? '')));
        if ($host === '') {
            $host = 'localhost';
        }

        $scheme = (!empty($server['HTTPS']) && strtolower((string) $server['HTTPS']) !== 'off') ? 'https' : 'http';
        $key = hash('sha256', implode("\n", [$scheme, $host, $path, $query]));

        return [
            'key' => $key,
            'path' => $path,
            'query' => $query,
            'host' => $host,
        ];
    }

    private function rawPathIsBypassed(string $rawPath): bool
    {
            $normalized = '/' . ltrim($rawPath, '/');
            $adminBase = function_exists('myronAdminBasePath') ? myronAdminBasePath() : '/app';
        $publicBase = function_exists('myronPublicBasePath') ? myronPublicBasePath() : '';
        $isPublicBaseRequest = $publicBase !== '' && ($normalized === $publicBase || str_starts_with($normalized, $publicBase . '/'));
            if (!$isPublicBaseRequest && $adminBase !== '' && ($normalized === $adminBase || str_starts_with($normalized, $adminBase . '/'))) {
                return true;
            }

        foreach (['/api/', '/assets/', '/modules/'] as $prefix) {
            if (str_starts_with($normalized, $prefix)) {
                return true;
            }
        }

        return false;
    }

    private function publicPathIsBypassed(string $path): bool
    {
        if (in_array($path, ['/llms.txt', '/robots.txt', '/sitemap.xml', '/search'], true)) {
            return true;
        }

        foreach (['/api/', '/media/', '/assets/', '/modules/'] as $prefix) {
            if (str_starts_with($path, $prefix)) {
                return true;
            }
        }

        return preg_match('#\.(?:avif|css|gif|ico|jpe?g|js|json|map|pdf|png|svg|txt|webp|woff2?|xml)$#i', $path) === 1;
    }

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

    private function normalizedQuery(string $requestUri): ?string
    {
        $query = parse_url($requestUri, PHP_URL_QUERY);
        if (!is_string($query) || trim($query) === '') {
            return '';
        }

        parse_str($query, $params);
        if (!is_array($params)) {
            return null;
        }

        $normalized = [];
        foreach ($params as $key => $value) {
            $name = strtolower(trim((string) $key));
            if ($name === '' || in_array($name, self::BYPASS_QUERY_KEYS, true)) {
                continue;
            }
            if (!in_array($name, self::ALLOWED_QUERY_KEYS, true)) {
                return null;
            }
            if (is_array($value)) {
                return null;
            }
            $normalized[$name] = trim((string) $value);
        }

        ksort($normalized);
        return http_build_query($normalized);
    }

    /**
     * @param array<string, mixed> $cookies
     */
    private function hasSessionCookie(array $cookies): bool
    {
        if (function_exists('myronSessionCookiePresent') && myronSessionCookiePresent()) {
            return true;
        }

        foreach ($cookies as $name => $value) {
            $cookieName = strtolower((string) $name);
            if (($cookieName === 'phpsessid' || str_contains($cookieName, 'myron')) && trim((string) $value) !== '') {
                return true;
            }
        }

        return false;
    }

    /**
     * @param list<string> $headers
     */
    private function responseIsCacheable(string $html, int $statusCode, array $headers): bool
    {
        if ($statusCode !== 200 || $html === '' || strlen($html) > self::MAX_ENTRY_BYTES) {
            return false;
        }
        if (!str_contains(strtolower(substr($html, 0, 512)), '<!doctype html') && !str_contains(strtolower(substr($html, 0, 512)), '<html')) {
            return false;
        }
        if (str_contains($html, 'myron-site-page--authenticated')) {
            return false;
        }

        foreach ($headers as $header) {
            $line = strtolower(trim((string) $header));
            if (str_starts_with($line, 'set-cookie:')) {
                return false;
            }
            if (str_starts_with($line, 'cache-control:') && preg_match('/\b(?:no-store|private)\b/', $line) === 1) {
                return false;
            }
            if (str_starts_with($line, 'content-type:') && !str_contains($line, 'text/html')) {
                return false;
            }
        }

        return true;
    }

    private function requestHasMatchingEtag(array $server, string $etag): bool
    {
        $header = trim((string) ($server['HTTP_IF_NONE_MATCH'] ?? ''));
        if ($header === '') {
            return false;
        }

        foreach (array_map('trim', explode(',', $header)) as $candidate) {
            if ($candidate === $etag || $candidate === '*') {
                return true;
            }
        }

        return false;
    }

    /**
     * @return array<string, mixed>
     */
    private function state(): array
    {
        $path = $this->statePath();
        if (is_file($path)) {
            try {
                $state = $this->readJsonFile($path);
                if ((string) ($state['generation'] ?? '') !== '') {
                    return $state;
                }
            } catch (Throwable) {
            }
        }

        $this->ensureCacheRoot();
        $state = [
            'schema' => self::CACHE_SCHEMA,
            'generation' => bin2hex(random_bytes(12)),
            'invalidatedAt' => gmdate('c'),
            'reason' => 'initial',
        ];
        $this->writeAtomic($path, json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n");
        return $state;
    }

    private function statePath(): string
    {
        return $this->cacheRoot . '/state.json';
    }

    /**
     * @return array{html:string,meta:string}
     */
    private function entryPaths(string $key): array
    {
        return [
            'html' => $this->cacheRoot . '/' . $key . '.html',
            'meta' => $this->cacheRoot . '/' . $key . '.json',
        ];
    }

    /**
     * @return array<string, mixed>
     */
    private function readJsonFile(string $path): array
    {
        $json = file_get_contents($path);
        if (!is_string($json)) {
            throw new RuntimeException('Cache metadata could not be read.');
        }
        $decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
        if (!is_array($decoded)) {
            throw new RuntimeException('Cache metadata is invalid.');
        }
        return $decoded;
    }

    private function ensureCacheRoot(): void
    {
        if (is_dir($this->cacheRoot)) {
            return;
        }
        if (function_exists('myronEnsureDirectory')) {
            myronEnsureDirectory($this->cacheRoot);
            return;
        }
        if (!mkdir($this->cacheRoot, 0775, true) && !is_dir($this->cacheRoot)) {
            throw new RuntimeException('Unable to create public page cache directory.');
        }
    }

    private function writeAtomic(string $path, string $contents): void
    {
        $this->ensureCacheRoot();
        $tmp = $path . '.tmp.' . bin2hex(random_bytes(6));
        if (file_put_contents($tmp, $contents, LOCK_EX) !== strlen($contents)) {
            @unlink($tmp);
            throw new RuntimeException('Unable to write public page cache file.');
        }
        if (!rename($tmp, $path)) {
            @unlink($tmp);
            throw new RuntimeException('Unable to publish public page cache file.');
        }
    }
}

if (!function_exists('myronPublicPageCache')) {
    function myronPublicPageCache(): MyronPublicPageCache
    {
        static $cache = null;
        if ($cache instanceof MyronPublicPageCache) {
            return $cache;
        }

        $cache = MyronPublicPageCache::fromProjectRoot(dirname(__DIR__, 3));
        return $cache;
    }
}

if (!function_exists('myronInvalidatePublicPageCache')) {
    function myronInvalidatePublicPageCache(string $reason = ''): void
    {
        try {
            myronPublicPageCache()->invalidateAll($reason);
        } catch (Throwable) {
            // Cache invalidation must never block the write path.
        }
    }
}
