<?php

/**
 * Resolves and outputs Parkstatic static files.
 *
 * @package Parkstatic
 */
declare (strict_types = 1);
if ( !defined( 'ABSPATH' ) ) {
    exit;
}
/**
 * Shared static file resolution and output.
 */
final class Parkstatic_Static_File_Server {
    public const OPTION_LEGACY_ASSET_BASE = 'parkstatic_legacy_asset_base';

    /**
     * Root directory the front end is served from.
     *
     * Premium build: the deployed export under uploads/parkstatic/static.
     * Free build: the read-only demo site bundled with the plugin. The
     * premium branch is removed from the free WordPress.org zip by the
     * Freemius preprocessor, so the free build only ever serves the demo and
     * never reads a user/externally supplied export.
     */
    public static function content_root() : string {
        return trailingslashit( PARKSTATIC_PLUGIN_DIR . 'demo' );
    }

    /**
     * @return array{file: string, content_root: string, status?: int}|null
     */
    public static function resolve( string $uri ) : ?array {
        $uri = self::normalize_uri( $uri );
        $static = untrailingslashit( self::content_root() );
        $resolved = self::resolve_relative_file( $static, $uri );
        if ( null !== $resolved ) {
            return array(
                'file'         => $resolved,
                'content_root' => $static,
            );
        }
        if ( !self::is_static_asset_request( $uri ) ) {
            // Prefer a prerendered 404 page when the build emitted one (SSR
            // builds do). It carries the framework's notFound hydration state,
            // which is URL-agnostic, so the client hydrates cleanly at any
            // unknown URL and the visitor gets a real 404. Serving the homepage
            // index.html instead would ship hydration state for "/", and
            // replaying that at a different URL trips the router's invariant and
            // blanks the page.
            $not_found = $static . '/404.html';
            if ( is_file( $not_found ) ) {
                return array(
                    'file'         => $not_found,
                    'content_root' => $static,
                    'status'       => 404,
                );
            }
            // SPA fallback: unknown non-asset paths render the root index.html
            // so client-side routing still works for routes the prerender did
            // not emit. Used by plain SPA builds, which have no 404.html and
            // re-mount cleanly via createRoot().
            $index = $static . '/index.html';
            if ( is_file( $index ) ) {
                return array(
                    'file'         => $index,
                    'content_root' => $static,
                );
            }
        }
        return null;
    }

    public static function normalize_uri( string $uri ) : string {
        $uri = Parkstatic_Storage::normalize_storage_uri( $uri );
        $home_path = Parkstatic_Static_Site_Switcher::get_home_path();
        if ( '' !== $home_path && 0 === strpos( $uri, $home_path ) ) {
            $uri = ( substr( $uri, strlen( $home_path ) ) ?: '/' );
        }
        $uri = '/' . trim( $uri, '/' );
        if ( '/index.php' === $uri ) {
            return '/';
        }
        return $uri;
    }

    /**
     * Public asset URL base used when rewriting HTML/CSS/JS output.
     *
     * Defaults to clean `/assets/` URLs. Two reasons:
     *
     *   1. They survive in HTTP caches and CDNs (immutable Cache-Control on
     *      hashed filenames), which the `?parkstatic_asset=` query form
     *      historically blocked.
     *   2. On Apache hosts they let the bundled .htaccess block short-circuit
     *      `/assets/...` straight to disk, skipping the full WordPress boot
     *      for every CSS/JS/image request.
     *
     * Hosts that intercept /assets/*.css before PHP need the legacy
     * query-string form; Parkstatic probes /assets/ on enable and deploy.
     * Override with `parkstatic_public_asset_base` or
     * `PARKSTATIC_LEGACY_ASSET_BASE`.
     * The `^assets/(.+)$` rewrite rule and the `parkstatic_asset` query var
     * both stay wired up so either URL shape resolves to the same file.
     */
    public static function get_public_asset_base() : string {
        $home_path = Parkstatic_Static_Site_Switcher::get_home_path();
        $prefix = ( '' !== $home_path ? $home_path : '' );
        $default = ( self::uses_legacy_asset_base() ? $prefix . '/?parkstatic_asset=' : $prefix . '/assets/' );
        /**
         * Filters the public asset URL base used when rewriting static
         * HTML/CSS/JS responses. Return a string ending in `/` for clean
         * paths or `=` for query-string form.
         */
        $filtered = apply_filters( 'parkstatic_public_asset_base', $default );
        return ( is_string( $filtered ) && '' !== $filtered ? $filtered : $default );
    }

