<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
//   Checks how the settings UI will draw every conf field, and proves the inference rules against
//   the conventions the conf files actually use. Two modes in one tool: fixed assertions over
//   synthetic snippets, and a report of what this host's real master.conf infers to.
//
// OPERATIONAL MODEL
//   Hand-run, reads only, writes nothing — the same shape as Tools/ai_log_check.sh. Run it after
//   touching _vv_conf_widget(), after adding a conf convention, or when a setting draws as the
//   wrong control and the question is whether the rule or the comment above it is at fault.
//
//     php Tools/conf_widget_check.php            assertions, then the live summary
//     php Tools/conf_widget_check.php --list     every live field and its inferred control
//
// WHY IT ASSERTS AGAINST SNIPPETS AND NOT THE LIVE CONF
//   The live conf is the thing being described, so it cannot also be the thing that proves the
//   description right — an inference rule that silently stopped matching would keep passing as
//   the conf drifted to suit it. The snippets are frozen copies of each convention as written,
//   so a rule change that breaks one shows up here rather than as a wrong control on a page.
//
// WHAT AN INFERENCE IS NOT
//   Consistent with confform.php, none of this validates. A number field carrying min and max is
//   a courtesy to whoever is typing, not a promise the value is sensible — the consuming script
//   still owns that question.
//
// DEPENDS ON
//   include/confform.php   _vv_conf_parse_field_range(), vv_conf_key_is_secret()
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once __DIR__ . '/../include/confform.php';

$pass = 0; $fail = 0; $failed = [];
function check(string $name, $got, $want): void {
    global $pass, $fail, $failed;
    if ($got === $want) { $pass++; return; }
    $fail++;
    $failed[] = sprintf("%-42s got %s want %s", $name, json_encode($got), json_encode($want));
}

// Parse one snippet and return its fields keyed by name.
function vv_wc_fields(string $snippet): array {
    $lines = explode("\n", $snippet);
    $out = [];
    foreach (_vv_conf_parse_field_range($lines, 0, count($lines), 'master.conf') as $f) {
        $out[$f['key']] = $f;
    }
    return $out;
}

// ── Booleans and numbers ─────────────────────────────────────────────────────────────────────
$f = vv_wc_fields(<<<'CONF'
    AI_ENABLED=true
    AI_OFF=false
    AI_QUOTED_BOOL="true"
    AI_CONNECT_TIMEOUT=5            # seconds — probe when resolving which node has Ollama
    AI_CHAT_HISTORY_MAX=10          # conversations kept — clamped to 1-50
    AI_PLAIN_NUMBER=32
    AI_NEGATIVE=-1
CONF);
check('bool true',              $f['AI_ENABLED']['widget'], 'bool');
check('bool false',             $f['AI_OFF']['widget'], 'bool');
check('bool quoted',            $f['AI_QUOTED_BOOL']['widget'], 'bool');
check('int',                    $f['AI_CONNECT_TIMEOUT']['widget'], 'int');
check('int unit',               $f['AI_CONNECT_TIMEOUT']['unit'] ?? null, 'seconds');
check('int without a unit',     $f['AI_PLAIN_NUMBER']['unit'] ?? null, null);
check('int range min',          $f['AI_CHAT_HISTORY_MAX']['min'] ?? null, 1);
check('int range max',          $f['AI_CHAT_HISTORY_MAX']['max'] ?? null, 50);
check('int range keeps unit',   $f['AI_CHAT_HISTORY_MAX']['unit'] ?? null, 'conversations');
check('negative int',           $f['AI_NEGATIVE']['widget'], 'int');

