<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
//   The scheduler's engine. Owns schedule.json, regenerates the Varaverk cron file, builds
//   the script library shown on the scheduler page, and reads each script's own header to
//   describe it in the UI.
//
// OPERATIONAL MODEL
//   Writes state that changes what the machine does on a timer. A bad write here does not
//   break a page — it changes which jobs run, or stops them running at all.
//
// DESIGN PRINCIPLES
//   The bash header is the description; the UI does not keep its own copy.
//       vv_script_description() parses the PURPOSE (or DESCRIPTION) block out of the script
//       itself. A script's documentation and its listing cannot drift apart, because they are
//       the same text. This is the live consumer of the repo-wide header convention.
//
//   schedule.json is per-host and never synced.
//       What a node runs is a property of that node. Syncing it would hand a partner this
//       host's job list, which is exactly wrong under a mutual-redundancy model.
//
//   The cron file is regenerated, never edited in place.
//       vv_cron_rebuild() emits the whole file from schedule.json. Incremental edits are how
//       a cron file accumulates entries nobody can account for.
//
//   Scripts are discovered from conf arrays and the filesystem.
//       Orchestrator job lists come from master.conf; user scripts are any *.sh dropped in
//       CUSTOM_SCRIPTS_DIR. Neither requires registration in a second place.
//
// OPERATIONAL SAFEGUARDS
//   The cron file must stay in /boot.
//       update_cron merges plugin *.cron files from there into /etc/cron.d/root. Relocating
//       it silently stops every scheduled job — nothing errors, the jobs simply never fire.
//
//   The legacy direct cron file is removed on rebuild.
//       A file left over from before the update_cron migration would fire every job a second
//       time. Rebuild deletes it rather than assuming it is gone.
//
//   Custom scripts live outside the git repo.
//       CUSTOM_SCRIPTS_DIR points at the User Scripts plugin's own storage, so a user's
//       scripts are never touched by a pull and never committed by accident.
//
//   Shell arguments are escaped where the schedule feeds a command line.
//
// EXPORTS
//   Schedule   vv_schedule_load(), vv_schedule_save(), vv_schedule_update(),
//              vv_schedule_update_batch(), vv_cron_rebuild(), vv_script_suggested_cron()
//   Library    vv_script_library(), vv_tools_scripts(), vv_custom_scripts(),
//              vv_rsync_standalone(), vv_orch_conf_arrays(), vv_job_tree(), vv_script_children()
//   Headers    vv_script_header(), vv_script_header_clean(), vv_script_description(),
//              vv_readme_section(), vv_parse_user_script_template()
//   Conf       vv_conf_script_map(), vv_conf_flag_value(), vv_conf_flag_set(),
//              vv_conf_toggle_script(), vv_parse_conf_array(), vv_parse_conf_array_full()
//   Paths      vv_job_flags(), vv_job_log_path(), vv_job_stat_path(), vv_folders_load()
//
// CONFIGURATION
//   SCHEDULE_FILE        SCRIPTS_DIR/schedule.json — per-host, never synced
//   CRON_FILE            /boot/config/plugins/varaverk/varaverk.cron — must stay in /boot
//   CUSTOM_SCRIPTS_DIR   user-authored scripts, outside the repo
//   master.conf          orchestrator job arrays drive the script tree
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// Scheduler — manages schedule.json and the Unraid plugin cron file.
// schedule.json is per-host, never synced.

require_once __DIR__ . '/config.php';
require_once __DIR__ . '/confform.php';

define('SCHEDULE_FILE', SCRIPTS_DIR . '/schedule.json');
define('CRON_FILE',     '/boot/config/plugins/varaverk/varaverk.cron'); // must stay in /boot — update_cron scans there

function vv_pretty_label(string $slug): string {
    return ucwords(str_replace('_', ' ', $slug));
}

function vv_schedule_load(): array {
    if (!file_exists(SCHEDULE_FILE)) return [];
    $data = json_decode(file_get_contents(SCHEDULE_FILE), true);
    return is_array($data) ? $data : [];
}

function vv_schedule_save(array $schedule): bool {
    $dir = dirname(SCHEDULE_FILE);
    if (!is_dir($dir)) mkdir($dir, 0755, true);
    return file_put_contents(SCHEDULE_FILE, json_encode($schedule, JSON_PRETTY_PRINT)) !== false;
}

