<?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') {
    $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'] ?? '[]';

    if (!$id) { echo json_encode(['ok' => false, 'error' => 'Missing id']); exit; }

    $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;
        }
    }

    $results = vv_conf_write_changes($changes);

    // 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();
    }

    echo json_encode(['ok' => !in_array(false, $results, true), 'files' => $results, 'push' => $push]);
    exit;
}

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