<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
//   Structured conf editing. GET returns the configuration fields relevant to one script,
//   grouped and typed for rendering as a form; POST writes a set of field changes back to
//   whichever conf files they belong to.
//
// OPERATIONAL MODEL
//   The counterpart to rawconf.php. That one hands over a text file; this one presents the
//   subset of keys a given script actually reads, with their types and current values, so a
//   threshold can be changed without opening master.conf and finding it.
//
//   Changes are keyed by file, not by form. Each change carries its own target file, because
//   one script's settings routinely span master.conf and a host conf — a threshold is shared,
//   the credential it applies to is not. A single save therefore writes to several files, and
//   reports per-file results.
//
//   A master.conf write is followed by a push to every partner, matching rawconf.php. The
//   two endpoints edit the same file and must distribute it the same way.
//
// DESIGN PRINCIPLES
//   Which fields belong to a script is derived, not configured.
//       vv_conf_fields_for_script() resolves them from the script itself, so a new conf
//       variable appears in the form as soon as the script reads it — there is no second list
//       to keep in step.
//
//   Validation is total before any write begins.
//       Every change in the set is checked first, and the endpoint exits on the first bad
//       one. A partially applied save across multiple conf files is far harder to reason
//       about than a rejected one.
//
//   ok reflects the whole set.
//       ok is false if any file failed, while files carries the per-file detail. A caller
//       that checks only ok is correct but coarse; one that wants to know which file failed
//       can see it.
//
// OPERATIONAL SAFEGUARDS
//   Every change names its own file, and every one is checked against the allowlist.
//       in_array(..., true) against vv_get_conf_files() per change — not once for the batch.
//       The allowlist is what enforces sparse checkout: HOST2 cannot be handed a change
//       targeting host1.conf, because host1.conf is not in its list.
//
//   Keys must look like shell variables.
//       ^[A-Z_][A-Z0-9_]*$ — no lowercase, no punctuation, no leading digit. The key is used
//       to locate and rewrite an assignment in a bash file, so anything that could not be a
//       variable name has no legitimate target.
//
//   Malformed payloads are refused, not coerced.
//       is_array() on the decoded changes, and an explicit missing-id check. A truncated body
//       becomes a rejection rather than an empty change set that would report success while
//       writing nothing.
//
//   The GET path blocks traversal on the id.
//       An explicit '..' check before the id reaches the field resolver.
//
//   Writes are atomic per file — vv_conf_write_changes() goes through the same tmp + rename
//   path as every other conf write, so a script sourcing a conf mid-save sees the old file or
//   the new one.
//
//   The push only happens after master.conf is confirmed written.
//       Guarded on the per-file result being exactly true, so a failed edit cannot distribute
//       a stale or partly-written master.conf to partners.
//
//   Unknown methods are refused explicitly, so a PUT or DELETE cannot fall through the two
//   handled blocks into an empty 200.
//
//   Narrower than rawconf.php, but not narrow enough to skip the syntax gate.
//       Every write rewrites the value of an existing, named key — no key can be added,
//       deleted, or moved. But only the scalar path escapes its value: the array,
//       array_single and assoc_array paths splice the caller's text into the file verbatim,
//       and the type is chosen by the request. vv_conf_write_changes() therefore runs the
//       same `bash -n` check config.php and rawconf.php apply, and a file that does not parse
//       is reported as a failed write with the original left intact.
//
// REQUEST
//   GET  ?id=<Category/name.sh>
//   POST id=<Category/name.sh>  changes=<JSON array of {file, key, value}>
//
// RESPONSE
//   GET   {"ok":true,"groups":[…]}
//   POST  {"ok":bool,"files":{"<conf>":bool, …},"push":[{"host","ok","ready","error"}, …]}
//   {"ok":false,"error":"Invalid id"|"Missing id"|"Invalid changes"
//                      |"Unauthorized file: …"|"Invalid key: …"|"Method not allowed"}
//
// DEPENDS ON
//   include/confform.php   vv_conf_fields_for_script(), vv_conf_write_changes()
//   include/config.php     vv_get_conf_files(), vv_push_master_conf(), vv_push_setup_state()
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
require_once dirname(__DIR__) . '/include/scheduler.php';
require_once dirname(__DIR__) . '/include/confform.php';

