<?php
// components/SvgCssOptimizer.php
// PHP 8 class that captures final HTML, extracts inline <svg> icons, replaces them with
// lightweight <span> placeholders and emits a single cached CSS bundle with
// data:image/svg+xml (or base64) background-image rules.

namespace Components;

/**
 * SvgCssOptimizer
 * - Captures buffered HTML and replaces inline <svg>…</svg> with lightweight <span class="svg-{hash}"></span>.
 * - Emits a single cached CSS bundle with data-URI background images for unique icons.
 * - Opt-out markers: add any of the following to an <svg> to keep it inline (and exclude from CSS):
 *   - data-svgcss="raw" | data-svgcss="keep" | data-svgcss="inline" | data-svgcss="ignore"
 *   - data-svgcss-ignore (boolean)
 *   - data-svg-keep (boolean)
 *   - class="no-svg-opt" (token)
 * - You can also configure a custom ignore regex via option 'ignore_regex' applied to the opening <svg …> tag.
 */
class SvgCssOptimizer
{
    /** @var string Absolute filesystem path to the cache directory */
    private string $cacheDir;
    /** @var string Public web path to the cache directory (must end with /) */
    private string $webPath;
    /** @var bool When true, encode SVG payload as base64 for CSP-safe mode */
    private bool $encodeBase64;
    /** @var string CSS class prefix for placeholders, e.g. 'svg-' => .svg-<hash> */
    private string $classPrefix;
    /** @var string CSS variable name for controlling icon size */
    private string $sizeVar;
    /** @var string|null Regex to detect SVGs to ignore (applied to the opening <svg ...> tag) */
    private ?string $ignoreRegex = null;
    /** @var bool Whether an output buffer is currently active */
    private bool $active = false;

    /** @var string|null Last generated bundle filename (basename only) */
    private ?string $lastBundleFile = null;

    public function __construct(array $options = [])
    {
        $root = defined('BEEZUI_ROOT') ? (string)BEEZUI_ROOT : dirname(__DIR__);
        $defaultCacheDir = $root . DIRECTORY_SEPARATOR . 'cache';
        $this->cacheDir = (string)($options['cache_dir'] ?? $defaultCacheDir);
        $this->webPath = rtrim((string)($options['web_path'] ?? '/cache'), '/') . '/';
        $this->encodeBase64 = (bool)($options['base64'] ?? false);
        $this->classPrefix = (string)($options['class_prefix'] ?? 'svg-');
        $this->sizeVar = (string)($options['size_var'] ?? '--svg-size');
        $this->ignoreRegex = isset($options['ignore_regex']) && $options['ignore_regex'] !== '' ? (string)$options['ignore_regex'] : null;
        // Ensure cache dir exists (lazy create on write as well)
        if (!is_dir($this->cacheDir)) {
            @mkdir($this->cacheDir, 0755, true);
        }
    }

    // Convenience: begin buffering and return the instance
    public static function begin(array $options = []): self
    {
        $inst = new self($options);
        $inst->start();
        return $inst;
        }

    // Start output buffering and install the processing callback
    public function start(): void
    {
        if ($this->active) return;
        $this->active = true;
        // Use callback mode: our process() receives the full buffer when flushed

        ob_start([$this, 'process']);
    }

    // Flush the buffer (invokes process())
    public function flush(): void
    {
        if ($this->active) {
            // ob_end_flush will call process() and echo its return
            @ob_end_flush();
            $this->active = false;
        }
    }

    // Cancel buffering without emitting
    public function discard(): void
    {
        if ($this->active) {
            @ob_end_clean();
            $this->active = false;
        }
    }

