<?php
namespace GriffinForms\Frontend\Ajax;

trait FileHandler
{
    /**
     * Compute temp file expiry in UTC based on Files Storage setting.
     * Falls back to 48 hours when unset/invalid or settings API unavailable.
     *
     * @return string MySQL datetime (UTC)
     */
    private function getTempExpiryUtc(): string
    {
        $hours = 48; // sensible default
        // Prefer config/settings option if available
        $opt = $this->settings->getOption('files_temp_retention_hours', 48);
        if (is_numeric($opt)) {
            $val = (int) $opt;
            if ($val >= 1) {
                $hours = $val;
            }
        }

        $now_ts     = (int) current_time('timestamp', true); // UTC
        $expires_ts = $now_ts + ($hours * HOUR_IN_SECONDS);
        return gmdate('Y-m-d H:i:s', $expires_ts);
    }

    /**
     * Convert per-file max size from settings (in MB) to bytes.
     * Returns null when the setting is unset/blank or <= 0.
     *
     * @param array $settings Field settings array containing 'max_size_mb'.
     * @return int|null Bytes limit or null if not enforced.
     */
    private function fieldMaxSizeBytes(array $settings): ?int
    {
        if (!isset($settings['max_size_mb']) || $settings['max_size_mb'] === '' ) { return null; }
        $mb = (float) $settings['max_size_mb'];
        if ($mb <= 0) { return null; }
        return (int) round($mb * 1024 * 1024);
    }

    /**
     * Validate a file size against the field-configured per-file limit.
     *
     * @param int   $bytes    File size in bytes to validate.
     * @param array $settings Field settings array (may include 'max_size_mb').
     * @return bool True if within limit or limit not set; false otherwise.
     */
    private function validateFieldMaxFileSize(int $bytes, array $settings): bool
    {
        $limit = $this->fieldMaxSizeBytes($settings);
        return $limit === null ? true : ($bytes <= $limit);
    }

    /**
     * Validate a file size against WordPress global upload size.
     * Uses wp_max_upload_size().
     *
     * @param int $bytes File size in bytes.
     * @return bool True if within site limit or site limit unavailable; false otherwise.
     */
    private function validateWpMaxFileSize(int $bytes): bool
    {
        $wp_max = (int) wp_max_upload_size();
        return $wp_max > 0 ? ($bytes <= $wp_max) : true;
    }

    /**
     * Convert total combined size from settings (in MB) to bytes.
     * Returns null when unset/blank or <= 0.
     *
     * @param array $settings Field settings array containing 'total_size_mb'.
     * @return int|null Bytes limit or null if not enforced.
     */
    private function fieldTotalSizeBytes(array $settings): ?int
    {
        if (!isset($settings['total_size_mb']) || $settings['total_size_mb'] === '') { return null; }
        $mb = (float) $settings['total_size_mb'];
        if ($mb <= 0) { return null; }
        return (int) round($mb * 1024 * 1024);
    }

    /**
     * Parse a comma/space-separated token list from POST data.
     * Ensures each token matches a 40-hex pattern (SHA1).
     *
     * @param string $key POST key to read tokens from (default 'existing_tokens').
     * @return string[] Array of unique sanitized tokens.
     */
    private function parseTokensFromPost(string $key = 'existing_tokens'): array
    {
        $raw = isset($_POST[$key]) ? wp_unslash($_POST[$key]) : '';
        if (!is_string($raw) || $raw === '') { return []; }
        $parts = preg_split('/[\s,]+/', $raw);
        $out = [];
        foreach ($parts as $t) {
            $t = trim((string) $t);
            if ($t !== '' && preg_match('/^[a-f0-9]{40}$/i', $t)) { $out[] = $t; }
        }
        return array_values(array_unique($out));
    }

    /**
     * Sum size_bytes for given tokens scoped to temp rows and current field/form/draft.
     *
     * @param string[] $tokens   Token list (SHA1 strings).
     * @param \wpdb    $wpdb     WordPress DB object.
     * @param string   $table    Files table name.
     * @param int      $form_id  Current form ID scope.
     * @param int      $field_id Current field ID scope.
     * @param string   $draft_id Current draft/session identifier.
     * @return int Total size in bytes for matching rows.
     */
    private function bytesForTokens(array $tokens, \wpdb $wpdb, string $table, int $form_id, int $field_id, string $draft_id): int
    {
        if (empty($tokens)) { return 0; }
        $conditions = ['status = %s', 'form_id = %d', 'field_id = %d'];
        $params     = ['temp', $form_id, $field_id];
        if ($draft_id !== '') { $conditions[] = 'draft_id = %s'; $params[] = $draft_id; }
        $placeholders = implode(',', array_fill(0, count($tokens), '%s'));
        $query = "SELECT SUM(size_bytes) FROM {$table} WHERE " . implode(' AND ', $conditions) . " AND token IN ($placeholders)";
        $params = array_merge($params, $tokens);
        $sum = $wpdb->get_var($wpdb->prepare($query, $params));
        return (int) $sum;
    }

    /**
     * Fetch maximum number of files allowed from settings.
     * Returns null if unset or <= 0 (no cap).
     *
     * @param array $settings Field settings array (may include 'max_files').
     * @return int|null Max files count or null if unlimited.
     */
    private function fieldMaxFiles(array $settings): ?int
    {
        if (!isset($settings['max_files'])) { return null; }
        $max_files_count = (int) $settings['max_files'];
        return $max_files_count > 0 ? $max_files_count : null;
    }

    /**
     * Count temp rows for given tokens in the current field/form/draft scope.
     *
     * @param string[] $tokens   Token list (SHA1 strings).
     * @param \wpdb    $wpdb     WordPress DB object.
     * @param string   $table    Files table name.
     * @param int      $form_id  Current form ID scope.
     * @param int      $field_id Current field ID scope.
     * @param string   $draft_id Current draft/session identifier.
     * @return int Number of matching rows.
     */
    private function countForTokens(array $tokens, \wpdb $wpdb, string $table, int $form_id, int $field_id, string $draft_id): int
    {
        if (empty($tokens)) { return 0; }
        $conditions = ['status = %s', 'form_id = %d', 'field_id = %d'];
        $params     = ['temp', $form_id, $field_id];
        if ($draft_id !== '') { $conditions[] = 'draft_id = %s'; $params[] = $draft_id; }
        $placeholders = implode(',', array_fill(0, count($tokens), '%s'));
        $query = "SELECT COUNT(*) FROM {$table} WHERE " . implode(' AND ', $conditions) . " AND token IN ($placeholders)";
        $params = array_merge($params, $tokens);
        $cnt = $wpdb->get_var($wpdb->prepare($query, $params));
        return (int) $cnt;
    }

    /**
     * Collect unique error codes from a batch error array returned during upload.
     *
     * @param array $errors Array of error structures with optional 'code' keys.
     * @return string[] List of unique error codes.
     */
    private function collectErrorCodes(array $errors): array
    {
        $codes = array();
        foreach ($errors as $e) {
            if (is_array($e) && !empty($e['code'])) {
                $codes[$e['code']] = true;
            }
        }
        return array_keys($codes);
    }