function vv_schedule_update(string $id, bool $enabled, string $cron, bool $log_enabled = false): bool {
    $schedule = vv_schedule_load();
    $schedule[$id] = [
        'id'          => $id,
        'enabled'     => $enabled,
        'cron'        => $cron,
        'log_enabled' => $log_enabled,
        'updated'     => date('c'),
    ];
    if (!vv_schedule_save($schedule)) return false;
    return vv_cron_rebuild($schedule);
}

function vv_schedule_update_batch(array $entries): bool {
    $schedule = vv_schedule_load();
    foreach ($entries as $e) {
        $id = trim($e['id'] ?? '');
        if (!$id) continue;
        $schedule[$id] = [
            'id'          => $id,
            'enabled'     => (bool)($e['enabled'] ?? false),
            'cron'        => trim($e['cron'] ?? ''),
            'log_enabled' => (bool)($e['log_enabled'] ?? false),
            'updated'     => date('c'),
        ];
    }
    if (!vv_schedule_save($schedule)) return false;
    return vv_cron_rebuild($schedule);
}

function vv_job_flags(string $id): string {
    $schedule = vv_schedule_load();
    return !empty($schedule[$id]['log_enabled']) ? '--log' : '';
}

function vv_job_log_path(string $id): string {
    return LOG_DIR . '/' . preg_replace('/\.sh$/', '.log', $id);
}

function vv_job_stat_path(string $id): string {
    return LOG_DIR . '/' . preg_replace('/\.sh$/', '.json', $id);
}

function vv_cron_rebuild(array $schedule): bool {
    if (!is_dir(LOG_DIR)) mkdir(LOG_DIR, 0755, true);

    $runner  = dirname(__DIR__) . '/run_job.sh';
    $lines   = ["# Varaverk — managed by plugin, do not edit manually"];
    $lines[] = "# Regenerated: " . date('Y-m-d H:i:s');
    $lines[] = "";

    // Build child→orch map so we can suppress a child's independent cron when its orch is enabled.
    $childToOrch = [];
    $confRaw     = file_get_contents(CONF_DIR . '/master.conf') ?: '';
    foreach (glob(SCRIPTS_DIR . '/Orchestrators/*.sh') ?: [] as $orchPath) {
        $orchId  = 'Orchestrators/' . basename($orchPath);
        $content = file_get_contents($orchPath) ?: '';
        preg_match_all('/\$[A-Z_]+\/(?:\.\.\/)?([A-Za-z][A-Za-z0-9_.\-]*\/[A-Za-z0-9_.\-]+\.sh)/', $content, $m1);
        foreach ($m1[1] as $rel) $childToOrch[$rel] = $orchId;
        preg_match_all('/\$\{([A-Z_]+_SCRIPTS)\[@\]\}/', $content, $refs);
        foreach (array_unique($refs[1] ?? []) as $var) {
            foreach (vv_parse_conf_array_full($confRaw, $var) as $item) $childToOrch[$item['path']] = $orchId;
        }
    }

    $scriptsDir = SCRIPTS_DIR;
    foreach ($schedule as $entry) {
        if (empty($entry['enabled']) || empty($entry['cron']) || empty($entry['id'])) continue;
        // Event-triggered jobs are handled by static event scripts, not cron.
        if (str_starts_with($entry['cron'], 'array_')) continue;
        $id = $entry['id'];
        // When a child's orch is enabled it is the sole trigger — suppress independent cron.
        if (isset($childToOrch[$id]) && !empty($schedule[$childToOrch[$id]]['enabled'])) continue;
        // Custom Scripts live outside the repo (CUSTOM_SCRIPTS_DIR) — everything else resolves under SCRIPTS_DIR.
        $script  = str_starts_with($id, 'Custom/')
            ? CUSTOM_SCRIPTS_DIR . '/' . substr($id, strlen('Custom/'))
            : "$scriptsDir/$id";
        $flags   = !empty($entry['log_enabled']) ? ' --log' : '';
        $lines[] = "{$entry['cron']} bash \"$runner\" \"$id\" \"$script\"$flags";
    }
    $lines[] = "";

    // Standalone rsync entries: fire when orch is disabled but location + cron are both configured.
    $scriptsDir = SCRIPTS_DIR;
    foreach ($schedule as $key => $entry) {
        if (!str_starts_with((string)$key, '__rsync_')) continue;
        $orchId   = $entry['orch_id']  ?? '';
        $location = $entry['location'] ?? '';
        $cron     = $entry['cron']     ?? '';
        if (!$orchId || !$location || !$cron) continue;
        // Skip if orch is still enabled
        if (!empty($schedule[$orchId]['enabled'])) continue;
        $rsyncScript = "$scriptsDir/Rsync/rsync.sh";
        if (!file_exists($rsyncScript)) continue;
        $locArg  = escapeshellarg('--location=' . $location);
        $logFlag = !empty($entry['log_enabled']) ? ' --log' : '';
        $lines[] = "$cron bash \"$runner\" \"Rsync/rsync.sh\" \"$rsyncScript\" $locArg$logFlag";
    }
    $lines[] = "";

    // Background writers — always injected, never user-configurable (excluded from scheduler UI).
    $toolsDir = SCRIPTS_DIR . '/Plugin/unraid/Tools';
    foreach ([
        ['* * * * *',   'api_cache_writer.sh'],
        ['0 */2 * * *', 'remote_arr_cache_writer.sh'],
    ] as [$cron, $script]) {
        $path = "$toolsDir/$script";
        if (file_exists($path)) $lines[] = "$cron bash \"$runner\" \"Plugin/unraid/Tools/$script\" \"$path\"";
    }
    $lines[] = "";

    // Write to the plugin cron file; update_cron merges all plugin *.cron files into /etc/cron.d/root.
    if (file_put_contents(CRON_FILE, implode("\n", $lines)) === false) return false;
    exec('/usr/local/sbin/update_cron');
    // Remove legacy direct cron file left from before update_cron migration — prevents duplicate job firing.
    @unlink('/etc/cron.d/varaverk');
    return true;
}

