<?php
/**
 * Extracts media items from WordPress post content for composite C2PA signing.
 *
 * Parses Gutenberg block HTML and classic editor HTML to find all inline images,
 * audio, and video. Returns structured media descriptors with local file paths,
 * MIME types, and positions suitable for building a RichArticleSignRequest.
 *
 * @package EncypherProvenance
 */

namespace EncypherProvenance;

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

class MediaBlockExtractor
{
    /**
     * Maximum counts per media type (matching RichArticleSignRequest schema limits).
     */
    private const MAX_IMAGES = 20;
    private const MAX_AUDIOS = 10;
    private const MAX_VIDEOS = 5;

    /**
     * Maximum file sizes per media type (matching API validation).
     */
    private const MAX_IMAGE_BYTES = 10_485_760;   // 10 MB
    private const MAX_AUDIO_BYTES = 52_428_800;   // 50 MB
    private const MAX_VIDEO_BYTES = 104_857_600;  // 100 MB

    /**
     * Supported MIME types per media category.
     */
    private const IMAGE_MIME_TYPES = [
        'image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/tiff',
        'image/avif', 'image/heic', 'image/heif', 'image/heic-sequence',
        'image/heif-sequence', 'image/x-adobe-dng', 'image/gif', 'image/jxl',
    ];

    private const AUDIO_MIME_TYPES = [
        'audio/wav', 'audio/x-wav', 'audio/mpeg', 'audio/mp3',
        'audio/mp4', 'audio/x-m4a', 'audio/aac', 'audio/x-aiff',
    ];

    private const VIDEO_MIME_TYPES = [
        'video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/x-m4v',
    ];

    /** @var int Running position counter for DOM-order assignment. */
    private int $position = 0;

    /** @var array<int, bool> Track seen attachment IDs to avoid duplicates. */
    private array $seen_attachments = [];

    /** @var array<string, int> Request-scoped memo of resolved media URL -> attachment id. */
    private array $url_to_id_cache = [];
    /** Whether signing-time API size limits apply to returned descriptors. */
    private bool $enforce_size_limits = true;


    /**
     * Extract all media items from a WordPress post.
     *
     * Returns an array of media descriptors sorted by position (DOM order).
     * The featured image (if any) is included at position 0.
     *
     * @param string $post_content           The raw post_content HTML (with Gutenberg block comments).
     * @param int    $post_id                The WordPress post ID.
     * @param bool   $include_featured_image Whether to include the featured image.
     * @param bool   $enforce_size_limits    Whether to apply signing-request byte limits.
     * @return array<int, array{
     *   attachment_id: int,
     *   file_path: string,
     *   filename: string,
     *   mime_type: string,
     *   media_type: string,
     *   position: int,
     *   alt_text: string,
     *   source_block: string,
     *   file_size: int
     * }>
     */
    public function extract_media_from_content(
        string $post_content,
        int $post_id,
        bool $include_featured_image = true,
        bool $enforce_size_limits = true
    ): array {
        $this->position = 0;
        $this->seen_attachments = [];
        $this->enforce_size_limits = $enforce_size_limits;
        $items = [];

        // 1. Featured image first (position 0), when site behavior enables it.
        if ($include_featured_image) {
            $featured = $this->extract_featured_image($post_id);
            if ($featured !== null) {
                $items[] = $featured;
            }
        }

        // 2. Gutenberg blocks (if block comments are present).
        $has_blocks = (strpos($post_content, '<!-- wp:') !== false);
        if ($has_blocks) {
            $items = array_merge($items, $this->extract_from_gutenberg_blocks($post_content));
        }

        // 3. Classic editor fallback (raw HTML tags not inside block comments).
        $classic_items = $this->extract_from_classic_html($post_content, $has_blocks);
        $items = array_merge($items, $classic_items);

        // 4. Enforce per-type count limits.
        $items = $this->enforce_limits($items);

        // 5. Sort by position for deterministic ingredient ordering.
        usort($items, fn($a, $b) => $a['position'] <=> $b['position']);

        return $items;
    }

    // =========================================================================
    // Featured image
    // =========================================================================

    /**
     * Extract the featured image (thumbnail) for a post.
     */
    private function extract_featured_image(int $post_id): ?array
    {
        $thumbnail_id = get_post_thumbnail_id($post_id);
        if (! $thumbnail_id) {
            return null;
        }

        $descriptor = $this->build_descriptor_from_attachment(
            (int) $thumbnail_id,
            'featured_image',
            ''
        );

        return $descriptor;
    }