    /**
     * Format a byte size using size_format() with 2 decimals for consistency.
     *
     * @param int $bytes Raw size in bytes.
     * @return string Human-readable size string (e.g., "1.23 MB").
     */
    private function hsize(int $bytes): string
    {
        return size_format(max(0, $bytes), 2);
    }

    /**
     * Build normalized allowed extensions list from field settings, with alias expansion.
     *
     * @param array $settings Field settings (allowed_mimes, custom_mimes).
     * @return string[] Lowercase extensions (e.g., ['jpg','jpeg','png','pdf']).
     */
    private function allowedExtensionsFromSettings(array $settings): array
    {
        // Base set from settings
        $base = array();
        if (!empty($settings['allowed_mimes']) && is_array($settings['allowed_mimes'])) {
            foreach ($settings['allowed_mimes'] as $ext) {
                $ext = strtolower(trim((string) $ext));
                if ($ext !== '') { $base[$ext] = true; }
            }
        }
        if (!empty($settings['custom_mimes']) && is_array($settings['custom_mimes'])) {
            foreach ($settings['custom_mimes'] as $ext) {
                $ext = strtolower(trim((string) $ext));
                if ($ext !== '') { $base[$ext] = true; }
            }
        }

        // Expand common aliases so jpg ↔ jpeg, tif ↔ tiff, htm ↔ html etc.
        $aliases = array(
            'jpg'  => array('jpg','jpeg'),
            'jpeg' => array('jpg','jpeg'),
            'tif'  => array('tif','tiff'),
            'tiff' => array('tif','tiff'),
            'htm'  => array('htm','html'),
            'html' => array('htm','html'),
            // add lightweight doc pairs
            'doc'  => array('doc','docx'),
            'docx' => array('doc','docx'),
            'xls'  => array('xls','xlsx'),
            'xlsx' => array('xls','xlsx'),
            'ppt'  => array('ppt','pptx'),
            'pptx' => array('ppt','pptx'),
        );

        $expanded = array();
        foreach (array_keys($base) as $ext) {
            if (isset($aliases[$ext])) {
                foreach ($aliases[$ext] as $alt) { $expanded[$alt] = true; }
            }
            $expanded[$ext] = true; // always include original
        }

        return array_keys($expanded);
    }

    /**
     * Build a non-expanded label list from settings for user messages.
     *
     * @param array $settings Field settings.
     * @return string[] Lowercase extensions as configured (no alias expansion).
     */
    private function allowedExtensionsLabelList(array $settings): array
    {
        $out = array();
        if (!empty($settings['allowed_mimes']) && is_array($settings['allowed_mimes'])) {
            foreach ($settings['allowed_mimes'] as $ext) {
                $ext = strtolower(trim((string) $ext));
                if ($ext !== '') { $out[$ext] = true; }
            }
        }
        if (!empty($settings['custom_mimes']) && is_array($settings['custom_mimes'])) {
            foreach ($settings['custom_mimes'] as $ext) {
                $ext = strtolower(trim((string) $ext));
                if ($ext !== '') { $out[$ext] = true; }
            }
        }
        return array_keys($out);
    }

    /**
     * Build a MIME map (ext => mime) from the allowed extensions in settings.
     * Uses wp_get_mime_types() so ext “jpg” resolves to “image/jpeg”, etc.
     *
     * @param array $settings Field settings with allowed_mimes/custom_mimes.
     * @return array<string,string> Map suitable for wp_handle_upload(['mimes' => ...]).
     */
    private function allowedMimeMapFromExtensions(array $settings): array
    {
        $allowed_exts = $this->allowedExtensionsFromSettings($settings);
        if (empty($allowed_exts)) { return array(); }

        $mimes_registry = wp_get_mime_types(); // keys like 'jpg|jpeg|jpe' => 'image/jpeg'
        $map = array();
        foreach ($allowed_exts as $ext) {
            foreach ($mimes_registry as $ext_group => $mime) {
                // Match ext within pipe-delimited group
                $parts = explode('|', (string) $ext_group);
                if (in_array($ext, $parts, true)) {
                    $map[$ext] = (string) $mime;
                    break;
                }
            }
        }

        // Fallbacks for safe text-based formats that some hosts/WP installs don't include
        // in the default mime registry. Without this, wp_handle_upload() can return
        // "Sorry, you are not allowed to upload this file type." even when the extension
        // is allowed in field settings and our pre-checks pass. These mappings are conservative
        // and only take effect when the admin explicitly allow-lists the extension.
        foreach ($allowed_exts as $ext) {
            if (isset($map[$ext])) { continue; }
            switch ($ext) {
                case 'md':
                case 'markdown':
                    // Prefer text/markdown (RFC 7763); WP may not ship it by default
                    $map[$ext] = 'text/markdown';
                    break;
                case 'json':
                    $map[$ext] = 'application/json';
                    break;
                case 'txt':
                    $map[$ext] = 'text/plain';
                    break;
                case 'csv':
                    $map[$ext] = 'text/csv';
                    break;
                default:
                    // As a last resort, allow via octet-stream so WP uses our allow-list by extension.
                    // This still passes through our ext+MIME anti-spoofing gate above and only applies
                    // when the extension was explicitly allow-listed by the admin.
                    $map[$ext] = 'application/octet-stream';
                    break;
            }
        }

        return $map;
    }

    /**
     * Detect actual file extension/mime using WP's filetype checker.
     *
     * @param array $file_entry One flattened $_FILES entry.
     * @return array{ext:string,type:string} Detected extension and mime (may be empty strings).
     */
    private function detectFileType(array $file_entry): array
    {
        $file_name = isset($file_entry['name']) ? (string) $file_entry['name'] : '';
        $tmp_name  = isset($file_entry['tmp_name']) ? (string) $file_entry['tmp_name'] : '';

        // wp_check_filetype_and_ext needs these includes already present (loaded in fileUploadTemp()).
        $check = wp_check_filetype_and_ext($tmp_name, $file_name);
        $ext   = isset($check['ext'])  ? (string) $check['ext']  : '';
        $type  = isset($check['type']) ? (string) $check['type'] : '';

        return array('ext' => strtolower($ext), 'type' => $type);
    }

    /**
     * Determine whether the given uploaded file is an image.
     * Uses WP's detected mime when available, then falls back to $_FILES mime,
     * and finally to the file extension for robustness.
     *
     * @param array $file_entry One flattened $_FILES entry with keys name,type,tmp_name.
     * @return bool True if the file is an image/* type; false otherwise.
     */
    private function isImageFile(array $file_entry): bool
    {
        // Prefer WordPress detection (more reliable than client-provided type)
        $detected = $this->detectFileType($file_entry);
        $mime     = isset($detected['type']) && $detected['type'] !== ''
            ? strtolower($detected['type'])
            : (isset($file_entry['type']) ? strtolower((string) $file_entry['type']) : '');

        if ($mime !== '' && strpos($mime, 'image/') === 0) {
            return true;
        }

        // Fallback to extension check when mime is unavailable
        $name     = isset($file_entry['name']) ? (string) $file_entry['name'] : '';
        $ext      = $name !== '' ? strtolower((string) pathinfo($name, PATHINFO_EXTENSION)) : '';
        if ($ext === '') { return false; }

        // Keep this list tight; it's only a fallback when mime is missing/unknown
        $image_exts = array_keys(\GriffinForms\Admin\Choices\Field::getInstance()->getChoices('image_mimes'));
        return in_array($ext, $image_exts, true);
    }