// Extract the suggested cron and label from a bash script header.
// Looks for:  # Schedule: */7 * * * * (every 7 minutes via User Scripts plugin)
function vv_script_suggested_cron(string $path): array {
    if (!file_exists($path)) return ['cron' => '', 'label' => ''];
    $lines = array_slice(file($path) ?: [], 0, 30);
    foreach ($lines as $raw) {
        $raw = rtrim($raw);
        if (!preg_match('/^#\s*Schedule:\s*(.+)$/i', $raw, $m)) continue;
        $tail  = trim($m[1]);
        $parts = preg_split('/\s+/', $tail, 6);
        $cron  = implode(' ', array_slice($parts, 0, 5));
        $label = isset($parts[5]) ? trim($parts[5], '() ') : '';
        return ['cron' => $cron, 'label' => $label];
    }
    return ['cron' => '', 'label' => ''];
}

// Parse user_script_plug-in.sh into an array of script blocks.
// Each block: title, schedule, desc (array of lines), scripts (array of {rel, cron})
function vv_parse_user_script_template(): array {
    $file   = SCRIPTS_DIR . '/Plugin/unraid/user_script_plug-in.sh';
    if (!file_exists($file)) return [];
    $lines  = file($file, FILE_IGNORE_NEW_LINES);
    $prefix = rtrim(SCRIPTS_DIR, '/') . '/';
    $blocks = [];
    $cur    = null;

    foreach ($lines as $line) {
        // Block header:  # ── TITLE ──...
        if (preg_match('/^# ── (.+?) ─/', $line, $m)) {
            if ($cur) $blocks[] = $cur;
            $cur = ['title' => trim($m[1]), 'schedule' => '', 'desc' => [], 'scripts' => []];
            continue;
        }
        if (!$cur) continue;

        // Schedule / Background — captured but not added to desc
        if (preg_match('/^# (Schedule|Background):\s+(.+)$/i', $line, $m)) {
            if (strtolower($m[1]) === 'schedule') $cur['schedule'] = trim($m[2]);
            continue;
        }

        // Sunday block inline cron:  # 0  6 * * 0  bash /path/script.sh [args]
        if (preg_match('/^#\s+(\S+ +\S+ +\S+ +\S+ +\S+)\s+bash\s+(\S+\.sh)/', $line, $m)) {
            $rel = str_replace($prefix, '', trim($m[2]));
            $cur['scripts'][] = ['rel' => $rel, 'cron' => preg_replace('/\s+/', ' ', trim($m[1]))];
            continue;
        }

        // Standard bash line:  # bash /prefix/path/script.sh [args]
        if (preg_match('/^# bash\s+(\S+\.sh)/', $line, $m)) {
            $rel = str_replace($prefix, '', $m[1]);
            // Only add primary command (dedupe by rel)
            $rels = array_column($cur['scripts'], 'rel');
            if (!in_array($rel, $rels)) {
                $cur['scripts'][] = ['rel' => $rel, 'cron' => ''];
            }
            continue;
        }

        // Description line
        if (preg_match('/^# ?(.*)$/', $line, $m)) {
            $inner = $m[1];
            if (!preg_match('/^[─━=\-]{3,}\s*$/', $inner) && !preg_match('/^█/', $inner)) {
                $cur['desc'][] = $inner;
            }
        }
    }
    if ($cur) $blocks[] = $cur;
    return $blocks;
}

