<?php

/**
 * Trait ThesisFileHandler
 *
 * Shared file-upload logic used by ThesisCreateController and
 * ThesisEditController.  All on-disk files are stored under:
 *
 *   theses/{YYYY}/{YYYY}_{AUTHORS}_{TITLE_SLUG}/
 *
 * Filenames follow:
 *
 *   {YYYY}_{AUTHORS}_{TITLE_SLUG}_{SEMANTIC}.ext          (single file)
 *   {YYYY}_{AUTHORS}_{TITLE_SLUG}_{SEMANTIC}_{XX}.ext     (numbered, 01-based)
 *
 * Semantic groups:
 *   COUVERTURE       – single cover image
 *   NOTE_INTENTION   – single PDF
 *   TFE_{XX}         – main thesis files (contiguous numbering)
 *   ANNEXE_{XX}      – annex files (separate numbering)
 *
 * TFE hierarchy (determines numbering order):
 *   1. PDFs
 *   2. Videos
 *   3. Audio
 *   4. Subtitles (VTT placed right after their video)
 *   5. Images
 *   6. Archives / other
 *
 * PeerTube and website URLs are stored as thesis_files rows with
 * file_path containing the URL; no on-disk file is created.
 *
 * The original user-provided filename is preserved in thesis_files.file_name.
 */
trait ThesisFileHandler
{
    /** @var string[] Warnings collected during file processing (e.g. invalid type, too large). */
    private array $fileWarnings = [];

    /** Maximum allowed file size for thesis files (bytes). */
    private const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB

    /** Maximum allowed file size for PDF files specifically (bytes). */
    private const MAX_PDF_SIZE = 100 * 1024 * 1024; // 100 MB

    /** Maximum allowed file size for video/audio files (bytes). */
    private const MAX_AV_SIZE = 2 * 1024 * 1024 * 1024; // 2 GB

    /** Cover image max size. */
    private const MAX_COVER_SIZE = 20 * 1024 * 1024; // 20 MB

    /** MIME types accepted for thesis files. */
    private const ALLOWED_MIME_TYPES = [
        'image/jpeg', 'image/png', 'image/gif', 'image/webp',
        'application/pdf',
        'video/mp4', 'video/webm', 'video/ogg', 'video/quicktime',
        'audio/mpeg', 'audio/mp3', 'audio/ogg', 'audio/wav',
        'audio/flac', 'audio/aac', 'audio/x-m4a', 'audio/mp4',
        'text/vtt',
        'application/zip', 'application/x-zip-compressed',
        'application/x-tar', 'application/gzip',
        'application/octet-stream',
    ];

    /** File extensions accepted for thesis files. */
    private const ALLOWED_EXTENSIONS = [
        'jpg', 'jpeg', 'png', 'gif', 'webp',
        'pdf',
        'mp4', 'webm', 'ogv', 'mov',
        'mp3', 'ogg', 'oga', 'wav', 'flac', 'aac', 'm4a',
        'vtt',
        'zip', 'tar', 'gz', 'tgz',
    ];

    // ── Public entry points (called by controllers) ──────────────────────────

    /**
     * Get warnings collected during file processing (invalid types, too large, etc.).
     * @return string[]
     */
    public function getFileWarnings(): array
    {
        return $this->fileWarnings;
    }

