<?php
/**
 * URL Rewriter for StaticQ
 *
 * Rewrites attachment URLs based on their storage location.
 * - local: Uses WordPress default URLs
 * - bucket/both: Uses Cloudflare CDN URLs
 *
 * Uses output buffering to process the final rendered HTML, catching images
 * from all sources: themes, page builders, widgets, shortcodes — not just
 * the_content filter. This is the industry-standard approach used by CDN
 * plugins (ShortPixel, WP Offload Media, etc.).
 *
 * @package suspended-cloud/cloudflare-media-offload
 * @since 2.0.0
 */

if (!defined('ABSPATH')) {
    exit;
}

class SQMedia_URL_Rewriter {

    /**
     * Cache of attachment storage locations
     */
    private $location_cache = [];

    /**
     * Cache of URL-to-attachment-ID lookups (avoids repeat DB queries)
     */
    private $url_id_cache = [];

    /**
     * Cache of has_webp lookups per attachment
     */
    private $webp_cache = [];

    /**
     * Upload directory info
     */
    private $upload_dir;

    /**
     * Counter of processed images in current page (for eager-first-image)
     */
    private $image_counter = 0;

    /**
     * Constructor
     */
    public function __construct() {
        $this->upload_dir = wp_upload_dir();
        $this->init_hooks();
    }

    /**
     * Initialize WordPress hooks.
     *
     * 1. wp_get_attachment_url      — CDN URLs for direct API calls (REST, admin, custom code)
     * 2. image_downsize             — CDN URLs for sized variants (featured image, thumbnails)
     * 3. attachment_url_to_postid   — reverse-lookup CDN URLs back to attachment IDs
     * 4. template_redirect          — output buffer for final HTML rewriting (all <img> tags)
     * 5. wp_calculate_image_srcset  — CDN URLs in srcset (admin media library, etc.)
     * 6. content_save_pre           — revert CDN URLs to local before saving post_content
     * 7. rest_api_init              — rewrite local URLs to CDN in editor REST responses
     */
    private function init_hooks(): void {
        // Direct URL calls: wp_get_attachment_url() returns CDN URL for
        // bucket/both attachments. Used by REST API, custom theme code, admin, etc.
        add_filter('wp_get_attachment_url', [$this, 'rewrite_attachment_url'], 99, 2);

        // Sized variants: WordPress's image_downsize() builds sized URLs by
        // string-replacing the full-size basename with the raw sized filename
        // from metadata, losing any percent-encoding we applied. This filter
        // short-circuits image_downsize for cloud-stored images and returns
        // a properly encoded CDN URL for the requested size.
        add_filter('image_downsize', [$this, 'downsize_cdn_url'], 10, 3);

        // Srcset: WordPress generates srcset from metadata using local URLs.
        // This rewrites them to CDN for bucket/both attachments (media library, admin, etc.).
        add_filter('wp_calculate_image_srcset', [$this, 'rewrite_srcset_urls'], 99, 5);

        // Reverse lookup: when code calls attachment_url_to_postid() with a CDN URL
        // (e.g. Bricks Builder resolving ACF image fields), convert back to local URL.
        add_filter('attachment_url_to_postid', [$this, 'fix_cdn_url_to_postid'], 10, 2);

        // Output buffer: process the final rendered HTML to rewrite all <img> tags.
        // Only active when picture delivery is enabled in plugin settings.
        // template_redirect only fires on frontend page loads (not admin/AJAX/cron/REST).
        if (SQMedia_Settings::is_picture_delivery_enabled()) {
            add_action('template_redirect', [$this, 'start_output_buffer']);
        }

        // Post-content URL rewriting: Gutenberg bakes <img src> into post_content
        // at edit time, so wp_get_attachment_url filters do not reach those URLs.
        // This filter rewrites them at render time. Independent of picture
        // delivery — runs whenever any rewriting is configured. Idempotent:
        // already-rewritten URLs pass through convert_to_cloudflare_url unchanged.
        add_filter('the_content', [$this, 'rewrite_content_img_urls'], 99);

        // Save path: revert CDN URLs to local in post_content before saving.
        // Ensures DB always stores local URLs, decoupling content from CDN config.
        add_filter('content_save_pre', [$this, 'revert_cdn_urls_in_content']);

        // Save path: fix stale filenames from big image scaling (runs after CDN revert).
        // Catches edge case where cron scaled an image while editor was still open.
        add_filter('content_save_pre', [$this, 'fix_stale_image_urls_on_save'], 20);

        // REST display path: rewrite local URLs to CDN for Gutenberg editor display.
        // Uses rest_api_init (fires after all post types registered) to catch custom types.
        add_action('rest_api_init', [$this, 'register_rest_content_filters']);
    }

    // ── Output Buffer ────────────────────────────────────────────────

    /**
     * Tracks the buffer level we opened so end_output_buffer only closes
     * our own buffer (not someone else's that may have started later).
     */
    private $ob_started_level = 0;

    /**
     * Start output buffering on frontend page loads.
     *
     * Pairs with end_output_buffer() registered on shutdown. The pair is
     * intentionally split across two methods because frontend page-wide
     * rewriting requires the buffer to span the entire request: opened at
     * template_redirect, closed at shutdown. Both calls are guaranteed to
     * run by the WordPress hook lifecycle on a normal page load.
     */
    public function start_output_buffer(): void {
        ob_start();
        $this->ob_started_level = ob_get_level();
        add_action('shutdown', [$this, 'end_output_buffer'], 0);
    }

    /**
     * Close the buffer started by start_output_buffer() and emit the
     * processed HTML.
     *
     * Uses ob_get_clean() — the explicit close paired with the ob_start()
     * in start_output_buffer(). Only acts if we are still at the buffer
     * level we opened, so an unrelated nested buffer cannot trigger us.
     */
    public function end_output_buffer(): void {
        if ($this->ob_started_level === 0 || ob_get_level() < $this->ob_started_level) {
            return;
        }
        $html = ob_get_clean();
        if ($html === false) {
            return;
        }
        $this->ob_started_level = 0;
        echo $this->process_final_html($html); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $html is the captured page output already escaped by WP/themes; per-img URL injections are esc_url()'d inside process_img_tag.
    }

