<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
//   Sync window editor. Lists the available scripts and rsync profiles, and saves which
//   scripts and which shares belong to one maintenance window — critical, intermediate, daily
//   or weekly.
//
// OPERATIONAL MODEL
//   One save writes to two files, because a window is defined in two places. The script list
//   is a *_MAINTENANCE_SCRIPTS array in master.conf, shared across the partnership; the share
//   list is a <HOST>_*_SYNC_SHARES array in this host's own conf, because what a host syncs
//   is local to it. The three-file model puts them there, and this endpoint respects that
//   split rather than flattening it.
//
//   The two halves are written independently and their failures reported separately. A shares
//   write that fails does not roll back a successful scripts write — they are different files
//   with different consumers, and a partial success is more useful than an all-or-nothing
//   that leaves both stale.
//
//   Scripts are edited by block surgery, shares by the structured conf writer. The script
//   array carries inline arguments and comment-disabled entries that must survive a round
//   trip; the shares array is a flat list this endpoint fully owns.
//
// DESIGN PRINCIPLES
//   The window name is a key into a fixed map, never a composed variable.
//       Four windows, each naming its two conf variables. An unrecognised window is rejected
//       before anything is read, so no part of the request can name a conf variable directly.
//
//   The script library excludes what cannot be scheduled here.
//       Plugin, .git, Orchestrators, Custom, Configurations, Deployment, State_Files, data and
//       the archive folders are filtered out, and root-level scripts are excluded by requiring
//       at least one directory component. Orchestrators are excluded specifically because
//       putting one inside another window's array is how a run becomes recursive.
//
//   Original entry lines are preserved through a save.
//       Existing entries are harvested keyed by script path and reused verbatim, so inline
//       flags survive. Only a newly added script is written as a bare quoted path.
//
//   Disabled entries stay in the file, commented — the same convention conf_toggle.php and
//   reorderarray.php use.
//
//   A share's profile is optional and encoded inline as path|profile, matching what rsync.sh
//   parses. No profile means the default.
//
// OPERATIONAL SAFEGUARDS
//   Every script id is validated, and an invalid one fails the request rather than being
//   skipped.
//       The block is regenerated from the submitted list alone, so a silently dropped entry is
//       a script silently removed from its window. Validation completes before the block is
//       rebuilt.
//
//   Share paths must be absolute with no traversal, and profile names are constrained.
//       ^[A-Za-z0-9_\-]+$ on the profile, because it is spliced into a quoted conf array
//       element where a quote would terminate the string and a paren would close the array.
//
//   A missing script array aborts that half of the save.
//       Both block boundaries must be found, otherwise an error is recorded and nothing is
//       written — without it, a splice would land at an undefined position.
//
//   Both writes are atomic.
//       The scripts half goes through vv_write_conf_raw() (tmp + rename) and the shares half
//       through vv_conf_write_changes(), which does the same. Every script sources master.conf;
//       a truncated write here would be a system-wide outage rather than a lost edit.
//
//   The shares write is syntax-checked before it lands.
//       vv_conf_write_changes() runs `bash -n` on the result, so a share path that would not
//       parse is reported as a failed write with the original conf intact.
//
//   master.conf is pushed to partners after a confirmed write.
//       It is a shared file; leaving one host's window definition ahead of the other's is what
//       makes the two run different work. Mirrors reorderarray, movescript and rawconf.
//
//   The script scan is wrapped in a try/catch, so an unreadable subdirectory yields a partial
//   library rather than a 500.
//
//   Known limit: block detection counts parens textually.
//       depth is tracked with substr_count, which does not know about quotes or comments. An
//       entry whose arguments contained an unbalanced paren would end the block early. No
//       current entry does — but a future one would be the thing that broke this.
//
// REQUEST
//   GET|POST ?action=list_scripts     .sh files grouped by folder, schedulable ones only
//   GET|POST ?action=list_profiles    rsync profile names declared in master.conf
//   POST action=save  win_key=critical|intermediate|daily|weekly
//        scripts=<JSON [{"id","enabled"}]>   shares=<JSON [{"path","profile"}]>
//        Either list may be omitted; only the ones supplied are written.
//
// RESPONSE
//   list_scripts   {"ok":true,"groups":{"<folder>":[{"id","label"}, …]}}
//   list_profiles  {"ok":true,"profiles":["…"]}
//   save           {"ok":bool,"errors":[…]}
//   {"ok":false,"error":"Invalid window"|"Invalid script id: …"|"Unknown action"}
//
// DEPENDS ON
//   include/config.php     SCRIPTS_DIR, CONF_DIR, vv_detect_host(), vv_read_conf_raw(),
//                          vv_write_conf_raw(), vv_push_master_conf(), vv_push_setup_state()
//   include/confform.php   vv_conf_write_changes()
//   Rsync/rsync.sh         consumer of the shares arrays this writes
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/config.php';
require_once dirname(__DIR__) . '/include/confform.php';
require_once dirname(__DIR__) . '/include/common.php';