    /**
     * Process a cover image upload.
     *
     * @param int        $thesisId
     * @param array|null $upload     Single-file $_FILES entry.
     * @param string     $folderPath Relative path from STORAGE_ROOT to the thesis folder (e.g. "theses/2025/2025_SMITH_Mon_Titre/").
     * @param string     $filePrefix The prefix shared by all files in this folder (e.g. "2025_SMITH_Mon_Titre").
     */
    protected function handleCoverUpload(int $thesisId, ?array $upload, string $folderPath, string $filePrefix): void
    {
        if (!$upload || !isset($upload['error']) || $upload['error'] !== UPLOAD_ERR_OK) {
            return;
        }

        $finfo    = new \finfo(FILEINFO_MIME_TYPE);
        $mimeType = $finfo->file($upload['tmp_name']);
        $ext      = strtolower(pathinfo($upload['name'], PATHINFO_EXTENSION));

        $allowedMimes = ['image/jpeg', 'image/png', 'image/webp'];
        $allowedExts  = ['jpg', 'jpeg', 'png', 'webp'];

        if (!in_array($mimeType, $allowedMimes, true)
            || !in_array($ext, $allowedExts, true)
            || $upload['size'] > self::MAX_COVER_SIZE) {
            $this->fileWarnings[] = "Couverture « {$upload['name']} » ignorée : format ou taille non accepté.";
            error_log("ThesisFileHandler: invalid cover MIME $mimeType / $ext / {$upload['size']} bytes, skipping");
            return;
        }

        $dir = STORAGE_ROOT . '/' . $folderPath;
        if (!is_dir($dir)) {
            mkdir($dir, 0755, true);
        }

        $targetName = $filePrefix . '_COUVERTURE.' . $ext;
        $targetPath = $dir . $targetName;

        if (!move_uploaded_file($upload['tmp_name'], $targetPath)) {
            error_log("ThesisFileHandler: failed to move cover to $targetPath");
            return;
        }

        chmod($targetPath, 0644);
        $relPath = $folderPath . $targetName;

        $this->db->insertThesisFile(
            $thesisId, 'cover',
            $relPath,
            basename($upload['name']),
            $upload['size'],
            $mimeType
        );
        error_log("ThesisFileHandler: cover uploaded → $relPath");
    }

    /**
     * Process the note d'intention upload (single PDF).
     *
     * @param int        $thesisId
     * @param array|null $upload     Single-file $_FILES entry.
     * @param string     $folderPath Relative path from STORAGE_ROOT to the thesis folder.
     * @param string     $filePrefix The shared file prefix.
     */
    protected function handleNoteIntentionUpload(int $thesisId, ?array $upload, string $folderPath, string $filePrefix): void
    {
        if (!$upload || !isset($upload['error']) || $upload['error'] !== UPLOAD_ERR_OK) {
            return;
        }

        $finfo    = new \finfo(FILEINFO_MIME_TYPE);
        $mimeType = $finfo->file($upload['tmp_name']);
        $ext      = strtolower(pathinfo($upload['name'], PATHINFO_EXTENSION));

        if ($mimeType !== 'application/pdf' || $ext !== 'pdf') {
            $this->fileWarnings[] = "Note d'intention « {$upload['name']} » ignorée : seul le format PDF est accepté.";
            error_log("ThesisFileHandler: invalid note d'intention MIME $mimeType, skipping");
            return;
        }
        if ($upload['size'] > self::MAX_PDF_SIZE) {
            $this->fileWarnings[] = "Note d'intention « {$upload['name']} » ignorée : fichier trop volumineux (max 100 MB).";
            error_log("ThesisFileHandler: note d'intention too large ({$upload['size']} bytes), skipping");
            return;
        }

        $dir = STORAGE_ROOT . '/' . $folderPath;
        if (!is_dir($dir)) {
            mkdir($dir, 0755, true);
        }

        $targetName = $filePrefix . '_NOTE_INTENTION.pdf';
        $targetPath = $dir . $targetName;

        if (!move_uploaded_file($upload['tmp_name'], $targetPath)) {
            error_log("ThesisFileHandler: failed to move note d'intention to $targetPath");
            return;
        }

        chmod($targetPath, 0644);
        $relPath = $folderPath . $targetName;

        $this->db->insertThesisFile(
            $thesisId, 'note_intention',
            $relPath,
            basename($upload['name']),
            $upload['size'],
            'application/pdf'
        );
        error_log("ThesisFileHandler: note d'intention uploaded → $relPath");
    }