    /**
     * OB callback: process the complete rendered HTML page.
     * Finds all <img> tags and rewrites src/srcset to CDN URLs.
     */
    public function process_final_html(string $html): string {
        if (empty($html)) {
            return $html;
        }

        // Skip non-HTML responses (XML feeds, partial AJAX fragments, etc.)
        if (stripos($html, '<html') === false && stripos($html, '<!DOCTYPE') === false) {
            return $html;
        }

        // No cdn_base gate here. is_picture_delivery_enabled() already gated
        // OB registration; picture wrapping + WebP source injection is the
        // primary value-add and runs regardless of whether a CDN is configured.
        // Per-image URL rewriting is gated inside process_img_tag.

        // Reset per-page image counter for eager-first-image logic
        $this->image_counter = 0;

        // Step 1: Process all <img> tags (rewrite src, inject srcset, mark for <picture>)
        $html = preg_replace_callback(
            '/<img\b[^>]+>/i',
            [$this, 'process_img_tag'],
            $html
        );

        // Step 2: Wrap marked images in <picture>, with WebP <source> where available
        $html = $this->wrap_picture_elements($html);

        return $html;
    }

    /**
     * Rewrite <img> URLs in post content (the_content filter).
     *
     * Gutenberg bakes the image src into post_content at edit time, so
     * wp_get_attachment_url-style filters cannot reach it. This filter
     * scans for <img> tags, identifies the attachment, and rewrites src
     * and srcset for registered cloud attachments. No picture wrapping —
     * that remains the frontend-delivery OB's job.
     */
    public function rewrite_content_img_urls(string $content): string {
        // Skip if there is nothing to rewrite (no CDN host AND no force-prefix).
        if (empty(SQMedia_Settings::get_cdn_base_url()) && !SQMedia_Settings::force_prefix_in_url()) {
            return $content;
        }
        if (empty($content) || stripos($content, '<img') === false) {
            return $content;
        }

        return preg_replace_callback(
            '/<img\b[^>]+>/i',
            [$this, 'rewrite_content_img_tag'],
            $content
        );
    }

    /**
     * Per-tag rewrite: only changes src/srcset, leaves everything else alone.
     */
    private function rewrite_content_img_tag(array $matches): string {
        $tag = $matches[0];

        if (!preg_match('/\bsrc=["\']([^"\']+)["\']/', $tag, $sm)) {
            return $tag;
        }
        $src = $sm[1];

        $attachment_id = $this->get_attachment_id_from_tag($tag, $src);
        if (!$attachment_id || !$this->should_use_cloudflare($attachment_id)) {
            return $tag;
        }

        $image_meta = wp_get_attachment_metadata($attachment_id);

        // Rewrite src using metadata-based builder (handles encoded URLs etc.).
        // esc_url() ensures the URL is safe to inject into HTML attribute output.
        $cdn_src = $this->build_cdn_src_from_meta($src, $attachment_id, $image_meta);
        if ($cdn_src && $cdn_src !== $src) {
            $tag = str_replace($src, esc_url($cdn_src), $tag);
        }

        // Rewrite srcset if present (each URL is esc_url()'d inside).
        if (preg_match('/\bsrcset=["\']([^"\']+)["\']/', $tag, $srcset_match)) {
            $tag = $this->rewrite_existing_srcset($tag, $srcset_match[1]);
        }

        return $tag;
    }

    /**
     * Check if an <img> tag should be ignored based on the ignore list.
     * Each entry is tested as an exact CSS class match on the tag,
     * OR as a substring match against the src URL/path.
     */
    private function should_ignore(string $tag, string $src): bool {
        $ignored = SQMedia_Settings::get_delivery_ignored_classes();
        if (empty($ignored)) {
            return false;
        }

        // Extract CSS classes from the tag (if any)
        $tag_classes = [];
        if (preg_match('/\bclass=["\']([^"\']+)["\']/', $tag, $m)) {
            $tag_classes = preg_split('/\s+/', $m[1]);
        }

        foreach ($ignored as $entry) {
            // Exact CSS class match
            if (!empty($tag_classes) && in_array($entry, $tag_classes, true)) {
                return true;
            }
            // Substring match against src URL/path
            if (strpos($src, $entry) !== false) {
                return true;
            }
        }

        return false;
    }

    /**
     * Process a single <img> tag: rewrite src, handle srcset, mark WebP, debug.
     */
    private function process_img_tag(array $matches): string {
        $tag = $matches[0];

        // Extract src URL
        if (!preg_match('/\bsrc=["\']([^"\']+)["\']/', $tag, $src_match)) {
            return $tag;
        }
        $src = $src_match[1];

        // Skip images matching the ignore list (CSS class or src path)
        if ($this->should_ignore($tag, $src)) {
            return $tag;
        }

        // Determine attachment ID — works regardless of src URL origin.
        // Images from other plugins (e.g., Leopard) have foreign src URLs
        // but still carry wp-image-{ID} class that identifies the attachment.
        $attachment_id = $this->get_attachment_id_from_tag($tag, $src);
        if (!$attachment_id || !$this->is_registered($attachment_id)) {
            return $tag;
        }

        $use_cdn = $this->should_use_cloudflare($attachment_id);

        // CDN rewrite: only for bucket/both storage
        if ($use_cdn) {
            $image_meta = wp_get_attachment_metadata($attachment_id);

            // Build CDN src directly from attachment metadata.
            // This works regardless of what domain is in the original src
            // (foreign rewrites, encoded/unencoded spaces, etc.).
            // esc_url() ensures the URL is safe to inject into HTML attribute output.
            $cdn_src = $this->build_cdn_src_from_meta($src, $attachment_id, $image_meta);
            if ($cdn_src && $cdn_src !== $src) {
                $tag = str_replace($src, esc_url($cdn_src), $tag);
            }

            // Handle srcset: rewrite existing local URLs, or inject from metadata
            if (preg_match('/\bsrcset=["\']([^"\']+)["\']/', $tag, $srcset_match)) {
                $tag = $this->rewrite_existing_srcset($tag, $srcset_match[1]);
            } else {
                $tag = $this->inject_srcset($tag, $attachment_id, $image_meta);
            }
        } else {
            $image_meta = wp_get_attachment_metadata($attachment_id);
        }

        // Mark for <picture> wrapping — all registered attachments get wrapped.
        // WebP <source> is added only when available.
        $tag = str_replace('<img ', '<img data-sqmedia-picture="1" ', $tag);

        $has_webp = $this->has_webp($attachment_id);
        if ($has_webp) {
            $webp_srcset = $this->build_webp_srcset($attachment_id, $image_meta);
            if (!empty($webp_srcset)) {
                $tag = str_replace(
                    '<img ',
                    '<img data-sqmedia-webp-srcset="' . esc_attr($webp_srcset) . '" ',
                    $tag
                );
            }
        }

        // Lazy loading: add loading attribute if not already present
        if (SQMedia_Settings::is_lazyload_enabled() && strpos($tag, 'loading=') === false) {
            $this->image_counter++;

            if ($this->image_counter === 1 && SQMedia_Settings::is_eager_first_image()) {
                $tag = str_replace('<img ', '<img loading="eager" ', $tag);
            } else {
                $tag = str_replace('<img ', '<img loading="lazy" ', $tag);
            }
        }

        return $tag;
    }

