<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
//   Moves a script between orchestrators. Removes its line from whichever *_SCRIPTS array in
//   master.conf currently holds it and inserts it into the named one — or into none, which
//   removes it from every orchestrator.
//
// OPERATIONAL MODEL
//   Line surgery on master.conf, not a re-serialisation. The file is read as lines, one line
//   is relocated, and the rest is written back byte for byte. master.conf is hand-maintained
//   and full of comments, grouping and deliberate ordering that no round trip through a
//   parser would preserve.
//
//   Two passes, in order: remove first, then insert. Doing it in one pass would need to know
//   whether the target array comes before or after the source, and getting that wrong would
//   either duplicate the entry or drop it.
//
//   Position within the target array is the end, immediately before its closing paren.
//   Orchestrators run their arrays in order, so appending is the only placement that does not
//   silently reorder someone else's work.
//
// DESIGN PRINCIPLES
//   Move is remove-plus-insert, and remove alone is a valid operation.
//       An empty to_array performs only the removal pass, which is how a script is taken out
//       of every orchestrator. That is a distinct intent from conf_toggle.php's commenting
//       out — this removes the line, that disables it in place.
//
//   Indentation is normalised on re-insertion.
//       The moved line is rewritten as two spaces and the quoted path, so a script does not
//       carry its old array's formatting into its new one.
//
//   A move that finds nothing to move still succeeds.
//       The removal pass is best-effort; only a missing *target* is an error. A script that
//       was in no array is simply added to the one requested.
//
// OPERATIONAL SAFEGUARDS
//   POST only, checked before any parameter is read.
//
//   Both inputs are pattern-matched, and neither is used as a path.
//       The script must match ^[A-Za-z0-9_.\-/]+\.sh$ with an explicit '..' check, and the
//       array name ^[A-Z_]+_SCRIPTS$. Both then reach the matcher only through preg_quote(),
//       so they are needles matched against existing lines — nothing is opened or executed
//       from either.
//
//   The scope of both passes is bounded to array bodies.
//       Each pass tracks whether it is inside a *_SCRIPTS=( block and ignores every line
//       outside one, so a matching string in a comment or an unrelated variable is never
//       moved or displaced.
//
//   A missing target array aborts before the write.
//       If the insert pass never finds the target, the endpoint returns an error and writes
//       nothing — the removal is discarded with it. Without that check a typo'd array name
//       would silently delete the script from the orchestrator it was in.
//
//   master.conf is confirmed present and readable before either pass.
//       Both file_exists() and the file() result are checked, so a missing or unreadable conf
//       returns a named error rather than writing a file built from an empty line list.
//
//   The write is atomic.
//       vv_write_conf_raw() writes .vv.tmp and rename()s. Every script sources master.conf,
//       so a truncated write here would be a system-wide outage rather than a lost move.
//
//   The push happens only after a confirmed write, and its result is returned.
//       A partner that did not receive the move is exactly the state that makes one host run
//       a script the other does not, so the per-host outcome is reported rather than
//       discarded.
//
// REQUEST
//   POST script=<Category/name.sh>  to_array=<NAME>_SCRIPTS
//   POST script=<Category/name.sh>  to_array=            remove from all arrays
//
// RESPONSE
//   {"ok":true,"push":[{"host","ok","ready","error"}, …]}
//   {"ok":false,"error":"POST only"|"Invalid script"|"Invalid array name"
//                      |"master.conf not found"|"Could not read master.conf"
//                      |"Target array \"…\" not found in master.conf"|"Write failed"}
//
// DEPENDS ON
//   include/config.php   CONF_DIR, vv_write_conf_raw(), vv_push_master_conf(),
//                        vv_push_setup_state()
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    echo json_encode(['ok' => false, 'error' => 'POST only']);
    exit;
}

$script  = trim($_POST['script']   ?? '');
$toArray = trim($_POST['to_array'] ?? '');

if (!$script || str_contains($script, '..') || !preg_match('/^[A-Za-z0-9_.\-\/]+\.sh$/', $script)) {
    echo json_encode(['ok' => false, 'error' => 'Invalid script']);
    exit;
}
if ($toArray && !preg_match('/^[A-Z_]+_SCRIPTS$/', $toArray)) {
    echo json_encode(['ok' => false, 'error' => 'Invalid array name']);
    exit;
}

$confPath = CONF_DIR . '/master.conf';
if (!file_exists($confPath)) {
    echo json_encode(['ok' => false, 'error' => 'master.conf not found']);
    exit;
}

$lines = file($confPath, FILE_KEEP_BLANK_LINES);
if (!$lines) {
    echo json_encode(['ok' => false, 'error' => 'Could not read master.conf']);
    exit;
}

$scriptEsc   = preg_quote($script, '/');
$removedLine = null;
$inArray     = false;

// Step 1: find and remove the script line from whatever array it is currently in.
$newLines = [];
foreach ($lines as $line) {
    if (preg_match('/^\s*[A-Z_]+_SCRIPTS\s*=\s*\(/', $line)) $inArray = true;
    if ($inArray && preg_match('/^\s*\)\s*(?:#.*)?$/', $line) && !str_contains($line, '(')) $inArray = false;
    if ($inArray && preg_match('/^\s*(?:#\s*)?"' . $scriptEsc . '(?:\s[^"]*)?"/', $line)) {
        $removedLine = '  "' . $script . '"' . "\n"; // normalise indentation when re-inserting
        continue; // drop from current location
    }
    $newLines[] = $line;
}

// Step 2: insert into target array (if specified).
if ($toArray) {
    $resultLines = [];
    $inTarget    = false;
    $inserted    = false;
    foreach ($newLines as $line) {
        if (preg_match('/^\s*' . preg_quote($toArray, '/') . '\s*=\s*\(/', $line)) $inTarget = true;
        if ($inTarget && !$inserted && preg_match('/^\s*\)\s*(?:#.*)?$/', $line) && !str_contains($line, '(')) {
            $resultLines[] = $removedLine ?? ('  "' . $script . '"' . "\n");
            $inTarget  = false;
            $inserted  = true;
        }
        $resultLines[] = $line;
    }
    if (!$inserted) {
        echo json_encode(['ok' => false, 'error' => 'Target array "' . $toArray . '" not found in master.conf']);
        exit;
    }
    $newLines = $resultLines;
}

if (!vv_write_conf_raw('master.conf', implode('', $newLines))) {
    echo json_encode(['ok' => false, 'error' => 'Write failed']);
    exit;
}

// Reported rather than discarded — a partner that did not receive the move is exactly the
// state that makes one host run a script the other does not. Mirrors rawconf/confform.
$push = vv_push_master_conf();
vv_push_setup_state();
echo json_encode(['ok' => true, 'push' => $push]);