    /**
     * Process multiple TFE file uploads (files[] — the main set).
     *
     * Files are stored with TFE_{XX} semantics.  Numbering is contiguous
     * across all sub-types in order: PDF → video → audio → subtitles → images → archives.
     * Subtitles (VTT) are placed immediately after their associated video.
     *
     * @param int        $thesisId
     * @param array|null $uploads    Multi-file $_FILES entry.
     * @param string     $folderPath Relative path from STORAGE_ROOT to the thesis folder.
     * @param string     $filePrefix The shared file prefix.
     * @param array      $post       $_POST for per-file labels/orders.
     * @param int        $startNum   Starting number for TFE_XX (usually 01).
     */
    protected function handleTfeFiles(int $thesisId, ?array $uploads, string $folderPath, string $filePrefix, array $post = [], int $startNum = 1): int
    {
        if (!$uploads || !is_array($uploads['name'] ?? null)) {
            return $startNum;
        }

        $dir = STORAGE_ROOT . '/' . $folderPath;
        if (!is_dir($dir)) {
            mkdir($dir, 0755, true);
        }

        $fileLabels = $post['file_labels'] ?? [];
        $fileOrders = $post['file_orders'] ?? [];

        // Collect all files, classify, sort by hierarchy
        $files = [];
        $count = count($uploads['name']);
        for ($i = 0; $i < $count; $i++) {
            if (($uploads['error'][$i] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
                continue;
            }
            if (($uploads['error'][$i] ?? -1) !== UPLOAD_ERR_OK) {
                error_log("ThesisFileHandler: TFE upload error {$uploads['error'][$i]} for {$uploads['name'][$i]}");
                continue;
            }

            $finfo    = new \finfo(FILEINFO_MIME_TYPE);
            $mimeType = $finfo->file($uploads['tmp_name'][$i]);
            $ext      = strtolower(pathinfo($uploads['name'][$i], PATHINFO_EXTENSION));

            if ($mimeType === 'text/plain' && $ext === 'vtt') {
                $mimeType = 'text/vtt';
            }
            if ($mimeType === 'application/octet-stream' && !in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
                $this->fileWarnings[] = "Fichier TFE « {$uploads['name'][$i]} » ignoré : format .$ext non accepté.";
                error_log("ThesisFileHandler: TFE extension not allowed {$uploads['name'][$i]} ($ext), skipping");
                continue;
            }
            if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)
                && !in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
                $this->fileWarnings[] = "Fichier TFE « {$uploads['name'][$i]} » ignoré : type non accepté ($mimeType).";
                error_log("ThesisFileHandler: invalid TFE type {$uploads['name'][$i]} ($mimeType / $ext), skipping");
                continue;
            }

            $isPdf     = ($mimeType === 'application/pdf' || $ext === 'pdf');
            $isAv      = preg_match('/^(video|audio)\//', $mimeType) || in_array($ext, ['mp4','webm','ogv','mov','mp3','ogg','oga','wav','flac','aac','m4a']);
            $sizeLimit = $isPdf ? self::MAX_PDF_SIZE : ($isAv ? self::MAX_AV_SIZE : self::MAX_FILE_SIZE);
            if ($uploads['size'][$i] > $sizeLimit) {
                $limitMb = round($sizeLimit / 1024 / 1024);
                $sizeMb  = round($uploads['size'][$i] / 1024 / 1024);
                $this->fileWarnings[] = "Fichier TFE « {$uploads['name'][$i]} » ignoré : trop volumineux ($sizeMb MB, max $limitMb MB).";
                error_log("ThesisFileHandler: TFE file too large {$uploads['name'][$i]} (" . $sizeMb . ' MB), skipping');
                continue;
            }

            $files[] = [
                'index'      => $i,
                'mimeType'   => $mimeType,
                'ext'        => $ext,
                'size'       => $uploads['size'][$i],
                'tmpName'    => $uploads['tmp_name'][$i],
                'origName'   => $uploads['name'][$i],
                'label'      => trim($fileLabels[$i] ?? ''),
                'sortOrder'  => isset($fileOrders[$i]) ? (int)$fileOrders[$i] : null,
                'hierarchy'  => $this->tfeHierarchyRank($mimeType, $ext),
                'fileType'   => $this->detectFileType($mimeType, $ext),
            ];
        }

        // Sort by hierarchy rank
        usort($files, fn($a, $b) => $a['hierarchy'] - $b['hierarchy']);

        // Assign contiguous TFE_XX numbers
        $videoCount = 0;
        $vttQueue   = [];

        // First pass: count videos to know where to insert VTTs
        foreach ($files as $f) {
            if ($f['fileType'] === 'video') {
                $videoCount++;
            }
        }

        $num   = $startNum;
        $vttIdx = 0;

        foreach ($files as $f) {
            // VTT files are inserted right after their corresponding video
            if ($f['fileType'] === 'caption') {
                $vttQueue[] = $f;
                continue;
            }

            if ($f['fileType'] === 'video') {
                // Write the video file
                $this->writeTfeFile($f, $thesisId, $dir, $folderPath, $filePrefix, $num);
                $num++;

                // Write any waiting VTT for this video
                if (!empty($vttQueue)) {
                    $vtt = array_shift($vttQueue);
                    $this->writeTfeFile($vtt, $thesisId, $dir, $folderPath, $filePrefix, $num);
                    $num++;
                }
            } else {
                $this->writeTfeFile($f, $thesisId, $dir, $folderPath, $filePrefix, $num);
                $num++;
            }
        }

