<?php

// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedNamespaceFound -- APD is the established namespace shared with the Pro add-on and retained for backward compatibility.
namespace APD\Support;

defined('ABSPATH') || exit;

class ModelUploads
{
    private const MIME_TYPES = [
        'glb' => 'model/gltf-binary',
        'gltf' => 'model/gltf+json',
    ];

    private const GLB_JSON_CHUNK = 0x4E4F534A;

    public function register() : void
    {
        add_filter('upload_mimes', [$this, 'allowMimeTypes']);
        add_filter('wp_check_filetype_and_ext', [$this, 'normalizeFiletype'], 10, 5);
        add_filter('wp_handle_upload_prefilter', [$this, 'validateUpload']);
        add_filter('wp_handle_sideload_prefilter', [$this, 'validateUpload']);
    }

    public function allowMimeTypes(array $mimes) : array
    {
        if ($this->canUploadModels()) {
            return array_merge($mimes, self::MIME_TYPES);
        }

        foreach (array_keys(self::MIME_TYPES) as $extension) {
            unset($mimes[$extension]);
        }

        return $mimes;
    }

    public function normalizeFiletype(
        array $data,
        string $file,
        string $filename,
        ?array $mimes,
        $realMime = false
    ) : array {
        $extension = $this->extensionFromFilename($filename);
        if (
            ! $this->canUploadModels()
            || ! isset(self::MIME_TYPES[$extension])
            || ! $this->isValidModel($file, $extension)
        ) {
            return $data;
        }

        return [
            'ext' => $extension,
            'type' => self::MIME_TYPES[$extension],
            'proper_filename' => $data['proper_filename'] ?? false,
        ];
    }

    public function validateUpload(array $file) : array
    {
        $extension = $this->extensionFromFilename((string) ($file['name'] ?? ''));
        if (! isset(self::MIME_TYPES[$extension])) {
            return $file;
        }

        if (! $this->canUploadModels()) {
            $file['error'] = __('3D model uploads are restricted to Asuka Designer administrators.', 'asuka-product-designer');
            return $file;
        }

        $temporaryPath = (string) ($file['tmp_name'] ?? '');
        if ($temporaryPath === '' || ! is_readable($temporaryPath)) {
            $file['error'] = __('The 3D model upload could not be read.', 'asuka-product-designer');
            return $file;
        }

        if (! $this->isValidModel($temporaryPath, $extension)) {
            $file['error'] = $extension === 'glb'
                ? __('The file is not a valid binary glTF 2.0 model.', 'asuka-product-designer')
                : __('The file is not a valid glTF 2.0 document.', 'asuka-product-designer');
            return $file;
        }

        $file['type'] = self::MIME_TYPES[$extension];
        return $file;
    }

    private function canUploadModels() : bool
    {
        return current_user_can(Capabilities::MANAGE) || current_user_can('manage_options');
    }

    private function extensionFromFilename(string $filename) : string
    {
        return strtolower((string) pathinfo($filename, PATHINFO_EXTENSION));
    }

    private function isValidModel(string $path, string $extension) : bool
    {
        return $extension === 'glb'
            ? $this->isValidGlb($path)
            : $this->isValidGltf($path);
    }

    private function isValidGlb(string $path) : bool
    {
        $fileSize = filesize($path);
        if (! is_int($fileSize) || $fileSize < 20) {
            return false;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- GLB validation streams only the binary header and JSON chunk instead of loading a large model into memory.
        $handle = fopen($path, 'rb');
        if ($handle === false) {
            return false;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- See the bounded binary-stream rationale above.
        $header = fread($handle, 20);
        if (! is_string($header) || strlen($header) !== 20 || substr($header, 0, 4) !== 'glTF') {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the bounded GLB validation stream.
            fclose($handle);
            return false;
        }

        $fields = unpack('Vversion/Vlength/VchunkLength/VchunkType', substr($header, 4));
        if (
            ! is_array($fields)
            || (int) ($fields['version'] ?? 0) !== 2
            || (int) ($fields['length'] ?? 0) !== $fileSize
            || (int) ($fields['chunkLength'] ?? 0) < 1
            || (int) ($fields['chunkType'] ?? 0) !== self::GLB_JSON_CHUNK
            || 20 + (int) $fields['chunkLength'] > $fileSize
        ) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the bounded GLB validation stream.
            fclose($handle);
            return false;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Reads only the validated GLB JSON chunk.
        $json = fread($handle, (int) $fields['chunkLength']);
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the bounded GLB validation stream.
        fclose($handle);

        return is_string($json) && $this->hasGltf2Asset(json_decode(trim($json), true));
    }

    private function isValidGltf(string $path) : bool
    {
        $contents = file_get_contents($path);
        return is_string($contents) && $this->hasGltf2Asset(json_decode($contents, true));
    }

    private function hasGltf2Asset($document) : bool
    {
        if (! is_array($document) || ! is_array($document['asset'] ?? null)) {
            return false;
        }

        $version = (string) ($document['asset']['version'] ?? '');
        return $version === '2' || str_starts_with($version, '2.');
    }
}