// ── File modes must never become spinners ────────────────────────────────────────────────────
// A spinner invites arrowing 755 to 756, and a number input normalises a leading zero away.
$f = vv_wc_fields(<<<'CONF'
    PERMISSIONS_DIR_MODE="755"      # directories
    PERMISSIONS_FILE_MODE="664"     # files
    TRANSCODE_CHMOD="755"
    LEADING_ZERO_MODE="0755"
    TRANSCODE_MANAGER_MODE="smart"
    SOME_TIMEOUT_MODE=30            # seconds
CONF);
check('dir mode is text',       $f['PERMISSIONS_DIR_MODE']['widget'], 'text');
check('file mode is text',      $f['PERMISSIONS_FILE_MODE']['widget'], 'text');
check('chmod is text',          $f['TRANSCODE_CHMOD']['widget'], 'text');
check('octal mode is text',     $f['LEADING_ZERO_MODE']['widget'], 'text');
check('worded mode is text',    $f['TRANSCODE_MANAGER_MODE']['widget'], 'text');
// The guard is about octal, not about the word MODE — a real duration is still a number.
check('non-octal MODE is int',  $f['SOME_TIMEOUT_MODE']['widget'], 'int');

// ── Enum, quoted-line convention ─────────────────────────────────────────────────────────────
$f = vv_wc_fields(<<<'CONF'
# Controls behaviour when versions differ.
# "warn"  — log warning and continue
# "abort" — refuse to continue (strict)
    UNRAID_VERSION_MISMATCH_ACTION="warn"
CONF);
check('quoted-line enum',       $f['UNRAID_VERSION_MISMATCH_ACTION']['widget'], 'enum');
check('enum choices',           array_column($f['UNRAID_VERSION_MISMATCH_ACTION']['choices'], 'value'),
                                ['warn', 'abort']);
check('enum keeps its hints',   $f['UNRAID_VERSION_MISMATCH_ACTION']['choices'][1]['hint'],
                                'refuse to continue (strict)');

// One quoted comment line is a quotation, not a choice list of one.
$f = vv_wc_fields("# \"off\" means something here\n    SOMETHING=\"off\"");
check('lone quoted line',       $f['SOMETHING']['widget'], 'text');

// A value outside its own documented list is surfaced, never dropped to tidy the list — dropping
// it would change the setting the moment the form saved.
$f = vv_wc_fields(<<<'CONF'
# "warn"  — log and continue
# "abort" — refuse
    DRIFTED="something_else"
CONF);
check('drifted value stays enum', $f['DRIFTED']['widget'], 'enum');
check('drifted value offered',    $f['DRIFTED']['choices'][0]['value'], 'something_else');
check('drifted list intact',      count($f['DRIFTED']['choices']), 3);

// ── Enum, inline pipe convention ─────────────────────────────────────────────────────────────
$f = vv_wc_fields(<<<'CONF'
    DIGEST_PROFILE="weekly"         # always | smart | weekly
    SONARR_MONITOR="all"            # monitor mode on add: all | future | first | none
    PROSE_WITH_PIPE="x"             # pipe the output through grep | sort
CONF);
check('inline enum',            $f['DIGEST_PROFILE']['widget'], 'enum');
check('inline enum choices',    array_column($f['DIGEST_PROFILE']['choices'], 'value'),
                                ['always', 'smart', 'weekly']);
check('inline enum after colon',$f['SONARR_MONITOR']['widget'], 'enum');
// The value is not among them, so this is prose that happens to contain a pipe.
check('prose with a pipe',      $f['PROSE_WITH_PIPE']['widget'], 'text');

// ── Secrets ──────────────────────────────────────────────────────────────────────────────────
$f = vv_wc_fields(<<<'CONF'
    HOST1_NPM_PASS="hunter2"
    HOST1_SONARR_API_KEY="abcdef"
    WEBHOOK_SECRET="s3cr3t"
    AI_TOKEN_DB="${AI_DATA_DIR}/ai_token_history.db"
    AI_TOKEN_RETAIN_ROWS=20000
    AI_TOKEN_SYNC_ENABLED=true
CONF);
check('password masked',        $f['HOST1_NPM_PASS']['widget'], 'secret');
check('api key masked',         $f['HOST1_SONARR_API_KEY']['widget'], 'secret');
check('secret masked',          $f['WEBHOOK_SECRET']['widget'], 'secret');
// LLM token accounting is not a credential. Masking these was the bug this exception fixes.
check('AI_TOKEN_DB visible',        $f['AI_TOKEN_DB']['widget'], 'path');
check('AI_TOKEN_RETAIN visible',    $f['AI_TOKEN_RETAIN_ROWS']['widget'], 'int');
check('AI_TOKEN_SYNC visible',      $f['AI_TOKEN_SYNC_ENABLED']['widget'], 'bool');

