<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
//   Recall and precision for the syslog triage patterns. See ai_log_check.sh for why both
//   halves exist and why neither alone is enough.
//
// OPERATIONAL MODEL
//   Reads ai_log_fixtures.txt and this host's /var/log/syslog*. Files nothing, writes nothing,
//   and calls no part of the sweep beyond vv_ai_syslog_findings() on lines it supplies itself.
//
// EXIT
//   0 when every fixture is recognised as written. Precision findings are reported but never
//   fail the run: what a real syslog contains is a fact about the machine, not about the
//   patterns, and a genuinely failing disk should not turn this into a red test.
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once dirname(__DIR__) . '/include/ai_repair.php';

$args      = array_slice($argv ?? [], 1);
$only      = in_array('--precision', $args, true) ? 'precision'
           : (in_array('--recall', $args, true) ? 'recall' : 'both');
$fixtures  = __DIR__ . '/ai_log_fixtures.txt';

$pass = 0; $fail = 0;
function ok(string $what, bool $cond, string $got = ''): void {
    global $pass, $fail;
    if ($cond) { $pass++; printf("  ok   %s\n", $what); }
    else       { $fail++; printf("  FAIL %s%s\n", $what, $got !== '' ? "\n         → $got" : ''); }
}

// One line at a time, so a fixture is asserted on its own rather than on whatever aggregated
// with it. The finders are given the line directly, bypassing the file, the timestamp filter and
// docker — those are tested separately, and a fixture dated last August would otherwise be
// silently dropped for being older than the marker.
function classify(string $line, string $source = 'sys'): ?array {
    if ($source === 'ctr') {
        $f = vv_ai_container_findings(0, ['fixture-container' => [$line]]);
        return $f[0] ?? null;
    }
    $f = vv_ai_syslog_findings(0, [$line]);
    return $f[0] ?? null;
}

if ($only !== 'precision') {
    echo "── recall: fixtures ──────────────────────────────────────────────\n";
    if (!is_readable($fixtures)) {
        echo "  FAIL cannot read $fixtures\n";
        exit(1);
    }
    foreach (file($fixtures, FILE_IGNORE_NEW_LINES) as $n => $raw) {
        $line = rtrim($raw);
        if ($line === '' || $line[0] === '#') continue;

        // A ! line must match nothing at all.  ! source | line
        if ($line[0] === '!') {
            $rest   = trim(substr($line, 1));
            $bits   = explode('|', $rest, 2);
            if (count($bits) !== 2) { ok(sprintf('L%d is malformed', $n + 1), false, $line); continue; }
            $src    = trim($bits[0]);
            $sample = trim($bits[1]);
            $got    = classify($sample, $src);
            ok(sprintf('L%-3d %s no match: %s', $n + 1, $src, mb_substr($sample, 0, 58)),
               $got === null, $got ? "matched as {$got['ref']} / {$got['subject']}" : '');
            continue;
        }

        $parts = explode('|', $line, 4);
        if (count($parts) !== 4) { ok(sprintf('L%d is malformed', $n + 1), false, $line); continue; }
        [$src, $wantSubject, $wantLevel, $sample] = array_map('trim', $parts);

        $got = classify($sample, $src);
        if ($got === null) {
            ok(sprintf('L%-3d %s', $n + 1, mb_substr($sample, 0, 64)), false, 'no pattern matched');
            continue;
        }
        ok(sprintf('L%-3d %-12s %-5s  %s', $n + 1, $got['subject'], $got['sys_level'],
                   mb_substr($got['ref'], 0, 30)),
           $got['subject'] === $wantSubject && $got['sys_level'] === $wantLevel,
           sprintf('wanted %s/%s, got %s/%s', $wantSubject, $wantLevel,
                   $got['subject'], $got['sys_level']));
    }
}

if ($only !== 'recall') {
    echo "\n── precision: this host's real syslog history ────────────────────\n";
    $total = 0; $hits = [];
    foreach (glob('/var/log/syslog*') ?: [] as $file) {
        if (!is_readable($file)) continue;
        $fh = @fopen($file, 'r');
        if (!$fh) continue;
        while (($l = fgets($fh)) !== false) {
            $total++;
            $got = classify(rtrim($l));
            if ($got === null) continue;
            $k = $got['ref'] . ' | ' . $got['subject'] . ' | ' . $got['sys_level'];
            $hits[$k] = ($hits[$k] ?? 0) + 1;
        }
        fclose($fh);
    }
    printf("  scanned %s lines\n", number_format($total));
    if ($hits) { arsort($hits); foreach ($hits as $k => $n) printf("  %-7s %s\n", number_format($n), $k); }
    else       { echo "  nothing matched\n"; }

    // Every running container, read far deeper than a sweep ever does. A pattern that is quiet
    // over this much real output is a pattern that will be quiet in service.
    echo "\n── precision: every running container's log ──────────────────────\n";
    $cTotal = 0; $hits = [];
    $names  = vv_ai_running_containers();
    foreach ($names as $name) {
        $lines = vv_ai_container_log($name, 1, 2000);
        $cTotal += count($lines);
        $found  = vv_ai_container_findings(0, [$name => $lines]);
        foreach ($found as $f) {
            $k = $f['ref'] . ' | ' . $f['subject'] . ' | ' . $f['sys_level'];
            $hits[$k] = ($hits[$k] ?? 0) + (int)filter_var($f['observed'], FILTER_SANITIZE_NUMBER_INT);
        }
    }
    printf("  scanned %s lines across %d container%s\n", number_format($cTotal), count($names),
           count($names) === 1 ? '' : 's');
    if (!$hits) {
        echo "  nothing matched — this host is not reporting any of these faults\n";
    } else {
        arsort($hits);
        foreach ($hits as $k => $n) printf("  %-7s %s\n", number_format($n), $k);
        echo "\n  Each line above is a fault the sweep would file. If any of them is normal\n"
           . "  operation on this machine, the pattern is wrong — add it to the fixtures as a\n"
           . "  ! line and tighten the pattern until it stops matching.\n";
    }
}

printf("\n%d passed, %d failed\n", $pass, $fail);
exit($fail ? 1 : 0);
