<?php

declare(strict_types=1);

class AdminVideoController
{
    public static function routes(): void
    {
        Router::get('/admin/videos', [self::class, 'index']);
        Router::get('/admin/videos/create', [self::class, 'create']);
        Router::post('/admin/videos/store', [self::class, 'store']);
        Router::get('/admin/videos/bulk-upload', [self::class, 'bulkUpload']);
        Router::post('/admin/videos/bulk-upload/store', [self::class, 'bulkUploadStore']);
        Router::get('/admin/videos/{id}/edit', [self::class, 'edit']);
        Router::post('/admin/videos/{id}/update', [self::class, 'update']);
        Router::post('/admin/videos/{id}/delete', [self::class, 'destroy']);
        Router::post('/admin/videos/bulk-delete', [self::class, 'bulkDelete']);
        Router::post('/admin/videos/bulk-delete-start', [self::class, 'bulkDeleteStart']);
        Router::post('/admin/videos/bulk-delete-chunk', [self::class, 'bulkDeleteChunk']);
        Router::get('/admin/videos/bulk-delete-status', [self::class, 'bulkDeleteStatus']);
        Router::post('/admin/videos/bulk-delete-cancel', [self::class, 'bulkDeleteCancel']);
        Router::post('/admin/videos/{id}/translations', [self::class, 'saveTranslations']);
        EntityTranslateAdmin::routes('video', 'videos', self::class);
    }

    public static function aiTranslateInit(): void   { EntityTranslateAdmin::init('video'); }
    public static function aiTranslateStart(): void  { EntityTranslateAdmin::start('video'); }
    public static function aiTranslateStatus(): void { EntityTranslateAdmin::status('video'); }
    public static function aiTranslateCancel(): void { EntityTranslateAdmin::cancel('video'); }
    public static function aiTranslatePause(): void  { EntityTranslateAdmin::pause('video'); }
    public static function aiTranslateResume(): void { EntityTranslateAdmin::resume('video'); }
    public static function aiTranslateRetry(): void  { EntityTranslateAdmin::retry('video'); }
    public static function aiTranslateAuto(): void   { EntityTranslateAdmin::auto('video'); }

    public static function index(): void
    {
        Auth::requireAdmin();
        $search = $_GET['q'] ?? '';
        $status = $_GET['status'] ?? '';
        $categoryId = !empty($_GET['category']) ? (int) $_GET['category'] : 0;
        $performerId = !empty($_GET['performer']) ? (int) $_GET['performer'] : 0;
        $hasThumbnail = $_GET['thumbnail'] ?? '';
        $sort = $_GET['sort'] ?? 'newest';

         
        $typeRows = Database::fetchAll('SELECT video_type, COUNT(*) as cnt FROM videos GROUP BY video_type ORDER BY ' . Video::typeFieldOrder());
        $availableTypes = array_column($typeRows, 'video_type');
        $showTypeFilter = count($availableTypes) > 1;
        $typeFilter = '';
        if ($showTypeFilter) {
            $typeFilter = $_GET['type'] ?? '';
            if ($typeFilter !== '' && !in_array($typeFilter, Video::VALID_TYPES, true)) {
                $typeFilter = '';
            }
        }

        $filters = ['admin' => true];
        if ($search !== '') $filters['search'] = $search;
        if ($status !== '') $filters['status'] = $status;
        if ($categoryId > 0) $filters['category_id'] = $categoryId;
        if ($performerId > 0) $filters['performer_id'] = $performerId;
        if ($hasThumbnail !== '') $filters['has_thumbnail'] = $hasThumbnail;
        if ($sort !== 'newest') $filters['sort'] = $sort;
        if ($typeFilter !== '') $filters['video_type'] = $typeFilter;

        $total = Video::count($filters);
        $pagination = Pagination::fromRequest($total, 100);
        $videos = Video::all($filters, $pagination->perPage, $pagination->offset);

         
        $videoTranslations = [];
        $videoIds = array_column($videos, 'id');
        if (!empty($videoIds)) {
            $placeholders = implode(',', array_fill(0, count($videoIds), '?'));
            $tRows = Database::fetchAll(
                "SELECT video_id, lang_code, title FROM video_translations WHERE video_id IN ({$placeholders}) ORDER BY video_id, lang_code",
                $videoIds
            );
            foreach ($tRows as $tr) {
                $videoTranslations[(int)$tr['video_id']][$tr['lang_code']] = $tr['title'];
            }
        }

        Router::render('admin/videos', [
            'pageTitle' => 'Videos',
            'headerExtra' => '<a href="/admin/videos/bulk-upload" class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 border border-gray-300 rounded-lg transition-colors no-underline"><svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 16.5V9.75m0 0l3 3m-3-3l-3 3M6.75 19.5a4.5 4.5 0 01-1.41-8.775 5.25 5.25 0 0110.233-2.33 3 3 0 013.758 3.848A3.752 3.752 0 0118 19.5H6.75z"/></svg> Bulk Upload</a> ' . EntityTranslateAdmin::headerButtons('video'),
            'activeMenu' => 'videos',
            'contentTemplate' => '_videos',
            'headerAction' => ['url' => '/admin/videos/create', 'label' => 'New Video'],
            'videos' => $videos,
            'pagination' => $pagination,
            'search' => $search,
            'filterCategories' => Database::fetchAll('SELECT id, name FROM categories ORDER BY name ASC'),
            'filterPerformers' => Database::fetchAll('SELECT id, name FROM performers ORDER BY name ASC'),
            'currentStatus' => $status,
            'currentCategory' => $categoryId,
            'currentPerformer' => $performerId,
            'currentThumbnail' => $hasThumbnail,
            'currentSort' => $sort,
            'showTypeFilter' => $showTypeFilter,
            'availableTypes' => $availableTypes,
            'currentType' => $typeFilter,
            'videoTranslations' => $videoTranslations,
        ]);
    }

