<?php
// Kolejka konwersji wideo. Plik jest uruchamiany wyłącznie z linii poleceń przez admin_ajax.php.
if (PHP_SAPI !== 'cli') {
    http_response_code(403);
    exit('Odmowa dostępu.');
}

if (!isset($_SERVER['REQUEST_METHOD'])) $_SERVER['REQUEST_METHOD'] = 'CLI';
if (!isset($_SERVER['REMOTE_ADDR'])) $_SERVER['REMOTE_ADDR'] = '127.0.0.1';

setlocale(LC_CTYPE, "pl_PL.UTF-8", "en_US.UTF-8", "C.UTF-8");
require_once __DIR__ . '/functions.php';

set_time_limit(0);
ignore_user_abort(true);

$jobsDir = __DIR__ . '/data/convert_jobs';
$tempRoot = __DIR__ . '/data/convert_temp';
@mkdir($jobsDir, 0777, true);
@mkdir($tempRoot, 0777, true);

$workerStartLog = $jobsDir . '/worker_start.log';
ini_set('display_errors', '0');
ini_set('log_errors', '1');
ini_set('error_log', $workerStartLog);
@file_put_contents($workerStartLog, '[' . date('Y-m-d H:i:s') . '] START PHP=' . PHP_BINARY . ' SAPI=' . PHP_SAPI . PHP_EOL, FILE_APPEND | LOCK_EX);

$workerLock = @fopen($jobsDir . '/worker.lock', 'c+');
if ($workerLock === false || !flock($workerLock, LOCK_EX | LOCK_NB)) {
    @file_put_contents($workerStartLog, '[' . date('Y-m-d H:i:s') . '] STOP: inny worker już posiada blokadę.' . PHP_EOL, FILE_APPEND | LOCK_EX);
    if (is_resource($workerLock)) fclose($workerLock);
    exit;
}

$workerPidFile = $jobsDir . '/worker.pid';
$heartbeatFile = $jobsDir . '/worker.heartbeat';
$GLOBALS['convertWorkerHeartbeatFile'] = $heartbeatFile;
@file_put_contents($workerPidFile, (string)getmypid(), LOCK_EX);
@touch($heartbeatFile);

register_shutdown_function(function () use ($workerPidFile, $heartbeatFile, $workerStartLog, $jobsDir) {
    @unlink($workerPidFile);
    @unlink($heartbeatFile);
    $lastError = error_get_last();
    if (is_array($lastError) && in_array($lastError['type'] ?? 0, [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR], true)) {
        $fatalMessage = 'Błąd krytyczny workera: ' . ($lastError['message'] ?? 'nieznany')
            . ' w ' . ($lastError['file'] ?? '?') . ':' . ($lastError['line'] ?? 0);
        @file_put_contents($workerStartLog, '[' . date('Y-m-d H:i:s') . '] ' . $fatalMessage . PHP_EOL, FILE_APPEND | LOCK_EX);

        // Zadanie nie może pozostać bez końca jako „OCZEKUJE”, jeżeli worker
        // zakończył się błędem przed wejściem do obsługi pojedynczego pliku.
        foreach (glob($jobsDir . '/job_*.json') ?: [] as $jobFile) {
            $job = readJsonFile($jobFile, []);
            if (!in_array($job['status'] ?? '', ['queued', 'probing', 'converting', 'remuxing', 'uploading'], true)) continue;
            updateJsonFile($jobFile, function (&$data) use ($fatalMessage) {
                $data['status'] = 'error';
                $data['progress'] = 0;
                $data['pid'] = 0;
                $data['message'] = $fatalMessage;
                $data['updated_at'] = time();
                $data['finished_at'] = time();
            }, []);
        }
    } else {
        @file_put_contents($workerStartLog, '[' . date('Y-m-d H:i:s') . '] STOP: worker zakończony.' . PHP_EOL, FILE_APPEND | LOCK_EX);
    }
});

function convertUpdateJob($jobFile, array $changes) {
    updateJsonFile($jobFile, function (&$job) use ($changes) {
        foreach ($changes as $key => $value) $job[$key] = $value;
        $job['updated_at'] = time();
    }, []);
}

