<?php
/**
 * Lightweight C2PA JUMBF detection for uploaded media files.
 *
 * Detects whether a file contains an embedded C2PA manifest by scanning
 * for JUMBF box signatures. Does not parse the manifest contents.
 *
 * JPEG: Scans for APP11 markers (0xFF 0xEB) containing JUMBF superbox
 *       with the "JP" signature (ISO 19566-5) and "c2pa" box type.
 * ISO BMFF (HEIF, AVIF, MP4): Scans for "jumb" box type code.
 * Other formats: Byte-level scan for JUMBF magic in first 256KB.
 *
 * @package EncypherProvenance
 */

namespace EncypherProvenance;

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

class C2paDetector
{
    /**
     * JUMBF superbox type code (ASCII "jumb").
     */
    private const JUMBF_BOX_TYPE = 'jumb';

    /**
     * C2PA manifest box type label (ASCII "c2pa").
     */
    private const C2PA_BOX_LABEL = 'c2pa';

    /**
     * JPEG APP11 marker (0xFF 0xEB) - used for JUMBF embedding per ISO 19566-5.
     */
    private const JPEG_APP11_MARKER = "\xFF\xEB";

    /**
     * JPEG SOI marker (0xFF 0xD8) - Start of Image.
     */
    private const JPEG_SOI = "\xFF\xD8";

    /**
     * Maximum bytes to scan for JUMBF detection in non-JPEG files.
     */
    private const MAX_SCAN_BYTES = 262144; // 256 KB

    /**
     * MIME types supported for C2PA detection.
     */
    private const SUPPORTED_MIMES = [
        'image/jpeg',
        'image/jpg',
        'image/png',
        'image/webp',
        'image/avif',
        'image/heic',
        'image/heif',
        'image/tiff',
        'image/gif',
        'video/mp4',
        'video/quicktime',
        'audio/mp4',
        'audio/x-m4a',
        'audio/aac',
        'audio/x-aac',
        'audio/wav',
        'audio/x-wav',
        'audio/wave',
        'audio/flac',
        'audio/x-flac',
    ];

    /**
     * JPEG-specific MIME types.
     */
    private const JPEG_MIMES = ['image/jpeg', 'image/jpg'];

    /**
     * ISO BMFF container MIME types (use box-level scanning).
     */
    private const ISO_BMFF_MIMES = [
        'image/avif',
        'image/heic',
        'image/heif',
        'video/mp4',
        'video/quicktime',
        'audio/mp4',
        'audio/x-m4a',
    ];

    /**
     * PNG MIME types (C2PA stored in a "caBX" chunk).
     */
    private const PNG_MIMES = ['image/png'];

    /**
     * RIFF container MIME types (WebP/WAV; C2PA stored in a "C2PA" chunk).
     */
    private const RIFF_MIMES = [
        'image/webp',
        'audio/wav',
        'audio/x-wav',
        'audio/wave',
    ];

    /**
     * PNG chunk type carrying a C2PA JUMBF box (ASCII "caBX").
     */
    private const PNG_C2PA_CHUNK = 'caBX';

    /**
     * RIFF chunk FourCC carrying a C2PA JUMBF box (ASCII "C2PA").
     */
    private const RIFF_C2PA_CHUNK = 'C2PA';

    /**
     * Detect whether a file contains C2PA JUMBF data.
     *
     * @param string      $file_path Absolute path to the file.
     * @param string|null $mime_type Optional MIME type hint. If null, detection
     *                               uses format-agnostic byte scanning.
     *
     * @return bool True if C2PA JUMBF markers are found.
     */
    public static function detect_jumbf(string $file_path, ?string $mime_type = null): bool
    {
        if (! file_exists($file_path) || ! is_readable($file_path)) {
            return false;
        }

        $file_size = filesize($file_path);
        if ($file_size === false || $file_size < 16) {
            return false;
        }

        // Route to format-specific scanner when MIME is known.
        if ($mime_type !== null) {
            $mime_lower = strtolower($mime_type);

            if (! in_array($mime_lower, self::SUPPORTED_MIMES, true)) {
                return false;
            }

            if (in_array($mime_lower, self::JPEG_MIMES, true)) {
                return self::detect_jumbf_in_jpeg($file_path);
            }

            if (in_array($mime_lower, self::ISO_BMFF_MIMES, true)) {
                return self::detect_jumbf_in_iso_bmff($file_path, $file_size);
            }

            if (in_array($mime_lower, self::PNG_MIMES, true)) {
                return self::detect_jumbf_in_png($file_path, $file_size);
            }

            if (in_array($mime_lower, self::RIFF_MIMES, true)) {
                return self::detect_jumbf_in_riff($file_path, $file_size);
            }
        }

        // Fallback: generic byte scan for JUMBF box type.
        return self::detect_jumbf_generic($file_path, $file_size);
    }