    public static function uploadLimitBytes(): int
    {
        $toBytes = static function (string $v): int {
            $v = trim($v);
            if ($v === '') {
                return 0;
            }
            $n = (int) $v;
            switch (strtolower(substr($v, -1))) {
                case 'g':
                    return $n * 1024 * 1024 * 1024;
                case 'm':
                    return $n * 1024 * 1024;
                case 'k':
                    return $n * 1024;
            }
            return $n;
        };
        $limits = [];
        foreach (['post_max_size', 'upload_max_filesize'] as $key) {
            $bytes = $toBytes((string) ini_get($key));
            if ($bytes > 0) {
                $limits[] = $bytes;
            }
        }
        return $limits === [] ? 0 : (int) min($limits);
    }

    public static function formatBytes(int $bytes): string
    {
        $units = ['B', 'KB', 'MB', 'GB', 'TB'];
        $value = (float) $bytes;
        $i = 0;
        while ($value >= 1024 && $i < count($units) - 1) {
            $value /= 1024;
            $i++;
        }
        $rounded = ($i === 0 || $value >= 10) ? (string) round($value) : (string) round($value, 1);
        return $rounded . ' ' . $units[$i];
    }

    private static function wantsJsonResponse(): bool
    {
        $requestedWith = $_SERVER['HTTP_X_REQUESTED_WITH'] ?? '';
        return is_string($requestedWith) && strcasecmp($requestedWith, 'XMLHttpRequest') === 0;
    }

    private static function respondToVideoForm(bool $ok, string $message, string $redirectTo): void
    {
        if (self::wantsJsonResponse()) {
            if ($ok) {
                $_SESSION['flash'] = ['type' => 'success', 'message' => $message];
            }
            if (!headers_sent()) {
                http_response_code($ok ? 200 : 422);
                header('Content-Type: application/json; charset=utf-8');
            }
            echo json_encode(['success' => $ok, 'message' => $message, 'redirect' => $redirectTo]);
            exit;
        }
        $_SESSION['flash'] = ['type' => $ok ? 'success' : 'error', 'message' => $message];
        Router::redirect($redirectTo);
    }