if ($_SERVER['REQUEST_METHOD'] === 'GET') {
    // Section-scoped read. A page that owns a subject rather than a script — the AI tab, and
    // partnership before it — wants the sections whose header names that subject, across every
    // conf file it is allowed to see. Same fields, same shape, same write path back; only the
    // question of "which fields" differs, so it is a mode here rather than a second endpoint
    // with its own copy of the allowlist and the master push.
    $match = trim($_GET['sections'] ?? '');
    if ($match !== '') {
        // Whole word, case-insensitive. A plain substring is far too loose on these headers —
        // "ai" alone also selects Maintenance, Containers, Failover and Arr Failed/Stalled
        // Recovery, which is nine wrong sections out of twenty-one and every one of them looks
        // deliberate once it is on the page.
        //
        // preg_quote first: the needle arrives from a query string, so it is matched as a literal
        // with boundaries around it rather than as a pattern a caller could widen to everything.
        // "*" is every section this host has. Deliberately not the same as an empty needle, which
        // would match everything by accident rather than on purpose.
        // Several words, pipe-separated, each still matched whole and still quoted as a literal.
        // One word was not enough: the Arr sections are named Arr Sync, Lidarr, Sonarr and
        // Radarr, and no single stem catches those without also catching "Array Start", which is
        // the Unraid disk array and nothing to do with them.
        $all = ($match === '*');
        $re  = $all ? '' : '/\b(' . implode('|', array_map(
                   fn($w) => preg_quote(trim($w), '/'),
                   array_filter(explode('|', $match), fn($w) => trim($w) !== ''))) . ')\b/i';
        $out = [];
        foreach (vv_get_conf_files() as $f) {
            foreach (vv_conf_all_groups($f) as $g) {
                $name = (string) ($g['subsection'] ?? '');
                // Excluded everywhere, not only from the catch-all. A section kept out of forms
                // because a text box is the wrong control for it does not become the right
                // control because a different page asked.
                if (isset(VV_UI_SECTION_EXCLUDE[$name])) continue;
                if ($all || preg_match($re, $name)) $out[] = $g;
            }
        }
        echo json_encode(['ok' => true, 'groups' => $out]);
        exit;
    }

    $id = trim($_GET['id'] ?? '');
    if (!$id || str_contains($id, '..')) {
        echo json_encode(['ok' => false, 'error' => 'Invalid id']);
        exit;
    }
    $groups = vv_conf_fields_for_script($id);
    echo json_encode(['ok' => true, 'groups' => $groups]);
    exit;
}

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $id      = trim($_POST['id']      ?? '');
    $rawJson = $_POST['changes'] ?? '[]';

    // Optional. It labels which script's form was open and is used nowhere in the write — every
    // change already names its own file and key, and those are what is validated below. A
    // section-scoped save has no script to name, and inventing one so this check would pass
    // would be a guard that only ever guarded against itself.

    $changes = json_decode($rawJson, true);
    if (!is_array($changes)) { echo json_encode(['ok' => false, 'error' => 'Invalid changes']); exit; }

    $allowed = vv_get_conf_files();
    foreach ($changes as $c) {
        if (empty($c['file']) || !in_array($c['file'], $allowed, true)) {
            echo json_encode(['ok' => false, 'error' => 'Unauthorized file: ' . ($c['file'] ?? '')]);
            exit;
        }
        if (empty($c['key']) || !preg_match('/^[A-Z_][A-Z0-9_]*$/', $c['key'])) {
            echo json_encode(['ok' => false, 'error' => 'Invalid key: ' . ($c['key'] ?? '')]);
            exit;
        }
    }

    // Declared before it is passed. It is a by-reference array parameter, and an undefined
    // variable arrives there as null — which under PHP 8 is a TypeError thrown before a single
    // byte is written, so every save through this endpoint died with a 500 and the page saw an
    // unparseable response rather than a refusal it could report. The callers that pass no
    // second argument were never affected, which is why it survived: this is the only one.
    $rejected = [];
    $results  = vv_conf_write_changes($changes, $rejected);

    // Propagate master.conf to partner hosts when the owner edits it (mirrors rawconf.php).
    $push = [];
    if (($results['master.conf'] ?? false) === true) {
        $push = vv_push_master_conf();
        vv_push_setup_state();
    }

    // A refused change never reaches a file, so it leaves no false in $results — a save whose
    // only change was refused used to answer ok:true and show the operator their old value back
    // with no explanation. Refusals are failures here and they are named: the whole point of the
    // path guard is that someone learns their edit would have widened a delete target.
    echo json_encode(['ok'       => !in_array(false, $results, true) && !$rejected,
                      'files'    => $results,
                      'rejected' => $rejected,
                      'error'    => $rejected
                          ? 'Refused: ' . implode(', ',
                              array_map(fn($r) => $r['key'] . ' (' . $r['reason'] . ')', $rejected))
                          : null,
                      'push'     => $push]);
    exit;
}

echo json_encode(['ok' => false, 'error' => 'Method not allowed']);