    /**
     * Whether asset URLs should use /?parkstatic_asset= instead of /assets/.
     */
    public static function uses_legacy_asset_base() : bool {
        if ( defined( 'PARKSTATIC_LEGACY_ASSET_BASE' ) ) {
            return (bool) PARKSTATIC_LEGACY_ASSET_BASE;
        }
        if ( null !== get_option( self::OPTION_LEGACY_ASSET_BASE, null ) ) {
            return (bool) get_option( self::OPTION_LEGACY_ASSET_BASE, false );
        }
        /**
         * Filter whether to serve assets via query-string URLs before probing.
         *
         * @param bool $use_legacy Default false until enable/deploy probes.
         */
        return (bool) apply_filters( 'parkstatic_use_legacy_asset_base', false );
    }

    /**
     * Probes how the host routes `/assets/<file>` and stores the asset base.
     *
     * Clean `/assets/` URLs are strongly preferred: they are the only base a
     * modern SSR/Vite build hydrates on. Such builds emit relative ES module
     * specifiers (`import("./chunk-HASH.js")`) and construct some asset URLs
     * at runtime. A relative specifier inside a module served from
     * `/?parkstatic_asset=<file>` resolves against the module URL's path —
     * which is `/`, the query string is dropped (WHATWG URL) — so it points at
     * a different URL than the `/assets/<file>` (or rewritten query) URL the
     * same chunk is referenced by elsewhere. The browser keys its module map
     * by URL, so one chunk fetched under two URLs is evaluated twice: two
     * React copies, "Invalid hook call" (#321), and a blank page. Under a
     * clean `/assets/` base every reference form — HTML tags, static and
     * relative imports, and runtime-built `/assets/…` URLs — resolves to the
     * same servable path, so nothing loads twice.
     *
     * Because of that asymmetry the failure bias is deliberately toward clean:
     * we only switch to the legacy query-string base on positive proof that
     * the host itself intercepts `/assets/` before WordPress. A loopback that
     * is blocked or answered by a CDN bot-challenge is NOT such proof, so it
     * must not trap an otherwise-fine build on the legacy base. We disambiguate
     * with the query-string form as a baseline: it always routes through
     * `index.php` → `serve.php` regardless of host `/assets/` handling, so if
     * it serves the asset while `/assets/` does not, the host is genuinely
     * intercepting `/assets/` and legacy is required. If the baseline also
     * fails, the loopback is untrustworthy and we keep the clean default.
     *
     * @param bool $force Re-probe even when a result is already stored.
     */
    public static function detect_and_store_legacy_asset_base( bool $force = false ) : void {
        if ( defined( 'PARKSTATIC_LEGACY_ASSET_BASE' ) || !Parkstatic_Static_Site_Switcher::is_enabled() ) {
            return;
        }
        if ( !$force && null !== get_option( self::OPTION_LEGACY_ASSET_BASE, null ) ) {
            return;
        }
        $probe_file = self::first_probe_asset_filename();
        if ( null === $probe_file ) {
            return;
        }
        // home_url() already carries any subdirectory path, so the file path is
        // appended once here rather than prefixed again.
        $clean = self::probe_serves_asset( home_url( '/assets/' . $probe_file ) );
        if ( true === $clean ) {
            update_option( self::OPTION_LEGACY_ASSET_BASE, 0, false );
            return;
        }
        // `/assets/` did not clearly serve the asset. Only conclude the host
        // intercepts it when the query-string form demonstrably does serve it:
        // `/` plus a query var always reaches WordPress without a rewrite rule,
        // so it is the reliable baseline. A shared CDN challenge fails both
        // probes, so we stay on the clean default rather than mis-flag legacy.
        $baseline = self::probe_serves_asset( home_url( '/?parkstatic_asset=' . rawurlencode( $probe_file ) ) );
        $needs_legacy = false === $clean && true === $baseline;
        update_option( self::OPTION_LEGACY_ASSET_BASE, ( $needs_legacy ? 1 : 0 ), false );
    }