// ── Paths, text, arrays ──────────────────────────────────────────────────────────────────────
$f = vv_wc_fields(<<<'CONF'
    AI_INDEX_DB="${AI_DATA_DIR}/ai_index.db"
    SOME_ABS="/mnt/user/appdata"
    AI_MODEL="qwen3:14b"
    AI_PROFILES="*"
    AI_CONF_WRITE_KEYS=()
    LIST_MULTI=(
      one
      two
    )
CONF);
check('templated path',         $f['AI_INDEX_DB']['widget'], 'path');
check('absolute path',          $f['SOME_ABS']['widget'], 'path');
check('plain text',             $f['AI_MODEL']['widget'], 'text');
check('wildcard text',          $f['AI_PROFILES']['widget'], 'text');
check('single-line array',      $f['AI_CONF_WRITE_KEYS']['widget'], 'lines');
check('multi-line array',       $f['LIST_MULTI']['widget'], 'lines');
// The scratch field the parser uses to reach the comment lines must not ride out to the browser.
check('no _lines leak',         isset($f['AI_MODEL']['_lines']), false);

printf("assertions: %d passed, %d failed\n", $pass, $fail);
foreach ($failed as $l) echo "  FAIL $l\n";

// ── What this host's conf actually infers to ─────────────────────────────────────────────────
$path = CONF_DIR . '/master.conf';
if (!is_readable($path)) {
    echo "\nmaster.conf not readable at $path — skipping the live summary\n";
    exit($fail ? 1 : 0);
}
$lines = explode("\n", (string) file_get_contents($path));
$hdrs  = [];
foreach ($lines as $i => $l) {
    if (preg_match('/^#\s*[━─]{2,}\s+([A-Za-z].+?)\s+[━─]{2,}/u', $l, $m)) $hdrs[] = [$i, trim($m[1])];
}

$list  = in_array('--list', $argv, true);
$tally = []; $n = 0; $enums = []; $ranges = [];
foreach ($hdrs as $k => [$i, $name]) {
    $end  = $hdrs[$k + 1][0] ?? count($lines);
    $rows = _vv_conf_parse_field_range($lines, $i + 1, $end, 'master.conf');
    if ($list && $rows) printf("\n── %s\n", $name);
    foreach ($rows as $x) {
        $n++;
        $w = $x['widget'] ?? '?';
        $tally[$w] = ($tally[$w] ?? 0) + 1;
        if ($w === 'enum')    $enums[]  = $x['key'] . ' = ' . implode(' | ', array_column($x['choices'], 'value'));
        if (isset($x['min'])) $ranges[] = $x['key'] . ' ' . $x['min'] . '-' . $x['max'];
        if ($list) {
            $e = [];
            if (isset($x['unit'])) $e[] = $x['unit'];
            if (isset($x['min']))  $e[] = $x['min'] . '-' . $x['max'];
            printf("   %-34s %-7s %s\n", $x['key'], $w, $e ? '[' . implode(' ', $e) . ']' : '');
        }
    }
}

printf("\nlive master.conf: %d fields across %d sections\n", $n, count($hdrs));
ksort($tally);
foreach ($tally as $k => $v) printf("   %-8s %d\n", $k, $v);
if ($enums)  { echo "\nchoice lists found:\n";   foreach ($enums as $e)  echo "   $e\n"; }
if ($ranges) { echo "\nbounded numbers found:\n"; foreach ($ranges as $e) echo "   $e\n"; }

exit($fail ? 1 : 0);