    // Main processor: transform HTML and inject CSS link
    public function process(string $html): string
    {
            return $html;
        // Fast path: if there's no <svg substring, return as-is
        if (stripos($html, '<svg') === false) {
            return $html;
        }

        $pattern = '~<svg\b[^>]*>(?>[^<]+|<(?!/?svg\b)|(?R))*</svg>~is';
        $unique = []; // hash => original SVG (normalized)

        // First pass: collect and map, skipping ignored SVGs
        if (preg_match_all($pattern, $html, $m)) {
            foreach ($m[0] as $svg) {
                if ($this->shouldIgnoreSvg($svg)) {
                    continue;
                }
                $normalized = $this->normalizeSvg($svg);
                $hash = md5($normalized);
                if (!isset($unique[$hash])) {
                    $unique[$hash] = $normalized; // store normalized SVG for embedding
                }
            }
        } else {
            return $html; // no matches
        }

        // If nothing to optimize (all were ignored), return as-is
        if (!$unique) {
            return $html;
        }

        // Second pass: replace with placeholders using callback
        $classPrefix = $this->classPrefix;
        $html = preg_replace_callback($pattern, function(array $m) use ($classPrefix) {
            $svg = $m[0];
            if ($this->shouldIgnoreSvg($svg)) {
                return $svg; // keep inline as-is
            }
            $hash = md5($this->normalizeSvg($svg));
            $cls = $classPrefix . $hash;
            return '<span class="' . htmlspecialchars($cls, ENT_QUOTES, 'UTF-8') . '"></span>';
        }, $html) ?? $html;

        // Build CSS once for all unique icons
        $css = $this->buildCss($unique);
        $bundleHash = md5($css);
        $bundleName = 'svg-' . $bundleHash . '.css';
        $this->lastBundleFile = $bundleName;
        $bundlePath = rtrim($this->cacheDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $bundleName;

        if (!is_file($bundlePath)) {
            $this->writeFileAtomic($bundlePath, $css);
        }

        // Inject <link> before </head> (first occurrence), else prepend to HTML
        $href = $this->webPath . $bundleName;
        $link = '<link rel="stylesheet" href="' . htmlspecialchars($href, ENT_QUOTES, 'UTF-8') . '">';
        if (preg_match('~</head>~i', $html)) {
            $html = preg_replace('~</head>~i', $link . "\n</head>", $html, 1) ?? ($link . $html);
        } else {
            $html = $link . "\n" . $html;
        }

        return $html;
    }

    // Build the CSS rules for all icons
    private function buildCss(array $hashToSvg): string
    {
        $lines = [];
        $lines[] = '/* Auto-generated by SvgCssOptimizer. Size via ' . $this->sizeVar . ' (default 1em). */';
        foreach ($hashToSvg as $hash => $svg) {
            $class = '.' . $this->classPrefix . $hash;
            $dataUrl = $this->makeDataUrl($svg);
            $rule = $class . ' { display:inline-block; vertical-align:middle; '
                . 'width: var(' . $this->sizeVar . ', 1em); height: var(' . $this->sizeVar . ', 1em); '
                . 'background-repeat: no-repeat; background-position: center; background-size: contain; '
                . 'background-image: url("' . $dataUrl . '"); }';
            $lines[] = $rule;
        }
        // Optionally add a helper class to set color with currentColor icons
        $lines[] = '/* End SvgCssOptimizer */';
        return implode("\n", $lines) . "\n";
    }

    private function makeDataUrl(string $svg): string
    {
        // Ensure xmlns and minimal minification for smaller payloads
        $svg = $this->ensureXmlns($svg);
        $svg = $this->minifySvg($svg);
        if ($this->encodeBase64) {
            $b64 = base64_encode($svg);
            return 'data:image/svg+xml;base64,' . $b64;
        }
        // URL-encode per RFC 3986 (rawurlencode), then decode safe characters to save bytes
        $enc = rawurlencode($svg);
        // Preserve characters that are safe in data URIs to improve compression/readability
        $enc = strtr($enc, [
            '%20' => ' ', '%2F' => '/', '%3A' => ':', '%3D' => '=', '%3F' => '?',
            '%26' => '&', '%23' => '#', '%2C' => ',', '%3B' => ';', '%2B' => '+',
            '%28' => '(', '%29' => ')', '%27' => "'",
        ]);
        return 'data:image/svg+xml;utf8,' . $enc;
    }

    // Whether this <svg> should be ignored (kept inline) based on markers or configured regex
    private function shouldIgnoreSvg(string $svg): bool
    {
        if (!preg_match('~<svg\b[^>]*>~i', $svg, $m)) {
            return false;
        }
        $open = $m[0];
        // Built-in markers
        if (preg_match('~\bdata-svgcss\s*=\s*("|\')(?:raw|keep|inline|ignore)\1~i', $open)) {
            return true;
        }
        if (preg_match('~\bdata-svgcss-ignore\b~i', $open)) {
            return true;
        }
        if (preg_match('~\bdata-svg-keep\b~i', $open)) {
            return true;
        }
        // class token check
        if (preg_match('~\bclass\s*=\s*("|\')([^"\']*)\1~i', $open, $cm)) {
            $classes = preg_split('/\s+/', strtolower(trim($cm[2])) ?: '');
            if (in_array('no-svg-opt', $classes, true)) {
                return true;
            }
        }
        // Custom regex, if provided
        if ($this->ignoreRegex) {
            $res = @preg_match($this->ignoreRegex, $open);
            if ($res === 1) return true;
        }
        return false;
    }

    private function normalizeSvg(string $svg): string
    {
        // Trim, collapse whitespace between tags, strip insignificant whitespace
        $s = trim($svg);
        $s = preg_replace("~>\s+<~", '><', $s) ?? $s;
        $s = preg_replace('~\s{2,}~', ' ', $s) ?? $s;
        // Normalize single/double quotes for attributes to reduce variance
        $s = preg_replace_callback('~<svg\b([^>]*)>~i', function($m) {
            $attrs = $m[1];
            // drop class attribute from hash to avoid variants purely from utility classes
            $attrs = preg_replace('~\sclass\s*=\s*("[^"]*"|\'[^\']*\')~i', '', $attrs) ?? $attrs;
            return '<svg' . $attrs . '>';
        }, $s) ?? $s;
        return $s;
    }

    private function ensureXmlns(string $svg): string
    {
        if (!preg_match('~<svg\b[^>]*xmlns=~i', $svg)) {
            // add xmlns to opening tag
            $svg = preg_replace('~<svg\b~i', '<svg xmlns="http://www.w3.org/2000/svg"', $svg, 1) ?? $svg;
        }
        return $svg;
    }

    private function minifySvg(string $svg): string
    {
        // Remove newlines and tabs
        $s = preg_replace("/[\r\n\t]+/", ' ', $svg) ?? $svg;
        // Collapse multiple spaces
        $s = preg_replace('/\s{2,}/', ' ', $s) ?? $s;
        // Remove spaces around =
        $s = preg_replace('/\s*=\s*/', '=', $s) ?? $s;
        // Trim spaces between tags
        $s = preg_replace('~>\s+<~', '><', $s) ?? $s;
        return trim($s);
    }

    private function writeFileAtomic(string $path, string $contents): void
    {
        $dir = dirname($path);
        if (!is_dir($dir)) {
            @mkdir($dir, 0755, true);
        }
        $tmp = $dir . DIRECTORY_SEPARATOR . ('.tmp-' . bin2hex(random_bytes(8)) . '.css');
        file_put_contents($tmp, $contents, LOCK_EX);
        // Use rename for atomic swap on same filesystem
        @rename($tmp, $path);
        @chmod($path, 0644);
    }

    // Accessor for last bundle file (basename)
    public function getLastBundleFile(): ?string
    {
        return $this->lastBundleFile;
    }
}