function convertRemoveTree($path) {
    if (!file_exists($path)) return;
    if (!is_dir($path) || is_link($path)) {
        @unlink($path);
        return;
    }
    $items = @scandir($path);
    if (is_array($items)) {
        foreach ($items as $item) {
            if ($item === '.' || $item === '..') continue;
            convertRemoveTree($path . '/' . $item);
        }
    }
    @rmdir($path);
}

function convertTailLog($file, $maxLines = 8) {
    if (!is_file($file)) return '';
    $lines = @file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
    if (!is_array($lines) || empty($lines)) return '';
    $lines = array_slice($lines, -$maxLines);
    $text = trim(implode(' | ', $lines));
    if (function_exists('mb_substr')) return mb_substr($text, 0, 900, 'UTF-8');
    return substr($text, 0, 900);
}

function convertProbe($file) {
    $cmd = '/usr/bin/ffprobe -v error -show_streams -show_format -of json ' . escapeshellarg($file);
    $raw = @shell_exec($cmd);
    $data = is_string($raw) ? json_decode($raw, true) : null;
    return is_array($data) ? $data : false;
}

function convertDurationFromProbe($probe) {
    return isset($probe['format']['duration']) ? max(0, (float)$probe['format']['duration']) : 0;
}

function convertPickVideoStream($probe) {
    foreach (($probe['streams'] ?? []) as $stream) {
        if (($stream['codec_type'] ?? '') !== 'video') continue;
        if ((int)($stream['disposition']['attached_pic'] ?? 0) === 1) continue;
        return $stream;
    }
    return false;
}

function convertAudioStreams($probe) {
    $result = [];
    foreach (($probe['streams'] ?? []) as $stream) {
        if (($stream['codec_type'] ?? '') === 'audio') $result[] = $stream;
    }
    return $result;
}

function convertIsHighBitDepth($pixFmt) {
    $pixFmt = strtolower((string)$pixFmt);
    return strpos($pixFmt, '10') !== false || strpos($pixFmt, '12') !== false || strpos($pixFmt, '14') !== false || strpos($pixFmt, '16') !== false || strpos($pixFmt, 'p010') !== false;
}

function convertIsHdr($video) {
    $transfer = strtolower((string)($video['color_transfer'] ?? ''));
    return in_array($transfer, ['smpte2084', 'arib-std-b67'], true);
}

function convertSafeColorValue($value, array $allowed, $fallback) {
    $value = strtolower((string)$value);
    return in_array($value, $allowed, true) ? $value : $fallback;
}

function convertOutputName($targetDir, $sourceName) {
    $base = pathinfo($sourceName, PATHINFO_FILENAME);
    $sourceExt = strtolower(pathinfo($sourceName, PATHINFO_EXTENSION));
    $candidate = $base . '.mp4';
    if ($sourceExt === 'mp4' || file_exists($targetDir . '/' . $candidate)) $candidate = $base . '_H264.mp4';
    $counter = 2;
    while (file_exists($targetDir . '/' . $candidate)) {
        $candidate = $base . '_H264_' . $counter . '.mp4';
        $counter++;
    }
    return $candidate;
}

function convertOutTimeSeconds($value) {
    if (!preg_match('/^(\d+):(\d+):(\d+(?:\.\d+)?)$/', trim($value), $m)) return 0;
    return ((int)$m[1] * 3600) + ((int)$m[2] * 60) + (float)$m[3];
}

function convertIonicePrefix() {
    if (!is_executable('/usr/bin/ionice')) return '';
    $out = [];
    $code = 1;
    @exec('/usr/bin/ionice -c3 /bin/true 2>/dev/null', $out, $code);
    return $code === 0 ? '/usr/bin/ionice -c3 ' : '';
}

function convertHasToneMapFilters() {
    static $available = null;
    if ($available !== null) return $available;
    $filters = @shell_exec('/usr/bin/ffmpeg -hide_banner -filters 2>/dev/null');
    $available = is_string($filters) && strpos($filters, ' zscale ') !== false && strpos($filters, ' tonemap ') !== false;
    return $available;
}