        // Any remaining VTTs (orphaned — write at end)
        foreach ($vttQueue as $vtt) {
            $this->writeTfeFile($vtt, $thesisId, $dir, $folderPath, $filePrefix, $num);
            $num++;
        }

        return $num;
    }

    /**
     * Extract a flat $_FILES-style sub-array from PHP's nested upload structure.
     *
     * PHP normalises FormData names like "queue_file[tfe][]" into:
     *   $_FILES['queue_file']['name']['tfe'] = [file1, file2, ...]
     *   $_FILES['queue_file']['tmp_name']['tfe'] = [/tmp/..., /tmp/...]
     * This helper extracts ['tfe'] → ['name' => [...], 'tmp_name' => [...], ...]
     */
    protected function extractFilesSubArray(array $parent, string $key): ?array
    {
        if (!isset($parent['name'][$key]) || !is_array($parent['name'][$key])) {
            return null;
        }
        $result = [];
        foreach (['name', 'tmp_name', 'error', 'size', 'type'] as $field) {
            $result[$field] = $parent[$field][$key] ?? [];
        }
        return $result;
    }

    /**
     * Process TFE file uploads from client-side JS queue (FormData).
     *
     * Files arrive via $_FILES['queue_file'] with PHP-nested key extraction.
     * They are written in the order the user specified (preserved in FormData order).
     *
     * @param int        $thesisId
     * @param array|null $uploads    Flat $_FILES-style array with 'name', 'tmp_name', etc.
     * @param string     $folderPath
     * @param string     $filePrefix
     * @param int        $startNum
     */
    protected function handleTfeQueueFiles(int $thesisId, ?array $uploads, string $folderPath, string $filePrefix, int $startNum = 1): int
    {
        if (!$uploads || !is_array($uploads['name'] ?? null)) {
            return $startNum;
        }

        $dir = STORAGE_ROOT . '/' . $folderPath;
        if (!is_dir($dir)) {
            mkdir($dir, 0755, true);
        }

        // Build entries, validate, and classify
        $files    = [];
        $vttQueue = [];
        $count    = count($uploads['name']);

        for ($i = 0; $i < $count; $i++) {
            if (($uploads['error'][$i] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
                continue;
            }

            $finfo    = new \finfo(FILEINFO_MIME_TYPE);
            $mimeType = $finfo->file($uploads['tmp_name'][$i]);
            $ext      = strtolower(pathinfo($uploads['name'][$i], PATHINFO_EXTENSION));

            if ($mimeType === 'text/plain' && $ext === 'vtt') {
                $mimeType = 'text/vtt';
            }
            if ($mimeType === 'application/octet-stream' && !in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
                $this->fileWarnings[] = "Fichier « {$uploads['name'][$i]} » ignoré : format .$ext non accepté.";
                error_log("ThesisFileHandler: queue file extension not allowed {$uploads['name'][$i]} ($ext), skipping");
                continue;
            }
            if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)
                && !in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
                $this->fileWarnings[] = "Fichier « {$uploads['name'][$i]} » ignoré : type non accepté ($mimeType).";
                error_log("ThesisFileHandler: invalid queue file type {$uploads['name'][$i]} ($mimeType / $ext), skipping");
                continue;
            }

            $isPdf     = ($mimeType === 'application/pdf' || $ext === 'pdf');
            $isAv      = preg_match('/^(video|audio)\//', $mimeType) || in_array($ext, ['mp4','webm','ogv','mov','mp3','ogg','oga','wav','flac','aac','m4a']);
            $sizeLimit = $isPdf ? self::MAX_PDF_SIZE : ($isAv ? self::MAX_AV_SIZE : self::MAX_FILE_SIZE);
            if ($uploads['size'][$i] > $sizeLimit) {
                $limitMb = round($sizeLimit / 1024 / 1024);
                $sizeMb  = round($uploads['size'][$i] / 1024 / 1024);
                $this->fileWarnings[] = "Fichier « {$uploads['name'][$i]} » ignoré : trop volumineux ($sizeMb MB, max $limitMb MB).";
                error_log("ThesisFileHandler: queue file too large {$uploads['name'][$i]} (" . $sizeMb . ' MB), skipping');
                continue;
            }

            $entry = [
                'mimeType'  => $mimeType,
                'ext'       => $ext,
                'size'      => $uploads['size'][$i],
                'tmpName'   => $uploads['tmp_name'][$i],
                'origName'  => $uploads['name'][$i],
                'label'     => '',
                'sortOrder' => null,
                'fileType'  => $this->detectFileType($mimeType, $ext),
            ];

            // VTTs are collected and paired with their preceding video
            if ($entry['fileType'] === 'caption') {
                $vttQueue[] = $entry;
            } else {
                $files[] = $entry;
            }
        }

        // Files are written in the order they arrived (user-specified via JS queue order).
        // VTTs are inserted immediately after the video they follow.
        $num        = $startNum;
        $videosSeen = 0;

        foreach ($files as $f) {
            $this->writeTfeFile($f, $thesisId, $dir, $folderPath, $filePrefix, $num);
            $num++;

            if ($f['fileType'] === 'video' && isset($vttQueue[$videosSeen])) {
                $this->writeTfeFile($vttQueue[$videosSeen], $thesisId, $dir, $folderPath, $filePrefix, $num);
                $num++;
                $videosSeen++;
            } elseif ($f['fileType'] === 'video') {
                $videosSeen++;
            }
        }

        // Orphaned VTTs (no preceding video in this batch)
        for ($i = $videosSeen; $i < count($vttQueue); $i++) {
            $this->writeTfeFile($vttQueue[$i], $thesisId, $dir, $folderPath, $filePrefix, $num);
            $num++;
        }

        return $num;
    }

    /**
     * Process annexe file uploads from client-side JS queue (FormData).
     *
     * Files arrive via $_FILES['queue_file']['annexe'].
     *
     * @param int        $thesisId
     * @param array|null $uploads    $_FILES['queue_file']['annexe']-style array
     * @param string     $folderPath
     * @param string     $filePrefix
     */
    protected function handleAnnexeQueueFiles(int $thesisId, ?array $uploads, string $folderPath, string $filePrefix): void
    {
        if (!$uploads || !is_array($uploads['name'] ?? null)) {
            return;
        }

        $dir = STORAGE_ROOT . '/' . $folderPath;
        if (!is_dir($dir)) {
            mkdir($dir, 0755, true);
        }

        $num   = 1;
        $count = count($uploads['name']);
        for ($i = 0; $i < $count; $i++) {
            if (($uploads['error'][$i] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
                continue;
            }

            $finfo    = new \finfo(FILEINFO_MIME_TYPE);
            $mimeType = $finfo->file($uploads['tmp_name'][$i]);
            $ext      = strtolower(pathinfo($uploads['name'][$i], PATHINFO_EXTENSION));

            if ($mimeType === 'text/plain' && $ext === 'vtt') {
                $mimeType = 'text/vtt';
            }
            if ($mimeType === 'application/octet-stream' && !in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
                $this->fileWarnings[] = "Annexe « {$uploads['name'][$i]} » ignorée : format .$ext non accepté.";
                error_log("ThesisFileHandler: queue annexe extension not allowed {$uploads['name'][$i]} ($ext), skipping");
                continue;
            }
            if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)
                && !in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
                $this->fileWarnings[] = "Annexe « {$uploads['name'][$i]} » ignorée : type non accepté ($mimeType).";
                error_log("ThesisFileHandler: invalid queue annexe type {$uploads['name'][$i]} ($mimeType / $ext), skipping");
                continue;
            }

            // Annexes: PDF max 100 MB, everything else max 500 MB
            $isPdf     = ($mimeType === 'application/pdf' || $ext === 'pdf');
            $sizeLimit = $isPdf ? self::MAX_PDF_SIZE : self::MAX_FILE_SIZE;
            if ($uploads['size'][$i] > $sizeLimit) {
                $limitMb = round($sizeLimit / 1024 / 1024);
                $sizeMb  = round($uploads['size'][$i] / 1024 / 1024);
                $this->fileWarnings[] = "Annexe « {$uploads['name'][$i]} » ignorée : trop volumineuse ($sizeMb MB, max $limitMb MB).";
                error_log("ThesisFileHandler: queue annexe too large {$uploads['name'][$i]} (" . $sizeMb . ' MB), skipping');
                continue;
            }

            $padded     = sprintf('%02d', $num);
            $targetName = $filePrefix . '_ANNEXE_' . $padded . '.' . $ext;
            $targetPath = $dir . $targetName;

            if (!move_uploaded_file($uploads['tmp_name'][$i], $targetPath)) {
                error_log("ThesisFileHandler: failed to move queue annexe {$uploads['name'][$i]}");
                continue;
            }

            chmod($targetPath, 0644);
            $relPath = $folderPath . $targetName;

            $this->db->insertThesisFile(
                $thesisId, 'annex',
                $relPath,
                basename($uploads['name'][$i]),
                $uploads['size'][$i],
                $mimeType,
                null,
                null
            );
            error_log("ThesisFileHandler: annexe (queue) moved → $targetName");
            $num++;
        }
    }

    /**
     * Process annex file uploads.
     *
     * @param int        $thesisId
     * @param array|null $uploads    Multi-file $_FILES entry.
     * @param string     $folderPath Relative path from STORAGE_ROOT to the thesis folder.
     * @param string     $filePrefix The shared file prefix.
     * @param array      $post       $_POST for per-file labels/orders.
     */
    protected function handleAnnexeFiles(int $thesisId, ?array $uploads, string $folderPath, string $filePrefix, array $post = []): void
    {
        if (!$uploads || !is_array($uploads['name'] ?? null)) {
            return;
        }

        $dir = STORAGE_ROOT . '/' . $folderPath;
        if (!is_dir($dir)) {
            mkdir($dir, 0755, true);
        }

        $fileLabels = $post['annexe_labels'] ?? [];
        $fileOrders = $post['annexe_orders'] ?? [];

        $num = 1;
        $count = count($uploads['name']);
        for ($i = 0; $i < $count; $i++) {
            if (($uploads['error'][$i] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
                continue;
            }
            if (($uploads['error'][$i] ?? -1) !== UPLOAD_ERR_OK) {
                error_log("ThesisFileHandler: annexe upload error {$uploads['error'][$i]} for {$uploads['name'][$i]}");
                continue;
            }

            $finfo    = new \finfo(FILEINFO_MIME_TYPE);
            $mimeType = $finfo->file($uploads['tmp_name'][$i]);
            $ext      = strtolower(pathinfo($uploads['name'][$i], PATHINFO_EXTENSION));

            if ($mimeType === 'text/plain' && $ext === 'vtt') {
                $mimeType = 'text/vtt';
            }
            if ($mimeType === 'application/octet-stream' && !in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
                $this->fileWarnings[] = "Annexe « {$uploads['name'][$i]} » ignorée : format .$ext non accepté.";
                error_log("ThesisFileHandler: annexe extension not allowed {$uploads['name'][$i]} ($ext), skipping");
                continue;
            }
            if (!in_array($mimeType, self::ALLOWED_MIME_TYPES, true)
                && !in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
                $this->fileWarnings[] = "Annexe « {$uploads['name'][$i]} » ignorée : type non accepté ($mimeType).";
                error_log("ThesisFileHandler: invalid annexe type {$uploads['name'][$i]} ($mimeType / $ext), skipping");
                continue;
            }

            $isPdf     = ($mimeType === 'application/pdf' || $ext === 'pdf');
            $isAv      = preg_match('/^(video|audio)\//', $mimeType) || in_array($ext, ['mp4','webm','ogv','mov','mp3','ogg','oga','wav','flac','aac','m4a']);
            $sizeLimit = $isPdf ? self::MAX_PDF_SIZE : ($isAv ? self::MAX_AV_SIZE : self::MAX_FILE_SIZE);
            if ($uploads['size'][$i] > $sizeLimit) {
                $limitMb = round($sizeLimit / 1024 / 1024);
                $sizeMb  = round($uploads['size'][$i] / 1024 / 1024);
                $this->fileWarnings[] = "Annexe « {$uploads['name'][$i]} » ignorée : trop volumineuse ($sizeMb MB, max $limitMb MB).";
                error_log("ThesisFileHandler: annexe too large {$uploads['name'][$i]} (" . $sizeMb . " MB), skipping");
                continue;
            }

            $padded     = sprintf('%02d', $num);
            $targetName = $filePrefix . '_ANNEXE_' . $padded . '.' . $ext;
            $targetPath = $dir . $targetName;

            if (!move_uploaded_file($uploads['tmp_name'][$i], $targetPath)) {
                error_log("ThesisFileHandler: failed to move annexe {$uploads['name'][$i]}");
                continue;
            }

            chmod($targetPath, 0644);
            $relPath   = $folderPath . $targetName;
            $label     = trim($fileLabels[$i] ?? '');
            $sortOrder = isset($fileOrders[$i]) ? (int)$fileOrders[$i] : null;
            $fileType  = ($ext === 'vtt' || $mimeType === 'text/vtt') ? 'caption' : 'annex';

            $this->db->insertThesisFile(
                $thesisId, $fileType,
                $relPath,
                basename($uploads['name'][$i]),
                $uploads['size'][$i],
                $mimeType,
                $label !== '' ? $label : null,
                $sortOrder
            );
            error_log("ThesisFileHandler: annexe uploaded → $targetName");
            $num++;
        }
    }

    // ── Helpers ──────────────────────────────────────────────────────────────

    /**
     * Write a single TFE file to disk and record in DB.
     */
    protected function writeTfeFile(array $f, int $thesisId, string $dir, string $folderPath, string $filePrefix, int $num): void
    {
        $padded     = sprintf('%02d', $num);
        $targetName = $filePrefix . '_TFE_' . $padded . '.' . $f['ext'];
        $targetPath = $dir . $targetName;

        if (!move_uploaded_file($f['tmpName'], $targetPath)) {
            error_log("ThesisFileHandler: failed to move TFE {$f['origName']}");
            return;
        }

        chmod($targetPath, 0644);
        $relPath = $folderPath . $targetName;

        $this->db->insertThesisFile(
            $thesisId, $f['fileType'],
            $relPath,
            basename($f['origName']),
            $f['size'],
            $f['mimeType'],
            $f['label'] !== '' ? $f['label'] : null,
            $f['sortOrder']
        );
        error_log("ThesisFileHandler: TFE uploaded → $targetName ({$f['fileType']})");
    }





    /**
     * Assign a hierarchy rank for sorting TFE files.
     * Lower = earlier in the sequence.
     *
     *  0 = PDF
     *  1 = video
     *  2 = audio
     *  3 = caption (VTT)
     *  4 = image
     *  5 = archive / other
     */
    private function tfeHierarchyRank(string $mimeType, string $ext): int
    {
        if ($mimeType === 'application/pdf' || $ext === 'pdf') {
            return 0;
        }
        if ($ext === 'vtt' || $mimeType === 'text/vtt') {
            return 3;
        }
        if (str_starts_with($mimeType, 'video/') || in_array($ext, ['mp4','webm','ogv','mov'], true)) {
            return 1;
        }
        if (str_starts_with($mimeType, 'audio/') || in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) {
            return 2;
        }
        if (str_starts_with($mimeType, 'image/') || in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) {
            return 4;
        }
        return 5;
    }

    /**
     * Determine the logical file_type from MIME type / extension.
     */
    protected function detectFileType(string $mimeType, string $ext): string
    {
        if ($ext === 'vtt' || $mimeType === 'text/vtt') {
            return 'caption';
        }
        if ($mimeType === 'application/pdf' || $ext === 'pdf') {
            return 'main';
        }
        if (str_starts_with($mimeType, 'video/') || in_array($ext, ['mp4','webm','ogv','mov'], true)) {
            return 'video';
        }
        if (str_starts_with($mimeType, 'audio/') || in_array($ext, ['mp3','ogg','oga','wav','flac','aac','m4a'], true)) {
            return 'audio';
        }
        if (str_starts_with($mimeType, 'image/') || in_array($ext, ['jpg','jpeg','png','gif','webp'], true)) {
            return 'image';
        }
        return 'other';
    }

    // ── String / filesystem helpers ──────────────────────────────────────────

    /**
     * Generate a filesystem-safe author slug from comma-separated author names.
     * Sorted alphabetically, joined with dash, accent-stripped, uppercase.
     */
    protected function generateAuthorSlug(string $authorNames): string
    {
        $names = array_values(array_filter(array_map('trim', explode(',', $authorNames)), fn($n) => $n !== ''));
        sort($names, SORT_NATURAL);
        $joined = implode('-', $names);

        $normalized = $joined;
        if (function_exists('iconv')) {
            $normalized = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized);
        }
        $accents = [
            'à' => 'a', 'â' => 'a', 'ä' => 'a',
            'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e',
            'î' => 'i', 'ï' => 'i',
            'ô' => 'o', 'ö' => 'o',
            'ù' => 'u', 'û' => 'u', 'ü' => 'u',
            'ç' => 'c',
            'À' => 'A', 'Â' => 'A', 'Ä' => 'A',
            'É' => 'E', 'È' => 'E', 'Ê' => 'E', 'Ë' => 'E',
            'Î' => 'I', 'Ï' => 'I',
            'Ô' => 'O', 'Ö' => 'O',
            'Ù' => 'U', 'Û' => 'U', 'Ü' => 'U',
            'Ç' => 'C',
        ];
        $normalized = strtr($normalized, $accents);
        $slug = preg_replace('/[^A-Za-z0-9]+/', '_', $normalized);
        $slug = trim($slug, '_');
        $slug = strtoupper($slug);
        if ($slug === '') {
            $slug = 'AUTHOR';
        }
        return $slug;
    }

    /**
     * Generate a filesystem-safe title slug (truncated to 60 chars).
     */
    protected function generateTitleSlug(string $title): string
    {
        $normalized = $title;
        if (function_exists('iconv')) {
            $normalized = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized);
        }
        $accents = [
            'à' => 'a', 'â' => 'a', 'ä' => 'a', 'æ' => 'ae',
            'é' => 'e', 'è' => 'e', 'ê' => 'e', 'ë' => 'e',
            'î' => 'i', 'ï' => 'i',
            'ô' => 'o', 'ö' => 'o', 'œ' => 'oe',
            'ù' => 'u', 'û' => 'u', 'ü' => 'u',
            'ç' => 'c',
            'À' => 'A', 'Â' => 'A', 'Ä' => 'A', 'Æ' => 'AE',
            'É' => 'E', 'È' => 'E', 'Ê' => 'E', 'Ë' => 'E',
            'Î' => 'I', 'Ï' => 'I',
            'Ô' => 'O', 'Ö' => 'O', 'Œ' => 'OE',
            'Ù' => 'U', 'Û' => 'U', 'Ü' => 'U',
            'Ç' => 'C',
            "'" => '', '"' => '', '«' => '', '»' => '',
        ];
        $normalized = strtr($normalized, $accents);
        $slug = preg_replace('/[^A-Za-z0-9]+/', '_', $normalized);
        $slug = trim($slug, '_');
        $slug = strtoupper($slug);
        if (strlen($slug) > 60) {
            $slug = substr($slug, 0, 60);
        }
        if ($slug === '') {
            $slug = 'TFE';
        }
        return $slug;
    }

    /**
     * Build the folder path and file prefix for a thesis.
     *
     * Folder:  theses/{YYYY}/{YYYY}_{AUTHORS}_{TITLE_SLUG}/
     * Prefix:  {YYYY}_{AUTHORS}_{TITLE_SLUG}
     *
     * @return array{folderPath: string, filePrefix: string, folderName: string}
     */
    protected function buildThesisFolder(int $year, string $authorsStr, string $title): array
    {
        $authorSlug = $this->generateAuthorSlug($authorsStr);
        $titleSlug  = $this->generateTitleSlug($title);

        $folderName = $year . '_' . $authorSlug . '_' . $titleSlug;
        $filePrefix = $folderName;
        $folderPath = 'theses/' . $year . '/' . $folderName . '/';

        return [
            'folderPath' => $folderPath,
            'filePrefix' => $filePrefix,
            'folderName' => $folderName,
        ];
    }

    /**
     * Ensure the thesis folder exists (create if needed).
     * If the folder already exists, append a numeric suffix.
     */
    protected function ensureUniqueFolder(string $folderPath): string
    {
        $baseDir = STORAGE_ROOT . '/' . dirname($folderPath) . '/';
        $candidate = basename(rtrim($folderPath, '/'));

        if (!is_dir($baseDir . $candidate)) {
            mkdir($baseDir . $candidate, 0755, true);
            return $candidate;
        }

        $suffix = 1;
        while (is_dir($baseDir . $candidate . '_' . $suffix)) {
            $suffix++;
        }
        $candidate .= '_' . $suffix;
        mkdir($baseDir . $candidate, 0755, true);
        return $candidate;
    }
}
