<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
//   Dry-run launcher. Starts one script through run_job.sh with --dry-run, so the scheduler
//   page can show what a job would do without letting it do any of it.
//
// OPERATIONAL MODEL
//   Identical to run.php but for two differences: --dry-run is added, and there is no
//   already-running guard. The missing guard is deliberate — a dry run changes nothing, so
//   there is no reason to refuse one while a real run is in progress, and the two write to
//   the same log where their output is distinguishable by the dry-run banner.
//
//   Fire and forget. The child is nohup'd and detached, and the response returns immediately
//   with ok:true meaning "launched", not "finished". Progress is followed through log.php.
//
// DESIGN PRINCIPLES
//   Never executes the target script directly.
//       Everything goes through run_job.sh, so a dry run gets the same locking, logging, stat
//       file and exit handling as a scheduled run. A second execution path would be a second
//       set of bugs.
//
//   --dry-run is added here, honoured there.
//       This endpoint guarantees the flag is passed; whether a given script actually respects
//       it is that script's contract. The scripts that accept the flag are the ones the UI
//       offers the button for.
//
// OPERATIONAL SAFEGUARDS
//   The job id is validated and then confirmed to exist.
//       ^[a-zA-Z0-9_./\-]+\.sh$ plus an explicit '..' check — the slash must be permitted for
//       Category/name.sh ids, so traversal is caught by its own test rather than by the
//       character class. file_exists() then confirms the resolved path is a real script, so a
//       well-formed id for a file that is not there fails before anything is spawned.
//
//   The location argument must be absolute and clean.
//       Leading slash required, '..' rejected, control characters rejected — then passed as a
//       single escapeshellarg'd --location= token.
//
//   Extra arguments cannot become shell syntax.
//       Control characters are rejected, then the string is split on whitespace and each
//       token escaped individually. The previous blocklist of metacharacters missed newlines,
//       which would have terminated the command line and started a second one — a blocklist
//       has to be right about every character, whereas escaping each token is right about all
//       of them.
//
//   Every interpolated value is escaped, including the ones that are already validated.
//       Runner path, id, script path and log path all go through escapeshellarg() even though
//       none of them can currently carry a metacharacter. Validation and escaping guard
//       different things, and the escaping is what stays correct if the validation is ever
//       loosened.
//
//   Output is appended, never truncated.
//       >> to the job's own log with stdin from /dev/null, so a dry run cannot consume the
//       request's stdin or discard the history of previous runs.
//
// REQUEST
//   POST id=<Category/name.sh>  [location=/absolute/path]  [extra_args=…]
//
// RESPONSE
//   {"ok":true}                              launched — not completed
//   {"ok":false,"error":"Invalid id"|"Script not found: …"|"Invalid location"
//                      |"Invalid extra_args"}
//
// DEPENDS ON
//   include/scheduler.php   vv_job_log_path(), vv_job_flags()
//   run_job.sh              the single execution path for every job
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/scheduler.php';

$id = trim($_POST['id'] ?? '');

if (!$id || !preg_match('/^[a-zA-Z0-9_.\/\-]+\.sh$/', $id) || str_contains($id, '..')) {
    echo json_encode(['ok' => false, 'error' => 'Invalid id']);
    exit;
}

$script = SCRIPTS_DIR . '/' . $id;
if (!file_exists($script)) {
    echo json_encode(['ok' => false, 'error' => 'Script not found: ' . $id]);
    exit;
}

$logFile = vv_job_log_path($id);
$logDir  = dirname($logFile);
if (!is_dir($logDir)) mkdir($logDir, 0755, true);

$location = trim($_POST['location'] ?? '');
if ($location && (!str_starts_with($location, '/') || str_contains($location, '..') || preg_match('/[\x00\n\r]/', $location))) {
    echo json_encode(['ok' => false, 'error' => 'Invalid location']);
    exit;
}

// Control characters are rejected outright — a newline would end the command line and start
// a second one. Everything that survives is split on whitespace and escaped per token, so
// the shell never parses any of it as syntax.
$extra_args = trim($_POST['extra_args'] ?? '');
if ($extra_args !== '' && preg_match('/[\x00-\x1f\x7f]/', $extra_args)) {
    echo json_encode(['ok' => false, 'error' => 'Invalid extra_args']);
    exit;
}

$runner   = dirname(__DIR__) . '/run_job.sh';
$flags    = vv_job_flags($id);
$locArg   = $location   ? ' ' . escapeshellarg('--location=' . $location) : '';
$extraStr = '';
foreach (preg_split('/\s+/', $extra_args, -1, PREG_SPLIT_NO_EMPTY) as $tok) {
    $extraStr .= ' ' . escapeshellarg($tok);
}
exec('nohup bash ' . escapeshellarg($runner) . ' ' . escapeshellarg($id) . ' ' . escapeshellarg($script) . ' --dry-run' . ($flags ? " $flags" : '') . ' --manual' . $locArg . $extraStr . ' >> ' . escapeshellarg($logFile) . ' 2>&1 </dev/null &');

echo json_encode(['ok' => true]);