    /**
     * Auto-rotate a JPEG/TIFF image in-place based on EXIF Orientation.
     * Safe no-op when EXIF is unavailable or unsupported. Uses WP_Image_Editor.
     *
     * @param string $path Absolute file path to the image.
     * @return bool True if rotated/saved, false otherwise.
     */
    private function maybeAutoRotateExif(string $path): bool
    {
        // Only attempt for JPEG/TIFF where EXIF Orientation is common
        $ft   = wp_check_filetype($path);
        $type = isset($ft['type']) ? strtolower((string) $ft['type']) : '';
        if (!in_array($type, array('image/jpeg','image/tiff','image/tiff-fx'), true)) {
            return false;
        }
        if (!function_exists('exif_read_data')) {
            return false;
        }
        $exif = @exif_read_data($path);
        if (!is_array($exif) || empty($exif['Orientation'])) {
            return false;
        }
        $orientation = (int) $exif['Orientation'];

        // Common rotate-only cases (mirror cases 2/4/5/7 are rare; ignored here)
        $angle = 0;
        switch ($orientation) {
            case 3: $angle = 180; break; // upside down
            case 6: $angle = 270; break; // 90° CW
            case 8: $angle =  90; break; // 90° CCW
            default: $angle = 0;  break;
        }
        if ($angle === 0) { return false; }

        $editor = wp_get_image_editor($path);
        if (is_wp_error($editor)) { return false; }
        $rot = $editor->rotate($angle);
        if (is_wp_error($rot))   { return false; }
        $saved = $editor->save($path);
        if (is_wp_error($saved)) { return false; }

        // Best-effort orientation normalize for viewers that honor EXIF
        if (class_exists('Imagick')) {
            try {
                $im = new \Imagick($path);
                if (defined('Imagick::ORIENTATION_TOPLEFT')) {
                    $im->setImageOrientation(\Imagick::ORIENTATION_TOPLEFT);
                }
                $im->writeImage($path);
                $im->clear();
                $im->destroy();
            } catch (\Exception $e) {
                // ignore
            }
        }
        return true;
    }

    /**
     * Strip EXIF/metadata from an image in-place.
     * Uses Imagick when available; otherwise re-saves via WP_Image_Editor (GD/WebP strip metadata by default).
     *
     * @param string $path Absolute file path.
     * @return bool True if metadata stripped/saved, false otherwise.
     */
    private function maybeStripExif(string $path): bool
    {
        if (class_exists('Imagick')) {
            try {
                $im = new \Imagick($path);
                $im->stripImage();
                $im->writeImage($path);
                $im->clear();
                $im->destroy();
                return true;
            } catch (\Exception $e) {
                // fall through
            }
        }
        $editor = wp_get_image_editor($path);
        if (is_wp_error($editor)) { return false; }
        $saved = $editor->save($path); // GD re-save drops EXIF
        return !is_wp_error($saved);
    }

    /**
     * Get image dimensions from the temporary uploaded file.
     *
     * @param array $file_entry One flattened $_FILES entry.
     * @return array{width:int,height:int}|null Width/height in pixels or null on failure.
     */
    private function getImageDimensions(array $file_entry): ?array
    {
        $tmp = isset($file_entry['tmp_name']) ? (string) $file_entry['tmp_name'] : '';
        if ($tmp === '' || !is_readable($tmp)) { return null; }
        $info = @getimagesize($tmp);
        if (!is_array($info) || empty($info[0]) || empty($info[1])) { return null; }
        return array('width' => (int) $info[0], 'height' => (int) $info[1]);
    }

    /**
     * Convert width/height to megapixels.
     *
     * @param int $width  Width in px.
     * @param int $height Height in px.
     * @return float Megapixels (two decimals precision when formatted).
     */
    private function pixelsToMegapixels(int $width, int $height): float
    {
        return ($width <= 0 || $height <= 0) ? 0.0 : (($width * $height) / 1000000.0);
    }

    /**
     * Format megapixels with two decimals.
     */
    private function hmp(float $mp): string
    {
        return number_format(max(0, $mp), 2) . ' MP';
    }

    /**
     * Parse a ratio string like "4:3" into two positive integers.
     *
     * @param string $ratio "W:H"
     * @return array{w:int,h:int}|null
     */
    private function parseAspect(string $ratio): ?array
    {
        $ratio = trim($ratio);
        if ($ratio === '' || strpos($ratio, ':') === false) { return null; }
        list($w, $h) = array_map('trim', explode(':', $ratio, 2));
        $w = (int) $w; $h = (int) $h;
        if ($w <= 0 || $h <= 0) { return null; }
        return array('w' => $w, 'h' => $h);
    }

    /**
     * Check if actual ratio matches target ratio within tolerance.
     *
     * @param int   $width      Actual width.
     * @param int   $height     Actual height.
     * @param int   $target_w   Target ratio numerator.
     * @param int   $target_h   Target ratio denominator.
     * @param float $tolerance  Allowed relative error (e.g., 0.02 for 2%).
     */
    private function aspectMatches(int $width, int $height, int $target_w, int $target_h, float $tolerance = 0.02): bool
    {
        if ($width <= 0 || $height <= 0 || $target_w <= 0 || $target_h <= 0) { return false; }
        $actual = $width / $height;
        $target = $target_w / $target_h;
        if ($target == 0.0) { return false; }
        $diff = abs(($actual - $target) / $target);
        return $diff <= $tolerance;
    }

    /**
     * Validate min width constraint. Returns an error array or null if OK/unused.
     */
    private function checkImageMinWidth(int $width, array $settings, string $file_name): ?array
    {
        if (empty($settings['image_min_width'])) { return null; }
        $min = (int) $settings['image_min_width'];
        if ($min <= 0) { return null; }
        if ($width < $min) {
            return array(
                'name'    => $file_name,
                'message' => sprintf(__('Minimum image width is %d px. Your image is %d px wide.', 'griffinforms-form-builder'), $min, $width),
                'code'    => 'img_min_width',
            );
        }
        return null;
    }

    /**
     * Validate max width constraint. Returns an error array or null if OK/unused.
     */
    private function checkImageMaxWidth(int $width, array $settings, string $file_name): ?array
    {
        if (empty($settings['image_max_width'])) { return null; }
        $max = (int) $settings['image_max_width'];
        if ($max <= 0) { return null; }
        if ($width > $max) {
            return array(
                'name'    => $file_name,
                'message' => sprintf(__('Maximum image width is %d px. Your image is %d px wide.', 'griffinforms-form-builder'), $max, $width),
                'code'    => 'img_max_width',
            );
        }
        return null;
    }