// Extract a one-line description from a bash script header.
// Supports two patterns:
//   1. # PURPOSE (or # DESCRIPTION) block — returns first non-separator line after it
//   2. First meaningful comment line after the banner
function vv_script_description(string $path): string {
    if (!file_exists($path)) return '';
    $lines = array_slice(file($path) ?: [], 0, 50);
    $purposeNext = false;
    $first = '';
    foreach ($lines as $raw) {
        $raw = rtrim($raw);
        if (str_starts_with($raw, '#!')) continue;                      // shebang
        if (!str_starts_with($raw, '#'))  continue;                      // non-comment
        $inner = ltrim(substr($raw, 1));                                  // strip leading #
        if ($inner === '') continue;                                        // blank
        if (preg_match('/^[\s=\-─━\*]+$/', $inner)) continue;             // pure separator
        if (preg_match('/^={3,}/', $inner)) continue;                      // banner (=== Title ===)
        if (preg_match('/^\s*(PURPOSE|DESCRIPTION|Description)\s*$/i', $inner)) {
            $purposeNext = true;
            continue;
        }
        if ($purposeNext) {
            return mb_substr(trim($inner), 0, 200);
        }
        if (!$first) $first = mb_substr(trim($inner), 0, 200);
    }
    return $first;
}

function vv_tools_scripts(): array {
    // Background writers managed automatically — not user-facing tools
    static $EXCLUDE = ['api_cache_writer.sh', 'remote_arr_cache_writer.sh'];

    $schedule = vv_schedule_load();
    $scripts  = [];

    $collect = function(string $dir, string $relPrefix) use ($schedule, $EXCLUDE, &$scripts): void {
        foreach (glob("$dir/*.sh") ?: [] as $path) {
            $base = basename($path);
            if (in_array($base, $EXCLUDE, true)) continue;
            $rel       = $relPrefix . $base;
            $entry     = $schedule[$rel] ?? [];
            $scripts[] = [
                'id'          => $rel,
                'label'       => vv_pretty_label(basename($path, '.sh')),
                'desc'        => vv_script_description($path),
                'enabled'     => (bool)($entry['enabled'] ?? false),
                'cron'        => $entry['cron'] ?? '',
                'log_enabled' => (bool)($entry['log_enabled'] ?? false),
            ];
        }
    };

    // General tools
    $collect(SCRIPTS_DIR . '/Tools', 'Tools/');

    // Platform adapter tools (Plugin/<platform>/Tools/)
    foreach (glob(SCRIPTS_DIR . '/Plugin/*/Tools') ?: [] as $toolsDir) {
        $platform = basename(dirname($toolsDir));
        $collect($toolsDir, "Plugin/$platform/Tools/");
    }

    usort($scripts, fn($a, $b) => strcmp($a['label'], $b['label']));
    return $scripts;
}

// Lists every Custom Script for the scheduler page. Discovery is glob-based, not a
// registry — any *.sh file dropped directly into CUSTOM_SCRIPTS_DIR (or a platform
// adapter's own Custom/ folder) shows up here, whether or not it was created via the
// page's "+ Create Script" editor or has a schedule.json entry yet.
function vv_custom_scripts(): array {
    $schedule = vv_schedule_load();
    $scripts  = [];

    $collect = function(string $dir, string $relPrefix) use ($schedule, &$scripts): void {
        foreach (glob("$dir/*.sh") ?: [] as $path) {
            $rel       = $relPrefix . basename($path);
            $entry     = $schedule[$rel] ?? [];
            $scripts[] = [
                'id'          => $rel,
                'label'       => vv_pretty_label(basename($path, '.sh')),
                'desc'        => vv_script_description($path),
                'enabled'     => (bool)($entry['enabled'] ?? false),
                'cron'        => $entry['cron'] ?? '',
                'log_enabled' => (bool)($entry['log_enabled'] ?? false),
            ];
        }
    };

    $collect(CUSTOM_SCRIPTS_DIR, 'Custom/');

    // Platform adapter custom scripts (Plugin/<platform>/Custom/)
    foreach (glob(SCRIPTS_DIR . '/Plugin/*/Custom') ?: [] as $customDir) {
        $platform = basename(dirname($customDir));
        $collect($customDir, "Plugin/$platform/Custom/");
    }

    return $scripts;
}