function convertBuildCommand($sourceFile, $localOutput, $probe, &$description, &$needsTranscode) {
    $video = convertPickVideoStream($probe);
    if ($video === false) return false;
    $audioStreams = convertAudioStreams($probe);

    $videoCodec = strtolower((string)($video['codec_name'] ?? ''));
    $pixFmt = strtolower((string)($video['pix_fmt'] ?? ''));
    $isHdr = convertIsHdr($video);
    $needsTranscode = $videoCodec !== 'h264' || convertIsHighBitDepth($pixFmt) || $isHdr;

    $args = [];
    $args[] = '/usr/bin/timeout --foreground --kill-after=30 259200';
    $args[] = convertIonicePrefix() . '/usr/bin/nice -n 19 /usr/bin/ffmpeg';
    $args[] = '-y -nostdin -hide_banner -loglevel warning';
    $args[] = '-i ' . escapeshellarg($sourceFile);
    $args[] = '-map 0:' . (int)$video['index'];

    foreach ($audioStreams as $stream) $args[] = '-map 0:' . (int)$stream['index'];

    $videoDescription = '';
    if (!$needsTranscode) {
        $args[] = '-c:v copy';
        $videoDescription = 'obraz H.264 skopiowany bez utraty jakości';
    } else {
        $args[] = '-c:v libx264 -preset slow -crf 18 -pix_fmt yuv420p -tag:v avc1';
        if ($isHdr) {
            if (!convertHasToneMapFilters()) return false;
            $matrix = convertSafeColorValue($video['color_space'] ?? '', ['bt2020nc', 'bt2020c', 'bt709', 'smpte170m', 'smpte240m'], 'bt2020nc');
            $transfer = convertSafeColorValue($video['color_transfer'] ?? '', ['smpte2084', 'arib-std-b67'], 'smpte2084');
            $primaries = convertSafeColorValue($video['color_primaries'] ?? '', ['bt2020', 'bt709', 'smpte170m', 'smpte240m'], 'bt2020');
            $range = strtolower((string)($video['color_range'] ?? ''));
            $range = in_array($range, ['pc', 'jpeg', 'full'], true) ? 'full' : 'limited';
            $filter = 'zscale=matrixin=' . $matrix . ':transferin=' . $transfer . ':primariesin=' . $primaries . ':rangein=' . $range
                . ':matrix=' . $matrix . ':transfer=linear:primaries=' . $primaries . ':npl=100,'
                . 'format=gbrpf32le,tonemap=tonemap=hable:desat=0,'
                . 'zscale=matrix=bt709:transfer=bt709:primaries=bt709:range=limited,format=yuv420p';
            $args[] = '-vf ' . escapeshellarg($filter);
            $args[] = '-color_primaries bt709 -color_trc bt709 -colorspace bt709 -color_range tv';
            $videoDescription = 'obraz HDR/H.265 przekodowany do H.264 SDR w wysokiej jakości';
        } else {
            $args[] = '-vf ' . escapeshellarg('format=yuv420p');
            $videoDescription = 'obraz ' . strtoupper($videoCodec ?: 'nieznany') . ' przekodowany do H.264 w wysokiej jakości';
        }
    }

    $audioCopied = 0;
    $audioConverted = 0;
    foreach ($audioStreams as $outputIndex => $stream) {
        $codec = strtolower((string)($stream['codec_name'] ?? ''));
        if ($codec === 'aac') {
            $args[] = '-c:a:' . $outputIndex . ' copy';
            $audioCopied++;
        } else {
            $channels = max(1, (int)($stream['channels'] ?? 2));
            if ($channels <= 1) $bitrate = '128k';
            elseif ($channels <= 2) $bitrate = '256k';
            elseif ($channels <= 6) $bitrate = '384k';
            else $bitrate = '512k';
            $args[] = '-c:a:' . $outputIndex . ' aac -b:a:' . $outputIndex . ' ' . $bitrate;
            $audioConverted++;
        }

        $language = trim((string)($stream['tags']['language'] ?? ''));
        $title = trim((string)($stream['tags']['title'] ?? ''));
        if ($language !== '') $args[] = '-metadata:s:a:' . $outputIndex . ' language=' . escapeshellarg($language);
        if ($title !== '') $args[] = '-metadata:s:a:' . $outputIndex . ' title=' . escapeshellarg($title);
    }

    if (empty($audioStreams)) $audioDescription = 'bez ścieżki audio';
    elseif ($audioConverted === 0) $audioDescription = 'audio AAC skopiowane bez utraty jakości';
    elseif ($audioCopied === 0) $audioDescription = 'audio przekodowane do AAC';
    else $audioDescription = 'zgodne audio skopiowane, pozostałe przekodowane do AAC';

    $description = $videoDescription . '; ' . $audioDescription;

    $args[] = '-map_metadata 0 -map_chapters 0 -sn -dn';
    $args[] = '-movflags +faststart+use_metadata_tags -max_muxing_queue_size 4096';
    $args[] = '-progress pipe:1 -nostats';
    $args[] = escapeshellarg($localOutput);
    return implode(' ', $args);
}