    /**
     * Validate min height constraint. Returns an error array or null if OK/unused.
     */
    private function checkImageMinHeight(int $height, array $settings, string $file_name): ?array
    {
        if (empty($settings['image_min_height'])) { return null; }
        $min = (int) $settings['image_min_height'];
        if ($min <= 0) { return null; }
        if ($height < $min) {
            return array(
                'name'    => $file_name,
                'message' => sprintf(__('Minimum image height is %d px. Your image is %d px high.', 'griffinforms-form-builder'), $min, $height),
                'code'    => 'img_min_height',
            );
        }
        return null;
    }

    /**
     * Validate max height constraint. Returns an error array or null if OK/unused.
     */
    private function checkImageMaxHeight(int $height, array $settings, string $file_name): ?array
    {
        if (empty($settings['image_max_height'])) { return null; }
        $max = (int) $settings['image_max_height'];
        if ($max <= 0) { return null; }
        if ($height > $max) {
            return array(
                'name'    => $file_name,
                'message' => sprintf(__('Maximum image height is %d px. Your image is %d px high.', 'griffinforms-form-builder'), $max, $height),
                'code'    => 'img_max_height',
            );
        }
        return null;
    }

    /**
     * Validate max megapixels. Returns an error array or null if OK/unused.
     */
    private function checkImageMaxMegapixels(int $width, int $height, array $settings, string $file_name): ?array
    {
        if (!isset($settings['image_max_megapixels']) || $settings['image_max_megapixels'] === '') { return null; }
        $max_mp = (float) $settings['image_max_megapixels'];
        if ($max_mp <= 0) { return null; }
        $mp = $this->pixelsToMegapixels($width, $height);
        if ($mp > $max_mp) {
            return array(
                'name'    => $file_name,
                'message' => sprintf(
                    __('Maximum image size allowed is %s megapixels. Your image is %s megapixels (approx. %d × %d px).', 'griffinforms-form-builder'),
                    number_format($max_mp, 2),
                    number_format($mp, 2),
                    $width,
                    $height
                ),
                'code'    => 'img_max_megapixels',
            );
        }
        return null;
    }

    /**
     * Validate aspect ratio based on mode (free/preset/custom). Returns error or null.
     */
    private function checkImageAspect(int $width, int $height, array $settings, string $file_name): ?array
    {
        $mode = isset($settings['image_aspect_mode']) ? (string) $settings['image_aspect_mode'] : 'free';
        if ($mode === 'free' || $mode === '') { return null; }

        $target = null;
        if ($mode === 'preset') {
            $target = $this->parseAspect((string)($settings['image_aspect_preset'] ?? ''));
        } elseif ($mode === 'custom') {
            $w = (int) ($settings['image_aspect_custom_w'] ?? 0);
            $h = (int) ($settings['image_aspect_custom_h'] ?? 0);
            if ($w > 0 && $h > 0) { $target = array('w' => $w, 'h' => $h); }
        }
        if (!$target) { return null; }

        if (!$this->aspectMatches($width, $height, (int)$target['w'], (int)$target['h'])) {
            $approx = ($height > 0) ? ($width / $height) : 0.0;
            return array(
                'name'    => $file_name,
                'message' => sprintf(
                    __('This image does not match the required aspect ratio (%1$s). Your image: %2$dx%3$d (~%4$.2f:1).', 'griffinforms-form-builder'),
                    (string)($target['w'] . ':' . $target['h']),
                    $width,
                    $height,
                    $approx
                ),
                'code'    => 'img_aspect_mismatch',
            );
        }
        return null;
    }

    /**
     * Pretty label for allowed extensions list to show in messages.
     *
     * @param array $settings Field settings.
     * @return string Human readable list like "jpg, png, pdf".
     */
    private function allowedListLabel(array $settings): string
    {
        $exts = $this->allowedExtensionsLabelList($settings);
        return implode(', ', $exts);
    }

    /**
     * Get a BotLimiter instance configured for file uploads with stricter overrides.
     * Keeps global defaults intact by using per-instance overrides.
     */
    private function getUploadLimiter()
    {
        if (!class_exists('GriffinForms\\Frontend\\Security\\BotLimiter')) {
            return null; // limiter unavailable
        }
        $limiter = \GriffinForms\Frontend\Security\BotLimiter::getInstance();
        // Tag this flow as uploads so it has its own counters per client
        if (method_exists($limiter, 'setEventType')) {
            $limiter->setEventType('upload');
        }
        // Apply conservative overrides for uploads (can be tuned later / surfaced in settings)
        if (method_exists($limiter, 'setOverrides')) {
            $limiter->setOverrides([
                // e.g. max 3 files per 30 seconds burst, and 20 per 10 minutes window
                'burst_limit'   => 3,
                'burst_window'  => 30,
                'window_limit'  => 20,
                'window_seconds'=> 600,
            ]);
        }
        return $limiter;
    }

    /**
     * Abort the upload request early if rate limit has been reached.
     * Counts attempts (number of files user is trying to upload now).
     */
    private function checkUploadLimiterOrAbort(int $attempt_count): void
    {
        $limiter = $this->getUploadLimiter();
        if (!$limiter || !method_exists($limiter, 'isRateLimited')) {
            return; // no limiter available
        }
        // We check for at least one attempt; limiter implementations usually track per-event increment separately
        if ($limiter->isRateLimited()) {
            wp_send_json_error([
                'message' => __('You are uploading too frequently. Please wait a moment and try again.', 'griffinforms-form-builder'),
                'code'    => 'rate_limited_uploads',
            ]);
            wp_die();
        }
    }

    /**
     * Mark this upload attempt in the limiter after processing.
     * We record the number of files attempted to keep counters fair when multiple
     * files are selected in one picker operation.
     */
    private function markUploadLimiter(int $attempt_count): void
    {
        $limiter = $this->getUploadLimiter();
        if (!$limiter) { return; }
        if (method_exists($limiter, 'markEventBy')) {
            $limiter->markEventBy(max(1, $attempt_count));
        } elseif (method_exists($limiter, 'markEvent')) {
            // Fallback: call markEvent() once per file
            $attempt_count = max(1, $attempt_count);
            for ($i = 0; $i < $attempt_count; $i++) {
                $limiter->markEvent();
            }
        }
    }

