<?php
/**
 * Animex remote softsub endpoint.
 *
 * Softsubs an already-uploaded .mkv on THIS download server (no download/re-upload):
 * adds a Persian subtitle track with the same rules as the Iran pipeline — every
 * existing track renamed "Animex", existing subtitles set to language "und" and not
 * default/forced, the new Persian track "fa", default + forced.
 *
 * Install: <web root>/animex-softsub/softsub.php, then set SECRET below.
 *
 * Security model
 *  - Every request must be a POST signed with HMAC-SHA256(SECRET, timestamp + "." + body),
 *    within a 5-minute window, and carry a nonce that is accepted only once (no replay).
 *    Anything else gets an empty 404, as if the file did not exist.
 *  - Fails closed: an unset/short SECRET, or an unsafe work dir, disables everything.
 *  - Only fixed actions; no caller-supplied command text. Every argument goes through
 *    escapeshellarg(), paths are always absolute (never parsed as options), track ids
 *    are forced to integers.
 *  - Paths are resolved with realpath() and must stay inside the web root; only .mkv.
 *  - "copy" mode (tests) writes "<name>.softsub.mkv" beside the original and deletes nothing.
 *    "replace" writes the verified output under the "[SS]" name (never over an existing
 *    file) and only then removes the old-named original. Apart from that, only this
 *    script's own temp and
 *    partial files are ever removed.
 *
 * Actions (JSON body, all require "nonce"):
 *   list     {dir}
 *   probe    {path}
 *   extract  {path, track_id}
 *   softsub  {path, subtitle_b64, subtitle_ext: "ass"|"srt", mode: "copy"|"replace"}
 *   status   {job_id}
 */

const SECRET = '6a6f7d19bdfbb83852553dd733382abfcf394576323cce7748c509bb33576ab9';
const MAX_SKEW = 300;
const WORK_DIR = '/tmp/animex-softsub';
const MAX_SUBTITLE_BYTES = 10 * 1024 * 1024;

$BASE = realpath(__DIR__ . '/..');

function not_found()
{
    http_response_code(404);
    exit;
}

function respond($code, $data)
{
    http_response_code($code);
    header('Content-Type: application/json; charset=utf-8');
    header('Cache-Control: no-store');
    echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    exit;
}

/** /tmp is shared: refuse a work dir that is a symlink or owned by someone else. */
function work_dir_ok()
{
    if (!is_dir(WORK_DIR) && !@mkdir(WORK_DIR, 0700)) {
        return false;
    }
    clearstatcache();
    if (is_link(WORK_DIR) || !is_dir(WORK_DIR)) {
        return false;
    }
    if (function_exists('posix_geteuid') && fileowner(WORK_DIR) !== posix_geteuid()) {
        return false;
    }
    @chmod(WORK_DIR, 0700);
    foreach (['jobs', 'nonces', 'locks', 'tmp'] as $sub) {
        $d = WORK_DIR . '/' . $sub;
        if (!is_dir($d) && !@mkdir($d, 0700)) {
            return false;
        }
        if (is_link($d)) {
            return false;
        }
    }
    return true;
}

// ---------------- authentication (fail closed) ----------------
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST' || strlen(SECRET) < 32 || SECRET === 'CHANGE_ME') {
    not_found();
}
$raw = file_get_contents('php://input', false, null, 0, MAX_SUBTITLE_BYTES * 2);
$ts = $_SERVER['HTTP_X_ANIMEX_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_ANIMEX_SIGNATURE'] ?? '';
if (!is_string($ts) || !ctype_digit($ts) || abs(time() - (int)$ts) > MAX_SKEW || !is_string($sig)) {
    not_found();
}
if (!hash_equals(hash_hmac('sha256', $ts . '.' . $raw, SECRET), $sig)) {
    not_found();
}
if (!work_dir_ok()) {
    respond(503, ['error' => 'unsafe or unwritable work dir']);
}
$req = json_decode($raw, true);
if (!is_array($req) || !is_string($req['action'] ?? null) || !is_string($req['nonce'] ?? null)
    || !preg_match('/^[a-f0-9]{32}$/', $req['nonce'])) {
    respond(400, ['error' => 'bad request']);
}
// one-time nonce: reject replays, forget nonces older than the timestamp window
$nonceFile = WORK_DIR . '/nonces/' . $req['nonce'];
$nf = @fopen($nonceFile, 'x');
if ($nf === false) {
    not_found();
}
fclose($nf);
foreach (glob(WORK_DIR . '/nonces/*') ?: [] as $old) {
    if (filemtime($old) < time() - 2 * MAX_SKEW) {
        @unlink($old);
    }
}

// ---------------- helpers ----------------
function run_cmd(array $argv)
{
    $cmd = implode(' ', array_map('escapeshellarg', $argv)) . ' 2>&1';
    $out = [];
    $code = 0;
    exec($cmd, $out, $code);
    return [$code, implode("\n", $out)];
}