    // =========================================================================
    // Gutenberg block parsing
    // =========================================================================

    /**
     * Extract media from Gutenberg block comments in post content.
     */
    private function extract_from_gutenberg_blocks(string $content): array
    {
        $items = [];

        // Match all block opening comments: <!-- wp:blocktype {"attrs":...} -->
        // The regex captures the block type and optional JSON attributes.
        $pattern = '/<!--\s+wp:(\S+)\s*(\{[^}]*\})?\s*-->/';
        if (! preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE)) {
            return $items;
        }

        for ($i = 0, $count = count($matches[0]); $i < $count; $i++) {
            $block_type = $matches[1][$i][0];
            $attrs_json = isset($matches[2][$i]) && $matches[2][$i][1] !== -1
                ? $matches[2][$i][0]
                : '{}';
            $block_offset = $matches[0][$i][1];

            $attrs = @json_decode($attrs_json, true);
            if (! is_array($attrs)) {
                $attrs = [];
            }

            // Get the block's inner HTML (between opening and closing comments).
            $inner_html = $this->get_block_inner_html($content, $block_type, $block_offset);

            switch ($block_type) {
                case 'image':
                    $item = $this->parse_image_block($attrs, $inner_html);
                    if ($item !== null) {
                        $items[] = $item;
                    }
                    break;

                case 'gallery':
                    $gallery_items = $this->parse_gallery_block($attrs, $inner_html);
                    $items = array_merge($items, $gallery_items);
                    break;

                case 'audio':
                    $item = $this->parse_audio_block($attrs, $inner_html);
                    if ($item !== null) {
                        $items[] = $item;
                    }
                    break;

                case 'video':
                    $item = $this->parse_video_block($attrs, $inner_html);
                    if ($item !== null) {
                        $items[] = $item;
                    }
                    break;

                case 'media-text':
                    $item = $this->parse_media_text_block($attrs, $inner_html);
                    if ($item !== null) {
                        $items[] = $item;
                    }
                    break;

                case 'cover':
                    $item = $this->parse_cover_block($attrs, $inner_html);
                    if ($item !== null) {
                        $items[] = $item;
                    }
                    break;
            }
        }