    /**
     * AJAX handler: process one or more files for temporary upload.
     * Flattens the $_FILES structure, validates limits, moves files, records rows, and returns JSON.
     *
     * @return void Sends a JSON response and terminates execution.
     */
    public function fileUploadTemp()
    {
        check_ajax_referer('file_upload_temp');
        global $wpdb;

        if (empty($_FILES['files'])) {
            wp_send_json_error(array(
                'message' => __('No files found in request.', 'griffinforms-form-builder'),
                'errors'  => array(array('code' => 'no_files_key', 'message' => '$_FILES["files"] missing')),
            ));
            wp_die();
        }

        // Context & setup
        $upload_context = $this->getUploadContext();
        require_once ABSPATH . 'wp-admin/includes/file.php';

        $files       = $this->flattenFilesArray($_FILES['files']);
        $files_count = is_array($files) ? count($files) : 0;

        // Rate limit uploads per client to protect server IO
        $this->checkUploadLimiterOrAbort($files_count);

        $result = array();
        $errors = array();

        $running_bytes = 0;
        $running_count = 0;
        // Process files
        foreach ($files as $file_entry) {
            $this->handleSingleTempFile($file_entry, $upload_context, $result, $errors, $wpdb, $running_bytes, $running_count);
        }

        // Record this attempt in rate limiter (count attempts, regardless of success)
        $this->markUploadLimiter($files_count);

        // Respond
        $this->sendUploadResponse($result, $errors, $files_count, $upload_context['settings'], $upload_context['wp_max_bytes']);
    }

    /**
     * AJAX handler: delete a temporary uploaded file by token.
     * Validates scope (form/field/draft), removes file (private storage), and deletes DB row.
     *
     * @return void Sends a JSON response and terminates execution.
     */
    public function fileUploadDelete(): void
    {
        check_ajax_referer('file_upload_delete');

        $token    = isset($_POST['token']) ? sanitize_text_field(wp_unslash($_POST['token'])) : '';
        $field_id = isset($_POST['field_id']) ? absint(wp_unslash($_POST['field_id'])) : 0;
        $draft_id = isset($_POST['draft_id']) ? sanitize_text_field(wp_unslash($_POST['draft_id'])) : '';
        $form_id  = isset($_POST['form_id']) ? absint(wp_unslash($_POST['form_id'])) : 0;

        if ($token === '' || !preg_match('/^[a-f0-9]{40}$/i', $token)) {
            wp_send_json_error([
                'message' => __('Invalid token.', 'griffinforms-form-builder'),
                'code'    => 'invalid_token',
            ]);
            wp_die();
        }

        global $wpdb;
        $table = $this->config->getTable('file');

        $conditions = array('status = %s', 'token = %s');
        $prep = array('temp', $token);
        if ($form_id > 0)   { $conditions[] = 'form_id = %d';  $prep[] = $form_id; }
        if ($field_id > 0)  { $conditions[] = 'field_id = %d'; $prep[] = $field_id; }
        if ($draft_id !== '') { $conditions[] = 'draft_id = %s'; $prep[] = $draft_id; }
        $query = "SELECT id, path, storage_mode FROM {$table} WHERE " . implode(' AND ', $conditions) . " LIMIT 1";
        $row   = $wpdb->get_row($wpdb->prepare($query, $prep), ARRAY_A);

        if (!$row) {
            wp_send_json_success([
                'message' => __('File not found or already removed.', 'griffinforms-form-builder'),
                'token'   => $token,
                'deleted' => false,
            ]); // idempotent
            wp_die();
        }

        // Unlink only for private temp storage
        $path = isset($row['path']) ? $row['path'] : '';
        if ($path && is_file($path) && is_readable($path) && isset($row['storage_mode']) && $row['storage_mode'] === 'private') {
            @unlink($path);
        }

        // Remove DB row
        $wpdb->delete($table, ['id' => (int) $row['id']], ['%d']);

        wp_send_json_success([
            'message' => __('File removed.', 'griffinforms-form-builder'),
            'token'   => $token,
            'deleted' => true,
        ]);
        wp_die();
    }

    /**
     * Normalize a $_FILES-style array into a flat list of file entries.
     * Supports both single and multiple upload shapes.
     *
     * @param array $filesInput The $_FILES['name'|'type'|'tmp_name'|'error'|'size'] structure.
     * @return array<int, array{name:string,type:string,tmp_name:string,error:int,size:int}> Flat list of files.
     */
    private function flattenFilesArray(array $filesInput) : array
    {
        $flat = array();
        // Single file case
        if (!is_array($filesInput['name'])) {
            return array($filesInput);
        }
        $count = count($filesInput['name']);
        for ($i = 0; $i < $count; $i++) {
            $flat[] = array(
                'name'     => $filesInput['name'][$i] ?? '',
                'type'     => $filesInput['type'][$i] ?? '',
                'tmp_name' => $filesInput['tmp_name'][$i] ?? '',
                'error'    => $filesInput['error'][$i] ?? UPLOAD_ERR_NO_FILE,
                'size'     => $filesInput['size'][$i] ?? 0,
            );
        }
        return $flat;
    }

    /**
     * Build a context array for the current upload request.
     * Returns the upload_context array (scoped limits and identifiers) used across the request.
     * Includes table names, scope (form/field/draft), settings, limits, existing tokens/bytes/count, and timestamps.
     *
     * @return array Associative context used by the upload pipeline.
     */
    private function getUploadContext(): array
    {
        $files_table = $this->config->getTable('file');
        $draft_id = isset($_POST['draft_id']) ? sanitize_text_field(wp_unslash($_POST['draft_id'])) : '';
        $field_id = isset($_POST['field_id']) ? absint(wp_unslash($_POST['field_id'])) : 0;
        $form_id  = isset($_POST['form_id']) ? absint(wp_unslash($_POST['form_id'])) : 0;

        $field = new \GriffinForms\Items\Field();
        $field->loadItem($field_id);
        $settings = $this->maybeDecode($field->getProp('field_settings'));

        $raw_storage   = isset($settings['storage_mode']) ? $settings['storage_mode'] : 'private';
        $storage_mode  = sanitize_key($raw_storage);
        if ($storage_mode === 'media_library') { $storage_mode = 'media'; }
        if (!in_array($storage_mode, array('private','media'), true)) { $storage_mode = 'private'; }

        $ctx = array(
            'files_table'  => $files_table,
            'draft_id'     => $draft_id,
            'field_id'     => $field_id,
            'form_id'      => $form_id,
            'field'        => $field,
            'settings'     => is_array($settings) ? $settings : array(),
            'max_files'        => $this->fieldMaxFiles(is_array($settings) ? $settings : []),
            'storage_mode' => $storage_mode,
            'now_utc'      => current_time('mysql', true),
            'expires'      => $this->getTempExpiryUtc(),
            'wp_max_bytes' => (int) wp_max_upload_size(),
            'total_limit_bytes' => (int) ($this->fieldTotalSizeBytes(is_array($settings)?$settings:[]) ?? 0),
            'existing_tokens'   => $this->parseTokensFromPost('existing_tokens'),
        );
        // compute already-attached bytes for this field in this draft
        $ctx['existing_bytes'] = $this->bytesForTokens($ctx['existing_tokens'], $GLOBALS['wpdb'], $ctx['files_table'], $ctx['form_id'], $ctx['field_id'], $ctx['draft_id']);
        $ctx['existing_count'] = $this->countForTokens($ctx['existing_tokens'], $GLOBALS['wpdb'], $ctx['files_table'], $ctx['form_id'], $ctx['field_id'], $ctx['draft_id']);
        return $ctx;
    }

