<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
//   One pass of the repair sweep. Reads the logs of jobs that finished since the last pass,
//   turns known error shapes into findings, probes for a correction, and either writes a proven
//   value or leaves the finding for the operator.
//
// OPERATIONAL MODEL
//   Called from ai_repair_sweep.sh, which the watchdog orchestrator runs. Everything it does
//   lives in include/ai_repair.php so the same code path is what the tests exercise; this file
//   owns only the lock, the log line, and the exit code.
//
// DESIGN PRINCIPLES
//   Non-fatal, always.
//       Exits 0 on a disabled feature, a held lock, or a sweep that found nothing. Repair is an
//       enhancement — the orchestrator that runs it has real work to do either side, and this
//       must never be the reason a watchdog cycle reports failure.
//
//   The summary is logged, not the reasoning.
//       What was scanned, what was found, what was written. The findings themselves are the
//       record; duplicating their contents into a log would be two copies to keep in step.
//
// OPERATIONAL SAFEGUARDS
//   One sweep at a time.
//       flock, non-blocking. A pass that overruns its fifteen-minute slot must not have a second
//       copy start probing and writing conf underneath it.
//
//   Nothing is written unless two switches say so.
//       AI_REPAIR_ENABLED gates the sweep; AI_REPAIR_AUTOFIX_ENABLED gates writing. With only
//       the first, this reads and files and changes no configuration at all.
//
// RUNTIME MODES
//   ai_repair_sweep.php              one pass
//   ai_repair_sweep.php --dry-run    probe and report, write nothing, leave the marker alone
//   ai_repair_sweep.php --status     what the last pass did, and what is open
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once dirname(__DIR__) . '/include/ai_repair.php';

$args   = array_slice($argv ?? [], 1);
$dryRun = in_array('--dry-run', $args, true);
$status = in_array('--status', $args, true);

function rlog(string $msg): void {
    if (!is_dir(LOG_DIR)) return;
    @file_put_contents(LOG_DIR . '/ai_repair.log',
        date('Y-m-d H:i:s') . ' ' . $msg . "\n", FILE_APPEND | LOCK_EX);
}

if ($status) {
    $last = vv_ai_sweep_last();
    $open = vv_ai_findings_list(['open', 'needs_operator']);
    printf("repair:   %s\n", vv_ai_repair_enabled() ? 'enabled' : 'disabled');
    printf("autofix:  %s\n", vv_ai_repair_autofix_enabled() ? 'enabled' : 'disabled (detect only)');
    printf("last pass: %s\n", $last ? date('Y-m-d H:i:s', $last) : 'never');
    printf("open findings: %d\n", count($open));
    // 'ref' rather than 'conf_key' — it is the conf key for the kinds that have one, and the
    // thing that identifies the finding for the kinds that do not. Printing the key left every
    // arr health row with a blank column.
    foreach ($open as $f) {
        printf("  %-7s %-9s %-28s seen %-4d %-14s %s\n",
            '[' . ($f['severity'] ?? '?') . ']',
            $f['subject'] ?? '?',
            $f['ref'] ?? ($f['conf_key'] ?? '?'),
            (int)($f['seen'] ?? 0),
            $f['state'] ?? '?',
            mb_substr((string)($f['observed'] ?? ''), 0, 44));
    }
    exit(0);
}

if (!vv_ai_repair_enabled()) exit(0);   // silent: a disabled feature is not an event

$lockPath = VV_CACHE_ROOT . '/ai_repair.lock';
if (!is_dir(dirname($lockPath))) @mkdir(dirname($lockPath), 0755, true);
$lock = @fopen($lockPath, 'c');
if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
    if ($lock) fclose($lock);
    rlog('skipped — a sweep is already running');
    exit(0);
}

try {
    $t0  = microtime(true);
    $sum = vv_ai_repair_sweep($dryRun);
    $ms  = (int)round((microtime(true) - $t0) * 1000);

    if (!($sum['ok'] ?? false)) {
        rlog('sweep refused — ' . ($sum['error'] ?? 'unknown'));
        exit(0);
    }

    // An announcement is worth a line whether or not this pass found anything new — a delivery
    // that failed on an earlier pass is retried here, and "we tried to tell you" is exactly the
    // thing someone reads this log to check.
    $ann = $sum['announced'] ?? [];
    if (($ann['count'] ?? 0) > 0) {
        rlog(sprintf('%sannounce: %d finding(s) — %s%s',
            $dryRun ? 'dry-run: ' : '', $ann['count'],
            ($ann['sent'] ?? false) ? 'sent' : ($dryRun ? 'not sent (dry run)' : 'DELIVERY FAILED'),
            ' — ' . ($ann['subject'] ?? '')));
    }

    // Nothing found and nothing to say. A line every fifteen minutes reporting no news is how a
    // log stops being read.
    if ($sum['findings'] === 0) {
        if ($sum['runs'] > 0 && $dryRun) rlog(sprintf('dry-run: %d run(s), nothing found (%dms)', $sum['runs'], $ms));
        exit(0);
    }

    rlog(sprintf('%s%d run(s): %d finding(s), %d fixed, %d for the operator, %d resolved, %d quiet (%dms)',
        $dryRun ? 'dry-run: ' : '', $sum['runs'], $sum['findings'], $sum['fixed'],
        $sum['needs_operator'], $sum['resolved'], $sum['quiet'], $ms));

    foreach ($sum['details'] as $d) rlog('  ' . $d);

} finally {
    flock($lock, LOCK_UN);
    fclose($lock);
}

exit(0);