// Load rsync standalone config (location + cron) for a flag name from schedule.json.
function vv_rsync_standalone(string $flagName): array {
    $s = vv_schedule_load();
    $r = $s['__rsync_' . $flagName] ?? [];
    return [
        'location' => (string)($r['location'] ?? ''),
        'cron'     => (string)($r['cron']     ?? ''),
    ];
}

// Extract *_SCRIPTS array variable names that an orchestrator iterates over.
function vv_orch_conf_arrays(string $orchPath): array {
    $content = file_get_contents($orchPath) ?: '';
    preg_match_all('/\$\{([A-Z_]+_SCRIPTS)\[@\]\}/', $content, $refs);
    return array_unique($refs[1] ?? []);
}

// Return .sh scripts that exist in SCRIPTS_DIR but are not referenced in any
// master.conf *_SCRIPTS array and are not shown in any other scheduler card.
function vv_script_library(): array {
    $scriptsDir = SCRIPTS_DIR;
    $confMap    = vv_conf_script_map();
    $orchIds    = [];
    foreach (glob("$scriptsDir/Orchestrators/*.sh") ?: [] as $p) {
        $orchIds[] = 'Orchestrators/' . basename($p);
    }
    // Scripts already shown in their own cards are not "unlisted"
    $schedule = vv_schedule_load();
    $cardIds  = array_flip(array_merge(
        array_column(vv_tools_scripts(),  'id'),
        array_column(vv_custom_scripts(), 'id')
    ));

    // UI-only subdirs under Plugin/<platform>/ — no runnable scripts
    $pluginUiDirs = ['api', 'include', 'pages', 'css', 'js', 'icons', 'event'];

    $exclude = ['.git', 'Orchestrators', 'Custom', 'Configurations'];
    $library = [];
    try {
        $ri   = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($scriptsDir, RecursiveDirectoryIterator::SKIP_DOTS)
        );
        $base = rtrim($scriptsDir, '/') . '/';
        foreach ($ri as $rf) {
            if (!$rf->isFile() || strtolower($rf->getExtension()) !== 'sh') continue;
            $rel   = ltrim(str_replace($base, '', $rf->getPathname()), '/');
            $parts = explode('/', $rel);
            if (in_array($parts[0], $exclude)) continue;
            if ($parts[0] === 'Plugin') {
                // Require Plugin/<platform>/<category>/<script>.sh — skip root-level adapter files
                if (count($parts) < 4) continue;
                // Skip UI-only category dirs
                if (in_array($parts[2], $pluginUiDirs)) continue;
            } elseif (count($parts) < 2) {
                continue;
            }
            if (in_array($rel, $orchIds) || isset($confMap[$rel]) || isset($cardIds[$rel]) || isset($schedule[$rel])) continue;
            $library[] = ['id' => $rel, 'label' => vv_pretty_label(basename($rel, '.sh'))];
        }
    } catch (Exception $e) {}
    usort($library, fn($a, $b) => strcmp($a['id'], $b['id']));
    return $library;
}

// Load custom-script folder assignments from schedule.json (__folders key).
function vv_folders_load(): array {
    $s = vv_schedule_load();
    $f = $s['__folders'] ?? [];
    return is_array($f) ? $f : [];
}