    /**
     * Handle a single file upload in the current batch.
     * Enforces file count, per-file size, WP size, and total size limits; moves file and inserts DB row.
     * Accumulates results and errors by reference and updates running totals.
     *
     * @param array $file_entry    One flattened file entry.
     * @param array $upload_context Upload context from getUploadContext().
     * @param array $result        Reference to array accumulating successful uploads.
     * @param array $errors        Reference to array accumulating per-file errors.
     * @param \wpdb $wpdb          WordPress DB object.
     * @param int   $running_bytes Reference to running bytes within this request.
     * @param int   $running_count Reference to running file count within this request.
     * @return void
     */
    private function handleSingleTempFile(array $file_entry, array $upload_context, array &$result, array &$errors, \wpdb $wpdb, int &$running_bytes, int &$running_count): void
    {
        if (!isset($file_entry['error']) || $file_entry['error'] !== UPLOAD_ERR_OK) {
            return; // skip failed item
        }

        $file_size = isset($file_entry['size']) ? (int) $file_entry['size'] : 0;
        $file_name = isset($file_entry['name']) ? sanitize_file_name($file_entry['name']) : '';

        // Enforce allowed extension+MIME (server-side, anti-spoofing)
        $allowed_exts     = $this->allowedExtensionsFromSettings($upload_context['settings']);
        $allowed_mime_map = $this->allowedMimeMapFromExtensions($upload_context['settings']);
        if (!empty($allowed_exts)) {
            $detected      = $this->detectFileType($file_entry);
            $detected_ext  = $detected['ext'];
            $detected_mime = strtolower((string) ($detected['type'] ?? ''));
            $name_ext      = strtolower(pathinfo($file_name, PATHINFO_EXTENSION));

            // Basic ext allow check (either detected or filename ext)
            $ext_ok = ($detected_ext !== '' && in_array($detected_ext, $allowed_exts, true))
                || ($name_ext !== '' && in_array($name_ext, $allowed_exts, true));

            // MIME must also be allowed when we know it. However, on many hosts PHP's fileinfo
            // reports generic types for safe text-based files (e.g., .md → text/plain, .json → application/octet-stream).
            // To avoid false negatives while still preventing spoofing, we accept these two generic mimes
            // *only when the extension itself is allow‑listed*.
            $allowed_mimes = !empty($allowed_mime_map) ? array_values($allowed_mime_map) : array();
            $generic_mimes = array('text/plain', 'application/octet-stream');
            $is_generic    = ($detected_mime !== '') && in_array($detected_mime, $generic_mimes, true);

            if ($detected_mime === '') {
                $mime_ok = true; // no reliable MIME detected; extension gate already applied
            } elseif ($is_generic && $ext_ok) {
                $mime_ok = true; // allow generic MIME only if extension passed our allow‑list
            } else {
                $mime_ok = in_array($detected_mime, $allowed_mimes, true);
            }

            if (!$ext_ok || !$mime_ok) {
                $parts_allowed = $this->allowedListLabel($upload_context['settings']);
                $errors[] = array(
                    'name'    => $file_name,
                    'message' => sprintf(
                        __('This file type is not allowed for this field. Allowed: %s', 'griffinforms-form-builder'),
                        esc_html($parts_allowed)
                    ),
                    'code'    => 'disallowed_type',
                );
                return;
            }
        } else {
            // No allow-list configured: still derive map for wp_handle_upload hardening
            $allowed_mime_map = array();
        }

        // If this is an image, enforce image constraints from settings
        if ($this->isImageFile($file_entry)) {
            $dim = $this->getImageDimensions($file_entry);
            if (!$dim) {
                $errors[] = array(
                    'name'    => $file_name,
                    'message' => __('We could not read this image. Please try another file.', 'griffinforms-form-builder'),
                    'code'    => 'img_unreadable',
                );
                return;
            }

            $img_checks = array(
                $this->checkImageMinWidth($dim['width'],  $upload_context['settings'], $file_name),
                $this->checkImageMaxWidth($dim['width'],  $upload_context['settings'], $file_name),
                $this->checkImageMinHeight($dim['height'], $upload_context['settings'], $file_name),
                $this->checkImageMaxHeight($dim['height'], $upload_context['settings'], $file_name),
                $this->checkImageMaxMegapixels($dim['width'], $dim['height'], $upload_context['settings'], $file_name),
                $this->checkImageAspect($dim['width'], $dim['height'], $upload_context['settings'], $file_name),
            );
            foreach ($img_checks as $err) {
                if (is_array($err)) { $errors[] = $err; return; }
            }
        }

        // Enforce maximum number of files (existing + this batch)
        if (!empty($upload_context['max_files'])) {
            $current_count   = (int) $upload_context['existing_count'] + (int) $running_count;
            $remaining_slots = (int) $upload_context['max_files'] - $current_count;
            if ($remaining_slots <= 0) {
                $errors[] = array(
                    'name'    => $file_name,
                    'message' => sprintf(
                        __('You have reached the maximum number of files allowed for this field. Max files: %1$d • Currently attached: %2$d • Remaining: %3$d', 'griffinforms-form-builder'),
                        (int) $upload_context['max_files'],
                        (int) $current_count,
                        max(0, (int) $remaining_slots)
                    ),
                    'code'    => 'max_files_reached',
                );
                return;
            }
        }

        // Field per-file limit
        if (!$this->validateFieldMaxFileSize($file_size, $upload_context['settings'])) {
            $errors[] = array(
                'name'    => $file_name,
                'message' => sprintf(
                    __('File exceeds the maximum size allowed for this field (%s).', 'griffinforms-form-builder'),
                    $this->hsize((int) $this->fieldMaxSizeBytes($upload_context['settings']))
                ),
                'code'    => 'field_size_exceeded',
            );
            return;
        }
        // WordPress global limit
        if (!$this->validateWpMaxFileSize($file_size)) {
            $errors[] = array(
                'name'    => $file_name,
                'message' => sprintf(__('File exceeds the site upload limit of %s.', 'griffinforms-form-builder'), $this->hsize((int) $upload_context['wp_max_bytes'])),
                'code'    => 'wp_size_exceeded',
            );
            return;
        }

        // Check combined total size limit (existing + this batch before moving)
        if (!empty($upload_context['total_limit_bytes'])) {
            $current_total = (int) $upload_context['existing_bytes'] + (int) $running_bytes;
            $remaining    = (int) $upload_context['total_limit_bytes'] - $current_total;
            if ($file_size > $remaining) {
                $errors[] = array(
                    'name'    => $file_name,
                    'message' => sprintf(
                        __('Adding this file would exceed the total upload limit. Max total: %1$s • Currently attached: %2$s • Remaining: %3$s • This file: %4$s', 'griffinforms-form-builder'),
                        $this->hsize((int) $upload_context['total_limit_bytes']),
                        $this->hsize((int) $current_total),
                        $this->hsize((int) max(0, (int) $remaining)),
                        $this->hsize((int) $file_size)
                    ),
                    'code'    => 'total_size_exceeded',
                );
                return;
            }
        }

        // Move upload (harden with allowed MIME map when present)
        // SECURITY: Temporarily widen WP's allowed mime list to include only the field-allowed extensions.
        // This filter is applied *only* for the duration of this upload request and is scoped to this specific operation.
        // It ensures that WordPress's upload system will accept ONLY the explicitly allowed MIME types/extensions
        // configured for this field, regardless of what is globally allowed in WP's MIME registry.
        // This prevents bypass attempts where a user could upload disallowed files by exploiting WP’s global MIME registry.
        // The filter is removed immediately after use, so these risky MIME rules do NOT persist across requests.
        $added_exts = array();
        $augment_upload_mimes = function(array $site_mimes) use ($allowed_mime_map, &$added_exts) {
            foreach ($allowed_mime_map as $ext => $mime) {
                if (empty($site_mimes[$ext])) {
                    $site_mimes[$ext] = $mime;
                    $added_exts[$ext] = true;
                }
            }
            return $site_mimes;
        };
        add_filter('upload_mimes', $augment_upload_mimes, 9999);
        $overrides = array(
            'test_form' => false,
            'mimes'     => !empty($allowed_mime_map) ? $allowed_mime_map : null,
        );
        $moved = wp_handle_upload($file_entry, $overrides);
        remove_filter('upload_mimes', $augment_upload_mimes, 9999);
        if (isset($moved['error'])) {
            $errors[] = array(
                'name'    => $file_name,
                'message' => esc_html( (string) $moved['error'] ),
                'code'    => 'wp_upload_failed',
            );
            return;
        }

        // Optional image post-processing (EXIF rotate / strip) based on field settings
        if ($this->isImageFile($file_entry)) {
            $path = isset($moved['file']) ? (string) $moved['file'] : '';
            if ($path && is_readable($path) && is_writable($path)) {
                $do_rotate = !empty($upload_context['settings']['image_auto_rotate_exif']);
                $do_strip  = !empty($upload_context['settings']['image_strip_exif']);
                if ($do_rotate) { $this->maybeAutoRotateExif($path); }
                if ($do_strip)  { $this->maybeStripExif($path); }
                clearstatcache(true, $path);
                $new_size = @filesize($path);
                if (is_int($new_size) && $new_size > 0) {
                    $file_size = $new_size; // keep DB accurate
                }
            }
        }

        // Recalculate final dimensions after any image post-processing (rotate/strip)
        $final_width  = null;
        $final_height = null;
        $final_mp     = null;
        if ($this->isImageFile($file_entry)) {
            $path = isset($moved['file']) ? (string) $moved['file'] : '';
            if ($path && is_readable($path)) {
                $img_info = @getimagesize($path);
                if (is_array($img_info) && !empty($img_info[0]) && !empty($img_info[1])) {
                    $final_width  = (int) $img_info[0];
                    $final_height = (int) $img_info[1];
                    $final_mp     = round($this->pixelsToMegapixels($final_width, $final_height), 2);
                }
            }
        }

        // Insert DB row
        $token = sha1(uniqid('', true));
        $data = array(
            'form_id'       => $upload_context['form_id'],
            'field_id'      => $upload_context['field_id'],
            'draft_id'      => $upload_context['draft_id'],
            'token'         => $token,
            'status'        => 'temp',
            'storage_mode'  => $upload_context['storage_mode'],
            'path'          => isset($moved['file']) ? $moved['file'] : '',
            'url'           => isset($moved['url']) ? $moved['url'] : '',
            'mime'          => isset($moved['type']) ? $moved['type'] : ($file_entry['type'] ?? ''),
            'size_bytes'    => $file_size,
            'created_at'    => $upload_context['now_utc'],
            'updated_at'    => $upload_context['now_utc'],
            'expires_at'    => $upload_context['expires'],
        );
        if ($final_width !== null && $final_height !== null && $final_mp !== null) {
            $data['image_w']    = $final_width;
            $data['image_h']    = $final_height;
            $data['megapixels'] = $final_mp; // decimal(8,2)
        }
        
        $format = array();
        foreach ($data as $k => $v) {
            if (in_array($k, array('form_id','field_id','size_bytes','image_w','image_h'), true)) {
                $format[] = '%d';
            } elseif ($k === 'megapixels') {
                $format[] = '%f';
            } else {
                $format[] = '%s';
            }
        }
        $ins = $wpdb->insert($upload_context['files_table'], $data, $format);
        if ($ins === false) {
            $errors[] = array(
                'name'    => $file_name,
                'message' => __('We couldn\'t save your file right now. Please try again. If it keeps failing, contact the site administrator.', 'griffinforms-form-builder'),
                'code'    => 'db_insert_failed',
            );
            return;
        }

        $running_bytes += $file_size;
        $running_count++;

        $result[] = array(
            'id'    => (int) $wpdb->insert_id,
            'token' => $token,
            'name'  => $file_name,
            'type'  => isset($moved['type']) ? $moved['type'] : ($file_entry['type'] ?? ''),
            'mime'  => isset($moved['type']) ? $moved['type'] : ($file_entry['type'] ?? ''),
            'size'  => $file_size,
            'url'   => $moved['url'],
        );
    }