    /**
     * Detect C2PA provenance and return a richer result.
     *
     * Performs the same lightweight presence scan as {@see detect_jumbf()}
     * and, when a manifest is present, makes a best-effort, dependency-free
     * attempt to surface the active manifest label and claim_generator from
     * the scanned JUMBF bytes (no full CBOR/COSE parse).
     *
     * @param string      $file_path Absolute path to the file.
     * @param string|null $mime_type Optional MIME type hint.
     *
     * @return array{
     *     detected: bool,
     *     format: ?string,
     *     manifest_present: bool,
     *     active_manifest_label: ?string,
     *     claim_generator: ?string
     * }
     */
    public static function detect(string $file_path, ?string $mime_type = null): array
    {
        $result = [
            'detected' => false,
            'format' => $mime_type !== null ? strtolower($mime_type) : null,
            'manifest_present' => false,
            'active_manifest_label' => null,
            'claim_generator' => null,
        ];

        if (! file_exists($file_path) || ! is_readable($file_path)) {
            return $result;
        }

        $file_size = filesize($file_path);
        if ($file_size === false || $file_size < 16) {
            return $result;
        }

        if (! self::detect_jumbf($file_path, $mime_type)) {
            return $result;
        }

        $result['detected'] = true;
        $result['manifest_present'] = true;

        $hints = self::extract_manifest_hints($file_path, $file_size);
        $result['active_manifest_label'] = $hints['label'];
        $result['claim_generator'] = $hints['claim_generator'];

        return $result;
    }

    /**
     * Detect JUMBF in a JPEG file by scanning APP11 markers.
     *
     * JPEG structure: SOI (FF D8) followed by marker segments.
     * Each marker: FF xx (2 bytes) + length (2 bytes big-endian, includes length bytes).
     * APP11 = FF EB. C2PA JUMBF lives inside APP11 segments.
     */
    private static function detect_jumbf_in_jpeg(string $file_path): bool
    {
        $data = file_get_contents($file_path); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown -- local media file read for C2PA/JUMBF detection, not a remote fetch.
        if ($data === false || strlen($data) < 4) {
            return false;
        }

        // Verify JPEG SOI.
        if (substr($data, 0, 2) !== self::JPEG_SOI) {
            return false;
        }

        $pos = 2;
        $len = strlen($data);

        while ($pos < $len - 1) {
            // Each marker starts with 0xFF.
            if ($data[$pos] !== "\xFF") {
                $pos++;
                continue;
            }

            $marker_type = ord($data[$pos + 1]);

            // SOS (0xDA) marks start of scan data - no more markers after this.
            if ($marker_type === 0xDA) {
                break;
            }

            // Skip standalone markers (RST0-RST7, SOI, EOI, TEM).
            if (($marker_type >= 0xD0 && $marker_type <= 0xD9) || $marker_type === 0x01) {
                $pos += 2;
                continue;
            }

            // Read segment length (2 bytes big-endian, includes the length field).
            if ($pos + 3 >= $len) {
                break;
            }
            $seg_length = (ord($data[$pos + 2]) << 8) | ord($data[$pos + 3]);
            if ($seg_length < 2) {
                break;
            }

            // APP11 (0xEB) - check for JUMBF content.
            if ($marker_type === 0xEB) {
                $seg_start = $pos + 4; // after marker + length
                $seg_data_len = $seg_length - 2;
                if ($seg_data_len > 0 && $seg_start + $seg_data_len <= $len) {
                    $segment = substr($data, $seg_start, min($seg_data_len, 128));
                    // Look for "jumb" or "c2pa" in the segment.
                    if (strpos($segment, self::JUMBF_BOX_TYPE) !== false
                        || strpos($segment, self::C2PA_BOX_LABEL) !== false) {
                        return true;
                    }
                }
            }

            // Advance past this marker segment.
            $pos += 2 + $seg_length;
        }

        return false;
    }