    /**
     * Extract attachment ID from an <img> tag.
     * Tries wp-image-{ID} class first (free), falls back to URL lookup (DB query).
     * If the URL contains a -WIDTHxHEIGHT size suffix, strips it and retries,
     * then validates the sized filename exists in that attachment's metadata.
     */
    private function get_attachment_id_from_tag(string $tag, string $src): int {
        // Fast path: wp-image-{ID} class (Gutenberg, standard WordPress output)
        if (preg_match('/\bwp-image-(\d+)\b/', $tag, $m)) {
            return (int) $m[1];
        }

        // Slow path: reverse-lookup by URL
        $local_url = $this->convert_to_local_url($src);

        if (isset($this->url_id_cache[$local_url])) {
            return $this->url_id_cache[$local_url];
        }

        $id = (int) attachment_url_to_postid($local_url);

        // Fallback: strip -WIDTHxHEIGHT suffix and retry
        if ($id === 0 && preg_match('/^(.+)-(\d+)x(\d+)(\.[a-z]{3,5})$/i', $local_url, $size_match)) {
            $unsized_url = $size_match[1] . $size_match[4];
            $candidate_id = (int) attachment_url_to_postid($unsized_url);

            if ($candidate_id > 0) {
                // Verify this sized filename is a real size of the candidate attachment
                $sized_file = wp_basename($local_url);
                $meta = wp_get_attachment_metadata($candidate_id);

                if (!empty($meta['sizes'])) {
                    foreach ($meta['sizes'] as $size_data) {
                        if ($size_data['file'] === $sized_file) {
                            $id = $candidate_id;
                            break;
                        }
                    }
                }
            }
        }

        $this->url_id_cache[$local_url] = $id;

        return $id;
    }

    /**
     * Check if a URL points to our CDN.
     */
    private function is_cdn_url(string $url): bool {
        $cdn_base = SQMedia_Settings::get_cdn_base_url();
        return !empty($cdn_base) && strpos($url, rtrim($cdn_base, '/')) === 0;
    }

    /**
     * Rewrite local upload URLs inside an existing srcset attribute value.
     *
     * Each URL produced by convert_to_cloudflare_url() is run through
     * esc_url() so the rebuilt srcset value is safe to inject into HTML.
     */
    private function rewrite_existing_srcset(string $tag, string $srcset_value): string {
        $base_url_pattern = preg_quote($this->upload_dir['baseurl'], '/');

        $new_srcset = preg_replace_callback(
            '/(' . $base_url_pattern . '\/[^\s,]+)/',
            function ($m) {
                return esc_url($this->convert_to_cloudflare_url($m[1]));
            },
            $srcset_value
        );

        if ($new_srcset !== $srcset_value) {
            return str_replace($srcset_value, $new_srcset, $tag);
        }

        return $tag;
    }

    /**
     * Inject srcset and sizes attributes from attachment metadata.
     * Used when WordPress couldn't generate srcset (files only on bucket).
     */
    private function inject_srcset(string $tag, int $attachment_id, ?array $image_meta = null): string {
        if (!$image_meta) {
            $image_meta = wp_get_attachment_metadata($attachment_id);
        }
        $srcset = $this->build_cdn_srcset($attachment_id, $image_meta);

        if (empty($srcset)) {
            return $tag;
        }

        $width = 0;
        if (preg_match('/\bwidth=["\']?(\d+)/', $tag, $w)) {
            $width = (int) $w[1];
        } elseif (!empty($image_meta['width'])) {
            $width = (int) $image_meta['width'];
        }

        $sizes_attr = $this->build_sizes_string($width);

        return str_replace(
            '<img ',
            '<img srcset="' . esc_attr($srcset) . '" sizes="' . esc_attr($sizes_attr) . '" ',
            $tag
        );
    }

    // ── Sized URL (image_downsize) ──────────────────────────────────

    /**
     * Short-circuit image_downsize for cloud-stored attachments.
     *
     * WordPress's default image_downsize() builds sized URLs by doing
     * str_replace(full_basename, sized_filename, full_url). This loses
     * any percent-encoding our wp_get_attachment_url filter applied
     * (e.g. spaces → %20 or %E2%80%AF). By returning a fully-encoded
     * CDN URL here, we bypass that lossy string replacement.
     */
    public function downsize_cdn_url($out, $id, $size) {
        if ($out !== false) {
            return $out;
        }

        $id = (int) $id;
        if (!wp_attachment_is_image($id) || !$this->should_use_cloudflare($id)) {
            return false;
        }

        $meta = wp_get_attachment_metadata($id);
        if (empty($meta['file'])) {
            return false;
        }

        if ($size === 'full') {
            $file   = wp_basename($meta['file']);
            $width  = (int) ($meta['width'] ?? 0);
            $height = (int) ($meta['height'] ?? 0);
            $is_intermediate = false;
        } else {
            // Handles both named sizes ('medium') and array sizes ([400, 535])
            $intermediate = image_get_intermediate_size($id, $size);
            if (empty($intermediate['file'])) {
                return false;
            }
            $file   = $intermediate['file'];
            $width  = (int) ($intermediate['width'] ?? 0);
            $height = (int) ($intermediate['height'] ?? 0);
            $is_intermediate = true;
        }

        $cdn_url = $this->build_encoded_cdn_url($meta, $file);
        if (empty($cdn_url)) {
            return false;
        }

        return [$cdn_url, $width, $height, $is_intermediate];
    }