    /**
     * Build and send a consistent JSON response for the upload attempt.
     * Handles mixed outcomes, all-success, all-failure (single/multi) and crafts user-facing messages.
     *
     * @param array $result       Successful file payloads for this request.
     * @param array $errors       Error payloads for this request.
     * @param int   $files_count  Number of files attempted in this request.
     * @param array $settings     Field settings used for limits.
     * @param int   $wp_max_bytes WordPress site upload size limit in bytes.
     * @return void
     */
    private function sendUploadResponse(array $result, array $errors, int $files_count, array $settings, int $wp_max_bytes): void
    {
        // Mixed outcome
        if (!empty($result) && !empty($errors)) {
            $msg = $this->composePartialMessage($settings, $wp_max_bytes, $errors);
            wp_send_json_success(array(
                'message' => $msg,
                'files'   => $result,
                'errors'  => $errors,
            ));
            wp_die();
        }

        // All good
        if (!empty($result)) {
            wp_send_json_success(array(
                'message' => __('Files uploaded successfully.', 'griffinforms-form-builder'),
                'files'   => $result,
            ));
            wp_die();
        }

        // All failed
        if (!empty($errors)) {
            if ($files_count === 1 && count($errors) === 1 && !empty($errors[0]['message'])) {
                wp_send_json_error(array(
                    'message' => $errors[0]['message'],
                    'errors'  => $errors,
                ));
                wp_die();
            }
            $msg = $this->composeFullRejectMessage($settings, $wp_max_bytes, $errors);
            wp_send_json_error(array(
                'message' => $msg,
                'errors'  => $errors,
            ));
            wp_die();
        }
        
        // No files and no errors captured
        wp_send_json_error(array(
            'message' => __('No files uploaded.', 'griffinforms-form-builder'),
            'errors'  => array(),
        ));
        wp_die();
    }