function convertVerifyOutput($file, $sourceDuration) {
    if (!is_file($file) || filesize($file) <= 0) return false;
    $probe = convertProbe($file);
    if ($probe === false) return false;
    $video = convertPickVideoStream($probe);
    if ($video === false || strtolower((string)($video['codec_name'] ?? '')) !== 'h264') return false;
    $duration = convertDurationFromProbe($probe);
    if ($sourceDuration > 0 && $duration > 0) {
        $allowedDifference = max(5, $sourceDuration * 0.02);
        if (abs($sourceDuration - $duration) > $allowedDifference) return false;
    }
    return true;
}

function convertProcessJob($jobFile, $jobsDir, $tempRoot) {
    $job = readJsonFile($jobFile, []);
    if (($job['status'] ?? '') !== 'queued') return;

    $jobId = basename($jobFile, '.json');
    $logFile = $jobsDir . '/' . $jobId . '.log';
    $tempDir = $tempRoot . '/' . $jobId;
    $localOutput = $tempDir . '/output.part.mp4';
    $activeMarker = $tempDir . '/.active';
    $cloudPart = '';
    $process = null;

    @mkdir($tempDir, 0777, true);
    @touch($activeMarker);
    @unlink($localOutput);
    @unlink($logFile);

    $cleanup = function () use (&$process, &$cloudPart, $localOutput, $tempDir) {
        if (is_resource($process)) @proc_terminate($process, 15);
        if ($cloudPart !== '') @unlink($cloudPart);
        @unlink($localOutput);
        convertRemoveTree($tempDir);
    };
    register_shutdown_function($cleanup);

    try {
        convertUpdateJob($jobFile, ['status'=>'probing', 'progress'=>0, 'message'=>'Analiza pliku i kodeków...', 'pid'=>0]);
        $resolved = resolveAllowedFile($job['folder'] ?? '', $job['path'] ?? '', $job['file'] ?? '');
        if ($resolved === false) throw new RuntimeException('Plik źródłowy nie istnieje lub znajduje się poza dozwolonym folderem.');

        $sourceSize = @filesize($resolved['full']);
        $sourceMtime = @filemtime($resolved['full']);
        if ($sourceSize === false || $sourceMtime === false) throw new RuntimeException('Nie można odczytać informacji o pliku źródłowym.');
        if ((int)($job['source_size'] ?? -1) !== (int)$sourceSize || (int)($job['source_mtime'] ?? -1) !== (int)$sourceMtime) {
            throw new RuntimeException('Plik źródłowy zmienił się po dodaniu zadania do kolejki.');
        }

        $probe = convertProbe($resolved['full']);
        if ($probe === false) throw new RuntimeException('FFprobe nie mógł odczytać informacji o filmie.');
        $video = convertPickVideoStream($probe);
        if ($video === false) throw new RuntimeException('Plik nie zawiera prawidłowej ścieżki wideo.');
        $duration = convertDurationFromProbe($probe);
        $audioStreams = convertAudioStreams($probe);
        $videoCodec = strtolower((string)($video['codec_name'] ?? ''));
        $sourceExt = strtolower(pathinfo($resolved['file'], PATHINFO_EXTENSION));
        $allAudioAac = true;
        foreach ($audioStreams as $audio) {
            if (strtolower((string)($audio['codec_name'] ?? '')) !== 'aac') { $allAudioAac = false; break; }
        }
        $alreadyCompatible = $sourceExt === 'mp4'
            && $videoCodec === 'h264'
            && !convertIsHighBitDepth($video['pix_fmt'] ?? '')
            && !convertIsHdr($video)
            && $allAudioAac;
        if ($alreadyCompatible) {
            convertUpdateJob($jobFile, [
                'status'=>'skipped', 'progress'=>100,
                'message'=>'Plik jest już zgodnym MP4 (H.264/AAC). Konwersja nie była potrzebna.',
                'output'=>$resolved['file'], 'pid'=>0
            ]);
            $cleanup();
            return;
        }

        $description = '';
        $needsTranscode = false;
        $command = convertBuildCommand($resolved['full'], $localOutput, $probe, $description, $needsTranscode);
        if ($command === false) {
            if (convertIsHdr($video) && !convertHasToneMapFilters()) {
                throw new RuntimeException('Ta instalacja FFmpeg nie ma filtrów zscale/tonemap wymaganych do bezpiecznej konwersji HDR do SDR.');
            }
            throw new RuntimeException('Nie udało się przygotować polecenia konwersji.');
        }

        $freeSpace = @disk_free_space($tempRoot);
        $requiredSpace = $needsTranscode ? ((float)$sourceSize * 2.5 + 1073741824) : ((float)$sourceSize + 536870912);
        if ($freeSpace !== false && $freeSpace < $requiredSpace) {
            throw new RuntimeException('Za mało wolnego miejsca na dysku serwera. Wymagane jest około ' . formatSize((int)$requiredSpace) . '.');
        }

        $statusName = $needsTranscode ? 'converting' : 'remuxing';
        convertUpdateJob($jobFile, [
            'status'=>$statusName, 'progress'=>0,
            'message'=>$description, 'details'=>$description
        ]);

        $descriptors = [
            0 => ['file', '/dev/null', 'r'],
            1 => ['pipe', 'w'],
            2 => ['file', $logFile, 'a']
        ];
        $process = @proc_open('exec ' . $command, $descriptors, $pipes, __DIR__);
        if (!is_resource($process)) throw new RuntimeException('Nie udało się uruchomić FFmpeg.');

        $processStatus = proc_get_status($process);
        convertUpdateJob($jobFile, ['pid'=>(int)($processStatus['pid'] ?? 0)]);
        stream_set_blocking($pipes[1], false);
        $buffer = '';
        $lastUpdate = 0;
        $lastPercent = -1;
        $exitCode = -1;

        while (true) {
            $chunk = stream_get_contents($pipes[1]);
            if ($chunk !== false && $chunk !== '') $buffer .= $chunk;
            while (($pos = strpos($buffer, "\n")) !== false) {
                $line = trim(substr($buffer, 0, $pos));
                $buffer = substr($buffer, $pos + 1);
                if (strpos($line, 'out_time=') === 0 && $duration > 0) {
                    $outSeconds = convertOutTimeSeconds(substr($line, 9));
                    $percent = (int)min(98, max(0, floor(($outSeconds / $duration) * 100)));
                    if ($percent !== $lastPercent && time() - $lastUpdate >= 2) {
                        convertUpdateJob($jobFile, ['progress'=>$percent, 'pid'=>(int)($processStatus['pid'] ?? 0)]);
                        $lastPercent = $percent;
                        $lastUpdate = time();
                    }
                }
            }

            @touch($activeMarker);
            @touch($GLOBALS['convertWorkerHeartbeatFile'] ?? '');
            $processStatus = proc_get_status($process);
            if (!$processStatus['running']) {
                $exitCode = (int)$processStatus['exitcode'];
                break;
            }
            usleep(250000);
        }

        fclose($pipes[1]);
        $closeCode = proc_close($process);
        $process = null;
        if ($exitCode < 0) $exitCode = $closeCode;

        if ($exitCode !== 0 || !convertVerifyOutput($localOutput, $duration)) {
            $logTail = convertTailLog($logFile);
            throw new RuntimeException('Konwersja FFmpeg nie zakończyła się prawidłowo.' . ($logTail !== '' ? ' ' . $logTail : ''));
        }

        $targetDir = dirname($resolved['full']);
        $outputName = convertOutputName($targetDir, $resolved['file']);
        $finalOutput = $targetDir . '/' . $outputName;
        $cloudPart = $targetDir . '/.' . $outputName . '.uploading.' . $jobId;
        @unlink($cloudPart);

        convertUpdateJob($jobFile, ['status'=>'uploading', 'progress'=>99, 'message'=>'Wysyłanie gotowego MP4 do chmury...', 'output'=>$outputName, 'pid'=>0]);
        if (!@copy($localOutput, $cloudPart)) throw new RuntimeException('Nie udało się skopiować gotowego pliku do chmury.');
        clearstatcache(true, $cloudPart);
        if (!is_file($cloudPart) || filesize($cloudPart) !== filesize($localOutput)) throw new RuntimeException('Rozmiar pliku w chmurze nie zgadza się z plikiem lokalnym.');
        if (!convertVerifyOutput($cloudPart, $duration)) throw new RuntimeException('Weryfikacja pliku przesłanego do chmury nie powiodła się.');
        if (file_exists($finalOutput)) {
            $outputName = convertOutputName($targetDir, $resolved['file']);
            $finalOutput = $targetDir . '/' . $outputName;
            convertUpdateJob($jobFile, ['output'=>$outputName]);
        }
        if (!@rename($cloudPart, $finalOutput)) throw new RuntimeException('Nie udało się nadać docelowej nazwy plikowi w chmurze.');
        $cloudPart = '';

        @unlink($localOutput);
        convertRemoveTree($tempDir);
        invalidateSearchIndex();
        @exec("nohup bash -c 'sleep 600 && sudo -n /usr/local/bin/clean_vfs.sh' >/dev/null 2>&1 < /dev/null &");

        convertUpdateJob($jobFile, [
            'status'=>'done', 'progress'=>100, 'pid'=>0,
            'message'=>'Gotowe: ' . $outputName . '. ' . $description,
            'output'=>$outputName, 'finished_at'=>time()
        ]);
    } catch (Throwable $e) {
        $cleanup();
        convertUpdateJob($jobFile, [
            'status'=>'error', 'progress'=>0, 'pid'=>0,
            'message'=>$e->getMessage(), 'finished_at'=>time()
        ]);
    }
}