    /**
     * Build a percent-encoded CDN URL for a file within an attachment's directory.
     *
     * @param array  $meta Attachment metadata (must contain 'file').
     * @param string $file Filename (basename only, e.g. 'image-400x535.jpg').
     * @return string Fully encoded CDN URL, or empty string on failure.
     */
    private function build_encoded_cdn_url(array $meta, string $file): string {
        if (empty($meta['file'])) {
            return '';
        }
        $dir = _wp_get_attachment_relative_path($meta['file']);
        $relative_path = ($dir && $dir !== '.' ? $dir . '/' : '') . $file;
        return SQMedia_Settings::build_bucket_url($relative_path);
    }

    /**
     * Normalize and percent-encode a path for CDN URLs.
     *
     * macOS uses U+202F (narrow no-break space) in screenshot timestamps,
     * but cloud storage (R2) normalizes it to a regular space on upload.
     * WordPress metadata retains U+202F, so rawurlencode() would produce
     * %E2%80%AF instead of the correct %20. This method normalizes first.
     *
     * Works for both full paths (dir/file) and bare filenames.
     */
    public static function encode_cdn_path(string $path): string {
        // U+202F narrow no-break space → regular space
        // U+00A0 non-breaking space    → regular space
        $path = str_replace(["\xE2\x80\xAF", "\xC2\xA0"], ' ', $path);
        return implode('/', array_map('rawurlencode', explode('/', $path)));
    }

    // ── URL Conversion ───────────────────────────────────────────────

    /**
     * Rewrite attachment URL (wp_get_attachment_url filter callback).
     */
    public function rewrite_attachment_url(string $url, int $attachment_id): string {
        if (!$this->should_use_cloudflare($attachment_id)) {
            return $url;
        }

        // Try metadata-based URL first (handles foreign domains, spaces, etc.)
        $meta = wp_get_attachment_metadata($attachment_id);
        $cdn_url = $this->build_cdn_src_from_meta($url, $attachment_id, $meta);
        if ($cdn_url) {
            return $cdn_url;
        }

        return $this->convert_to_cloudflare_url($url);
    }

    /**
     * Rewrite local URLs to CDN in WordPress-generated srcset sources.
     *
     * WordPress builds srcset from attachment metadata using local upload URLs.
     * This converts them to CDN URLs for cloud-stored attachments, covering
     * the admin media library, editor sidebars, and anywhere WP outputs srcset.
     */
    public function rewrite_srcset_urls($sources, $size_array, $image_src, $image_meta, $attachment_id) {
        if (!is_array($sources) || empty($attachment_id)) {
            return $sources;
        }

        if (!$this->should_use_cloudflare((int) $attachment_id)) {
            return $sources;
        }

        $base_url = $this->upload_dir['baseurl'];

        foreach ($sources as &$source) {
            if (!empty($source['url']) && strpos($source['url'], $base_url) === 0) {
                $source['url'] = $this->convert_to_cloudflare_url($source['url']);
            }
        }

        return $sources;
    }

    /**
     * Fix attachment_url_to_postid() for CDN URLs.
     *
     * When wp_get_attachment_url rewrites URLs to CDN, other code that calls
     * attachment_url_to_postid() with those CDN URLs fails to find the attachment
     * (e.g. Bricks Builder resolving ACF Image fields with URL return format).
     * This filter converts CDN URLs back to local upload URLs and retries the lookup.
     */
    public function fix_cdn_url_to_postid($post_id, string $url): int {
        $post_id = (int) $post_id;

        if ($post_id > 0) {
            return $post_id;
        }

        $local_url = $this->convert_to_local_url($url);

        if ($local_url === $url) {
            return $post_id;
        }

        // Temporarily remove this filter to avoid infinite recursion
        remove_filter('attachment_url_to_postid', [$this, 'fix_cdn_url_to_postid'], 10);
        $post_id = attachment_url_to_postid($local_url);
        add_filter('attachment_url_to_postid', [$this, 'fix_cdn_url_to_postid'], 10, 2);

        return $post_id;
    }

    /**
     * Build CDN src URL from attachment metadata instead of rewriting the existing src.
     *
     * Extracts the filename from the src, matches it against the attachment's
     * master file or registered sizes, then builds the CDN URL from the metadata
     * path. Works regardless of the domain in the original src.
     */
    private function build_cdn_src_from_meta(string $src, int $attachment_id, ?array $meta): string {
        if (empty($meta['file'])) {
            return '';
        }

        $src_basename = wp_basename($src);
        // Decode the basename in case the src was already percent-encoded
        $src_basename_decoded = rawurldecode($src_basename);
        $dir = _wp_get_attachment_relative_path($meta['file']);

        $matched_file = null;

        // Check master file
        if (wp_basename($meta['file']) === $src_basename_decoded) {
            $matched_file = wp_basename($meta['file']);
        }

        // Check original_image (pre-scaled)
        if (!$matched_file && !empty($meta['original_image']) && $meta['original_image'] === $src_basename_decoded) {
            $matched_file = $meta['original_image'];
        }

        // Check registered sizes
        if (!$matched_file && !empty($meta['sizes'])) {
            foreach ($meta['sizes'] as $size_data) {
                if (!empty($size_data['file']) && $size_data['file'] === $src_basename_decoded) {
                    $matched_file = $size_data['file'];
                    break;
                }
            }
        }

        if (!$matched_file) {
            return '';
        }

        $relative_path = ($dir && $dir !== '.' ? $dir . '/' : '') . $matched_file;
        return SQMedia_Settings::build_bucket_url($relative_path);
    }

    /**
     * Convert WordPress upload URL to Cloudflare/CDN URL.
     */
    public function convert_to_cloudflare_url(string $url): string {
        $base_url = $this->upload_dir['baseurl'];

        if (strpos($url, $base_url) !== 0) {
            return $url;
        }

        $relative_path = substr($url, strlen($base_url) + 1);
        $new_url = SQMedia_Settings::build_bucket_url($relative_path);

        return !empty($new_url) ? $new_url : $url;
    }