    /**
     * Fetches a probe URL and reports whether it returned the actual asset.
     *
     * @return bool|null true when the asset was served, false when something
     *                   else (HTML page, redirect target, error status) came
     *                   back, null when the request could not be completed at
     *                   all (so the result carries no signal).
     */
    private static function probe_serves_asset( string $url ) : ?bool {
        $response = wp_remote_get( $url, array(
            'timeout'     => 15,
            'redirection' => 3,
            'headers'     => array(
                'Accept' => '*/*',
            ),
        ) );
        if ( is_wp_error( $response ) ) {
            return null;
        }
        if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
            return false;
        }
        $content_type = (string) wp_remote_retrieve_header( $response, 'content-type' );
        // An HTML response is a homepage/SPA fallback or a CDN challenge, never
        // the CSS/JS asset we asked for.
        if ( false !== stripos( $content_type, 'html' ) ) {
            return false;
        }
        if ( preg_match( '#(javascript|ecmascript|text/css|application/json)#i', $content_type ) ) {
            return true;
        }
        // Unknown or empty content type: sniff the body rather than trust an
        // ambiguous 200. A real CSS/JS asset never opens with an HTML tag.
        $body = ltrim( (string) wp_remote_retrieve_body( $response ) );
        if ( '' === $body ) {
            return false;
        }
        return '<' !== $body[0];
    }

    private static function first_probe_asset_filename() : ?string {
        $assets_dir = untrailingslashit( self::content_root() ) . '/assets';
        if ( !is_dir( $assets_dir ) ) {
            return null;
        }
        foreach ( array('css', 'js') as $extension ) {
            $matches = glob( $assets_dir . '/*.' . $extension );
            if ( !is_array( $matches ) || empty( $matches[0] ) ) {
                continue;
            }
            $basename = basename( $matches[0] );
            if ( is_string( $basename ) && '' !== $basename ) {
                return $basename;
            }
        }
        return null;
    }

    /**
     * Rewrites root-relative /assets/ references for the current install.
     *
     * Negative-lookbehind on a name-character set: we replace every
     * occurrence of `/assets/` UNLESS it's preceded by a letter, digit,
     * underscore, or slash. That covers all the contexts a URL legitimately
     * appears in (HTML attribute values quoted or unquoted, CSS url(), JS
     * strings, template literals, JSON, HTML-entity-encoded quotes like
     * `&quot;/assets/...`, srcset comma lists, meta tag content) while still
     * leaving paths that are part of a longer URL alone — e.g. the rewriter
     * will not rewrite `/assets/` inside `https://example.com/assets/foo` or
     * inside a path like `..//assets/foo` because the preceding character
     * is a name-character or another slash.
     */
    public static function rewrite_asset_urls( string $content ) : string {
        return (string) preg_replace( '#(?<![A-Za-z0-9_/])/assets/#', self::get_public_asset_base(), $content );
    }

    public static function output( string $file, string $content_root, int $status = 200 ) : void {
        if ( !self::is_safe_content_path( $content_root, $file ) || !is_readable( $file ) ) {
            self::send_status( 404 );
            exit;
        }
        // A cold request has already paid the full WordPress + Freemius
        // bootstrap before reaching here. On shared hosting the remaining
        // rewrite/read/stream can then run into a low max_execution_time and
        // be killed mid-response, turning a merely slow page into a truncated
        // body or a 5xx that a crawler or CDN may cache. Lift the limit for
        // the static stream itself so a slow cold read still finishes as a
        // complete 200. Guarded because some hosts disable set_time_limit().
        if ( function_exists( 'set_time_limit' ) ) {
            @set_time_limit( 0 );
            // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, Squiz.PHP.DiscouragedFunctions.Discouraged -- lifts the execution cap for a slow cold static stream so it finishes as a complete 200; guarded by function_exists and a no-op where disabled.
        }
        while ( ob_get_level() > 0 ) {
            ob_end_clean();
        }
        $stat = @stat( $file );
        if ( false === $stat ) {
            self::send_status( 404 );
            exit;
        }
        $extension = strtolower( pathinfo( $file, PATHINFO_EXTENSION ) );
        $mtime = (int) $stat['mtime'];
        $file_size = (int) $stat['size'];
        // Text formats may reference `/assets/` URLs we need to rewrite to the
        // install's actual asset base. When the base is already `/assets/`
        // (the default for root installs) the rewrite is a pass-through, so
        // we can skip the file_get_contents() pass and stream directly.
        $needs_rewrite = self::file_needs_rewrite( $extension );
        $needs_html = 'html' === $extension && Parkstatic_Meta_Injector::needs_html_processing();
        $rewritten = null;
        $content_size = $file_size;
        if ( $needs_rewrite || $needs_html ) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
            $body = file_get_contents( $file );
            if ( !is_string( $body ) ) {
                self::send_status( 500 );
                exit;
            }
            if ( $needs_rewrite ) {
                $body = self::rewrite_asset_urls( $body );
            }
            if ( $needs_html ) {
                $body = Parkstatic_Meta_Injector::process_html( $body );
            }
            $rewritten = $body;
            $content_size = strlen( $rewritten );
        }
        // ETag covers content length + mtime, plus the install signature when
        // the response is rewritten (because the same file on disk renders
        // differently after a domain / subdirectory move).
        $etag_seed = $content_size . '-' . $mtime;
        if ( $needs_rewrite ) {
            $etag_seed .= '-' . self::install_signature();
        }
        if ( $needs_html ) {
            $etag_seed .= '-' . Parkstatic_Meta_Injector::meta_signature();
        }
        $etag = '"ps-' . substr( md5( $etag_seed ), 0, 16 ) . '"';
        $last_modified = gmdate( 'D, d M Y H:i:s', $mtime ) . ' GMT';
        $content_type = self::content_type_for( $extension );
        // Conditional GETs only short-circuit for normal 200 responses. An
        // error response (e.g. the 404 page) must always send its real status,
        // never a bare 304, so crawlers and browsers see the correct code.
        if ( 200 === $status && self::client_has_fresh_copy( $etag, $mtime ) ) {
            self::send_status( 304 );
            self::send_cache_headers( $file, $extension );
            self::send_security_headers( $extension );
            header( 'ETag: ' . $etag );
            header( 'Last-Modified: ' . $last_modified );
            if ( null !== $content_type ) {
                header( 'Content-Type: ' . $content_type );
            }
            exit;
        }
        self::send_status( $status );
        self::send_cache_headers( $file, $extension );
        self::send_security_headers( $extension );
        header( 'ETag: ' . $etag );
        header( 'Last-Modified: ' . $last_modified );
        if ( null !== $content_type ) {
            header( 'Content-Type: ' . $content_type );
        }
        header( 'Content-Length: ' . (string) $content_size );
        if ( null !== $rewritten ) {
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- raw static asset bytes after URL rewrite.
            echo $rewritten;
            exit;
        }
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- stream static file from disk.
        readfile( $file );
        exit;
    }

    /**
     * Whether the rewriter needs to touch this file's bytes before serving.
     *
     * Returns false when the install's asset base is already `/assets/`
     * (the default root-install case): the regex replace would be a no-op,
     * so we skip it and stream the file straight from disk.
     */
    private static function file_needs_rewrite( string $extension ) : bool {
        static $rewrite_exts = array(
            'html',
            'css',
            'js',
            'json',
            'map',
            'webmanifest',
            'svg'
        );
        if ( !in_array( $extension, $rewrite_exts, true ) ) {
            return false;
        }
        return '/assets/' !== self::get_public_asset_base();
    }

    /**
     * Cache-Control policy by file role:
     *   - HTML  : revalidate every navigation so a new deploy is picked up
     *             immediately, but allow conditional GETs to short-circuit
     *             on ETag.
     *   - hashed: hashed filenames are content-addressed (Vite/Lovable emit
     *             `name-HASH.ext`), so it is safe to mark them immutable for
     *             one year. New deploys ship new hashes => new URLs.
     *   - other : non-hashed assets (favicon.ico, robots.txt, etc.) get a
     *             short cache so they refresh on the next deploy.
     */
    private static function send_cache_headers( string $file, string $extension ) : void {
        if ( 'html' === $extension ) {
            // max-age=0 still forces a revalidation on every navigation (via
            // ETag / Last-Modified) so a new deploy is picked up immediately.
            // stale-while-revalidate lets a shared cache/CDN serve the last
            // good copy instantly while it revalidates in the background,
            // hiding a slow cold origin. stale-if-error lets it serve that
            // same copy when the cold origin times out or 5xxes, so a
            // transient cold-path failure never becomes a cached error or a
            // blank document for crawlers. must-revalidate is intentionally
            // dropped: it forbids serving stale and would negate both.
            header( 'Cache-Control: public, max-age=0, stale-while-revalidate=86400, stale-if-error=86400' );
            return;
        }
        if ( self::is_immutable_asset( $file ) ) {
            header( 'Cache-Control: public, max-age=31536000, immutable' );
            return;
        }
        header( 'Cache-Control: public, max-age=600, must-revalidate' );
    }

    /**
     * Emits security headers on every served response.
     *
     *   - X-Content-Type-Options: nosniff is sent on every response so a
     *     browser can never reinterpret a served file type (e.g. an HTML
     *     document disguised as .txt). This is defense-in-depth on top of
     *     the explicit Content-Type header set in content_type_for().
     *   - SVG responses get a locked-down Content-Security-Policy
     *     (default-src 'none') so a visitor navigating directly to an .svg
     *     URL cannot execute any <script> the static export embedded in it.
     *     When an SVG is loaded via <img>, the browser already ignores
     *     script and external resources, so the CSP does not break images.
     *   - HTML documents get the admin-configured CSP (premium-only). The
     *     free WordPress.org build emits no HTML CSP.
     */
    private static function send_security_headers( string $extension ) : void {
        header( 'X-Content-Type-Options: nosniff' );
        if ( 'svg' === $extension ) {
            header( "Content-Security-Policy: default-src 'none'" );
            return;
        }
        if ( 'html' !== $extension ) {
            return;
        }
    }

    private static function is_immutable_asset( string $file ) : bool {
        // Vite/Lovable use `name-HASH.ext`; the hash is a base64-ish slug of
        // 8+ chars. Six is the safe minimum to avoid matching innocuous names
        // like `apple-touch-icon.png` (segments are 5 chars).
        return (bool) preg_match( '/-[A-Za-z0-9_]{6,}\\.[A-Za-z0-9]+$/', basename( $file ) );
    }

    private static function client_has_fresh_copy( string $etag, int $mtime ) : bool {
        $if_none_match = ( isset( $_SERVER['HTTP_IF_NONE_MATCH'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ) ) : '' );
        if ( '' !== $if_none_match && self::etag_matches( $if_none_match, $etag ) ) {
            return true;
        }
        $if_modified_since = ( isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) : '' );
        if ( '' !== $if_modified_since ) {
            $client_time = strtotime( $if_modified_since );
            if ( false !== $client_time && $client_time >= $mtime ) {
                return true;
            }
        }
        return false;
    }

    private static function etag_matches( string $header, string $etag ) : bool {
        foreach ( preg_split( '/\\s*,\\s*/', $header ) as $token ) {
            $token = trim( $token );
            if ( '*' === $token || $token === $etag ) {
                return true;
            }
        }
        return false;
    }

    private static function install_signature() : string {
        $home = (string) (( wp_parse_url( home_url( '/' ), PHP_URL_PATH ) ?: '/' ));
        return substr( md5( $home ), 0, 8 );
    }

    private static function content_type_for( string $extension ) : ?string {
        static $types = array(
            'html'        => 'text/html; charset=UTF-8',
            'css'         => 'text/css; charset=UTF-8',
            'js'          => 'application/javascript; charset=UTF-8',
            'json'        => 'application/json; charset=UTF-8',
            'jpg'         => 'image/jpeg',
            'jpeg'        => 'image/jpeg',
            'png'         => 'image/png',
            'gif'         => 'image/gif',
            'webp'        => 'image/webp',
            'svg'         => 'image/svg+xml',
            'ico'         => 'image/x-icon',
            'woff'        => 'font/woff',
            'woff2'       => 'font/woff2',
            'ttf'         => 'font/ttf',
            'map'         => 'application/json; charset=UTF-8',
            'txt'         => 'text/plain; charset=UTF-8',
            'xml'         => 'application/xml; charset=UTF-8',
            'webmanifest' => 'application/manifest+json; charset=UTF-8',
        );
        return $types[$extension] ?? null;
    }

    /**
     * @return array{static_assets: int, static_files: int, referenced: string[], missing: string[], index_referenced: int}
     */
    public static function get_diagnostics() : array {
        $static_assets_dir = untrailingslashit( self::content_root() ) . '/assets';
        $static_dir = untrailingslashit( self::content_root() );
        $referenced = self::extract_referenced_assets();
        $missing = array();
        foreach ( $referenced as $asset_path ) {
            $resolved = self::resolve( $asset_path );
            if ( null === $resolved ) {
                $missing[] = $asset_path;
            }
        }
        $index = self::content_root() . 'index.html';
        $index_referenced = 0;
        if ( is_readable( $index ) ) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
            $html = file_get_contents( $index );
            if ( is_string( $html ) && preg_match_all( '#(?:href|src)=["\'](?:\\./assets/|/assets/)([^"\']+)["\']#', $html, $matches ) ) {
                $index_referenced = count( array_unique( $matches[1] ) );
            }
        }
        return array(
            'static_assets'    => self::count_files( $static_assets_dir ),
            'static_files'     => self::count_files( $static_dir ),
            'referenced'       => $referenced,
            'missing'          => $missing,
            'index_referenced' => $index_referenced,
        );
    }

    public static function count_static_files() : int {
        return self::count_files( untrailingslashit( self::content_root() ) );
    }

    /**
     * @return string[]
     */
    private static function extract_referenced_assets() : array {
        $static_dir = untrailingslashit( self::content_root() );
        if ( !is_dir( $static_dir ) ) {
            return array();
        }
        $referenced = array();
        $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($static_dir, FilesystemIterator::SKIP_DOTS));
        foreach ( $iterator as $item ) {
            /** @var SplFileInfo $item */
            if ( !$item->isFile() ) {
                continue;
            }
            $extension = strtolower( $item->getExtension() );
            if ( !in_array( $extension, array(
                'html',
                'js',
                'css',
                'json',
                'svg',
                'webmanifest'
            ), true ) ) {
                continue;
            }
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
            $content = file_get_contents( $item->getPathname() );
            if ( !is_string( $content ) || '' === $content ) {
                continue;
            }
            if ( 'html' === $extension && preg_match_all( '#(?:href|src)=["\'](\\./assets/[^"\']+|/assets/[^"\']+)["\']#', $content, $html_matches ) ) {
                foreach ( $html_matches[1] as $path ) {
                    $referenced[] = self::normalize_asset_reference( $path );
                }
            }
            if ( preg_match_all( '#(?:\\./assets/|/assets/)[A-Za-z0-9._/-]+#', $content, $matches ) ) {
                foreach ( $matches[0] as $path ) {
                    // Strip query string and fragment. Tilde delimiter so the
                    // pattern's literal `#` inside the character class isn't
                    // mistaken for the closing delimiter by PCRE.
                    $referenced[] = self::normalize_asset_reference( (string) preg_replace( '~[?#].*$~', '', $path ) );
                }
            }
        }
        $referenced = array_values( array_unique( $referenced ) );
        sort( $referenced );
        return $referenced;
    }

    private static function count_files( string $directory ) : int {
        if ( !is_dir( $directory ) ) {
            return 0;
        }
        $count = 0;
        $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS));
        foreach ( $iterator as $item ) {
            /** @var SplFileInfo $item */
            if ( $item->isFile() ) {
                ++$count;
            }
        }
        return $count;
    }

    private static function normalize_asset_reference( string $path ) : string {
        if ( 0 === strpos( $path, './assets/' ) ) {
            return '/assets/' . substr( $path, 9 );
        }
        return $path;
    }

    private static function resolve_relative_file( string $content_dir, string $relative ) : ?string {
        $relative = '/' . trim( $relative, '/' );
        if ( '/' === $relative ) {
            $candidate = $content_dir . '/index.html';
            return ( is_file( $candidate ) ? $candidate : null );
        }
        $path = $content_dir . $relative;
        if ( is_file( $path ) ) {
            return $path;
        }
        if ( is_dir( $path ) && is_file( $path . '/index.html' ) ) {
            return $path . '/index.html';
        }
        return null;
    }

    private static function is_safe_content_path( string $content_dir, string $file ) : bool {
        $normalized_file = str_replace( '\\', '/', $file );
        $real_file = realpath( $file );
        if ( false !== $real_file ) {
            $normalized_file = str_replace( '\\', '/', $real_file );
        }
        $real_root = realpath( $content_dir );
        $root = ( false !== $real_root ? str_replace( '\\', '/', $real_root ) : rtrim( str_replace( '\\', '/', $content_dir ), '/' ) );
        return $normalized_file === $root || 0 === strpos( $normalized_file, $root . '/' );
    }

    private static function is_static_asset_request( string $uri ) : bool {
        $extension = strtolower( pathinfo( $uri, PATHINFO_EXTENSION ) );
        $assets = array(
            'css',
            'js',
            'json',
            'jpg',
            'jpeg',
            'png',
            'gif',
            'webp',
            'svg',
            'ico',
            'woff',
            'woff2',
            'ttf',
            'map',
            'txt',
            'xml',
            'webmanifest'
        );
        return '' !== $extension && 'html' !== $extension && 'php' !== $extension && in_array( $extension, $assets, true );
    }

    private static function send_status( int $code ) : void {
        if ( function_exists( 'status_header' ) ) {
            status_header( $code );
            return;
        }
        http_response_code( $code );
    }

}