    private static function rejectOversizedPost(string $redirectTo): void
    {
        if (!empty($_POST) || !empty($_FILES)) {
            return;
        }
        $limit = self::uploadLimitBytes();
        $sent = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0);
        if ($limit <= 0 || $sent <= $limit) {
            return;
        }
        self::respondToVideoForm(
            false,
            'The upload was refused because it exceeds what this server accepts (' . self::formatBytes($limit) . ' maximum, ' . self::formatBytes($sent) . ' sent). Raise post_max_size and upload_max_filesize in PHP and the body size limit in your web server, then try again.',
            $redirectTo
        );
    }

    public static function create(): void
    {
        Auth::requireAdmin();
        Router::render('admin/video_form', [
            'pageTitle' => 'New Video',
            'activeMenu' => 'videos',
            'contentTemplate' => '_video_form',
            'video' => null,
            'categories' => Category::all(),
            'tags' => Tag::all(),
            'performers' => Performer::all('', 1000, 0),
            'channels' => Channel::all('', 1000, 0),
            'selectedCategories' => [],
            'selectedTags' => [],
            'selectedPerformers' => [],
            'selectedChannels' => [],
            'error' => null,
        ]);
    }

    public static function store(): void
    {
        Auth::requireAdmin();
        self::rejectOversizedPost('/admin/videos/create');
        if (!Router::verifyCsrf()) {
            self::respondToVideoForm(false, 'Your session has expired. Please refresh the page and try again.', '/admin/videos/create');
        }

        $data = [
            'title' => trim($_POST['title'] ?? ''),
            'slug' => Slug::fromInput('videos', $_POST['slug'] ?? '', $_POST['title'] ?? ''),
            'description' => trim($_POST['description'] ?? ''),
            'embed_code' => trim($_POST['embed_code'] ?? ''),
            'duration' => (int) ($_POST['duration'] ?? 0),
            'video_type' => in_array($_POST['video_type'] ?? '', Video::VALID_TYPES, true) ? $_POST['video_type'] : Video::DEFAULT_TYPE,
            'status' => in_array($_POST['status'] ?? '', ['draft', 'published', 'private'], true) ? $_POST['status'] : 'draft',
            'views' => max(0, (int) ($_POST['views'] ?? 0)),
            'likes' => max(0, (int) ($_POST['likes'] ?? 0)),
            'dislikes' => max(0, (int) ($_POST['dislikes'] ?? 0)),
            'user_id' => Auth::id(),
        ];

        if ($data['status'] === 'published') {
            $data['published_at'] = date('Y-m-d H:i:s');
        }

        if ($data['embed_code'] !== '') {
            $data['embed_code'] = Sanitizer::embedCode($data['embed_code']);
        }

        $hasVideoFile = !empty($_FILES['video_file']['name']) && $_FILES['video_file']['error'] === UPLOAD_ERR_OK;
        $hasThumbnail = !empty($_FILES['thumbnail']['name']) && $_FILES['thumbnail']['error'] === UPLOAD_ERR_OK;

        if (($hasVideoFile || $hasThumbnail) && MediaStorage::isAllFull()) {
            self::respondToVideoForm(false, 'All storage servers are full. Add more storage space before uploading.', '/admin/videos/create');
            return;
        }

        if ($data['embed_code'] !== '' && $hasVideoFile) {
            $hasVideoFile = false;
        } elseif ($hasVideoFile) {
            $data['embed_code'] = '';
        }

        $created = self::createVideo(
            $data,
            self::relationsFromPost(),
            $hasVideoFile ? $_FILES['video_file'] : null,
            $hasThumbnail ? $_FILES['thumbnail'] : null
        );

        self::respondToVideoForm(true, 'Video created successfully.' . ($created['queued'] > 0 ? ' Transcoding queued.' : ''), '/admin/videos');
    }

    private static function createVideo(array $data, array $relations, ?array $videoFile, ?array $thumbFile): array
    {
        $hasVideoFile = $videoFile !== null;
        $hasThumbnail = $thumbFile !== null;

        $folder = '';
        if ($hasVideoFile || $hasThumbnail) {
            $folder = VideoAsset::createFolder();
            $data['video_folder'] = $folder;
            $defaultStorageId = MediaStorage::activeDefaultId();
            if ($defaultStorageId > 0) $data['storage_id'] = $defaultStorageId;
        }

        $sid = isset($data['storage_id']) ? (int) $data['storage_id'] : null;

        $videoUploadOk = true;
        if ($hasVideoFile) {
            $mime = Mime::fromFile($videoFile['tmp_name'], $videoFile['name'] ?? null, 'video/mp4');
            $videoUploadOk = VideoAsset::saveVideo($folder, $videoFile['tmp_name'], $mime, $sid);
        }

        if ($hasThumbnail) {
            VideoAsset::saveThumb($folder, $thumbFile['tmp_name'], 'thumb', $sid);
        }

        $videoId = Video::create($data);

        if (!empty($relations['categories'])) {
            Video::syncCategories($videoId, $relations['categories']);
        }
        if (!empty($relations['tags'])) {
            Video::syncTags($videoId, $relations['tags']);
        }
        if (!empty($relations['performers'])) {
            Video::syncPerformers($videoId, $relations['performers']);
        }
        if (!empty($relations['channels'])) {
            Video::syncChannels($videoId, $relations['channels']);
        }

        HookSystem::doAction('video.created', $videoId);

        $queued = 0;
        if ($hasVideoFile && TranscodeManager::isEnabled()) {
            $queued = TranscodeManager::queue($videoId);
            if ($queued > 0 && TranscodeManager::needsSourceReencode()) {
                // The video is not watchable until the re-encode lands, so it carries no publication
                // date yet: TranscodeManager stamps it when the conversion completes and it goes live.
                Database::query("UPDATE videos SET status = 'processing', published_at = NULL WHERE id = ? AND status = 'published'", [$videoId]);
            }
        }

        if ($hasVideoFile && $sid && MediaStorage::isRemote($sid) && !$videoUploadOk) {
            Database::query("UPDATE videos SET status = 'error' WHERE id = ?", [$videoId]);
        } elseif ($hasVideoFile && $sid && MediaStorage::isRemote($sid) && $queued === 0) {
            VideoAsset::purgeLocalFolder($folder);
        }

        SitemapController::markDirty();

        return ['id' => $videoId, 'queued' => $queued, 'upload_ok' => $videoUploadOk];
    }

    private static function relationsFromPost(): array
    {
        $out = [];
        foreach (['categories', 'tags', 'performers', 'channels'] as $key) {
            $out[$key] = is_array($_POST[$key] ?? null) ? $_POST[$key] : [];
        }
        return $out;
    }

    public static function bulkUpload(): void
    {
        Auth::requireAdmin();
        Router::render('admin/video_form', [
            'pageTitle' => 'Bulk Upload',
            'activeMenu' => 'videos',
            'contentTemplate' => '_video_bulk_upload',
            'categories' => Category::all(),
            'tags' => Tag::all(),
            'performers' => Performer::all('', 1000, 0),
            'channels' => Channel::all('', 1000, 0),
        ]);
    }

    public static function bulkUploadStore(): void
    {
        Auth::requireAdmin();
        self::rejectOversizedPost('/admin/videos/bulk-upload');
        if (!Router::verifyCsrf()) {
            Router::json(['success' => false, 'message' => 'Your session has expired. Reload the page and start the upload again.'], 403);
        }

        $file = $_FILES['video_file'] ?? null;
        $err = is_array($file) ? (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE) : UPLOAD_ERR_NO_FILE;
        if ($err !== UPLOAD_ERR_OK || empty($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
            Router::json(['success' => false, 'message' => self::uploadErrorMessage($err)], 422);
        }
        if ((int) ($file['size'] ?? 0) <= 0) {
            Router::json(['success' => false, 'message' => 'The file is empty (0 bytes).'], 422);
        }
        if (MediaStorage::isAllFull()) {
            Router::json(['success' => false, 'message' => 'All storage servers are full. Add more storage space before uploading.'], 422);
        }
        $mime = Mime::fromFile($file['tmp_name'], $file['name'] ?? null, 'video/mp4');
        if (!str_starts_with($mime, 'video/')) {
            Router::json(['success' => false, 'message' => 'This file is not a video (' . $mime . ').'], 422);
        }

        $title = mb_substr(trim((string) ($_POST['title'] ?? '')), 0, 255);
        if ($title === '') {
            $title = self::titleFromFilename((string) ($file['name'] ?? ''));
        }
        $data = [
            'title' => $title,
            'slug' => Slug::fromInput('videos', '', $title),
            'description' => trim((string) ($_POST['description'] ?? '')),
            'embed_code' => '',
            'duration' => max(0, (int) ($_POST['duration'] ?? 0)),
            'video_type' => in_array($_POST['video_type'] ?? '', Video::VALID_TYPES, true) ? $_POST['video_type'] : Video::DEFAULT_TYPE,
            'status' => in_array($_POST['status'] ?? '', ['draft', 'published', 'private'], true) ? $_POST['status'] : 'draft',
            'views' => 0,
            'likes' => 0,
            'dislikes' => 0,
            'user_id' => Auth::id(),
        ];
        if ($data['status'] === 'published') {
            $data['published_at'] = date('Y-m-d H:i:s');
        }

        $thumb = null;
        if (!empty($_FILES['thumbnail']['tmp_name'])
            && (int) ($_FILES['thumbnail']['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_OK
            && str_starts_with(Mime::fromFile($_FILES['thumbnail']['tmp_name'], $_FILES['thumbnail']['name'] ?? null), 'image/')) {
            $thumb = $_FILES['thumbnail'];
        }

        $created = self::createVideo($data, self::relationsFromPost(), $file, $thumb);
        $status = (string) (Database::fetchOne('SELECT status FROM videos WHERE id = ?', [$created['id']])['status'] ?? $data['status']);

        Router::json([
            'success' => true,
            'id' => $created['id'],
            'title' => $title,
            'status' => $status,
            'queued' => $created['queued'],
            'edit_url' => '/admin/videos/' . $created['id'] . '/edit',
            'message' => 'Video created.' . ($created['queued'] > 0 ? ' Transcoding queued.' : ''),
        ]);
    }

    private static function uploadErrorMessage(int $err): string
    {
        $limit = self::uploadLimitBytes();
        $limitLabel = $limit > 0 ? ' (' . self::formatBytes($limit) . ' maximum)' : '';
        return match ($err) {
            UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'The file is larger than this server accepts' . $limitLabel . '. Raise upload_max_filesize and post_max_size in PHP and the body size limit in your web server.',
            UPLOAD_ERR_PARTIAL => 'The upload stopped before the whole file arrived. Try again.',
            UPLOAD_ERR_NO_FILE => 'No video file was received.',
            UPLOAD_ERR_NO_TMP_DIR, UPLOAD_ERR_CANT_WRITE => 'The server could not store the upload: its temporary folder is missing or not writable.',
            UPLOAD_ERR_EXTENSION => 'A PHP extension blocked this upload.',
            default => 'The upload failed (error ' . $err . ').',
        };
    }

    public static function titleFromFilename(string $name): string
    {
        $base = pathinfo(trim($name), PATHINFO_FILENAME);
        $title = (string) preg_replace('/[_.\-]+/u', ' ', (string) $base);
        $title = trim((string) preg_replace('/\s+/u', ' ', $title));
        if ($title === '') {
            return 'Untitled video';
        }
        return mb_substr(mb_strtoupper(mb_substr($title, 0, 1)) . mb_substr($title, 1), 0, 255);
    }

    public static function edit(): void
    {
        Auth::requireAdmin();
        $id = (int) Router::param('id');
        $video = Video::find($id);
        if (!$video) {
            $_SESSION['flash'] = ['type' => 'error', 'message' => 'Video not found.'];
            Router::redirect('/admin/videos');
        }

         
        $translations = Database::fetchAll(
            'SELECT lang_code, title, description FROM video_translations WHERE video_id = ?',
            [$id]
        );
        $translationMap = [];
        foreach ($translations as $tr) {
            $translationMap[$tr['lang_code']] = $tr;
        }

         
        $languages = Translator::available();
        $defaultLang = Translator::getDefaultCode();

        Router::render('admin/video_form', [
            'pageTitle' => 'Edit Video',
            'activeMenu' => 'videos',
            'contentTemplate' => '_video_form',
            'video' => $video,
            'categories' => Category::all(),
            'tags' => Tag::all(),
            'performers' => Performer::all('', 1000, 0),
            'channels' => Channel::all('', 1000, 0),
            'selectedCategories' => array_column($video['categories'], 'id'),
            'selectedTags' => array_column($video['tags'], 'id'),
            'selectedPerformers' => array_column($video['performers'], 'id'),
            'selectedChannels' => array_column($video['channels'] ?? [], 'id'),
            'videoTranslations' => $translationMap,
            'videoLanguages' => $languages,
            'defaultLang' => $defaultLang,
            'error' => null,
        ]);
    }

    public static function update(): void
    {
        Auth::requireAdmin();
        self::rejectOversizedPost('/admin/videos');
        if (!Router::verifyCsrf()) {
            self::respondToVideoForm(false, 'Your session has expired. Please refresh the page and try again.', '/admin/videos');
        }

        $id = (int) Router::param('id');
        $video = Video::find($id);
        if (!$video) {
            self::respondToVideoForm(false, 'Video not found.', '/admin/videos');
        }

        $data = [
            'title' => trim($_POST['title'] ?? ''),
            'slug' => Slug::fromInput('videos', $_POST['slug'] ?? '', $_POST['title'] ?? '', $id),
            'description' => trim($_POST['description'] ?? ''),
            'embed_code' => trim($_POST['embed_code'] ?? ''),
            'duration' => (int) ($_POST['duration'] ?? 0),
            'video_type' => in_array($_POST['video_type'] ?? '', Video::VALID_TYPES, true) ? $_POST['video_type'] : Video::DEFAULT_TYPE,
            'status' => in_array($_POST['status'] ?? '', ['draft', 'published', 'private'], true) ? $_POST['status'] : 'draft',
            'views' => max(0, (int) ($_POST['views'] ?? $video['views'])),
            'likes' => max(0, (int) ($_POST['likes'] ?? $video['likes'])),
            'dislikes' => max(0, (int) ($_POST['dislikes'] ?? $video['dislikes'])),
        ];

        if ($data['status'] === 'published' && empty($video['published_at'])) {
            $data['published_at'] = date('Y-m-d H:i:s');
        }

        if ($data['embed_code'] !== '') {
            $data['embed_code'] = Sanitizer::embedCode($data['embed_code']);
        }

        $hasVideoFile = !empty($_FILES['video_file']['name']) && $_FILES['video_file']['error'] === UPLOAD_ERR_OK;
        $hasThumbnail = !empty($_FILES['thumbnail']['name']) && $_FILES['thumbnail']['error'] === UPLOAD_ERR_OK;

         
        if ($data['embed_code'] !== '' && $hasVideoFile) {
            $hasVideoFile = false;
        } elseif ($hasVideoFile) {
            $data['embed_code'] = '';
        }

        $folder = $video['video_folder'] ?? '';
        if (($hasVideoFile || $hasThumbnail) && $folder === '') {
            $folder = VideoAsset::createFolder();
            $data['video_folder'] = $folder;
            $defaultStorageId = MediaStorage::activeDefaultId();
            if ($defaultStorageId > 0 && empty($video['storage_id'])) {
                $data['storage_id'] = $defaultStorageId;
            }
        }

        $sid = isset($video['storage_id']) ? (int)$video['storage_id'] : null;

        $videoUploadOk = true;
        if ($hasVideoFile) {
            $mime = Mime::fromFile($_FILES['video_file']['tmp_name'], $_FILES['video_file']['name'] ?? null, 'video/mp4');
            $videoUploadOk = VideoAsset::saveVideo($folder, $_FILES['video_file']['tmp_name'], $mime, $sid);
        }

        if ($hasThumbnail) {
            VideoAsset::saveThumb($folder, $_FILES['thumbnail']['tmp_name'], 'thumb', $sid);
        }

        Video::update($id, $data);

        Video::syncCategories($id, $_POST['categories'] ?? []);
        Video::syncTags($id, $_POST['tags'] ?? []);
        Video::syncPerformers($id, $_POST['performers'] ?? []);
        Video::syncChannels($id, $_POST['channels'] ?? []);

        if ($hasVideoFile && TranscodeManager::isEnabled()) {
            Database::delete('transcode_jobs', 'video_id = ?', [$id]);
            $queued = TranscodeManager::queue($id);
            if ($queued > 0 && TranscodeManager::needsSourceReencode()) {
                // Same rule as on create — but a video that was already live keeps the date it went
                // live with; only one that has never been published waits for the conversion.
                Database::query(
                    empty($video['published_at'])
                        ? "UPDATE videos SET status = 'processing', published_at = NULL WHERE id = ? AND status = 'published'"
                        : "UPDATE videos SET status = 'processing' WHERE id = ? AND status = 'published'",
                    [$id]
                );
            }
        }

         
         
        if ($hasVideoFile && $sid && MediaStorage::isRemote($sid) && !$videoUploadOk) {
            Database::query("UPDATE videos SET status = 'error' WHERE id = ?", [$id]);
        } elseif ($hasVideoFile && $sid && MediaStorage::isRemote($sid) && empty($queued)) {
             
            VideoAsset::purgeLocalFolder($folder);
        }

        SitemapController::markDirty();
        self::respondToVideoForm(true, 'Video updated successfully.' . (isset($queued) && $queued > 0 ? ' Transcoding queued.' : ''), '/admin/videos');
    }

    public static function destroy(): void
    {
        Auth::requireAdmin();
        if (!Router::verifyCsrf()) {
            Router::redirect('/admin/videos');
        }

        $id = (int) Router::param('id');
        Video::delete($id);

        $_SESSION['flash'] = ['type' => 'success', 'message' => 'Video deleted successfully.'];
        SitemapController::markDirty();
        Router::redirect('/admin/videos');
    }

    public static function bulkDelete(): void
    {
        Auth::requireAdmin();
        if (!Router::verifyCsrf()) {
            http_response_code(403);
            echo json_encode(['error' => 'Invalid CSRF token']);
            return;
        }
        $input = json_decode(file_get_contents('php://input'), true);
        $ids = array_values(array_filter(array_map('intval', $input['ids'] ?? []), fn($id) => $id > 0));

        // Batched, and capped: this endpoint answers a browser request, so it must not run until the
        // request times out. Anything larger belongs to the background job (bulk-delete-start).
        $deleted = 0;
        foreach (array_chunk($ids, 50) as $slice) {
            $res = Video::deleteMany($slice);
            $deleted += (int) $res['deleted'];
            if ($res['assets']) self::purgeAssets($res['assets']);
        }
        $_SESSION['flash'] = ['type' => 'success', 'message' => $deleted . ' video(s) deleted.'];
        echo json_encode(['deleted' => $deleted]);
    }

    

    public static function bulkDeleteStart(): void
    {
        Auth::requireAdmin();
        if (!Router::verifyCsrf()) {
            Router::json(['error' => 'Invalid CSRF token'], 403);
            return;
        }
        $input = json_decode(file_get_contents('php://input'), true) ?: [];
        $ids = array_filter(array_map('intval', $input['ids'] ?? []), fn($id) => $id > 0);
        $filters = $input['filters'] ?? null;
        $selectAll = !empty($input['select_all']);

        if ($selectAll && is_array($filters)) {
             
            $adminFilters = array_merge($filters, ['admin' => true]);
            $total = Video::count($adminFilters);
            if ($total === 0) {
                Router::json(['error' => 'No videos match the current filters.'], 400);
                return;
            }
            $jobId = Database::insert('bulk_delete_jobs', [
                'total_count' => $total,
                'deleted_count' => 0,
                'status' => 'pending',
                'filters' => json_encode($adminFilters),
            ]);
        } elseif (!empty($ids)) {
            $jobId = Database::insert('bulk_delete_jobs', [
                'total_count' => count($ids),
                'deleted_count' => 0,
                'status' => 'pending',
                'filters' => json_encode(['ids' => $ids]),
            ]);
        } else {
            Router::json(['error' => 'No videos selected.'], 400);
            return;
        }

         
        self::spawnDeleteWorker();

        Router::json(['job_id' => $jobId]);
    }

    

    /** A chunk aims for this wall-clock duration, whatever the server is worth. */
    private const CHUNK_TARGET_SECONDS = 4.0;
    private const CHUNK_MIN = 5;
    private const CHUNK_MAX = 500;
    /** Give up on a job that makes no progress this many chunks in a row. */
    private const MAX_STALLED_CHUNKS = 3;

    /**
     * Starting batch size. A CLI worker has no time limit and can open big; a chunk driven by a
     * browser request must fit inside max_execution_time with room to spare.
     */
    private static function initialChunkSize(): int
    {
        $limit = (int) ini_get('max_execution_time');
        if (PHP_SAPI === 'cli' || $limit === 0) return 50;
        return $limit >= 60 ? 40 : 15;
    }

    /**
     * Resize the next batch from what the last one actually cost. This is what makes the same code
     * work on a small shared host and on a dedicated server: the batch converges on whatever the
     * machine can do in CHUNK_TARGET_SECONDS, instead of a hardcoded 50 that is far too slow for one
     * and far too heavy for the other. Movement is capped at a factor of two per step so a single
     * slow batch (a cold cache, a busy disk) cannot send it wild.
     */
    private static function adaptChunkSize(int $current, float $elapsed, int $processed): int
    {
        if ($processed <= 0 || $elapsed <= 0.05) return $current;
        $perItem = $elapsed / $processed;
        $target  = (int) round(self::CHUNK_TARGET_SECONDS / $perItem);
        $target  = max((int) floor($current / 2), min($target, $current * 2));
        return max(self::CHUNK_MIN, min(self::CHUNK_MAX, $target));
    }

    /** Seconds still safely usable in this request, or null when there is no limit (CLI). */
    private static function timeBudgetLeft(): ?float
    {
        $limit = (int) ini_get('max_execution_time');
        if (PHP_SAPI === 'cli' || $limit === 0) return null;
        return max(0.0, $limit - (microtime(true) - (float) ($_SERVER['REQUEST_TIME_FLOAT'] ?? microtime(true))) - 5.0);
    }

    /**
     * Process one batch of a bulk-delete job.
     *
     * $jobId targets a specific job: without it, a second admin's job could be advanced while the
     * first one's progress bar sat still, because the oldest pending job was always picked.
     *
     * Returns true when there is more work to do.
     */
    public static function processDeleteChunk(?int $jobId = null): bool
    {
        // A worker that died mid-batch leaves the job 'processing'; hand it back after 5 minutes.
        Database::query(
            "UPDATE bulk_delete_jobs SET status = 'pending', started_at = NULL
             WHERE status = 'processing' AND started_at < DATE_SUB(NOW(), INTERVAL 5 MINUTE)"
        );

        $job = ($jobId !== null && $jobId > 0)
            ? Database::fetchOne("SELECT * FROM bulk_delete_jobs WHERE id = ? AND status IN ('pending','processing')", [$jobId])
            : Database::fetchOne("SELECT * FROM bulk_delete_jobs WHERE status IN ('pending','processing') ORDER BY id ASC LIMIT 1");
        if (!$job) return false;

        $id = (int) $job['id'];
        $lock = 'bulkdel_' . $id;
        if ((int) (Database::fetchOne('SELECT GET_LOCK(?, 0) AS l', [$lock])['l'] ?? 0) !== 1) {
            return true;  // another worker holds it; it is progressing
        }

        try {
            Database::query(
                "UPDATE bulk_delete_jobs SET status = 'processing', started_at = NOW()
                  WHERE id = ? AND status IN ('pending','processing')",
                [$id]
            );
            // Re-read rather than trusting rowCount(): MySQL reports rows CHANGED, so a second batch
            // landing in the same second as the previous one updates nothing and would look like a
            // job that had been cancelled - which silently stopped fast jobs after one batch.
            $current = Database::fetchOne('SELECT status FROM bulk_delete_jobs WHERE id = ?', [$id]);
            if (!$current || !in_array($current['status'], ['pending', 'processing'], true)) {
                return false;  // cancelled or finished under us
            }

            $state = json_decode((string) $job['filters'], true) ?: [];
            $chunk = (int) ($state['_chunk'] ?? self::initialChunkSize());
            $chunk = max(self::CHUNK_MIN, min(self::CHUNK_MAX, $chunk));

            $budget = self::timeBudgetLeft();
            if ($budget !== null && $budget < 1.0) return true;  // no room left in this request

            // Pick this batch's ids.
            if (isset($state['ids'])) {
                $remaining = array_values(array_filter(array_map('intval', $state['ids']), static fn($i) => $i > 0));
                $batch = array_slice($remaining, 0, $chunk);
            } else {
                $filters = $state;
                unset($filters['_chunk'], $filters['_stalled'], $filters['_error']);
                $batch = array_map('intval', array_column(Video::all($filters, $chunk, 0), 'id'));
            }

            if (!$batch) {
                self::finishJob($id, $state);
                return false;
            }

            $t0 = microtime(true);
            $deleted = 0;
            $assets = [];
            try {
                $res = Video::deleteMany($batch);
                $deleted = (int) $res['deleted'];
                $assets = $res['assets'];
            } catch (\Throwable $e) {
                error_log("Bulk delete job {$id}: batch failed: " . $e->getMessage());
                $state['_error'] = $e->getMessage();
            }

            // Storage cleanup happens outside the transaction, grouped per storage so remote
            // backends get one batched call instead of one round trip per file.
            if ($assets) self::purgeAssets($assets);

            $elapsed = microtime(true) - $t0;

            if (isset($state['ids'])) {
                $state['ids'] = array_values(array_slice($remaining, count($batch)));
            }
            $state['_chunk'] = self::adaptChunkSize($chunk, $elapsed, $deleted);
            $state['_stalled'] = $deleted > 0 ? 0 : (int) ($state['_stalled'] ?? 0) + 1;

            Database::query(
                'UPDATE bulk_delete_jobs SET filters = ?, deleted_count = deleted_count + ?, started_at = NOW() WHERE id = ?',
                [json_encode($state), $deleted, $id]
            );

            // Done when there is genuinely nothing left - not merely because a batch deleted nothing,
            // which used to mark a job "completed" while videos remained.
            $remainingWork = isset($state['ids'])
                ? count($state['ids'])
                : (int) Video::count(array_diff_key($state, array_flip(['_chunk', '_stalled', '_error'])));

            if ($remainingWork <= 0) {
                self::finishJob($id, $state);
                return false;
            }
            if ((int) $state['_stalled'] >= self::MAX_STALLED_CHUNKS) {
                error_log("Bulk delete job {$id}: no progress after " . self::MAX_STALLED_CHUNKS . ' batches, stopping.');
                self::finishJob($id, $state, 'stalled');
                return false;
            }
            return true;
        } finally {
            Database::query('SELECT RELEASE_LOCK(?)', [$lock]);
        }
    }

    private static function finishJob(int $id, array $state, ?string $reason = null): void
    {
        if ($reason !== null) $state['_error'] = $state['_error'] ?? $reason;
        Database::query(
            "UPDATE bulk_delete_jobs SET status = 'completed', completed_at = NOW(), filters = ? WHERE id = ?",
            [json_encode($state), $id]
        );
        SitemapController::markDirty();
    }

    /** Group deleted videos by storage so each backend is purged in as few calls as possible. */
    private static function purgeAssets(array $assets): void
    {
        $byStorage = [];
        foreach ($assets as $a) {
            $folder = (string) ($a['video_folder'] ?? '');
            if ($folder === '') continue;
            $byStorage[(int) ($a['storage_id'] ?? 0)][] = $folder;
        }
        foreach ($byStorage as $storageId => $folders) {
            $sid = $storageId > 0 ? $storageId : null;
            try {
                MediaStorage::deleteRemoteFolders($folders, $sid);
            } catch (\Throwable $e) {
                error_log('Bulk delete: remote purge failed: ' . $e->getMessage());
            }
            // Local files always need removing, whatever the remote backend did.
            foreach ($folders as $folder) {
                try { VideoAsset::purgeLocalFolder($folder); } catch (\Throwable $e) {}
            }
        }
    }

    public static function spawnDeleteWorker(): void
    {
        $pidFile = APP_ROOT . '/storage/tmp/bulk-delete-worker.pid';
        if (file_exists($pidFile)) {
            $pid = (int) @file_get_contents($pidFile);
            if (Env::processAlive($pid)) {
                return;  
            }
            @unlink($pidFile);
        }

        $script = APP_ROOT . '/bin/bulk-delete-worker.php';
        if (!is_file($script) || !function_exists('exec')) {
             
            App::selfPing();
            return;
        }

        $phpBin = Env::phpCli();

        $tmpDir = APP_ROOT . '/storage/tmp';
        if (!is_dir($tmpDir)) mkdir($tmpDir, 0755, true);

        @exec(escapeshellarg($phpBin) . ' ' . escapeshellarg($script) . ' > /dev/null 2>&1 &');
    }

    

    public static function bulkDeleteChunk(): void
    {
        Auth::requireAdmin();
        if (!Router::verifyCsrf()) {
            Router::json(['error' => 'Invalid CSRF token'], 403);
            return;
        }
        $input = json_decode(file_get_contents('php://input'), true) ?: [];
        $jobId = (int) ($input['job_id'] ?? ($_GET['job_id'] ?? 0));

        // Advance the job the caller is actually watching. Without the id the oldest pending job was
        // picked instead, so a second admin's progress bar could sit still while someone else's job ran.
        self::processDeleteChunk($jobId > 0 ? $jobId : null);

        $job = $jobId > 0
            ? Database::fetchOne('SELECT * FROM bulk_delete_jobs WHERE id = ?', [$jobId])
            : Database::fetchOne("SELECT * FROM bulk_delete_jobs ORDER BY id DESC LIMIT 1");
        if (!$job) {
            Router::json(['done' => true, 'deleted_count' => 0, 'total_count' => 0]);
            return;
        }
        Router::json([
            'deleted_count' => (int) $job['deleted_count'],
            'total_count' => (int) $job['total_count'],
            'status' => $job['status'],
            'done' => in_array($job['status'], ['completed', 'cancelled'], true),
        ]);
    }

    

    public static function bulkDeleteStatus(): void
    {
        Auth::requireAdmin();
        $jobId = (int) ($_GET['job_id'] ?? 0);
        $job = Database::fetchOne('SELECT * FROM bulk_delete_jobs WHERE id = ?', [$jobId]);
        if (!$job) {
            Router::json(['error' => 'Job not found.'], 404);
            return;
        }
        Router::json([
            'deleted_count' => (int) $job['deleted_count'],
            'total_count' => (int) $job['total_count'],
            'status' => $job['status'],
            'done' => in_array($job['status'], ['completed', 'cancelled'], true),
        ]);
    }

    

    public static function bulkDeleteCancel(): void
    {
        Auth::requireAdmin();
        if (!Router::verifyCsrf()) {
            Router::json(['error' => 'Invalid CSRF token'], 403);
            return;
        }
        $input = json_decode(file_get_contents('php://input'), true) ?: [];
        $jobId = (int) ($input['job_id'] ?? 0);
        Database::query("UPDATE bulk_delete_jobs SET status = 'cancelled', completed_at = NOW() WHERE id = ? AND status IN ('pending','processing')", [$jobId]);
        Router::json(['cancelled' => true]);
    }

    public static function saveTranslations(): void
    {
        Auth::requireAdmin();
        if (!Router::verifyCsrf()) {
            Router::json(['error' => 'Invalid CSRF token'], 403);
        }
        $id = (int) Router::param('id');
        $input = json_decode(file_get_contents('php://input'), true) ?: [];
        $translations = $input['translations'] ?? [];

        foreach ($translations as $langCode => $fields) {
            $langCode = preg_replace('/[^a-z]/', '', strtolower($langCode));
            if ($langCode === '' || strlen($langCode) > 5) continue;

            $title = mb_substr(trim($fields['title'] ?? ''), 0, 500);
            $description = trim($fields['description'] ?? '');

            if ($title === '') {
                Database::query(
                    'DELETE FROM video_translations WHERE video_id = ? AND lang_code = ?',
                    [$id, $langCode]
                );
            } else {
                $slug = Slug::uniqueVideoTranslation($title, $langCode, $id);
                $existing = Database::fetchOne(
                    'SELECT id FROM video_translations WHERE video_id = ? AND lang_code = ?',
                    [$id, $langCode]
                );
                if ($existing) {
                    Database::query(
                        'UPDATE video_translations SET title = ?, slug = ?, description = ? WHERE video_id = ? AND lang_code = ?',
                        [$title, $slug, $description, $id, $langCode]
                    );
                } else {
                    Database::query(
                        'INSERT INTO video_translations (video_id, lang_code, title, slug, description) VALUES (?, ?, ?, ?, ?)',
                        [$id, $langCode, $title, $slug, $description]
                    );
                }
            }
        }

        SitemapController::markDirty();
        Router::json(['success' => true]);
    }
}