    /**
     * Compose a partial-failure message (some files succeeded) including only relevant limit hints.
     *
     * @param array $settings     Field settings for limit values.
     * @param int   $wp_max_bytes WordPress site upload size limit in bytes.
     * @param array $errors       Batch errors to detect which limits failed.
     * @return string Message string for user display.
     */
    private function composePartialMessage(array $settings, int $wp_max_bytes, array $errors): string
    {
        $details = array();
        $codes   = $this->collectErrorCodes($errors);
        $hasField = in_array('field_size_exceeded', $codes, true);
        $hasTotal = in_array('total_size_exceeded', $codes, true);
        $hasWP    = in_array('wp_size_exceeded', $codes, true);
        $hasMaxF  = in_array('max_files_reached', $codes, true);
        $has_disallowed = in_array('disallowed_type', $codes, true);

        // Per-file limit (only if relevant)
        if ($hasField) {
            $per_bytes = $this->fieldMaxSizeBytes($settings);
            if (!empty($per_bytes)) {
                $details[] = sprintf(__('Max per file: %s', 'griffinforms-form-builder'), $this->hsize((int) $per_bytes));
            }
        }

        // Total combined size (only if relevant)
        if ($hasTotal) {
            $total_bytes = $this->fieldTotalSizeBytes($settings);
            if (!empty($total_bytes)) {
                $existing_tokens = $this->parseTokensFromPost('existing_tokens');
                $existing_bytes  = $this->bytesForTokens($existing_tokens, $GLOBALS['wpdb'], $this->config->getTable('file'), (int)($_POST['form_id']??0), (int)($_POST['field_id']??0), (string)($_POST['draft_id']??''));
                $remaining = max(0, (int)$total_bytes - (int)$existing_bytes);
                $details[] = sprintf(__('Max total: %s', 'griffinforms-form-builder'), $this->hsize((int) $total_bytes));
                $details[] = sprintf(__('Remaining total upload capacity: %s', 'griffinforms-form-builder'), $this->hsize((int) $remaining));
            }
        }

        // WordPress site limit (only if relevant)
        if ($hasWP && !empty($wp_max_bytes)) {
            $details[] = sprintf(__('Site limit: %s', 'griffinforms-form-builder'), $this->hsize((int) $wp_max_bytes));
        }

        // Files count limits (only if relevant)
        if ($hasMaxF) {
            $max_files = $this->fieldMaxFiles($settings);
            if (!empty($max_files)) {
                $existing_tokens = $this->parseTokensFromPost('existing_tokens');
                $existing_count  = $this->countForTokens($existing_tokens, $GLOBALS['wpdb'], $this->config->getTable('file'), (int)($_POST['form_id']??0), (int)($_POST['field_id']??0), (string)($_POST['draft_id']??''));
                $remaining_slots = max(0, (int)$max_files - (int)$existing_count);
                $details[] = sprintf(__('Max files: %d', 'griffinforms-form-builder'), (int)$max_files);
                $details[] = sprintf(__('Remaining files you can attach: %d', 'griffinforms-form-builder'), (int)$remaining_slots);
            }
        }

        // Disallowed file type (only if relevant)
        if ($has_disallowed) {
            $details[] = sprintf(
                __('Allowed types: %s', 'griffinforms-form-builder'),
                esc_html($this->allowedListLabel($settings))
            );
        }
        $message  = __('Some files could not be uploaded', 'griffinforms-form-builder');
        if (!empty($details)) {
            $message .= "\n• " . implode("\n• ", $details);
        }
        return $message;
    }

    /**
     * Compose a full-rejection message (no files succeeded) including only relevant limit hints.
     *
     * @param array $settings     Field settings for limit values.
     * @param int   $wp_max_bytes WordPress site upload size limit in bytes.
     * @param array $errors       Batch errors to detect which limits failed.
     * @return string Message string for user display.
     */
    private function composeFullRejectMessage(array $settings, int $wp_max_bytes, array $errors): string
    {
        $details = array();
        $codes   = $this->collectErrorCodes($errors);
        $hasField = in_array('field_size_exceeded', $codes, true);
        $hasTotal = in_array('total_size_exceeded', $codes, true);
        $hasWP    = in_array('wp_size_exceeded', $codes, true);
        $hasMaxF  = in_array('max_files_reached', $codes, true);
        $has_disallowed = in_array('disallowed_type', $codes, true);

        if ($hasField) {
            $per_bytes = $this->fieldMaxSizeBytes($settings);
            if (!empty($per_bytes)) {
                $details[] = sprintf(__('Max per file: %s', 'griffinforms-form-builder'), $this->hsize((int) $per_bytes));
            }
        }

        if ($hasTotal) {
            $total_bytes = $this->fieldTotalSizeBytes($settings);
            if (!empty($total_bytes)) {
                $existing_tokens = $this->parseTokensFromPost('existing_tokens');
                $existing_bytes  = $this->bytesForTokens($existing_tokens, $GLOBALS['wpdb'], $this->config->getTable('file'), (int)($_POST['form_id']??0), (int)($_POST['field_id']??0), (string)($_POST['draft_id']??''));
                $remaining = max(0, (int)$total_bytes - (int)$existing_bytes);
                $details[] = sprintf(__('Max total: %s', 'griffinforms-form-builder'), $this->hsize((int) $total_bytes));
                $details[] = sprintf(__('Remaining total upload capacity: %s', 'griffinforms-form-builder'), $this->hsize((int) $remaining));
            }
        }

        if ($hasWP && !empty($wp_max_bytes)) {
            $details[] = sprintf(__('Site limit: %s', 'griffinforms-form-builder'), $this->hsize((int) $wp_max_bytes));
        }

        if ($hasMaxF) {
            $max_files = $this->fieldMaxFiles($settings);
            if (!empty($max_files)) {
                $existing_tokens = $this->parseTokensFromPost('existing_tokens');
                $existing_count  = $this->countForTokens($existing_tokens, $GLOBALS['wpdb'], $this->config->getTable('file'), (int)($_POST['form_id']??0), (int)($_POST['field_id']??0), (string)($_POST['draft_id']??''));
                $remaining_slots = max(0, (int)$max_files - (int)$existing_count);
                $details[] = sprintf(__('Max files: %d', 'griffinforms-form-builder'), (int)$max_files);
                $details[] = sprintf(__('Remaining files you can attach: %d', 'griffinforms-form-builder'), (int)$remaining_slots);
            }
        }

        // Disallowed file type (only if relevant)
        if ($has_disallowed) {
            $details[] = sprintf(
                __('Allowed types: %s', 'griffinforms-form-builder'),
                esc_html($this->allowedListLabel($settings))
            );
        }
        $message  = __('None of the selected files could be uploaded', 'griffinforms-form-builder');
        if (!empty($details)) {
            $message .= "\n• " . implode("\n• ", $details);
        }
        return $message;
    }
}