    /**
     * Convert CDN URL back to WordPress upload URL.
     */
    public function convert_to_local_url(string $url): string {
        $cdn_base = SQMedia_Settings::get_cdn_base_url();
        $bucket_prefix = SQMedia_Settings::get_url_prefix();

        if (empty($cdn_base)) {
            return $url;
        }

        $cdn_pattern = rtrim($cdn_base, '/');
        if (!empty($bucket_prefix)) {
            $cdn_pattern .= '/' . $bucket_prefix;
        }
        $cdn_pattern .= '/';

        if (strpos($url, $cdn_pattern) !== 0) {
            return $url;
        }

        $relative_path = substr($url, strlen($cdn_pattern));

        return $this->upload_dir['baseurl'] . '/' . $relative_path;
    }

    // ── Content URL Management ──────────────────────────────────────

    /**
     * Revert CDN URLs to local WordPress URLs in post_content before saving.
     *
     * Ensures the database always stores local URLs, decoupling stored content
     * from external CDN infrastructure. Catches all save paths: Gutenberg,
     * Classic Editor, autosave, revisions, programmatic updates.
     */
    public function revert_cdn_urls_in_content(string $content): string {
        $local_prefix = $this->upload_dir['baseurl'] . '/';
        $cdn_prefix   = $this->compute_current_cdn_prefix();

        // Nothing to revert when:
        //  - No CDN is configured (cdn_prefix is empty).
        //  - The CDN URL prefix equals the local prefix. In Worker mode with no
        //    separate CDN host, the public CDN URL and the local URL are
        //    literally identical; there are no CDN URLs distinct from local
        //    URLs to revert. (This guard is what blocks the lambocars-style
        //    Worker-same-host corruption — without it the previous code
        //    computed a too-short cdn_prefix and str_replace prepended
        //    /wp-content/uploads/ to every internal URL on save.)
        if (empty($cdn_prefix) || $cdn_prefix === $local_prefix) {
            return $content;
        }

        // Fast path: no CDN URLs in content
        if (strpos($content, $cdn_prefix) === false) {
            return $content;
        }

        return str_replace($cdn_prefix, $local_prefix, $content);
    }

    /**
     * Compute the canonical public URL prefix used by already-rewritten CDN
     * URLs in stored or rendered content. Always ends with a trailing slash;
     * returns empty string when no CDN is configured.
     *
     * Two modes:
     *
     * Direct mode (`force_prefix_in_url = true`):
     *   `{cdn_base}/{r2_prefix}/`
     *   The R2 bucket prefix appears in URLs; the Worker is not involved in
     *   request routing.
     *
     * Worker mode (default):
     *   `{cdn_base}/wp-content/uploads/` (or whatever path `wp_upload_dir()`
     *   reports), with the WordPress host swapped to the CDN host when the
     *   two differ. When the CDN host equals the site host — or when no CDN
     *   host is configured at all — the CDN prefix IS the local prefix.
     *
     * Worker-mode same-host is the configuration where two earlier sites
     * computed a too-short prefix (`{cdn_base}/` instead of
     * `{cdn_base}/wp-content/uploads/`) and produced wrong behavior:
     * `revert_cdn_urls_in_content()` mangled saved post_content;
     * `encode_upload_url_paths()` stripped the uploads-path segment when
     * rewriting legacy/foreign CDN URLs on output. Both now route through
     * this helper to avoid that class of error recurring.
     */
    private function compute_current_cdn_prefix(): string {
        $cdn_base = SQMedia_Settings::get_cdn_base_url();
        if (empty($cdn_base)) {
            return '';
        }

        // Direct mode: bucket prefix is in the URL.
        if (SQMedia_Settings::force_prefix_in_url()) {
            $bucket_prefix = SQMedia_Settings::get_url_prefix();
            $url = rtrim($cdn_base, '/');
            if (!empty($bucket_prefix)) {
                $url .= '/' . $bucket_prefix;
            }
            return $url . '/';
        }

        // Worker mode: same path shape as the local upload baseurl, with the
        // host swapped to the CDN host if a different one is configured.
        $local_prefix = $this->upload_dir['baseurl'] . '/';
        $wp_host  = wp_parse_url(home_url(), PHP_URL_HOST);
        $cdn_host = wp_parse_url($cdn_base, PHP_URL_HOST);

        if (empty($wp_host) || empty($cdn_host) || $wp_host === $cdn_host) {
            return $local_prefix;
        }

        $cdn_scheme = wp_parse_url($cdn_base, PHP_URL_SCHEME) ?: 'https';
        return preg_replace(
            '#^https?://' . preg_quote($wp_host, '#') . '#',
            $cdn_scheme . '://' . $cdn_host,
            $local_prefix
        );
    }

    /**
     * Fix stale image filenames in post_content on save.
     *
     * When cron scales a big image (image.jpg → image-scaled.jpg) while the
     * editor is still open, the editor still holds the old filename. This
     * filter catches those stale references at save time by checking each
     * referenced attachment's metadata for an original_image rename.
     *
     * Runs at priority 20 (after CDN revert at default priority 10) so it
     * operates on local URLs only.
     */
    public function fix_stale_image_urls_on_save(string $content): string {
        $attachment_ids = $this->extract_attachment_ids($content);
        if (empty($attachment_ids)) {
            return $content;
        }

        foreach ($attachment_ids as $id) {
            $meta = wp_get_attachment_metadata($id);
            if (empty($meta['original_image']) || empty($meta['file'])) {
                continue;
            }

            $current_filename = wp_basename($meta['file']);
            $original_filename = $meta['original_image'];

            if ($original_filename === $current_filename) {
                continue;
            }

            $dir = dirname($meta['file']);
            $old_rel = ($dir && $dir !== '.' ? $dir . '/' : '') . $original_filename;
            $new_rel = $meta['file'];

            $content = str_replace($old_rel, $new_rel, $content);
        }

        return $content;
    }