/** Web-root-relative path -> absolute path inside the web root, or null. */
function resolve_inside($rel, $mustBeMkv)
{
    global $BASE;
    if (!is_string($rel) || $rel === '' || strlen($rel) > 1000
        || strpos($rel, "\0") !== false || strpos($rel, '..') !== false) {
        return null;
    }
    $full = realpath($BASE . '/' . ltrim($rel, '/'));
    if ($full === false || ($full !== $BASE && strpos($full, $BASE . '/') !== 0)) {
        return null;
    }
    if ($mustBeMkv) {
        return (is_file($full) && strtolower(pathinfo($full, PATHINFO_EXTENSION)) === 'mkv') ? $full : null;
    }
    return is_dir($full) ? $full : null;
}

function relative($full)
{
    global $BASE;
    return substr($full, strlen($BASE));
}

function mkv_info($full)
{
    [, $out] = run_cmd(['mkvmerge', '-J', $full]);
    $data = json_decode($out, true);
    if (!is_array($data) || !isset($data['tracks']) || !is_array($data['tracks'])) {
        return null;
    }
    $tracks = [];
    foreach ($data['tracks'] as $t) {
        $p = $t['properties'] ?? [];
        $tracks[] = [
            'id' => (int)$t['id'],
            'type' => (string)($t['type'] ?? ''),
            'codec' => (string)($t['codec'] ?? ''),
            'language' => (string)($p['language'] ?? ''),
            'name' => (string)($p['track_name'] ?? ''),
            'default' => (bool)($p['default_track'] ?? false),
            'forced' => (bool)($p['forced_track'] ?? false),
        ];
    }
    $dur = $data['container']['properties']['duration'] ?? null;
    return ['tracks' => $tracks, 'duration' => $dur !== null ? $dur / 1e9 : null, 'size' => filesize($full)];
}

function has_persian(array $info)
{
    foreach ($info['tracks'] as $t) {
        if ($t['type'] === 'subtitles' && in_array(strtolower($t['language']), ['fa', 'per', 'fas'], true)) {
            return true;
        }
    }
    return false;
}

/** Softsubbed files carry "[SS]": "X.[480][MixFlixTop].mkv" -> "X.[SS][480][MixFlixTop].mkv". */
function ss_name($filename)
{
    $stem = pathinfo($filename, PATHINFO_FILENAME);
    if (stripos($stem, '[SS]') !== false) {
        return $filename;
    }
    $pos = strpos($stem, '.[');
    $stem = $pos !== false ? substr($stem, 0, $pos) . '.[SS]' . substr($stem, $pos + 1) : $stem . '.[SS]';
    return $stem . '.mkv';
}

