<?php

declare(strict_types=1);

namespace PpPhp\Core;

/**
 * Serves a module's own JS/CSS straight from `modules/…` with real HTTP caching.
 *
 * Module views historically inlined their assets with readfile() because nothing
 * under modules/ is web-reachable (only public/ is). That works, but the bytes
 * ride along inside the HTML on EVERY page load — the browser can never cache
 * them, and the Designer alone was re-sending ~840 KB per visit.
 *
 * This gives those files a public, versioned URL instead:
 *
 *     /module-asset/Designer/Views/app/registry.js?v=8f2c1ad39b
 *
 * The `v` token is derived from the file's mtime+size, so the URL changes the
 * moment the file changes. That makes it safe to answer with a one-year
 * `immutable` cache — the browser refetches only when the token differs, and
 * edits still show up on the next reload with no cache-clearing ritual.
 *
 * serve() runs BEFORE the container, module discovery and the database are
 * touched, so an asset hit costs a stat() and a readfile(), not a full boot.
 */
final class ModuleAssets
{
    /** URL prefix that marks a module-asset request. */
    public const ROUTE = '/module-asset/';

    /** Only these extensions are ever served — no .php, no .json config, no dotfiles. */
    private const TYPES = [
        'js'    => 'application/javascript; charset=utf-8',
        'mjs'   => 'application/javascript; charset=utf-8',
        'css'   => 'text/css; charset=utf-8',
        'map'   => 'application/json; charset=utf-8',
        'svg'   => 'image/svg+xml',
        'png'   => 'image/png',
        'jpg'   => 'image/jpeg',
        'jpeg'  => 'image/jpeg',
        'gif'   => 'image/gif',
        'webp'  => 'image/webp',
        'woff'  => 'font/woff',
        'woff2' => 'font/woff2',
    ];

    private const MAX_AGE = 31536000;          // one year — the URL carries the version

    /** Absolute path of the modules/ directory. */
    private static function moduleRoot(): string
    {
        return rtrim((string) constant('BASE_PATH'), '/') . '/modules';
    }

    /**
     * Resolve `$module` + `$relative` to a real file inside modules/, or null.
     * Rejects traversal, disallowed extensions and anything that escapes the
     * module directory — checked against the RESOLVED path, so symlinks and
     * `..` cannot be used to read outside it.
     */
    private static function resolve(string $module, string $relative): ?string
    {
        if (!preg_match('/^[A-Za-z0-9_]+$/', $module)) return null;
        if ($relative === '' || str_contains($relative, "\0") || str_contains($relative, '..')) return null;

        $ext = strtolower((string) pathinfo($relative, PATHINFO_EXTENSION));
        if (!isset(self::TYPES[$ext])) return null;

        $base = self::moduleRoot() . '/' . $module;
        $baseReal = realpath($base);
        if ($baseReal === false) return null;

        $real = realpath($base . '/' . ltrim($relative, '/'));
        if ($real === false || !is_file($real)) return null;
        // must still live under the module directory after resolution
        if (!str_starts_with($real, $baseReal . DIRECTORY_SEPARATOR)) return null;

        return $real;
    }

    /** Short content version from mtime+size — a stat(), never a file read. */
    public static function version(string $module, string $relative): string
    {
        $real = self::resolve($module, $relative);
        if ($real === null) return '0';
        $st = @stat($real);
        return $st === false ? '0' : substr(md5($st['mtime'] . ':' . $st['size']), 0, 10);
    }

    /**
     * Public, cache-busted URL for a module file.
     * `$relative` is relative to modules/{Module}/ e.g. 'Views/app/registry.js'.
     */
    public static function url(string $module, string $relative, string $baseUrl = ''): string
    {
        $base = $baseUrl !== '' ? $baseUrl : (defined('BASE_URL') ? (string) constant('BASE_URL') : '');
        return $base . self::ROUTE . rawurlencode($module) . '/'
            . implode('/', array_map('rawurlencode', explode('/', ltrim($relative, '/'))))
            . '?v=' . self::version($module, $relative);
    }

    /** `<script src>` tag for a module JS file (ordered, blocking — same semantics as inlining). */
    public static function script(string $module, string $relative, string $baseUrl = ''): string
    {
        return '<script src="' . htmlspecialchars(self::url($module, $relative, $baseUrl), ENT_QUOTES) . '"></script>';
    }

    /** `<link rel=stylesheet>` tag for a module CSS file. */
    public static function style(string $module, string $relative, string $baseUrl = '', string $id = ''): string
    {
        return '<link rel="stylesheet"' . ($id !== '' ? ' id="' . htmlspecialchars($id, ENT_QUOTES) . '"' : '')
            . ' href="' . htmlspecialchars(self::url($module, $relative, $baseUrl), ENT_QUOTES) . '">';
    }

    /**
     * Handle the request when it targets a module asset. Returns true once the
     * response has been sent (the caller should stop), false to keep routing.
     */
    public static function serve(string $basePath = ''): bool
    {
        $uri = (string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH);
        if ($basePath !== '' && str_starts_with($uri, $basePath)) {
            $uri = substr($uri, strlen($basePath));
        }
        if (!str_starts_with($uri, self::ROUTE)) return false;

        $rest = substr($uri, strlen(self::ROUTE));
        $parts = explode('/', $rest, 2);
        $module   = rawurldecode($parts[0] ?? '');
        $relative = isset($parts[1]) ? implode('/', array_map('rawurldecode', explode('/', $parts[1]))) : '';

        $real = self::resolve($module, $relative);
        if ($real === null) {
            http_response_code(404);
            header('Content-Type: text/plain; charset=utf-8');
            echo 'Not found';
            return true;
        }

        $st   = stat($real);
        $etag = '"' . substr(md5($st['mtime'] . ':' . $st['size']), 0, 10) . '"';
        $ext  = strtolower((string) pathinfo($real, PATHINFO_EXTENSION));

        header('Content-Type: ' . self::TYPES[$ext]);
        header('Cache-Control: public, max-age=' . self::MAX_AGE . ', immutable');
        header('ETag: ' . $etag);
        header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $st['mtime']) . ' GMT');
        header('X-Content-Type-Options: nosniff');

        // Revalidation shortcut for clients that kept a copy but re-asked anyway.
        $ifNone = trim((string) ($_SERVER['HTTP_IF_NONE_MATCH'] ?? ''));
        $ifMod  = strtotime((string) ($_SERVER['HTTP_IF_MODIFIED_SINCE'] ?? '')) ?: 0;
        if ($ifNone === $etag || ($ifMod > 0 && $ifMod >= $st['mtime'])) {
            http_response_code(304);
            return true;
        }

        header('Content-Length: ' . $st['size']);
        if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'HEAD') {
            readfile($real);
        }
        return true;
    }
}