    /**
     * Extract attachment IDs referenced in post content.
     *
     * Finds IDs from two common patterns:
     * - Gutenberg block comments: {"id":780, ...}
     * - CSS classes: wp-image-780
     *
     * @return int[] Unique attachment IDs
     */
    private function extract_attachment_ids(string $content): array {
        $ids = [];

        // Gutenberg block comments: {"id":780, ...}
        if (preg_match_all('/"id"\s*:\s*(\d+)/', $content, $m)) {
            $ids = array_merge($ids, $m[1]);
        }

        // CSS classes: wp-image-780
        if (preg_match_all('/wp-image-(\d+)/', $content, $m)) {
            $ids = array_merge($ids, $m[1]);
        }

        return array_unique(array_map('intval', $ids));
    }

    /**
     * Register REST API content filters for all REST-enabled post types.
     *
     * Called on rest_api_init (after all post types are registered) to ensure
     * custom post types are included.
     */
    public function register_rest_content_filters(): void {
        foreach (get_post_types(['show_in_rest' => true]) as $post_type) {
            add_filter("rest_prepare_{$post_type}", [$this, 'rewrite_content_urls_for_rest'], 10, 3);
        }
    }

    /**
     * Rewrite local URLs to CDN in REST response content for editor display.
     *
     * When Gutenberg loads a post for editing, the DB contains local URLs.
     * This rewrites them to CDN URLs in content.raw so images display correctly,
     * even if local files were deleted (cloud_only/bucket storage mode).
     *
     * Only applies to edit context (not frontend REST requests).
     */
    public function rewrite_content_urls_for_rest($response, $post, $request) {
        if ($request->get_param('context') !== 'edit') {
            return $response;
        }

        $data = $response->get_data();
        if (empty($data['content']['raw'])) {
            return $response;
        }

        $rewritten = $this->replace_local_urls_with_cdn($data['content']['raw']);
        // Encode special characters (spaces, U+202F) in any upload URL,
        // regardless of domain. Catches old CDN domains baked in DB.
        $rewritten = $this->encode_upload_url_paths($rewritten);
        if ($rewritten !== $data['content']['raw']) {
            $data['content']['raw'] = $rewritten;
            $response->set_data($data);
        }

        return $response;
    }

    /**
     * Encode special characters and normalize domains in upload URLs.
     *
     * Finds any URL containing /wp-content/uploads/ (on any domain) and:
     * 1. Percent-encodes the path (normalizes U+202F → space before encoding)
     * 2. Rewrites legacy/foreign CDN domains to the current CDN domain
     *    (e.g. static.lambocars.com → www.lambocars.com) if the attachment
     *    is registered and cloud-stored.
     */
    private function encode_upload_url_paths(string $content): string {
        $local_base = $this->upload_dir['baseurl'] . '/';

        // Use the shared CDN-prefix helper so the foreign-domain
        // detection at $base !== $current_cdn_base correctly recognizes
        // already-rewritten CDN URLs, AND so the rewrite output at
        // $current_cdn_base . $encoded preserves the /wp-content/uploads/
        // path segment. Previous inline computation produced just
        // {cdn_base}/ in Worker mode and corrupted both checks.
        // When no CDN is configured, fall back to the local base so the
        // foreign-domain test still works (any URL on a different host
        // gets classified as foreign).
        $current_cdn_base = $this->compute_current_cdn_prefix();
        if (empty($current_cdn_base)) {
            $current_cdn_base = $local_base;
        }

        return preg_replace_callback(
            '#(https?://[^"\'<>\r\n\t]+?/wp-content/uploads/)([^"\'<>\r\n\t]+)#i',
            function ($m) use ($local_base, $current_cdn_base) {
                $base = $m[1];
                $path = $m[2];
                $decoded = rawurldecode($path);
                $encoded = self::encode_cdn_path($decoded);

                // If URL is on a foreign domain (not local, not current CDN),
                // resolve the attachment and rewrite to current CDN if cloud-stored.
                if ($base !== $local_base && $base !== $current_cdn_base) {
                    $local_url = $local_base . $decoded;
                    $attachment_id = $this->resolve_attachment_id_from_url($local_url);

                    // U+202F / U+00A0 ↔ regular space mismatch: try alternate variants
                    if ($attachment_id === 0) {
                        $with_space = str_replace(["\xE2\x80\xAF", "\xC2\xA0"], ' ', $decoded);
                        $with_narrow = str_replace(' ', "\xE2\x80\xAF", $with_space);
                        foreach ([$with_space, $with_narrow] as $variant) {
                            if ($variant !== $decoded) {
                                $attachment_id = $this->resolve_attachment_id_from_url($local_base . $variant);
                                if ($attachment_id > 0) {
                                    break;
                                }
                            }
                        }
                    }

                    // Fallback: LIKE query on basename (catches sanitized filenames)
                    if ($attachment_id === 0) {
                        global $wpdb;
                        $basename = wp_basename($decoded);
                        // Normalize spaces/U+202F to single-char wildcard for LIKE
                        $like_basename = str_replace(
                            ["\xE2\x80\xAF", "\xC2\xA0", ' '],
                            '_',
                            $basename
                        );
                        $like_pattern = '%/' . $wpdb->esc_like($like_basename);
                        // Restore the single-char wildcards we just set
                        $like_pattern = str_replace('\\_', '_', $like_pattern);
                        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
                        $candidate_id = (int) $wpdb->get_var($wpdb->prepare(
                            "SELECT post_id FROM $wpdb->postmeta WHERE meta_key = '_wp_attached_file' AND meta_value LIKE %s LIMIT 1",
                            $like_pattern
                        ));
                        if ($candidate_id > 0) {
                            $attachment_id = $candidate_id;
                        }
                    }

                    if ($attachment_id > 0 && $this->should_use_cloudflare($attachment_id)) {
                        return $current_cdn_base . $encoded;
                    }
                }

                if ($encoded === $path) {
                    return $m[0];
                }
                return $base . $encoded;
            },
            $content
        );
    }

    /**
     * Replace local upload URLs with CDN URLs for cloud-stored attachments.
     *
     * Per-attachment aware: only rewrites URLs for attachments with
     * storage_location=bucket or storage_location=both. Pending attachments
     * (storage_location=local) keep their local URLs since files exist locally.
     */
    private function replace_local_urls_with_cdn(string $content): string {
        $base_url = $this->upload_dir['baseurl'];

        // Fast path: no local upload URLs in content
        if (strpos($content, $base_url) === false) {
            return $content;
        }

        $base_url_pattern = preg_quote($base_url, '/');

        return preg_replace_callback(
            '/(' . $base_url_pattern . '\/[^"\'<>\)\r\n\t]+)/i',
            function ($matches) {
                $url = $matches[1];
                $attachment_id = $this->resolve_attachment_id_from_url($url);

                if ($attachment_id > 0 && $this->should_use_cloudflare($attachment_id)) {
                    return $this->convert_to_cloudflare_url($url);
                }

                return $url;
            },
            $content
        );
    }