$action = $_GET['action'] ?? $_POST['action'] ?? '';

// ── Script library — all .sh files grouped by folder ─────────────────────────
if ($action === 'list_scripts') {
    $base    = rtrim(SCRIPTS_DIR, '/') . '/';
    $exclude = ['Plugin', '.git', 'Orchestrators', 'Custom', 'Configurations',
                'Deployment', 'State_Files', 'data', 'Old_Arch_Still_Works', 'Kernel'];
    $groups  = [];
    try {
        $ri = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator(SCRIPTS_DIR, RecursiveDirectoryIterator::SKIP_DOTS)
        );
        foreach ($ri as $rf) {
            if (!$rf->isFile() || strtolower($rf->getExtension()) !== 'sh') continue;
            $rel    = ltrim(str_replace($base, '', $rf->getPathname()), '/');
            $parts  = explode('/', $rel);
            if (count($parts) < 2 || in_array($parts[0], $exclude)) continue;
            $folder = $parts[0];
            $label  = str_replace('_', ' ', basename($rel, '.sh'));
            $groups[$folder][] = ['id' => $rel, 'label' => $label];
        }
    } catch (Exception $e) {}
    ksort($groups);
    foreach ($groups as &$g) usort($g, fn($a, $b) => strcmp($a['id'], $b['id']));
    echo json_encode(['ok' => true, 'groups' => $groups]);
    exit;
}

// ── Profile names from master.conf ────────────────────────────────────────────
if ($action === 'list_profiles') {
    $raw = vv_read_conf_raw('master.conf');
    preg_match_all('/^\s*PROFILE_([A-Z0-9_]+)_RSYNC_OPTS\s*=/m', $raw, $m);
    $profiles = array_values(array_unique(
        array_map(fn($n) => strtolower(str_replace('_', '-', $n)), $m[1] ?? [])
    ));
    echo json_encode(['ok' => true, 'profiles' => $profiles]);
    exit;
}