        return $items;
    }

    /**
     * Get the inner HTML of a block (between opening and closing comments).
     */
    private function get_block_inner_html(string $content, string $block_type, int $start_offset): string
    {
        $open_end = strpos($content, '-->', $start_offset);
        if ($open_end === false) {
            return '';
        }
        $inner_start = $open_end + 3;

        // Find the matching closing comment.
        $close_tag = '<!-- /wp:' . $block_type . ' -->';
        $close_pos = strpos($content, $close_tag, $inner_start);
        if ($close_pos === false) {
            // Self-closing block or no closing comment.
            return '';
        }

        return substr($content, $inner_start, $close_pos - $inner_start);
    }

    /**
     * Parse a wp:image block.
     */
    private function parse_image_block(array $attrs, string $inner_html): ?array
    {
        $attachment_id = $attrs['id'] ?? 0;
        $alt_text = '';
        $rendered_url = '';

        // Extract alt and src from the same block as the attachment id. Keeping
        // this URL in the descriptor lets public verification bind both values
        // to one rendered media element rather than matching them anywhere in
        // the post.
        if (preg_match('/<img\s[^>]*\balt=["\']([^"\']*)["\'][^>]*>/i', $inner_html, $m)) {
            $alt_text = html_entity_decode($m[1], ENT_QUOTES, 'UTF-8');
        }
        if (preg_match('/<img\s[^>]*\bsrc=["\']([^"\']+)["\'][^>]*>/i', $inner_html, $m)) {
            $rendered_url = html_entity_decode($m[1], ENT_QUOTES, 'UTF-8');
            if (! $attachment_id) {
                $attachment_id = $this->url_to_attachment_id($rendered_url);
            }
        }

        if (! $attachment_id) {
            return null;
        }

        return $this->build_descriptor_from_attachment(
            (int) $attachment_id,
            'wp:image',
            $alt_text,
            $rendered_url
        );
    }

    /**
     * Parse a wp:gallery block.
     *
     * Modern galleries (WP 5.9+) use nested wp:image blocks.
     * Legacy galleries store attachment IDs in the gallery attrs.
     */
    private function parse_gallery_block(array $attrs, string $inner_html): array
    {
        $items = [];

        // Modern format: nested wp:image blocks inside the gallery.
        if (strpos($inner_html, '<!-- wp:image') !== false) {
            $nested_items = $this->extract_from_gutenberg_blocks($inner_html);
            return $nested_items;
        }

        // Legacy format: ids array in gallery attributes.
        $ids = $attrs['ids'] ?? [];
        if (is_array($ids)) {
            foreach ($ids as $attachment_id) {
                if (! $attachment_id) {
                    continue;
                }
                $descriptor = $this->build_descriptor_from_attachment(
                    (int) $attachment_id,
                    'wp:gallery',
                    ''
                );
                if ($descriptor !== null) {
                    $items[] = $descriptor;
                }
            }
        }

        return $items;
    }

    /**
     * Parse a wp:audio block.
     */
    private function parse_audio_block(array $attrs, string $inner_html): ?array
    {
        $attachment_id = $attrs['id'] ?? 0;
        $rendered_url = '';

        if (preg_match('/<audio\b[^>]*\bsrc=["\']([^"\']+)["\'][^>]*>/i', $inner_html, $m)) {
            $rendered_url = html_entity_decode($m[1], ENT_QUOTES, 'UTF-8');
        } elseif (preg_match('/<source\b[^>]*\bsrc=["\']([^"\']+)["\'][^>]*>/i', $inner_html, $m)) {
            $rendered_url = html_entity_decode($m[1], ENT_QUOTES, 'UTF-8');
        }
        if (! $attachment_id && '' !== $rendered_url) {
            $attachment_id = $this->url_to_attachment_id($rendered_url);
        }
        if (! $attachment_id) {
            return null;
        }

        return $this->build_descriptor_from_attachment(
            (int) $attachment_id,
            'wp:audio',
            '',
            $rendered_url
        );
    }

    /**
     * Parse a wp:video block.
     */
    private function parse_video_block(array $attrs, string $inner_html): ?array
    {
        $attachment_id = $attrs['id'] ?? 0;
        $rendered_url = '';

        if (preg_match('/<video\b[^>]*\bsrc=["\']([^"\']+)["\'][^>]*>/i', $inner_html, $m)) {
            $rendered_url = html_entity_decode($m[1], ENT_QUOTES, 'UTF-8');
        } elseif (preg_match('/<source\b[^>]*\bsrc=["\']([^"\']+)["\'][^>]*>/i', $inner_html, $m)) {
            $rendered_url = html_entity_decode($m[1], ENT_QUOTES, 'UTF-8');
        }
        if (! $attachment_id && '' !== $rendered_url) {
            $attachment_id = $this->url_to_attachment_id($rendered_url);
        }
        if (! $attachment_id) {
            return null;
        }

        return $this->build_descriptor_from_attachment(
            (int) $attachment_id,
            'wp:video',
            '',
            $rendered_url
        );
    }

    /**
     * Parse a wp:media-text block (side-by-side media + text layout).
     */
    private function parse_media_text_block(array $attrs, string $inner_html): ?array
    {
        $attachment_id = $attrs['mediaId'] ?? 0;
        if (! $attachment_id) {
            return null;
        }

        $rendered_url = isset($attrs['mediaUrl']) ? (string) $attrs['mediaUrl'] : '';
        if (
            '' === $rendered_url
            && preg_match('/<(?:img|video|audio|source)\b[^>]*\bsrc=["\']([^"\']+)["\'][^>]*>/i', $inner_html, $m)
        ) {
            $rendered_url = html_entity_decode($m[1], ENT_QUOTES, 'UTF-8');
        }

        return $this->build_descriptor_from_attachment(
            (int) $attachment_id,
            'wp:media-text',
            '',
            $rendered_url
        );
    }

    /**
     * Parse a wp:cover block (background image or video).
     */
    private function parse_cover_block(array $attrs, string $inner_html): ?array
    {
        $attachment_id = $attrs['id'] ?? 0;
        if (! $attachment_id) {
            return null;
        }

        return $this->build_descriptor_from_attachment(
            (int) $attachment_id,
            'wp:cover',
            '',
            isset($attrs['url']) ? (string) $attrs['url'] : ''
        );
    }

    // =========================================================================
    // Classic editor HTML parsing
    // =========================================================================

    /**
     * Extract media from classic editor HTML (non-Gutenberg content).
     *
     * When $has_blocks is true, only extract media tags that appear outside
     * of block comment regions to avoid double-counting.
     */
    private function extract_from_classic_html(string $content, bool $has_blocks): array
    {
        if ($has_blocks) {
            // Strip all block regions to avoid double-counting Gutenberg media.
            $content = preg_replace('/<!--\s+wp:\S+.*?<!--\s+\/wp:\S+\s+-->/s', '', $content);
            // Strip self-closing blocks too.
            $content = preg_replace('/<!--\s+wp:\S+\s*\/-->/s', '', $content);
        }

        $items = [];

        // Images: <img src="...">
        if (preg_match_all('/<img\s[^>]*\bsrc=["\']([^"\']+)["\'][^>]*>/i', $content, $matches)) {
            foreach ($matches[0] as $idx => $full_tag) {
                $src = $matches[1][$idx];
                $attachment_id = $this->url_to_attachment_id($src);
                if (! $attachment_id) {
                    continue;
                }

                $alt_text = '';
                if (preg_match('/\balt=["\']([^"\']*)["\']/', $full_tag, $am)) {
                    $alt_text = html_entity_decode($am[1], ENT_QUOTES, 'UTF-8');
                }

                $descriptor = $this->build_descriptor_from_attachment(
                    $attachment_id,
                    'classic_img',
                    $alt_text,
                    $src
                );
                if ($descriptor !== null) {
                    $items[] = $descriptor;
                }
            }
        }

        // Audio: <audio src="..."> or <audio><source src="..."></audio>
        if (preg_match_all('/<audio\b([^>]*)>(.*?)<\/audio>/is', $content, $matches)) {
            foreach ($matches[0] as $idx => $full_tag) {
                $src = '';
                if (preg_match('/\bsrc=["\']([^"\']+)["\']/', $matches[1][$idx], $sm)) {
                    $src = $sm[1];
                }
                if (! $src && preg_match('/<source\b[^>]*\bsrc=["\']([^"\']+)["\']/', $matches[2][$idx], $sm)) {
                    $src = $sm[1];
                }
                if (! $src) {
                    continue;
                }
                $attachment_id = $this->url_to_attachment_id($src);
                if (! $attachment_id) {
                    continue;
                }
                $descriptor = $this->build_descriptor_from_attachment($attachment_id, 'classic_audio', '', $src);
                if ($descriptor !== null) {
                    $items[] = $descriptor;
                }
            }
        }

        // Video: <video src="..."> or <video><source src="..."></video>
        if (preg_match_all('/<video\b([^>]*)>(.*?)<\/video>/is', $content, $matches)) {
            foreach ($matches[0] as $idx => $full_tag) {
                $src = '';
                if (preg_match('/\bsrc=["\']([^"\']+)["\']/', $matches[1][$idx], $sm)) {
                    $src = $sm[1];
                }
                if (! $src && preg_match('/<source\b[^>]*\bsrc=["\']([^"\']+)["\']/', $matches[2][$idx], $sm)) {
                    $src = $sm[1];
                }
                if (! $src) {
                    continue;
                }
                $attachment_id = $this->url_to_attachment_id($src);
                if (! $attachment_id) {
                    continue;
                }
                $descriptor = $this->build_descriptor_from_attachment($attachment_id, 'classic_video', '', $src);
                if ($descriptor !== null) {
                    $items[] = $descriptor;
                }
            }
        }

        return $items;
    }

    // =========================================================================
    // Descriptor builder
    // =========================================================================

    /**
     * Build a media descriptor from a WordPress attachment ID.
     *
     * Returns null if the attachment is invalid, missing on disk, has an
     * unsupported MIME type, or exceeds the size limit for its media type.
     */
    private function build_descriptor_from_attachment(int $attachment_id, string $source_block, string $alt_text, string $rendered_url = ''): ?array
    {
        // Skip duplicates (same attachment referenced multiple times).
        if (isset($this->seen_attachments[$attachment_id])) {
            return null;
        }

        $file_path = get_attached_file($attachment_id);
        if (! $file_path || ! file_exists($file_path)) {
            return null;
        }

        $mime_type = get_post_mime_type($attachment_id);
        if (! $mime_type) {
            return null;
        }
        $mime_lower = strtolower($mime_type);

        // Classify media type and validate MIME + size.
        $media_type = $this->classify_media_type($mime_lower);
        if ($media_type === null) {
            return null;
        }

        $file_size = filesize($file_path);
        if ($file_size === false || $file_size === 0) {
            return null;
        }

        $max_bytes = match ($media_type) {
            'image' => self::MAX_IMAGE_BYTES,
            'audio' => self::MAX_AUDIO_BYTES,
            'video' => self::MAX_VIDEO_BYTES,
        };
        if ($this->enforce_size_limits && $file_size > $max_bytes) {
            return null;
        }

        $this->seen_attachments[$attachment_id] = true;

        $descriptor = [
            'attachment_id' => $attachment_id,
            'file_path'     => $file_path,
            'filename'      => basename($file_path),
            'mime_type'     => $mime_lower,
            'media_type'    => $media_type,
            'position'      => $this->position++,
            'alt_text'      => $alt_text,
            'source_block'  => $source_block,
            'rendered_url'  => $rendered_url,
            'file_size'     => (int) $file_size,
        ];

        // Flag assets with existing C2PA provenance (detected on upload).
        if (get_post_meta($attachment_id, '_encypher_source_has_c2pa', true)) {
            $descriptor['has_existing_c2pa'] = true;
        }
        if (
            get_post_meta($attachment_id, '_encypher_source_c2pa_valid', true)
            && get_post_meta($attachment_id, '_encypher_source_c2pa_rights', true)
        ) {
            // The verified source manifest is the authority for this asset.
            // Rich signing keeps those terms inside the immutable ingredient
            // instead of applying the current post/site policy over them.
            $descriptor['preserve_source_rights'] = true;
        }

        return $descriptor;
    }

    /**
     * Classify a MIME type into 'image', 'audio', 'video', or null (unsupported).
     */
    private function classify_media_type(string $mime_lower): ?string
    {
        if (in_array($mime_lower, self::IMAGE_MIME_TYPES, true)) {
            return 'image';
        }
        if (in_array($mime_lower, self::AUDIO_MIME_TYPES, true)) {
            return 'audio';
        }
        if (in_array($mime_lower, self::VIDEO_MIME_TYPES, true)) {
            return 'video';
        }
        return null;
    }

    /**
     * Resolve a media URL to a WordPress attachment ID.
     *
     * Tries attachment_url_to_postid() first (handles scaled/resized URLs),
     * then falls back to querying by guid.
     */
    private function url_to_attachment_id(string $url): int
    {
        if (empty($url)) {
            return 0;
        }

        // Request-scoped memo: the same media URL can appear on several blocks of
        // one post, and attachment_url_to_postid() runs an uncached query each
        // call, so resolve any given URL at most once per extraction pass.
        if (isset($this->url_to_id_cache[$url])) {
            return $this->url_to_id_cache[$url];
        }

        // Remove query string and fragments.
        $clean_url = strtok($url, '?#');

        // Strip size suffixes (e.g. image-300x200.jpg -> image.jpg).
        $clean_url = preg_replace('/-\d+x\d+(\.\w+)$/', '$1', $clean_url);

        // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.attachment_url_to_postid_attachment_url_to_postid -- memoized per request via $url_to_id_cache; resolves a media URL to its attachment id.
        $id = attachment_url_to_postid($clean_url);
        if ($id > 0) {
            $this->url_to_id_cache[$url] = $id;
            return $id;
        }

        // Fallback: try the original URL without size stripping.
        // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.attachment_url_to_postid_attachment_url_to_postid -- memoized per request via $url_to_id_cache; fallback lookup by the original URL.
        $id = attachment_url_to_postid(strtok($url, '?#'));
        $id = $id > 0 ? $id : 0;
        $this->url_to_id_cache[$url] = $id;
        return $id;
    }

    /**
     * Enforce per-type count limits by dropping excess items (lowest priority = highest position).
     */
    private function enforce_limits(array $items): array
    {
        $images = [];
        $audios = [];
        $videos = [];
        $result = [];

        foreach ($items as $item) {
            switch ($item['media_type']) {
                case 'image':
                    $images[] = $item;
                    break;
                case 'audio':
                    $audios[] = $item;
                    break;
                case 'video':
                    $videos[] = $item;
                    break;
            }
        }

        // Keep only up to the max per type (items are already in position order).
        return array_merge(
            array_slice($images, 0, self::MAX_IMAGES),
            array_slice($audios, 0, self::MAX_AUDIOS),
            array_slice($videos, 0, self::MAX_VIDEOS)
        );
    }
}