// Walk the scripts repo and return the job tree.
// Type is derived from the saved cron value: array_start / array_stop → 'event', else 'orchestrator'.
// Well-known event orchs get their cron seeded from $eventDefaults when not yet in schedule.json.
// Any orch or custom script can carry array_start / array_stop as its cron — the event scripts fire all of them.
function vv_job_tree(): array {
    $scriptsDir = SCRIPTS_DIR;
    $schedule   = vv_schedule_load();

    // First-run seeds only — applied when schedule.json has no entry for these IDs yet.
    $eventDefaults = [
        'Orchestrators/array_started.sh'  => 'array_start',
        'Orchestrators/array_stopping.sh' => 'array_stop',
    ];

    $orchs = [];
    foreach (glob("$scriptsDir/Orchestrators/*.sh") ?: [] as $path) {
        $id      = 'Orchestrators/' . basename($path);
        $default = $eventDefaults[$id] ?? '';
        $entry   = $schedule[$id] ?? ['enabled' => false, 'cron' => $default];
        $cron    = $entry['cron'] ?? $default;
        $isEvent = str_starts_with($cron, 'array_');
        $suggested = $isEvent ? ['cron' => '', 'label' => ''] : vv_script_suggested_cron($path);
        $orchs[] = [
            'id'              => $id,
            'label'           => vv_pretty_label(basename($path, '.sh')),
            'desc'            => vv_script_description($path),
            'type'            => $isEvent ? 'event' : 'orchestrator',
            'enabled'         => (bool)($entry['enabled'] ?? false),
            'cron'            => $cron,
            'log_enabled'     => (bool)($entry['log_enabled'] ?? false),
            'suggested_cron'  => $suggested['cron'],
            'suggested_label' => $suggested['label'],
            'children'        => vv_script_children($path, $schedule),
            'conf_arrays'     => vv_orch_conf_arrays($path),
        ];
    }

    // Events first (array_start before array_stop), then alphabetical by label.
    usort($orchs, function($a, $b) {
        $ae = $a['type'] === 'event' ? 0 : 1;
        $be = $b['type'] === 'event' ? 0 : 1;
        if ($ae !== $be) return $ae - $be;
        if ($ae === 0) {
            $as = str_contains($a['cron'], 'start') ? 0 : 1;
            $bs = str_contains($b['cron'], 'start') ? 0 : 1;
            if ($as !== $bs) return $as - $bs;
        }
        return strcmp($a['label'], $b['label']);
    });

    return $orchs;
}

// Parse a bash array from master.conf content and return its script paths.
// Handles entries with inline args ("script.sh --flag") and skips commented lines (#"...").
function vv_parse_conf_array(string $conf, string $varName): array {
    if (!preg_match('/^\s*' . preg_quote($varName, '/') . '\s*=\s*\((.*?)^\s*\)/ms', $conf, $m)) {
        return [];
    }
    $scripts = [];
    preg_match_all('/^\s*(?!#)"([^"]+)"/m', $m[1], $entries);
    foreach ($entries[1] as $entry) {
        $parts = preg_split('/\s+/', trim($entry));
        $path  = $parts[0] ?? '';
        if (substr($path, -3) === '.sh') $scripts[] = $path;
    }
    return $scripts;
}

// Like vv_parse_conf_array but includes commented entries.
// Returns array of ['path' => string, 'enabled' => bool].
function vv_parse_conf_array_full(string $conf, string $varName): array {
    if (!preg_match('/^\s*' . preg_quote($varName, '/') . '\s*=\s*\((.*?)^\s*\)/ms', $conf, $m)) {
        return [];
    }
    $results = [];
    foreach (explode("\n", $m[1]) as $line) {
        if (!preg_match('/^\s*(#\s*)?"([^"]+)"/', $line, $e)) continue;
        $commented = trim($e[1]) !== '';
        $parts     = preg_split('/\s+/', trim($e[2]));
        $path      = $parts[0] ?? '';
        if (substr($path, -3) !== '.sh') continue;
        $results[] = ['path' => $path, 'enabled' => !$commented];
    }
    return $results;
}

// Build a map of script rel-path → conf status by scanning all *_SCRIPTS arrays in master.conf.
// Cached per-request so multiple callers only read the file once.
function vv_conf_script_map(): array {
    static $cache = null;
    if ($cache !== null) return $cache;
    $confPath = CONF_DIR . '/master.conf';
    if (!file_exists($confPath)) return $cache = [];
    $lines    = file($confPath, FILE_IGNORE_NEW_LINES) ?: [];
    $map      = [];
    $inArray  = false;
    $arrayVar = '';
    foreach ($lines as $line) {
        if (preg_match('/^\s*([A-Z_]+_SCRIPTS)\s*=\s*\(/', $line, $am)) { $inArray = true;  $arrayVar = $am[1]; }
        if ($inArray && preg_match('/^\s*\)\s*(?:#.*)?$/', $line) && !str_contains($line, '(')) $inArray = false;
        if (!$inArray) continue;
        if (!preg_match('/^\s*(#\s*)?"([^"]+)"/', $line, $e)) continue;
        $commented = trim($e[1]) !== '';
        $parts     = preg_split('/\s+/', trim($e[2]));
        $path      = $parts[0] ?? '';
        if (substr($path, -3) !== '.sh') continue;
        if (!isset($map[$path])) $map[$path] = ['array' => $arrayVar, 'enabled' => !$commented, 'managed' => true];
    }
    return $cache = $map;
}