function job_write($id, array $data)
{
    $data['updated_at'] = time();
    $path = WORK_DIR . '/jobs/' . $id . '.json';
    file_put_contents($path . '.tmp', json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
    rename($path . '.tmp', $path);
}

// ---------------- actions ----------------
switch ($req['action']) {

    case 'list':
        $dir = resolve_inside($req['dir'] ?? '', false);
        if ($dir === null) {
            respond(400, ['error' => 'invalid dir']);
        }
        $dirs = [];
        $files = [];
        foreach (scandir($dir) ?: [] as $n) {
            if ($n === '' || $n[0] === '.') {
                continue;
            }
            $f = $dir . '/' . $n;
            if (is_dir($f)) {
                $dirs[] = $n;
            } elseif (strtolower(pathinfo($n, PATHINFO_EXTENSION)) === 'mkv') {
                $files[] = ['name' => $n, 'size' => filesize($f)];
            }
        }
        respond(200, ['dir' => relative($dir), 'dirs' => $dirs, 'files' => $files]);

    case 'probe':
        $full = resolve_inside($req['path'] ?? '', true);
        if ($full === null) {
            respond(400, ['error' => 'invalid path']);
        }
        $info = mkv_info($full);
        if ($info === null) {
            respond(500, ['error' => 'could not read the file']);
        }
        $info['has_persian'] = has_persian($info);
        respond(200, $info);

    case 'extract':
        $full = resolve_inside($req['path'] ?? '', true);
        $track = $req['track_id'] ?? null;
        if ($full === null || !is_int($track) || $track < 0 || $track > 999) {
            respond(400, ['error' => 'invalid path or track']);
        }
        $out = WORK_DIR . '/tmp/extract_' . bin2hex(random_bytes(8));
        [$code, $log] = run_cmd(['mkvextract', 'tracks', $full, $track . ':' . $out]);
        if ($code > 1 || !is_file($out) || filesize($out) > MAX_SUBTITLE_BYTES) {
            @unlink($out);
            respond(500, ['error' => 'extract failed', 'log' => substr($log, -600)]);
        }
        $content = base64_encode(file_get_contents($out));
        unlink($out);
        respond(200, ['subtitle_b64' => $content]);

    case 'status':
        $id = $req['job_id'] ?? '';
        $path = WORK_DIR . '/jobs/' . (is_string($id) ? $id : '') . '.json';
        if (!is_string($id) || !preg_match('/^[a-f0-9]{16}$/', $id) || !is_file($path)) {
            respond(404, ['error' => 'unknown job']);
        }
        respond(200, json_decode(file_get_contents($path), true));

    case 'softsub':
        $full = resolve_inside($req['path'] ?? '', true);
        $mode = $req['mode'] ?? '';
        $ext = strtolower((string)($req['subtitle_ext'] ?? ''));
        $sub = base64_decode((string)($req['subtitle_b64'] ?? ''), true);
        if ($full === null || !in_array($mode, ['copy', 'replace'], true) || !in_array($ext, ['ass', 'srt'], true)
            || $sub === false || strlen($sub) < 10 || strlen($sub) > MAX_SUBTITLE_BYTES) {
            respond(400, ['error' => 'invalid softsub request']);
        }
        $dir = dirname($full);
        if (!is_writable($dir)) {
            respond(409, ['error' => 'folder is not writable by the web server user']);
        }
        $target = $mode === 'replace'
            ? $dir . '/' . ss_name(basename($full))
            : $dir . '/' . pathinfo($full, PATHINFO_FILENAME) . '.softsub.mkv';
        if ($target !== $full && file_exists($target)) {
            respond(409, ['error' => 'output name already exists; nothing overwritten', 'output' => relative($target)]);
        }
        // one job per file at a time
        $lock = fopen(WORK_DIR . '/locks/' . sha1($full) . '.lock', 'c');
        if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
            respond(409, ['error' => 'a softsub job is already running for this file']);
        }
        $before = mkv_info($full);
        if ($before === null) {
            respond(500, ['error' => 'could not read the file']);
        }
        if (has_persian($before)) {
            respond(409, ['error' => 'file already has a Persian subtitle track']);
        }
        $free = @disk_free_space($dir);
        if ($free !== false && $free < $before['size'] * 1.05) {
            respond(507, ['error' => 'not enough free disk space for the new file']);
        }

        $id = bin2hex(random_bytes(8));
        $subFile = WORK_DIR . '/tmp/' . $id . '.' . $ext;
        file_put_contents($subFile, $sub);
        $part = $dir . '/.animex-' . $id . '.part.mkv';   // same folder: final rename is atomic
        $meta = ['path' => relative($full), 'mode' => $mode, 'started_at' => time()];
        job_write($id, $meta + ['status' => 'running']);

        // Answer now; keep working after the connection closes.
        ignore_user_abort(true);
        set_time_limit(0);
        $answered = false;
        if (function_exists('fastcgi_finish_request')) {
            http_response_code(202);
            header('Content-Type: application/json; charset=utf-8');
            echo json_encode(['job_id' => $id, 'status' => 'running']);
            fastcgi_finish_request();
            $answered = true;
        }

        $argv = ['mkvmerge', '-o', $part];
        foreach ($before['tracks'] as $t) {
            $tid = (int)$t['id'];
            array_push($argv, '--track-name', $tid . ':Animex');
            if ($t['type'] === 'subtitles') {
                array_push($argv, '--language', $tid . ':und', '--default-track', $tid . ':no', '--forced-track', $tid . ':no');
            }
        }
        array_push($argv, $full, '--default-track', '0:yes', '--forced-track', '0:yes',
            '--track-name', '0:Animex', '--language', '0:fa', $subFile);

        [$code, $log] = run_cmd($argv);
        @unlink($subFile);
        $error = null;
        if ($code > 1 || !is_file($part) || filesize($part) === 0) {
            $error = 'mkvmerge failed: ' . substr($log, -600);
        } else {
            $after = mkv_info($part);
            if ($after === null || count($after['tracks']) !== count($before['tracks']) + 1 || !has_persian($after)) {
                $error = 'verification failed: unexpected tracks in the output';
            } elseif ($before['duration'] !== null && abs($after['duration'] - $before['duration']) > 1.0) {
                $error = 'verification failed: duration changed';
            }
        }
        if ($error === null && !rename($part, $target)) {
            $error = 'could not move the output into place';
        }
        if ($error !== null) {
            @unlink($part);   // only our own partial output
            job_write($id, $meta + ['status' => 'failed', 'error' => $error]);
        } else {
            // replace mode: the verified [SS] file is in place; only now drop the old name
            $removedOriginal = false;
            if ($mode === 'replace' && $target !== $full) {
                $removedOriginal = @unlink($full);
            }
            job_write($id, $meta + [
                'status' => 'done',
                'output' => relative($target),
                'size' => filesize($target),
                'original_removed' => $removedOriginal,
            ]);
        }
        flock($lock, LOCK_UN);
        fclose($lock);
        if (!$answered) {
            respond(200, json_decode(file_get_contents(WORK_DIR . '/jobs/' . $id . '.json'), true) + ['job_id' => $id]);
        }
        exit;

    default:
        respond(400, ['error' => 'unknown action']);
}