// ── Save scripts + shares for a window ───────────────────────────────────────
if ($action === 'save' && $_SERVER['REQUEST_METHOD'] === 'POST') {
    $winKey  = trim($_POST['win_key'] ?? '');
    $scripts = json_decode($_POST['scripts'] ?? 'null', true);
    $shares  = json_decode($_POST['shares']  ?? 'null', true);

    $winMap = [
        'critical'     => ['CRITICAL_MAINTENANCE_SCRIPTS',     'CRITICAL_SYNC_SHARES'],
        'intermediate' => ['INTERMEDIATE_MAINTENANCE_SCRIPTS', 'INTERMEDIATE_SYNC_SHARES'],
        'daily'        => ['DAILY_MAINTENANCE_SCRIPTS',        'DAILY_SYNC_SHARES'],
        'weekly'       => ['WEEKLY_MAINTENANCE_SCRIPTS',       'WEEKLY_SYNC_SHARES'],
    ];

    if (!isset($winMap[$winKey])) {
        echo json_encode(['ok' => false, 'error' => 'Invalid window']); exit;
    }

    [$scriptsVar, $sharesBase] = $winMap[$winKey];
    $myId      = strtoupper(vv_detect_host());
    $sharesVar = "{$myId}_{$sharesBase}";
    $errors    = [];

    // ── Scripts → master.conf ─────────────────────────────────────────────────
    if (is_array($scripts)) {
        $confPath = CONF_DIR . '/master.conf';
        $lines    = file($confPath, FILE_KEEP_BLANK_LINES) ?: [];
        $esc      = preg_quote($scriptsVar, '/');
        $blockStart = $blockEnd = null;
        $depth      = 0;
        $origLines  = [];

        foreach ($lines as $i => $line) {
            if ($blockStart === null) {
                if (preg_match('/^\s*' . $esc . '\s*=\s*\(/', $line)) { $blockStart = $i; $depth = 1; }
                continue;
            }
            $depth += substr_count($line, '(') - substr_count($line, ')');
            if ($depth <= 0) { $blockEnd = $i; break; }
            if (preg_match('/^\s*(?:#\s*)?"([^"]+)"/', $line, $m)) {
                $path = explode(' ', trim($m[1]))[0];
                if (str_ends_with($path, '.sh') && !isset($origLines[$path]))
                    $origLines[$path] = '"' . $m[1] . '"';
            }
        }

        if ($blockStart !== null && $blockEnd !== null) {
            // Validate the whole list before rebuilding. The block is regenerated from this
            // loop alone, so skipping an invalid entry would silently drop that script from
            // the orchestrator rather than reporting a bad request.
            $bad = null;
            foreach ($scripts as $item) {
                $id = trim((string)($item['id'] ?? ''));
                if (!$id || str_contains($id, '..') || !preg_match('/^[A-Za-z0-9_.\-\/]+\.sh$/', $id)) {
                    $bad = $id;
                    break;
                }
            }
            if ($bad !== null) {
                echo json_encode(['ok' => false, 'error' => 'Invalid script id: ' . $bad]);
                exit;
            }

            $newBlock = [$lines[$blockStart]];
            foreach ($scripts as $item) {
                $id         = trim((string)$item['id']);
                $enabled    = !isset($item['enabled']) || (bool)$item['enabled'];
                $entry      = $origLines[$id] ?? '"' . $id . '"';
                $prefix     = $enabled ? '        ' : '        #';
                $newBlock[] = $prefix . $entry . "\n";
            }
            $newBlock[] = $lines[$blockEnd];
            array_splice($lines, $blockStart, $blockEnd - $blockStart + 1, $newBlock);
            // tmp+rename — every script sources master.conf, so a truncated write here is a
            // system-wide outage, not a lost edit.
            if (!vv_write_conf_raw('master.conf', implode('', $lines))) {
                $errors[] = 'scripts write failed';
            } else {
                // master.conf is shared — mirrors reorderarray/movescript/rawconf.
                vv_push_master_conf();
                vv_push_setup_state();
            }
        } else {
            $errors[] = "Array $scriptsVar not found in master.conf";
        }
    }

    // ── Shares → host*.conf ───────────────────────────────────────────────────
    if (is_array($shares)) {
        $confFile = strtolower($myId) . '.conf';
        $inner    = '';
        foreach ($shares as $item) {
            $path    = trim((string)($item['path']    ?? ''));
            $profile = trim((string)($item['profile'] ?? ''));
            if (!$path || str_contains($path, '..') || !str_starts_with($path, '/')) continue;
            // The profile name is spliced into a quoted conf array element; anything outside
            // this set could terminate the string or the array.
            if ($profile !== '' && !preg_match('/^[A-Za-z0-9_\-]+$/', $profile)) continue;
            $val    = $profile ? "{$path}|{$profile}" : $path;
            $inner .= '        "' . $val . '"' . "\n";
        }
        $results = vv_conf_write_changes([[
            'file'  => $confFile,
            'key'   => $sharesVar,
            'value' => rtrim($inner),
            'type'  => 'array',
        ]]);
        if (in_array(false, $results, true)) $errors[] = 'shares write failed';
    }

    echo json_encode(['ok' => empty($errors), 'errors' => $errors]);
    exit;
}

echo json_encode(['ok' => false, 'error' => 'Unknown action']);