    /**
     * Resolve attachment ID from a raw URL.
     *
     * Similar to get_attachment_id_from_tag() but works on URLs directly.
     * Handles sized variants (-WIDTHxHEIGHT suffix) with metadata validation.
     * Results are cached in $this->url_id_cache.
     */
    private function resolve_attachment_id_from_url(string $url): int {
        if (isset($this->url_id_cache[$url])) {
            return $this->url_id_cache[$url];
        }

        // Temporarily remove our own filter to avoid recursion
        remove_filter('attachment_url_to_postid', [$this, 'fix_cdn_url_to_postid'], 10);
        $id = (int) attachment_url_to_postid($url);
        add_filter('attachment_url_to_postid', [$this, 'fix_cdn_url_to_postid'], 10, 2);

        // Fallback 1: strip -WIDTHxHEIGHT suffix and retry
        if ($id === 0 && preg_match('/^(.+)-(\d+)x(\d+)(\.[a-z]{3,5})$/i', $url, $size_match)) {
            $unsized_url = $size_match[1] . $size_match[4];

            remove_filter('attachment_url_to_postid', [$this, 'fix_cdn_url_to_postid'], 10);
            $candidate_id = (int) attachment_url_to_postid($unsized_url);
            add_filter('attachment_url_to_postid', [$this, 'fix_cdn_url_to_postid'], 10, 2);

            if ($candidate_id > 0) {
                $sized_file = wp_basename($url);
                $meta = wp_get_attachment_metadata($candidate_id);

                if (!empty($meta['sizes'])) {
                    foreach ($meta['sizes'] as $size_data) {
                        if ($size_data['file'] === $sized_file) {
                            $id = $candidate_id;
                            break;
                        }
                    }
                }
            }
        }

        $this->url_id_cache[$url] = $id;

        return $id;
    }

    // ── Srcset Builder ───────────────────────────────────────────────

    /**
     * Build CDN srcset string from attachment metadata.
     * Used when WordPress can't generate srcset (files not on local disk).
     */
    private function build_cdn_srcset(int $attachment_id, ?array $image_meta = null): string {
        if (!$image_meta) {
            $image_meta = wp_get_attachment_metadata($attachment_id);
        }

        if (!$image_meta || empty($image_meta['sizes']) || empty($image_meta['file'])) {
            return '';
        }

        $dirname = _wp_get_attachment_relative_path($image_meta['file']);
        $dir_prefix = $dirname ? $dirname . '/' : '';

        $sources = [];

        // Full/original image. esc_url() each URL so the rebuilt srcset
        // is safe to inject into HTML regardless of caller.
        $full_url = SQMedia_Settings::build_bucket_url($dir_prefix . wp_basename($image_meta['file']));
        $full_width = (int) ($image_meta['width'] ?? 0);
        if ($full_width > 0 && !empty($full_url)) {
            $sources[$full_width] = esc_url($full_url) . ' ' . $full_width . 'w';
        }

        // Each registered size
        $ignored_sizes = SQMedia_Settings::get_delivery_ignored_sizes();
        foreach ($image_meta['sizes'] as $size_name => $size_data) {
            if (in_array($size_name, $ignored_sizes, true)) {
                continue;
            }
            if (empty($size_data['file'])) {
                continue;
            }
            $w = (int) ($size_data['width'] ?? 0);
            if ($w > 0 && !isset($sources[$w])) {
                $size_url = SQMedia_Settings::build_bucket_url($dir_prefix . $size_data['file']);
                if (!empty($size_url)) {
                    $sources[$w] = esc_url($size_url) . ' ' . $w . 'w';
                }
            }
        }

        if (count($sources) < 2) {
            return '';
        }

        ksort($sources);
        return implode(', ', $sources);
    }

    /**
     * Build a sizes attribute string for responsive images.
     */
    private function build_sizes_string(int $width): string {
        if ($width > 0) {
            return "(max-width: {$width}px) 100vw, {$width}px";
        }
        return '100vw';
    }

    // ── WebP / Picture Delivery ─────────────────────────────────────

    /**
     * Check if attachment has WebP variants (cached).
     */
    private function has_webp(int $attachment_id): bool {
        if (isset($this->webp_cache[$attachment_id])) {
            return $this->webp_cache[$attachment_id];
        }

        $record = SQMedia_Attachment_Record::find($attachment_id);
        $has = $record !== null && $record->has_webp();
        $this->webp_cache[$attachment_id] = $has;

        return $has;
    }

    /**
     * Build WebP srcset string by deriving .webp filenames from metadata.
     * WebP filenames are deterministic: image-300x200.jpg → image-300x200.webp
     */
    private function build_webp_srcset(int $attachment_id, ?array $image_meta = null): string {
        if (!$image_meta) {
            $image_meta = wp_get_attachment_metadata($attachment_id);
        }

        if (!$image_meta || empty($image_meta['file'])) {
            return '';
        }

        $use_cloud = $this->should_use_cloudflare($attachment_id);
        $dirname = _wp_get_attachment_relative_path($image_meta['file']);
        $dir_prefix = $dirname ? $dirname . '/' : '';

        // Build a URL for a file that lives next to the attachment master.
        // esc_url() applied per-URL so the resulting srcset is safe to embed in HTML.
        $url_for = function (string $filename) use ($use_cloud, $dir_prefix): string {
            $rel = $dir_prefix . $filename;
            if ($use_cloud) {
                return esc_url(SQMedia_Settings::build_bucket_url($rel));
            }
            return esc_url(rtrim($this->upload_dir['baseurl'], '/') . '/' . self::encode_cdn_path($rel));
        };

        $sources = [];

        // Full/original as WebP
        $full_file = wp_basename($image_meta['file']);
        $full_webp = preg_replace('/\.(jpe?g|png|gif)$/i', '.webp', $full_file);
        $full_width = (int) ($image_meta['width'] ?? 0);
        if ($full_width > 0 && $full_webp !== $full_file) {
            $url = $url_for($full_webp);
            if (!empty($url)) {
                $sources[$full_width] = $url . ' ' . $full_width . 'w';
            }
        }

        // Each size as WebP
        $ignored_sizes = SQMedia_Settings::get_delivery_ignored_sizes();
        if (!empty($image_meta['sizes'])) {
            foreach ($image_meta['sizes'] as $size_name => $size_data) {
                if (str_ends_with($size_name, '-webp')) {
                    continue;
                }
                if (in_array($size_name, $ignored_sizes, true)) {
                    continue;
                }
                $w = (int) ($size_data['width'] ?? 0);
                if ($w > 0 && !isset($sources[$w]) && !empty($size_data['file'])) {
                    $webp_file = preg_replace('/\.(jpe?g|png|gif)$/i', '.webp', $size_data['file']);
                    if ($webp_file !== $size_data['file']) {
                        $url = $url_for($webp_file);
                        if (!empty($url)) {
                            $sources[$w] = $url . ' ' . $w . 'w';
                        }
                    }
                }
            }
        }

        if (empty($sources)) {
            return '';
        }

        ksort($sources);
        return implode(', ', $sources);
    }