// Zadanie oznaczone jako aktywne, lecz bez aktualizacji od 15 minut, pochodzi po przerwanym workerze.
foreach (glob($jobsDir . '/job_*.json') ?: [] as $jobFile) {
    @touch($heartbeatFile);
    $job = readJsonFile($jobFile, []);
    if (!in_array($job['status'] ?? '', ['probing', 'converting', 'remuxing', 'uploading'], true)) continue;
    if (time() - (int)($job['updated_at'] ?? 0) < 900) continue;
    $pid = (int)($job['pid'] ?? 0);
    if ($pid > 1) @exec('/bin/kill -TERM ' . $pid . ' >/dev/null 2>&1');
    convertUpdateJob($jobFile, ['status'=>'queued', 'progress'=>0, 'pid'=>0, 'message'=>'Zadanie wznowione po przerwaniu procesu w tle.']);
}

while (true) {
    @touch($heartbeatFile);
    $queued = [];
    foreach (glob($jobsDir . '/job_*.json') ?: [] as $jobFile) {
        $job = readJsonFile($jobFile, []);
        if (($job['status'] ?? '') === 'queued') $queued[] = ['file'=>$jobFile, 'created'=>(int)($job['created_at'] ?? 0)];
    }
    if (empty($queued)) break;
    usort($queued, function($a, $b) { return $a['created'] <=> $b['created']; });
    convertProcessJob($queued[0]['file'], $jobsDir, $tempRoot);
}

@unlink($heartbeatFile);
@file_put_contents($workerStartLog, '[' . date('Y-m-d H:i:s') . '] STOP: kolejka zakończona.' . PHP_EOL, FILE_APPEND | LOCK_EX);
flock($workerLock, LOCK_UN);
fclose($workerLock);