// Read a boolean flag value (e.g. INTERMEDIATE_RSYNC_ENABLED) from master.conf.
function vv_conf_flag_value(string $name): bool {
    $conf = file_get_contents(CONF_DIR . '/master.conf') ?: '';
    if (preg_match('/^\s*' . preg_quote($name, '/') . '\s*=\s*(true|false)\s*(?:#.*)?$/m', $conf, $m)) {
        return $m[1] === 'true';
    }
    return false;
}

// Write a boolean flag value to master.conf.
function vv_conf_flag_set(string $name, bool $value): bool {
    $confPath = CONF_DIR . '/master.conf';
    $content  = file_get_contents($confPath);
    if ($content === false) return false;
    $val      = $value ? 'true' : 'false';
    $new      = preg_replace(
        '/^(\s*' . preg_quote($name, '/') . '\s*=\s*)(true|false)(\s*(?:#.*)?)$/m',
        '${1}' . $val . '${3}',
        $content, -1, $count
    );
    if (!$count) return false;
    // tmp+rename — every script sources master.conf, so a truncated write here is a
    // system-wide outage, not a lost toggle.
    return vv_write_conf_raw('master.conf', $new);
}

// Comment or uncomment a script's line in the first master.conf array that contains it.
function vv_conf_toggle_script(string $rel, bool $enable): bool {
    $confPath = CONF_DIR . '/master.conf';
    $lines    = file($confPath, FILE_KEEP_BLANK_LINES);
    if (!$lines) return false;
    $changed  = false;
    $inArray  = false;
    $relEsc   = preg_quote($rel, '/');
    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) continue;
        if (!preg_match('/^\s*(?:#\s*)?"' . $relEsc . '(?:\s[^"]*)?"/', $line)) continue;
        $isCommented = (bool)preg_match('/^\s*#/', $line);
        if ($enable && $isCommented) {
            $line    = preg_replace('/^(\s*)#\s*("' . $relEsc . ')/', '$1$2', $line);
            $changed = true;
        } elseif (!$enable && !$isCommented) {
            $line    = preg_replace('/^(\s*)("' . $relEsc . ')/', '$1# $2', $line);
            $changed = true;
        }
        break;
    }
    unset($line);
    if (!$changed) return true;
    // tmp+rename — every script sources master.conf, so a truncated write here is a
    // system-wide outage, not a lost toggle.
    return vv_write_conf_raw('master.conf', implode('', $lines));
}