    /**
     * Detect JUMBF in an ISO BMFF container (HEIF, AVIF, MP4).
     *
     * Scans top-level boxes for a "jumb" box type. Box structure:
     * [4 bytes size][4 bytes type][... data ...].
     */
    private static function detect_jumbf_in_iso_bmff(string $file_path, int $file_size): bool
    {
        $fp = fopen($file_path, 'rb'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- read-only 'rb' handle for a bounded binary C2PA/JUMBF box scan of a local media file; WP_Filesystem has no seek/partial read.
        if ($fp === false) {
            return false;
        }

        $scan_limit = min($file_size, self::MAX_SCAN_BYTES);
        $pos = 0;

        try {
            while ($pos < $scan_limit) {
                $header = fread($fp, 8); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- sequential binary read of a local media file for C2PA/JUMBF box detection.
                if ($header === false || strlen($header) < 8) {
                    break;
                }

                $box_size = unpack('N', substr($header, 0, 4))[1];
                $box_type = substr($header, 4, 4);

                if ($box_type === self::JUMBF_BOX_TYPE) {
                    return true;
                }

                // Extended size: when box_size == 1, actual size is in next 8 bytes.
                if ($box_size === 1) {
                    $ext = fread($fp, 8); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- sequential binary read of a local media file for C2PA/JUMBF box detection.
                    if ($ext === false || strlen($ext) < 8) {
                        break;
                    }
                    $hi = unpack('N', substr($ext, 0, 4))[1];
                    $lo = unpack('N', substr($ext, 4, 4))[1];
                    $box_size = ($hi << 32) | $lo;
                    if ($box_size < 16) {
                        break;
                    }
                    $skip = $box_size - 16;
                } elseif ($box_size === 0) {
                    // Box extends to end of file.
                    break;
                } else {
                    if ($box_size < 8) {
                        break;
                    }
                    $skip = $box_size - 8;
                }

                $pos += $box_size;
                if ($skip > 0) {
                    fseek($fp, $skip, SEEK_CUR);
                }
            }
        } finally {
            fclose($fp); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closes the read-only 'rb' handle from the guarded binary C2PA/JUMBF box scan above; WP_Filesystem exposes no stream handle to close.
        }

        return false;
    }

    /**
     * Generic byte-level scan for JUMBF box type in any file format.
     *
     * Scans the first MAX_SCAN_BYTES for the ASCII string "jumb" followed
     * by structural context clues ("c2pa" within 64 bytes).
     */
    private static function detect_jumbf_generic(string $file_path, int $file_size): bool
    {
        $scan_size = min($file_size, self::MAX_SCAN_BYTES);
        $data = file_get_contents($file_path, false, null, 0, $scan_size); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown -- bounded local media read (<= MAX_SCAN_BYTES) for C2PA/JUMBF detection, not a remote fetch.
        if ($data === false) {
            return false;
        }

        // Look for "jumb" followed by "c2pa" within 64 bytes.
        $jumb_pos = strpos($data, self::JUMBF_BOX_TYPE);
        if ($jumb_pos === false) {
            return false;
        }

        // Verify "c2pa" appears nearby (within the same JUMBF superbox).
        $search_end = min($jumb_pos + 64, strlen($data));
        $nearby = substr($data, $jumb_pos, $search_end - $jumb_pos);
        return strpos($nearby, self::C2PA_BOX_LABEL) !== false;
    }

    /**
     * Detect a C2PA JUMBF box in a PNG file.
     *
     * PNG structure: 8-byte signature followed by chunks of
     * [4-byte length BE][4-byte type][data][4-byte CRC]. C2PA is carried in
     * a chunk whose type is "caBX".
     */
    private static function detect_jumbf_in_png(string $file_path, int $file_size): bool
    {
        $fp = fopen($file_path, 'rb'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- read-only 'rb' handle for a bounded binary C2PA/JUMBF box scan of a local media file; WP_Filesystem has no seek/partial read.
        if ($fp === false) {
            return false;
        }

        try {
            $sig = fread($fp, 8); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- sequential binary read of a local media file for C2PA/JUMBF box detection.
            if ($sig === false || $sig !== "\x89PNG\r\n\x1a\n") {
                return false;
            }

            $read = 8;
            $scan_limit = min($file_size, self::MAX_SCAN_BYTES);

            while ($read + 8 <= $scan_limit) {
                $header = fread($fp, 8); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- sequential binary read of a local media file for C2PA/JUMBF box detection.
                if ($header === false || strlen($header) < 8) {
                    break;
                }

                $chunk_len = unpack('N', substr($header, 0, 4))[1];
                $chunk_type = substr($header, 4, 4);

                if ($chunk_type === self::PNG_C2PA_CHUNK) {
                    return true;
                }

                if ($chunk_type === 'IEND') {
                    break;
                }

                // Skip chunk data plus the 4-byte CRC.
                $skip = $chunk_len + 4;
                if (fseek($fp, $skip, SEEK_CUR) !== 0) {
                    break;
                }
                $read += 8 + $skip;
            }
        } finally {
            fclose($fp); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closes the read-only 'rb' handle from the guarded binary C2PA/JUMBF box scan above; WP_Filesystem exposes no stream handle to close.
        }

        return false;
    }

    /**
     * Detect a C2PA JUMBF box in a RIFF container (WebP, WAV).
     *
     * RIFF structure: "RIFF" + 4-byte size (LE) + form type + chunks of
     * [4-byte FourCC][4-byte size LE][data], padded to even length. C2PA is
     * carried in a chunk whose FourCC is "C2PA".
     */
    private static function detect_jumbf_in_riff(string $file_path, int $file_size): bool
    {
        $fp = fopen($file_path, 'rb'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- read-only 'rb' handle for a bounded binary C2PA/JUMBF box scan of a local media file; WP_Filesystem has no seek/partial read.
        if ($fp === false) {
            return false;
        }

        try {
            $header = fread($fp, 12); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- sequential binary read of a local media file for C2PA/JUMBF box detection.
            if ($header === false || strlen($header) < 12 || substr($header, 0, 4) !== 'RIFF') {
                return false;
            }

            $read = 12;
            $scan_limit = min($file_size, self::MAX_SCAN_BYTES);

            while ($read + 8 <= $scan_limit) {
                $chunk_header = fread($fp, 8); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- sequential binary read of a local media file for C2PA/JUMBF box detection.
                if ($chunk_header === false || strlen($chunk_header) < 8) {
                    break;
                }

                $fourcc = substr($chunk_header, 0, 4);
                $size = unpack('V', substr($chunk_header, 4, 4))[1];

                if ($fourcc === self::RIFF_C2PA_CHUNK) {
                    return true;
                }

                // RIFF chunks are padded to an even number of bytes.
                $skip = $size + ($size % 2);
                if (fseek($fp, $skip, SEEK_CUR) !== 0) {
                    break;
                }
                $read += 8 + $skip;
            }
        } finally {
            fclose($fp); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closes the read-only 'rb' handle from the guarded binary C2PA/JUMBF box scan above; WP_Filesystem exposes no stream handle to close.
        }

        return false;
    }

    /**
     * Best-effort, dependency-free extraction of manifest hints.
     *
     * Scans the leading bytes for the active manifest URN (stored as a JUMBF
     * box label) and the claim_generator value (a CBOR text string that
     * follows the "claim_generator" key in the C2PA claim). Returns null for
     * either hint when it cannot be cheaply recovered.
     *
     * @return array{label: ?string, claim_generator: ?string}
     */
    private static function extract_manifest_hints(string $file_path, int $file_size): array
    {
        $hints = ['label' => null, 'claim_generator' => null];

        $scan_size = min($file_size, self::MAX_SCAN_BYTES);
        $data = file_get_contents($file_path, false, null, 0, $scan_size); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown -- bounded local media read (<= MAX_SCAN_BYTES) for C2PA/JUMBF detection, not a remote fetch.
        if ($data === false) {
            return $hints;
        }

        // Active manifest label: C2PA manifest URN stored as a JUMBF box label.
        if (preg_match('/urn:(?:c2pa|uuid):[A-Za-z0-9:_.\-]+/', $data, $m)) {
            $hints['label'] = $m[0];
        }

        $hints['claim_generator'] = self::read_cbor_text_after_key($data, 'claim_generator');

        return $hints;
    }

    /**
     * Read the CBOR text string that immediately follows a map key.
     *
     * In a CBOR-encoded claim the map key (e.g. "claim_generator") is stored
     * as a length-prefixed UTF-8 string, immediately followed by its value.
     * This reads the value when it is a definite-length CBOR text string
     * (major type 3). Longer keys such as "claim_generator_info" are skipped.
     */
    private static function read_cbor_text_after_key(string $data, string $key): ?string
    {
        $data_len = strlen($data);
        $key_len = strlen($key);
        $offset = 0;

        while (($pos = strpos($data, $key, $offset)) !== false) {
            $after = $pos + $key_len;

            // Skip when this is a prefix of a longer key (e.g. claim_generator_info).
            if ($after < $data_len && $data[$after] === '_') {
                $offset = $after;
                continue;
            }

            if ($after >= $data_len) {
                return null;
            }

            $ib = ord($data[$after]);
            if (($ib >> 5) !== 3) { // not a CBOR text string value
                $offset = $after;
                continue;
            }

            $minor = $ib & 0x1f;
            $p = $after + 1;

            if ($minor < 24) {
                $len = $minor;
            } elseif ($minor === 24) {
                if ($p >= $data_len) {
                    return null;
                }
                $len = ord($data[$p]);
                $p += 1;
            } elseif ($minor === 25) {
                if ($p + 1 >= $data_len) {
                    return null;
                }
                $len = (ord($data[$p]) << 8) | ord($data[$p + 1]);
                $p += 2;
            } else {
                $offset = $after;
                continue;
            }

            if ($len < 1 || $len > 256 || $p + $len > $data_len) {
                $offset = $after;
                continue;
            }

            $str = substr($data, $p, $len);
            // Accept only valid UTF-8 without control characters.
            if (preg_match('//u', $str) && ! preg_match('/[\x00-\x08\x0E-\x1F\x7F]/', $str)) {
                return $str;
            }

            $offset = $after;
        }

        return null;
    }
}