    /**
     * Wrap <img> tags marked with data-sqmedia-webp-srcset in <picture> elements.
     * Adds a WebP <source> before the <img> fallback.
     */
    private function wrap_picture_elements(string $html): string {
        return preg_replace_callback(
            '/<img\b[^>]*\bdata-sqmedia-picture="1"[^>]*\/?>/i',
            function ($m) {
                $img_tag = $m[0];

                // Remove marker attribute
                $clean_img = preg_replace('/\s*data-sqmedia-picture="1"/', '', $img_tag);

                // Build WebP <source> if available
                $webp_source = '';
                if (preg_match('/\bdata-sqmedia-webp-srcset="([^"]*)"/', $clean_img, $wm)) {
                    $webp_srcset = $wm[1];
                    $clean_img = preg_replace('/\s*data-sqmedia-webp-srcset="[^"]*"/', '', $clean_img);

                    $sizes_attr = '';
                    if (preg_match('/\bsizes="([^"]*)"/', $clean_img, $s)) {
                        $sizes_attr = ' sizes="' . $s[1] . '"';
                    }

                    $webp_source = '<source srcset="' . $webp_srcset . '"' . $sizes_attr . ' type="image/webp">';
                }

                return '<picture>' . $webp_source . $clean_img . '</picture>';
            },
            $html
        );
    }

    // ── Storage Location ─────────────────────────────────────────────

    /**
     * Get storage location for attachment (cached).
     */
    private function get_storage_location(int $attachment_id): string {
        if (isset($this->location_cache[$attachment_id])) {
            $cached = $this->location_cache[$attachment_id];
            // 'unregistered' is our sentinel — treat as 'local' for callers
            return $cached === 'unregistered' ? 'local' : $cached;
        }

        $record = SQMedia_Attachment_Record::find($attachment_id);

        if ($record) {
            $location = $record->get_storage_location();
        } else {
            $location = 'unregistered';
        }

        $this->location_cache[$attachment_id] = $location;

        return $location === 'unregistered' ? 'local' : $location;
    }

    /**
     * Check if attachment is registered in StaticQ.
     */
    private function is_registered(int $attachment_id): bool {
        // get_storage_location returns 'local' for unregistered attachments,
        // but also for registered ones with local storage. Check the record directly.
        if (isset($this->location_cache[$attachment_id])) {
            return $this->location_cache[$attachment_id] !== 'unregistered';
        }

        $record = SQMedia_Attachment_Record::find($attachment_id);

        if ($record) {
            $this->location_cache[$attachment_id] = $record->get_storage_location();
            return true;
        }

        $this->location_cache[$attachment_id] = 'unregistered';
        return false;
    }

    /**
     * Check if attachment should use Cloudflare CDN URLs (bucket or both storage).
     */
    private function should_use_cloudflare(int $attachment_id): bool {
        $location = $this->get_storage_location($attachment_id);
        return $location === 'bucket' || $location === 'both';
    }

    // ── Public API ───────────────────────────────────────────────────

    /**
     * Get the appropriate URL for an attachment.
     */
    public function get_url(int $attachment_id, string $size = 'full'): string {
        if ($size === 'full') {
            return wp_get_attachment_url($attachment_id);
        }

        $src = wp_get_attachment_image_src($attachment_id, $size);
        return $src ? $src[0] : '';
    }

    /**
     * Get all URLs for an attachment (all sizes).
     */
    public function get_all_urls(int $attachment_id): array {
        $urls = [];

        $metadata = wp_get_attachment_metadata($attachment_id);
        $use_cloudflare = $this->should_use_cloudflare($attachment_id);

        $full_url = wp_get_attachment_url($attachment_id);
        $urls['full'] = $use_cloudflare ? $this->convert_to_cloudflare_url($full_url) : $full_url;

        if (!empty($metadata['sizes'])) {
            $base_url = dirname($full_url);

            foreach ($metadata['sizes'] as $size_name => $size_data) {
                $size_url = $base_url . '/' . $size_data['file'];
                $urls[$size_name] = $use_cloudflare ? $this->convert_to_cloudflare_url($size_url) : $size_url;
            }
        }

        return $urls;
    }

    // ── Cache ────────────────────────────────────────────────────────

    /**
     * Clear all caches.
     */
    public function clear_cache(): void {
        $this->location_cache = [];
        $this->url_id_cache = [];
        $this->webp_cache = [];
    }

    /**
     * Clear cache for specific attachment.
     */
    public function clear_attachment_cache(int $attachment_id): void {
        unset($this->location_cache[$attachment_id]);
    }

    /**
     * Preload location cache for multiple attachments (batch query).
     */
    public function preload_cache(array $attachment_ids): void {
        $db = sqmedia()->get_database();
        $records = $db->get_records_by_ids($attachment_ids);

        foreach ($attachment_ids as $id) {
            if (isset($records[$id])) {
                $this->location_cache[$id] = $records[$id]->storage_location;
            } else {
                $this->location_cache[$id] = 'local';
            }
        }
    }

}