// Parse an orchestrator script to find which child scripts it calls.
// Two strategies, merged and deduped:
//   1. Static paths: $SCRIPT_DIR/../Category/script.sh or $SCRIPTS_ROOT/Category/script.sh
//   2. master.conf arrays: detects ${VARNAME[@]} iteration and reads the array from master.conf
// Root-level files (load_config.sh etc.) excluded — must be in a subdirectory.
// Each candidate validated against the filesystem.
function vv_script_children(string $orchPath, array $schedule): array {
    $scriptsDir = SCRIPTS_DIR;
    $content    = file_get_contents($orchPath) ?: '';
    $children   = [];
    $seen       = [];
    $confMap    = vv_conf_script_map();

    // Detect which tier rsync flag this orch controls (e.g. "INTERMEDIATE" → INTERMEDIATE_RSYNC_ENABLED)
    $rsyncFlagName = null;
    if (preg_match('/check_rsync_enabled\s+"([A-Z]+)"/', $content, $rm)) {
        $rsyncFlagName = $rm[1] . '_RSYNC_ENABLED';
    }

    $addChild = function(string $rel) use ($scriptsDir, $schedule, $confMap, &$children, &$seen) {
        if (isset($seen[$rel]) || !file_exists("$scriptsDir/$rel")) return;
        $seen[$rel]  = true;
        $entry       = $schedule[$rel] ?? ['enabled' => false, 'cron' => ''];
        $conf        = $confMap[$rel] ?? ['array' => null, 'enabled' => null, 'managed' => false];
        $suggested   = vv_script_suggested_cron("$scriptsDir/$rel");
        $children[]  = [
            'id'              => $rel,
            'label'           => vv_pretty_label(basename($rel, '.sh')),
            'desc'            => vv_script_description("$scriptsDir/$rel"),
            'type'            => 'script',
            'enabled'         => (bool)($entry['enabled'] ?? false),
            'cron'            => $entry['cron'] ?? '',
            'log_enabled'     => (bool)($entry['log_enabled'] ?? false),
            'conf_managed'    => $conf['managed'],
            'conf_enabled'    => $conf['enabled'],  // null if not in any *_SCRIPTS array
            'conf_array'      => $conf['array'],
            'suggested_cron'  => $suggested['cron'],
            'suggested_label' => $suggested['label'],
        ];
    };

    // Strategy 1: static variable paths ($VAR/../Category/script.sh or $VAR/Category/script.sh)
    preg_match_all(
        '/\$[A-Z_]+\/(?:\.\.\/)?([A-Za-z][A-Za-z0-9_.\-]*\/[A-Za-z0-9_.\-]+\.sh)/',
        $content, $m
    );
    foreach ($m[1] as $rel) $addChild($rel);

    // Strategy 2: master.conf arrays — includes commented (disabled) entries so they appear in the UI
    preg_match_all('/\$\{([A-Z_]+_SCRIPTS)\[@\]\}/', $content, $refs);
    if (!empty($refs[1])) {
        $confRaw = file_get_contents(CONF_DIR . '/master.conf') ?: '';
        foreach (array_unique($refs[1]) as $varName) {
            foreach (vv_parse_conf_array_full($confRaw, $varName) as $item) $addChild($item['path']);
        }
    }

    // Annotate Rsync/rsync.sh as a conf_flag child if this orch controls a rsync tier flag
    if ($rsyncFlagName) {
        foreach ($children as &$c) {
            if ($c['id'] === 'Rsync/rsync.sh') {
                $c['type']       = 'conf_flag';
                $c['flag_name']  = $rsyncFlagName;
                $c['flag_value'] = vv_conf_flag_value($rsyncFlagName);
                break;
            }
        }
        unset($c);
    }

    return $children;
}

// Extract the comment header block from a bash script (shebang + all leading comment lines).
// Returns raw lines with # markers intact.
function vv_script_header(string $path): string {
    if (!file_exists($path)) return '';
    $lines = array_slice(file($path) ?: [], 0, 80);
    $out   = [];
    foreach ($lines as $line) {
        $t = rtrim($line);
        if (str_starts_with($t, '#') || ($out === [] && str_starts_with($t, '#!'))) {
            $out[] = $t;
        } elseif ($t === '' && !empty($out)) {
            $out[] = $t; // allow blank lines within header
        } else {
            break;
        }
    }
    // Trim trailing blank lines
    while (!empty($out) && trim(end($out)) === '') array_pop($out);
    return implode("\n", $out);
}

// Strip the leading # marker from each line of a script header for cleaner display.
// Also drops the shebang line (#!/bin/bash) since it's not informative in this context.
function vv_script_header_clean(string $path): string {
    $raw   = vv_script_header($path);
    if (!$raw) return '';
    $lines = explode("\n", $raw);
    $out   = [];
    foreach ($lines as $line) {
        if (str_starts_with($line, '#!')) continue; // shebang — not useful in header display
        $out[] = preg_replace('/^#\s?/', '', $line); // strip # and optional space
    }
    while (!empty($out) && trim(end($out)) === '') array_pop($out);
    return implode("\n", $out);
}

// Read a named section from a markdown file.
// Calls $matcher(heading, isIntro) where isIntro=true for content before the first heading.
// Returns the first matching section body, capped at $maxChars.
function vv_readme_section(string $readmePath, callable $matcher, int $maxChars = 3000): string {
    if (!file_exists($readmePath)) return '';
    $content = file_get_contents($readmePath) ?: '';
    $parts   = preg_split('/^(#{1,4}[^\n]*)/m', $content, -1, PREG_SPLIT_DELIM_CAPTURE);
    $heading = '';
    $isIntro = true;
    foreach ($parts as $i => $part) {
        if ($i % 2 === 1) {
            $heading = trim(preg_replace('/^#{1,4}\s*/', '', $part));
            $isIntro = false;
            continue;
        }
        $body = trim($part);
        if ($body === '') continue;
        if ($matcher($heading, $isIntro)) {
            return strlen($body) > $maxChars ? substr($body, 0, $maxChars) . "\n[…]" : $body;
        }
    }
    return '';
}
