<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
//   Findings about this installation being misconfigured, and the record of what was done about
//   them. A finding is "Emby is not answering at the address the conf gives", not "Varaverk has
//   a bug" — the second is what ai_bugs holds.
//
// OPERATIONAL MODEL
//   A sweep reads a completed run's log, deterministic patterns turn error lines into typed
//   findings, and each finding is either repaired or handed to the operator. Findings persist
//   because the repair may need something only a human can supply, and that conversation has to
//   survive the page being closed.
//
//   Four candidate sources: the arrs' own health, the system log, container logs, and the
//   watchdog counters. The first three read text; the fourth reads state the watchdogs already
//   maintain, and is the only one whose findings can never be repaired from here — see
//   vv_ai_watchdog_findings() for why that is structural rather than a setting.
//
// WHY THIS IS NOT ai_bugs
//   Same storage shape, different lifecycle, and the difference is the whole reason for a second
//   store. A bug is open until Varaverk's code changes; nothing on this host can close it. A
//   finding is open until this host's configuration is right, and the same probe that proved a
//   fix can later prove the fault is gone — so findings close themselves and bugs cannot.
//
//   Filing them together would mean a list where half the rows are actionable by the operator
//   and half are actionable by whoever maintains the project, with no way to tell which is which
//   except by reading them.
//
// DESIGN PRINCIPLES
//   A finding names something specific, or it is not a finding.
//       The point of the record is that something can be done about it. "The daily sync looked
//       unhappy" is a feeling; "HOST1_EMBY_URL points at a host that refuses connections" is a
//       finding. Triage that cannot resolve a target produces nothing rather than a vague row.
//
//       For the conf-bound kinds that is a conf key, and it is still required — a proposal to
//       edit a key that does not exist can never be actioned. For the rest it is whatever
//       identifies the thing: an arr reporting "all lists are unavailable" is specific and
//       actionable with no Varaverk key to change, because the action is in Radarr's own UI.
//
//   Evidence is the log line, quoted.
//       Same rule as ai_bugs, for the same reason: a finding that cannot show the line it came
//       from cannot be checked, and this store is meant to be checkable.
//
//   Identity is kind + subject + reference, not the message text.
//       A port that has been wrong for a week is one finding seen 400 times, not 400 findings.
//       Wording drifts as logs change; the thing being wrong does not. "Indexers unavailable:
//       NzbNoob" becoming "NzbNoob, Miatrix" is the same finding getting worse.
//
// OPERATIONAL SAFEGUARDS
//   A proposed value is recorded, never trusted.
//       'proposed' is what something might be changed to; 'proven' is whether a probe actually
//       got an answer from it. Only proven values are ever written to conf, and the two fields
//       are kept separate so a record cannot imply verification it did not have.
//
//   Closing is evidence-driven, not time-driven.
//       A finding closes when its probe passes or the operator dismisses it. It does not expire,
//       because "we stopped seeing it in the log" is equally consistent with the job no longer
//       running at all.
//
//   Under data/ and therefore gitignored: these quote this installation's logs and name its
//   hosts, ports and containers.
//
// EXPORTS
//   vv_ai_findings_dir()        the store
//   vv_ai_finding_write()       file or increment one finding
//   vv_ai_findings_list()       findings, newest activity first
//   vv_ai_finding_get()         one by id
//   vv_ai_finding_close()       mark resolved, with how
//   vv_ai_finding_dismiss()     operator says this is not a problem
//   vv_ai_findings_for_chat()   the open ones worth opening a conversation about
//
// CONFIGURATION
//   AI_DATA_DIR             findings live in ai_findings/ beneath it
//   AI_FINDING_RETAIN_DAYS  closed findings older than this are removed (default 90)
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/ai.php';
// For vv_notify() only. A finding that cannot reach the operator is the one thing this file
// cannot do on its own, and reimplementing the channels here would be a second answer to a
// question common.sh already answers.
require_once __DIR__ . '/common.php';

// What a finding can be. The kind decides which repair is even conceivable, so an unknown kind
// is refused rather than stored as an untyped row nothing knows how to act on.
const VV_AI_FINDING_KINDS = [
    'unreachable'    => 'a configured address or port refused, timed out, or did not resolve',
    'auth_rejected'  => 'the endpoint answered, and rejected the credential',
    'unknown_target' => 'a conf entry names a container or share that does not exist here',
    'missing_value'  => 'a conf key required by the job that ran is empty',
    'arr_health'     => 'an arr is reporting a problem about itself',
    'system_fault'   => 'the kernel reported a hardware or filesystem fault about this machine',
    'container_fault' => 'a container is logging a fault about its own environment',
    'watchdog_strike' => 'a watchdog has counted something far enough to be worth a record',
];

// Which kinds are a statement about Varaverk's configuration, and which are a statement about
// something else that is nonetheless worth recording.
//
// The rule was originally "a finding names a conf key or it is not a finding", to stop the store
// filling with observations nobody could act on. What that rule was really protecting is that
// every finding identifies something specific and actionable — naming a conf key was the proxy,
// because at the time every source of findings was a conf problem.
//
// An arr reporting "all lists are unavailable" is specific and actionable, and there is no
// Varaverk key to change: the action is in Radarr's own UI. So the identity widens to a general
// reference, and the conf-key requirement narrows to the kinds it was written for. What has not
// changed is that a finding with nothing to point at is still refused.
const VV_AI_CONF_BOUND_KINDS = ['unreachable', 'auth_rejected', 'unknown_target', 'missing_value'];

function vv_ai_kind_is_conf_bound(string $kind): bool {
    return in_array($kind, VV_AI_CONF_BOUND_KINDS, true);
}

// How a finding ended, when it ends.
const VV_AI_FINDING_STATES = [
    'open'           => 'seen, not yet acted on',
    'needs_operator' => 'cannot be repaired here — the value is not derivable from this host',
    'acknowledged'   => 'the operator knows, and it stays quiet until the state it was acked at changes',
    'fixed'          => 'a proven value was written to conf',
    'resolved'       => 'the probe now passes; whatever was wrong is no longer wrong',
    'dismissed'      => 'the operator says this is not a problem, permanently',
];

// ── Why acknowledged is not dismissed ────────────────────────────────────────────────────────
// "I know critical rsync is off, stop telling me" and "this is never a problem" are different
// instructions, and collapsing them loses the half that matters. An acknowledgement is scoped to
// the state it was given in: CRITICAL_RSYNC_ENABLED being false is a deliberate choice today and
// a stale note the moment it goes true again.
//
// So an ack records what the key read when it was given, and expires when that changes. The
// finding comes back on its own, without the operator having to remember to look — which is the
// difference between a note and a silence.

// ── Toggles are the operator's, always ───────────────────────────────────────────────────────
// A repair may never enable or disable anything on its own. Not because it would get the value
// wrong — a boolean has only two — but because the value is not a fact to be discovered. Whether
// critical rsync should be on is a decision about intent, and a probe cannot prove intent the
// way it can prove that a port answers.
//
// The Fix action still writes it when the operator asks for it. What is forbidden is the
// unattended path choosing for them.
function vv_ai_conf_is_toggle(string $key): bool {
    $v = strtolower(trim((string)(vv_conf_vars()[$key] ?? '')));
    return $v === 'true' || $v === 'false';
}

// May the sweep write this without being asked? Two conditions, both required: a probe actually
// answered on the proposed value, and the key is not a toggle.
function vv_ai_finding_may_autofix(array $f): bool {
    if (!vv_ai_repair_autofix_enabled())            return false;
    if (empty($f['proven']))                        return false;
    if (($f['proposed'] ?? null) === null)          return false;
    if (vv_ai_conf_is_toggle((string)($f['conf_key'] ?? ''))) return false;

    // A third condition, for paths only. "Proven" means a probe answered, and every probe this
    // has is a network probe — nothing in it can answer a filesystem question, so a path
    // proposal reaches here carrying a proof that is about something else entirely. Requiring
    // the directory to exist is the equivalent evidence, and it is the difference between
    // pointing a cleanup at a real share and pointing it at a typo that will be created empty
    // by the first script to write there.
    //
    // vv_conf_path_write_ok() still runs inside the writer underneath this. That one refuses
    // what is dangerous; this one refuses what is merely unproven, which is a bar only the
    // unattended path has to clear.
    $proposed = (string)$f['proposed'];
    if ($proposed !== '' && $proposed[0] === '/' && !file_exists($proposed)) return false;

    return true;
}

// ── Severity is derived, never supplied ──────────────────────────────────────────────────────
// Same ladder run_job.sh records runs against — ok / warn / error — so a finding and the run it
// came from cannot describe the same event at two different volumes.
//
// The rule that matters: a finding whose key is a toggle can never be an error. Something not
// happening because it was switched off is the switch working. That is true whether the switch
// was flipped deliberately last month or by accident this morning, and the store cannot tell
// those apart — so it reports the fact and lets the operator supply the intent.
//
// Everything else takes its level from what the fault costs. A credential the endpoint rejected
// stops that integration dead; an address that does not answer might be a host still booting.
function vv_ai_finding_severity(array $f): string {
    $key = (string)($f['conf_key'] ?? '');

    // Deliberate-state findings never escalate, whatever their kind.
    if ($key !== '' && vv_ai_conf_is_toggle($key)) return 'warn';

    // An arr grades its own health and is the authority on it — "error" from Radarr means Radarr
    // has stopped doing something, which is not a judgement to second-guess from out here.
    if (($f['kind'] ?? '') === 'arr_health') {
        return ($f['arr_type'] ?? '') === 'error' ? 'error' : 'warn';
    }

    // Same shape as arr_health, for the same reason: the level belongs to the pattern that
    // matched, because "correctable PCIe error" and "I/O error on a disk" are the same kind of
    // finding and nothing like the same news. Carried on the candidate rather than inferred here.
    // Shared by both log-derived kinds — one field, because they mean exactly the same thing.
    if (in_array($f['kind'] ?? '', ['system_fault', 'container_fault'], true)) {
        return ($f['sys_level'] ?? '') === 'error' ? 'error' : 'warn';
    }

    return match ($f['kind'] ?? '') {
        'auth_rejected'  => 'error',   // answered and refused — nothing gets through until fixed
        'missing_value'  => 'error',   // configured to use something that was never supplied
        'unreachable'    => 'warn',    // may be transient; the strike system is what escalates it
        'unknown_target' => 'warn',
        default          => 'warn',
    };
}

// ── Gates ────────────────────────────────────────────────────────────────────────────────────
// Two switches, because detecting and repairing are separate things to trust.
//
// AI_REPAIR_ENABLED alone gives a system that reads logs, files findings and offers fixes, and
// writes nothing. That is the state this should live in first — long enough to read what it
// found and disagree with some of it. A subsystem that starts by editing conf has to be believed
// before there is any evidence for believing it.
//
// AI_REPAIR_AUTOFIX_ENABLED is what lets a proven value be written without being asked, and it
// is meaningless on its own: nothing to write if nothing is looking. Both must be true, in the
// same layered way AI_ENABLED is necessary but never sufficient.
function vv_ai_repair_enabled(): bool {
    if (!vv_ai_config()['enabled']) return false;
    return strtolower(trim((string)(vv_conf_vars()['AI_REPAIR_ENABLED'] ?? 'false'))) === 'true';
}

function vv_ai_repair_autofix_enabled(): bool {
    if (!vv_ai_repair_enabled()) return false;
    return strtolower(trim((string)(vv_conf_vars()['AI_REPAIR_AUTOFIX_ENABLED'] ?? 'false'))) === 'true';
}

// The first consumer AI_ASSIST_WATCHDOG has ever had. It shipped with the rest of the AI_ASSIST_
// tier flags and nothing read it, which meant a switch that looked like it did something.
//
// Separate from AI_REPAIR_ENABLED rather than folded into it because the two answer different
// questions. Repair asks "may this read job logs and offer to change conf". This asks "may this
// have an opinion about what the watchdogs are counting" — a different subject, a different
// appetite for noise, and one you may well want off while repair stays on.
function vv_ai_assist_watchdog_enabled(): bool {
    if (!vv_ai_repair_enabled()) return false;
    return strtolower(trim((string)(vv_conf_vars()['AI_ASSIST_WATCHDOG'] ?? 'false'))) === 'true';
}

function vv_ai_findings_dir(): string {
    $d = AI_DATA_DIR . '/ai_findings';
    if (!is_dir($d)) @mkdir($d, 0755, true);
    return $d;
}

function vv_ai_finding_retain_days(): int {
    $n = (int)(vv_conf_vars()['AI_FINDING_RETAIN_DAYS'] ?? 90);
    return max(1, $n);
}

// kind + subject + reference. Deliberately not the message: the same wrong port produces slightly
// different log text as the software around it changes, and that must not mint a second record.
//
// The reference is the conf key for a conf-bound kind, and whatever else identifies the thing
// otherwise — for an arr health item, the check that raised it. "Indexers unavailable: NzbNoob"
// becomes "Indexers unavailable: NzbNoob, Miatrix" as more fail, and that is the same finding
// getting worse rather than a second one.
function vv_ai_finding_id(string $kind, string $subject, string $ref): string {
    return substr(sha1(strtolower($kind . '|' . $subject . '|' . $ref)), 0, 12);
}

function vv_ai_finding_path(string $id): ?string {
    if (!preg_match('/^[0-9a-f]{12}$/', $id)) return null;
    return vv_ai_findings_dir() . '/' . $id . '.json';
}

function vv_ai_finding_get(string $id): ?array {
    $p = vv_ai_finding_path($id);
    if ($p === null || !is_file($p)) return null;
    $r = json_decode((string)@file_get_contents($p), true);
    return is_array($r) ? $r : null;
}

// Files a finding, or increments the one already describing this fault.
//
// $f expects: kind, subject, conf_key, conf_file, observed, evidence, source_log
// and optionally: proposed, proven, state, note
function vv_ai_finding_write(array $f): array {
    $kind    = (string)($f['kind'] ?? '');
    $subject = trim((string)($f['subject'] ?? ''));
    $confKey = trim((string)($f['conf_key'] ?? ''));
    $evidence = trim((string)($f['evidence'] ?? ''));

    // What identifies this finding. Conf-bound kinds are identified by their key; everything else
    // supplies its own reference, and a finding with neither points at nothing and is refused.
    $ref = trim((string)($f['ref'] ?? $confKey));

    if (!isset(VV_AI_FINDING_KINDS[$kind]))       return ['ok' => false, 'error' => 'unknown kind'];
    if ($subject === '' || $ref === '')           return ['ok' => false, 'error' => 'subject and a reference are required'];
    if ($evidence === '')                         return ['ok' => false, 'error' => 'evidence required'];

    if (vv_ai_kind_is_conf_bound($kind)) {
        if ($confKey === '')                      return ['ok' => false, 'error' => 'conf_key required for this kind'];
        // The key has to be a real shell identifier for the same reason the conf writer insists
        // on it: a finding is a proposal to edit that key, and a malformed one cannot be actioned.
        if (!vv_conf_key_valid($confKey))         return ['ok' => false, 'error' => 'malformed conf key'];
    }

    $state = (string)($f['state'] ?? 'open');
    if (!isset(VV_AI_FINDING_STATES[$state]))     $state = 'open';

    $now = time();
    $id  = vv_ai_finding_id($kind, $subject, $ref);
    $rec = [
        'id'         => $id,
        'kind'       => $kind,
        'subject'    => mb_substr($subject, 0, 120),
        'conf_key'   => $confKey,
        'conf_file'  => (string)($f['conf_file'] ?? 'master.conf'),
        // Secrets never enter this store. A finding about a rejected API key is about the key
        // being wrong, and the wrong value is of no use to anyone reading the record later.
        'observed'   => vv_conf_key_is_secret($confKey) ? '<redacted>'
                                                        : mb_substr((string)($f['observed'] ?? ''), 0, 300),
        'proposed'   => isset($f['proposed']) && !vv_conf_key_is_secret($confKey)
                        ? mb_substr((string)$f['proposed'], 0, 300) : null,
        'proven'     => (bool)($f['proven'] ?? false),
        'state'      => $state,
        'evidence'   => mb_substr(vv_ai_redact($evidence), 0, 1200),
        'source_log' => mb_substr((string)($f['source_log'] ?? ''), 0, 200),
        'note'       => mb_substr((string)($f['note'] ?? ''), 0, 1000),
        // Recomputed on every sighting rather than stored once: a key that becomes a toggle, or
        // a toggle that is replaced by a real value, changes what this finding means.
        'ref'        => mb_substr($ref, 0, 120),
        // Every field the grader consults has to be handed to it. It was given a hand-picked
        // three, so a kind added later that grades on a fourth silently came out as a warning —
        // which is how a disk throwing I/O errors would have been filed at the same level as a
        // switched-off toggle, and notified as a warning rather than an alert.
        'severity'   => vv_ai_finding_severity(['kind' => $kind, 'conf_key' => $confKey,
                                                'arr_type'  => (string)($f['arr_type'] ?? ''),
                                                'sys_level' => (string)($f['sys_level'] ?? '')]),
        'host'       => vv_detect_host(),
        'first'      => $now,
        'last'       => $now,
        'seen'       => 1,
        'closed_at'  => null,
        // What the key read when the operator acknowledged it. Null unless acked; the ack
        // expires the moment the live value stops matching this.
        'ack_value'  => null,
        // A stable identity for the kinds whose evidence changes on every pass. Empty for the
        // rest, which pin to a conf value or to the evidence itself. Stored rather than derived
        // so an ack given in the browser pins to exactly what the sweep will compare against.
        'pin'        => mb_substr((string)($f['pin'] ?? ''), 0, 120),
        // What this finding looked like when it was last announced. Carried across sightings
        // below — a stamp that reset every fifteen minutes would be a notification every fifteen
        // minutes, which is how an operator learns to ignore the channel.
        'notified'   => null,
    ];

    $p = vv_ai_finding_path($id);
    if ($p === null) return ['ok' => false, 'error' => 'bad id'];

    if (is_file($p)) {
        $old = json_decode((string)@file_get_contents($p), true);
        if (is_array($old)) {
            $rec['first'] = $old['first'] ?? $now;
            $rec['seen']  = (int)($old['seen'] ?? 0) + 1;
            // A dismissed finding stays dismissed however many times the log repeats it —
            // otherwise "this is fine, stop telling me" lasts exactly one cycle. A fixed one
            // reopens, because seeing the fault again after a repair means the repair did not
            // hold, which is the single most important thing this store can tell anyone.
            if (($old['state'] ?? '') === 'dismissed') {
                $rec['state']     = 'dismissed';
                $rec['closed_at'] = $old['closed_at'] ?? null;
            }
            // An acknowledgement holds only while the thing acknowledged is still true. Compare
            // what it is pinned to now against what it read when the ack was given: unchanged
            // means stay quiet, changed means the note is stale and the finding comes back by
            // itself. $rec carries this sighting's evidence, so a fault that has changed shape
            // fails this comparison even when no conf key is involved.
            if (($old['state'] ?? '') === 'acknowledged') {
                $ackedAt = (string)($old['ack_value'] ?? '');
                if ($ackedAt === vv_ai_finding_ack_pin($rec)) {
                    $rec['state']     = 'acknowledged';
                    $rec['ack_value'] = $ackedAt;
                    $rec['closed_at'] = $old['closed_at'] ?? null;
                }
                // Otherwise $rec keeps the state this sighting computed — it has reopened.
            }
            // Preserve an operator's note over a generated one.
            if ($rec['note'] === '' && !empty($old['note'])) $rec['note'] = $old['note'];

            // Carried, never recomputed. This record is rebuilt from scratch on every sighting,
            // so anything not copied forward here is reset — and a reset announcement stamp
            // means this finding is announced again on the next pass, and the one after that.
            $rec['notified'] = $old['notified'] ?? null;
        }
    }

    if (@file_put_contents($p, json_encode($rec, JSON_PRETTY_PRINT)) === false) {
        return ['ok' => false, 'error' => 'write failed'];
    }
    return ['ok' => true, 'id' => $id, 'seen' => $rec['seen'], 'state' => $rec['state']];
}

// $states filters; empty means everything. Newest activity first, because a finding seen in the
// last cycle matters more than one that has been sitting fixed for a month.
function vv_ai_findings_list(array $states = ['open', 'needs_operator']): array {
    $out = [];
    foreach ((array)@glob(vv_ai_findings_dir() . '/*.json') as $file) {
        $r = json_decode((string)@file_get_contents($file), true);
        if (!is_array($r)) continue;
        if ($states && !in_array($r['state'] ?? 'open', $states, true)) continue;
        $out[] = $r;
    }
    usort($out, fn($a, $b) => ($b['last'] ?? 0) <=> ($a['last'] ?? 0));
    return $out;
}

function vv_ai_finding_set_state(string $id, string $state, string $note = ''): bool {
    if (!isset(VV_AI_FINDING_STATES[$state])) return false;
    $r = vv_ai_finding_get($id);
    if ($r === null) return false;

    $r['state']     = $state;
    $r['closed_at'] = in_array($state, ['open', 'needs_operator'], true) ? null : time();
    if ($note !== '') $r['note'] = mb_substr(vv_ai_redact($note), 0, 1000);

    $p = vv_ai_finding_path($id);
    return $p !== null && @file_put_contents($p, json_encode($r, JSON_PRETTY_PRINT)) !== false;
}

function vv_ai_finding_close(string $id, string $note = ''): bool {
    return vv_ai_finding_set_state($id, 'resolved', $note);
}

function vv_ai_finding_dismiss(string $id, string $note = ''): bool {
    return vv_ai_finding_set_state($id, 'dismissed', $note);
}

// What an acknowledgement is pinned to — the thing that has to stay the same for the ack to keep
// meaning what it meant.
//
// For a conf-bound finding that is the key's value, which is what the operator was looking at
// when they said "I know". A finding with no key had nothing to pin to and so compared '' with
// '' — every ack on an arr health item was silently permanent, which is dismiss wearing ack's
// label. Those pin to the shape of the fault instead: "indexers unavailable: NzbNoob" and
// "indexers unavailable: NzbNoob, Miatrix" are one finding getting worse, and an ack given for
// the first has not been given for the second.
// A finding may supply its own pin, and the log-derived kinds must. Their evidence carries a line
// count and the timestamp of the first matching line, so it is different on every single pass —
// pinning to it would expire an acknowledgement within fifteen minutes and re-announce a fault
// the operator had just said they knew about, forever. A PCIe controller that has thrown
// correctable errors since the machine was built is exactly that case.
//
// The pin those kinds supply is their identity: the fault class and the thing it is about. A
// fault that gets genuinely worse — correctable becoming uncorrectable — has a different class,
// so it is a different finding and announces on its own rather than hiding behind this one's ack.
function vv_ai_finding_ack_pin(array $f): string {
    if (($f['pin'] ?? '') !== '') return (string)$f['pin'];
    $key = (string)($f['conf_key'] ?? '');
    if ($key !== '') return (string)(vv_conf_vars()[$key] ?? '');
    return 'ev:' . substr(sha1((string)($f['evidence'] ?? '')), 0, 16);
}

// "I know about this — leave it, and tell me if it changes."
//
// Stamps what it is pinned to onto the record. Every later sighting compares against that stamp,
// so the acknowledgement covers this state and not the finding forever. Acking that critical
// rsync is off says nothing about critical rsync being on.
function vv_ai_finding_ack(string $id, string $note = ''): bool {
    $r = vv_ai_finding_get($id);
    if ($r === null) return false;

    $r['state']     = 'acknowledged';
    $r['ack_value'] = vv_ai_finding_ack_pin($r);
    $r['closed_at'] = time();
    if ($note !== '') $r['note'] = mb_substr(vv_ai_redact($note), 0, 1000);

    $p = vv_ai_finding_path($id);
    return $p !== null && @file_put_contents($p, json_encode($r, JSON_PRETTY_PRINT)) !== false;
}

// What the operator can do about a finding, and what each choice means. Returned rather than
// hardcoded in the UI so the chat and the page cannot offer different options for the same row,
// and enforced in vv_ai_finding_apply_action() so neither can act on one it was not offered.
//
// Fix appears for anything with a proposed value, toggle or not — the prohibition is on the
// sweep choosing, never on the operator choosing. Open rows also carry ack, dismiss and cancel,
// because "I know", "this is never a problem" and "not now" are all valid answers to being told
// something, and they are three different answers.
function vv_ai_finding_actions(array $f): array {
    // A closed finding has one question left, and it is not the original one: was closing it
    // right? Offering fix or ack on a row that is already dismissed is offering to decide
    // something that has been decided. Reopen is here because dismiss is otherwise permanent —
    // the write path keeps a dismissed finding dismissed however many times the fault recurs,
    // so a mis-click would need someone editing JSON on disk to undo.
    if (!in_array((string)($f['state'] ?? 'open'), ['open', 'needs_operator'], true)) {
        return ['reopen' => 'Put it back on the list — either closing it was wrong, or it is back'];
    }

    $actions = [];

    if (($f['proposed'] ?? null) !== null) {
        $actions['fix'] = vv_ai_conf_is_toggle((string)($f['conf_key'] ?? ''))
            ? 'Set ' . $f['conf_key'] . ' — a toggle, so this only ever happens because you asked'
            : 'Write the proven value to ' . $f['conf_key'];
    }

    $actions['ack']     = ($f['conf_key'] ?? '') !== ''
        ? 'Known and intended. Stays quiet until ' . $f['conf_key'] . ' changes'
        : 'Known and intended. Stays quiet until the fault itself changes';
    $actions['dismiss'] = 'Not a problem, ever. Stays closed even when it is seen again';
    $actions['cancel']  = 'Leave it alone for now';

    return $actions;
}

// Closed findings are kept for a while because "this happened before and here is what fixed it"
// is worth more than the disk it costs. Open ones are never pruned — an unresolved problem does
// not stop mattering because it is old.
function vv_ai_findings_prune(): int {
    $cutoff = time() - (vv_ai_finding_retain_days() * 86400);
    $n = 0;
    foreach ((array)@glob(vv_ai_findings_dir() . '/*.json') as $file) {
        $r = json_decode((string)@file_get_contents($file), true);
        if (!is_array($r)) continue;
        if (in_array($r['state'] ?? 'open', ['open', 'needs_operator'], true)) continue;
        if ((int)($r['closed_at'] ?? 0) > $cutoff) continue;
        if (@unlink($file)) $n++;
    }
    return $n;
}

// ── The system's own log ─────────────────────────────────────────────────────────────────────
// Everything above this reads Varaverk's logs and asks the arrs about themselves. Neither can
// see a disk throwing I/O errors, a filesystem going read-only, or a PCIe link retraining every
// two minutes — and those are the faults that explain the ones Varaverk does notice.
//
// /var/log/syslog and not dmesg. Unraid's syslog carries the kernel ring buffer's lines already,
// prefixed with `kernel:`, and it carries a real date on every line. dmesg's ring buffer wraps,
// has no persistence across a reboot, and its -T timestamps are derived from uptime rather than
// recorded, which makes "since the last pass" unanswerable from it.
//
// What is deliberately NOT here:
//   Container restarts, crash loops and OOMKilled — docker_watchdog owns those and notifies on
//   them, and a second opinion arriving by a second channel is not more information.
//   ZFS pool health — `zpool status` answers that exactly, and inferring it from log lines when
//   the authoritative command is one call away would be guessing on purpose.
//
// A host-level OOM kill IS here, and does overlap docker_watchdog when the process killed was in
// a container. They are different halves: the watchdog reports that a container restarted, this
// reports what the kernel killed and that it was memory. The store folds repeats into one row.
const VV_AI_SYSLOG_PATTERNS = [
    // Verified against this host's own syslog, which produced 109 of these in a day. Correctable
    // means the link recovered, which is why it is a warning and not an error — but a device
    // retraining continuously is failing slowly.
    ['re' => '/AER: (?:Multiple )?Corrected error.*?(?:from|received from) (?<subject>[0-9a-f:.]+)/i',
     'level' => 'warn',  'what' => 'PCIe correctable errors'],
    ['re' => '/AER: (?:Multiple )?Correctable error message received from (?<subject>[0-9a-f:.]+)/i',
     'level' => 'warn',  'what' => 'PCIe correctable errors'],
    // The severity word is followed by a parenthetical often enough that requiring "error" to
    // come straight after it misses the common form: "Uncorrected (Non-Fatal) error received
    // from". Found by a fixture, not by reasoning about it.
    ['re' => '/AER: (?:Multiple )?(?:Uncorrected|Fatal|Uncorrectable)[^:]{0,20}? error.*?from (?<subject>[0-9a-f:.]+)/i',
     'level' => 'error', 'what' => 'PCIe uncorrectable errors'],

    // Block layer. blk_update_request is where a failed read or write surfaces with the device
    // named, which is the line worth keeping — the ATA exception above it names a port, not a
    // disk, and a port number is not something an operator can act on.
    ['re' => '/blk_update_request: (?:critical )?(?:I\/O|medium|target|nonexistent) error, dev (?<subject>[a-z0-9]+)/i',
     'level' => 'error', 'what' => 'block I/O errors'],
    ['re' => '/Buffer I\/O error on dev (?<subject>[a-z0-9]+)/i',
     'level' => 'error', 'what' => 'buffer I/O errors'],
    // The SCSI layer writes the device in brackets — "sd 1:0:3:0: [sdo] tag#28 FAILED" — so the
    // name is not followed by a colon the way it is everywhere else. Both forms accepted.
    ['re' => '/(?:\[(?<subject>sd[a-z]+|nvme\d+n\d+)\]|(?<subject2>sd[a-z]+|nvme\d+n\d+):).*?(?:unrecovered read error|medium error|rejecting I\/O to|failed command)/i',
     'level' => 'error', 'what' => 'device errors'],

    // Filesystems. Each names the device inside its own parentheses, which is the identity the
    // finding is keyed on — one finding per filesystem, however many lines it emits.
    ['re' => '/XFS \((?<subject>[^)]+)\): (?:Metadata|Corruption|corruption|log I\/O error|writeback error|Internal error)/',
     'level' => 'error', 'what' => 'XFS errors'],
    ['re' => '/BTRFS (?:error|critical) \(device (?<subject>[^)]+)\)/',
     'level' => 'error', 'what' => 'BTRFS errors'],
    ['re' => '/EXT4-fs error \(device (?<subject>[^)]+)\)/',
     'level' => 'error', 'what' => 'ext4 errors'],
    ['re' => '/(?:Remounting|remounting) filesystem read-only/',
     'level' => 'error', 'what' => 'a filesystem went read-only', 'subject' => 'filesystem'],

    // Memory. The process name is the subject, so "the kernel keeps killing shfs" is one finding
    // rather than one per occurrence.
    ['re' => '/Out of memory: Killed process \d+ \((?<subject>[^)]+)\)/',
     'level' => 'error', 'what' => 'out-of-memory kills'],

    // The kernel saying it has broken. No subject to extract that means anything, so the finding
    // is about the machine.
    ['re' => '/(?:kernel BUG at|general protection fault|Oops: |Kernel panic)/',
     'level' => 'error', 'what' => 'kernel faults', 'subject' => 'kernel'],
];

function vv_ai_syslog_path(): string {
    return '/var/log/syslog';
}

function vv_ai_syslog_enabled(): bool {
    if (!vv_ai_repair_enabled()) return false;
    return strtolower(trim((string)(vv_conf_vars()['AI_REPAIR_SYSLOG_ENABLED'] ?? 'true'))) !== 'false';
}

function vv_ai_syslog_max_lines(): int {
    $n = (int)(vv_conf_vars()['AI_REPAIR_SYSLOG_MAX_LINES'] ?? 4000);
    return max(100, min(50000, $n));
}

// "Aug  9 21:46:07" — syslog's format carries no year, which is a real problem exactly once a
// year. Parsed against the current one, and anything landing more than a day in the future is
// read as last year's: on 1 January, December's lines would otherwise be stamped eleven months
// ahead and every one of them would look newer than the last sweep, forever.
//
// Returns null for a line that does not start with a timestamp, which is a continuation line and
// belongs to whatever preceded it.
function vv_ai_syslog_ts(string $line, ?int $now = null): ?int {
    if (!preg_match('/^([A-Z][a-z]{2}\s+\d{1,2} \d{2}:\d{2}:\d{2})/', $line, $m)) return null;
    $now = $now ?? time();
    $ts  = strtotime($m[1] . ' ' . date('Y', $now));
    if ($ts === false) return null;
    if ($ts > $now + 86400) {
        $ts = strtotime($m[1] . ' ' . (int)(date('Y', $now) - 1));
        if ($ts === false) return null;
    }
    return $ts;
}

// The tail of syslog, bounded twice: by the line cap and by the timestamp. The cap is applied
// first and is what stops a rotation, a boot, or a flood from turning one pass into a scan of
// the whole file.
function vv_ai_syslog_lines(int $since, ?int $maxLines = null, ?string $path = null): array {
    $path = $path ?? vv_ai_syslog_path();
    if (!is_readable($path)) return [];
    $max  = $maxLines ?? vv_ai_syslog_max_lines();

    $out = []; $rc = 0;
    exec('tail -n ' . (int)$max . ' ' . escapeshellarg($path) . ' 2>/dev/null', $out, $rc);
    if ($rc !== 0) return [];

    $now  = time();
    $kept = [];
    foreach ($out as $line) {
        $ts = vv_ai_syslog_ts($line, $now);
        // A line with no timestamp cannot be placed in time. Kept only if the line before it was
        // kept, since that is what a continuation is.
        if ($ts === null) { if ($kept) $kept[] = $line; continue; }
        if ($ts <= $since) continue;
        $kept[] = $line;
    }
    return $kept;
}

// Log lines in, finding candidates out. One candidate per (pattern, subject) however many lines
// matched, with the count carried in the evidence — a disk that threw four hundred I/O errors in
// a quarter of an hour is one fault, and four hundred findings would be four hundred ways to
// miss it.
function vv_ai_syslog_findings(int $since, ?array $lines = null): array {
    if (!vv_ai_syslog_enabled()) return [];
    $lines = $lines ?? vv_ai_syslog_lines($since);
    if (!$lines) return [];

    $agg = [];
    foreach ($lines as $line) {
        foreach (VV_AI_SYSLOG_PATTERNS as $p) {
            if (!preg_match($p['re'], $line, $m)) continue;

            // subject2 is the second branch of an alternation. PHP refuses two groups with the
            // same name in one pattern, so a pattern that can find its subject in either of two
            // shapes has to name them apart and try both here.
            $subject = trim((string)($m['subject'] ?? ''));
            if ($subject === '') $subject = trim((string)($m['subject2'] ?? ''));
            if ($subject === '') $subject = trim((string)($p['subject'] ?? ''));
            if ($subject === '') $subject = 'system';

            $key = $p['what'] . '|' . $subject;
            if (!isset($agg[$key])) {
                $agg[$key] = ['what' => $p['what'], 'subject' => $subject, 'level' => $p['level'],
                              'count' => 0, 'first_line' => trim($line)];
            }
            $agg[$key]['count']++;
            // The worst level wins when two patterns describe the same subject.
            if ($p['level'] === 'error') $agg[$key]['level'] = 'error';
            break;   // one pattern per line; the list is ordered most specific first
        }
    }

    $found = [];
    foreach ($agg as $a) {
        $found[] = [
            'kind'       => 'system_fault',
            'subject'    => $a['subject'],
            // Identity, not contents — the evidence below counts lines and quotes a timestamp,
            // so it differs every pass and would expire an acknowledgement immediately.
            'pin'        => 'sys:' . $a['what'] . '|' . $a['subject'],
            // What identifies it: the class of fault, not the message. The wording of a kernel
            // line changes between releases and the fault does not.
            'ref'        => $a['what'],
            'conf_key'   => '',
            'conf_file'  => '',
            'sys_level'  => $a['level'],
            'observed'   => $a['count'] . ' since the last pass',
            'evidence'   => sprintf('%s on %s — %d line%s since the last pass. First: %s',
                                    $a['what'], $a['subject'], $a['count'],
                                    $a['count'] === 1 ? '' : 's',
                                    mb_substr($a['first_line'], 0, 200)),
            'source_log' => vv_ai_syslog_path(),
            // Nothing here is repairable from conf, so it goes straight to the operator rather
            // than sitting open waiting for a probe that will never run.
            'state'      => 'needs_operator',
        ];
    }
    return $found;
}

// ── What the containers are saying ───────────────────────────────────────────────────────────
// docker_watchdog watches container *state* — is it up, does its port answer, is it restarting.
// None of that reads a line the application wrote, so a container that is running perfectly and
// has been unable to write to its database for a day looks completely healthy from out there.
//
// The patterns are deliberately about the container's environment rather than its behaviour.
// Fifty containers run here and they are fifty different applications; there is no useful shared
// vocabulary for "this app is malfunctioning". There is an exact shared vocabulary for "the disk
// is full", "the filesystem is read-only" and "my database is corrupt", because those come from
// libc, the kernel and SQLite rather than from the application — the same string in every one of
// them. Anything app-specific belongs in that app's own health endpoint, which is where the arr
// checks already come from.
//
// Noise is the whole risk here. Every pattern below was run against the real logs of all fifty
// containers on this host before being kept; see Tools/ai_container_check.sh.
const VV_AI_CONTAINER_PATTERNS = [
    ['re' => '/no space left on device/i',
     'level' => 'error', 'what' => 'disk full'],
    ['re' => '/read-only file ?system|Read-only file system/i',
     'level' => 'error', 'what' => 'read-only filesystem'],
    ['re' => '/disk quota exceeded/i',
     'level' => 'error', 'what' => 'disk quota exceeded'],
    // SQLite's own wording. "database is locked" is deliberately absent: it is contention, it is
    // transient, and the arrs emit it in normal operation.
    ['re' => '/database disk image is malformed|database or disk is full|file is (?:not a database|encrypted or is not a database)/i',
     'level' => 'error', 'what' => 'database corruption'],
    ['re' => '/too many open files/i',
     'level' => 'error', 'what' => 'file descriptor limit'],
    ['re' => '/certificate has expired|certificate is not yet valid|certificate verify failed/i',
     'level' => 'error', 'what' => 'certificate problems'],
];

function vv_ai_container_logs_enabled(): bool {
    if (!vv_ai_repair_enabled()) return false;
    return strtolower(trim((string)(vv_conf_vars()['AI_REPAIR_CONTAINER_LOGS_ENABLED'] ?? 'true'))) !== 'false';
}

function vv_ai_container_log_lines(): int {
    $n = (int)(vv_conf_vars()['AI_REPAIR_CONTAINER_LOG_LINES'] ?? 400);
    return max(50, min(5000, $n));
}

// Running containers only. A stopped one has nothing new to say, and its last words before it
// stopped are docker_watchdog's business.
function vv_ai_running_containers(): array {
    $out = []; $rc = 0;
    exec('docker ps --format {{.Names}} 2>/dev/null', $out, $rc);
    if ($rc !== 0) return [];
    return array_values(array_filter(array_map('trim', $out), fn($n) => $n !== ''));
}

// One container's log since the marker, bounded by both a time and a line count. --since alone
// is not a bound: a container that logged a million lines in the last quarter hour would hand
// back all of them.
function vv_ai_container_log(string $name, int $since, ?int $maxLines = null): array {
    if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9_.-]*$/', $name)) return [];
    $max = $maxLines ?? vv_ai_container_log_lines();

    // Both streams: applications disagree about which one an error belongs on, and several of
    // these write everything to stdout.
    $cmd = 'docker logs --since ' . escapeshellarg((string)max(1, $since))
         . ' --tail ' . (int)$max . ' ' . escapeshellarg($name) . ' 2>&1';
    $out = []; $rc = 0;
    exec($cmd, $out, $rc);
    return $rc === 0 ? $out : [];
}

// Same aggregation as the syslog pass: one candidate per (container, fault class), with the
// count in the evidence rather than a finding per line.
function vv_ai_container_findings(int $since, ?array $logsByContainer = null): array {
    if ($logsByContainer === null && !vv_ai_container_logs_enabled()) return [];

    $agg = [];
    $names = $logsByContainer !== null ? array_keys($logsByContainer) : vv_ai_running_containers();

    foreach ($names as $name) {
        $lines = $logsByContainer !== null ? $logsByContainer[$name]
                                           : vv_ai_container_log($name, $since);
        foreach ($lines as $line) {
            foreach (VV_AI_CONTAINER_PATTERNS as $p) {
                if (!preg_match($p['re'], $line)) continue;
                $key = $name . '|' . $p['what'];
                if (!isset($agg[$key])) {
                    $agg[$key] = ['name' => $name, 'what' => $p['what'], 'level' => $p['level'],
                                  'count' => 0, 'first_line' => trim($line)];
                }
                $agg[$key]['count']++;
                break;
            }
        }
    }

    $found = [];
    foreach ($agg as $a) {
        $found[] = [
            'kind'       => 'container_fault',
            'subject'    => $a['name'],
            'pin'        => 'ctr:' . $a['what'] . '|' . $a['name'],
            'ref'        => $a['what'],
            'conf_key'   => '',
            'conf_file'  => '',
            'sys_level'  => $a['level'],
            'observed'   => $a['count'] . ' since the last pass',
            'evidence'   => sprintf('%s in %s — %d line%s since the last pass. First: %s',
                                    $a['what'], $a['name'], $a['count'],
                                    $a['count'] === 1 ? '' : 's',
                                    mb_substr($a['first_line'], 0, 200)),
            'source_log' => 'docker logs ' . $a['name'],
            'state'      => 'needs_operator',
        ];
    }
    return $found;
}

// ── What the watchdogs have counted ──────────────────────────────────────────────────────────
// The fourth candidate source. The other three read logs; this reads the counters the watchdogs
// keep, and turns the ones that have gone far enough into a record with a name on it.
//
// It reads vv_wd_all() rather than the state files, for the reason the Watchdog tab does: the
// meaning of a strike lives in include/watchdog.php, and a second parser here would be a second
// opinion about it. It also gets the thresholds in the same payload, so a count is always judged
// against the limit that was in force when it was read rather than one fetched separately.
//
// Deliberately not conf-bound. 'watchdog_strike' is absent from VV_AI_CONF_BOUND_KINDS, so these
// findings carry no conf key, so vv_ai_finding_may_autofix() can never return true for one. That
// is a structural guarantee rather than a switch: there is no setting that makes this able to
// change anything, and none can be added without also deciding which key it would write.
//
// What it does NOT do is explain. The text below is derived, not generated — no model is asked
// anything on this path. The reading is a click away in the troubleshoot profile, already scoped
// to the orchestrator's log by the Why? buttons on the Watchdog tab. Detection has to be worth
// believing before it is worth narrating, and a wrong cause stated fluently is worse than none.
const VV_AI_WD_PRESSURE_LEVELS = [2 => 'medium', 3 => 'hard'];

function vv_ai_watchdog_findings(?array $payload = null): array {
    if ($payload === null && !vv_ai_assist_watchdog_enabled()) return [];

    $wd = $payload ?? vv_cache_read('watchdog', 900);
    if ($wd === null) {
        require_once __DIR__ . '/watchdog.php';
        $wd = vv_wd_all();
    }

    $cfg   = $wd['cfg']   ?? [];
    $found = [];

    foreach ($wd['nodes'] ?? [] as $node) {
        // This host only. A partner's counters are its own to file findings about, and the
        // operator reading them here could not act on them from this side anyway.
        if (empty($node['is_me'])) continue;

        $st = $node['states'] ?? null;
        if (!$st) continue;

        $id = (string)($node['id'] ?? 'this host');
        // The operator has already said they know about these. Suppressing here rather than at
        // write time keeps them out of the pin store entirely — 7DaysToDie is the worked example:
        // knowingly broken, deliberately ignored, and a finding about it every fifteen minutes
        // would be exactly the crying-wolf this is supposed to avoid.
        $ignore = array_flip((array)($node['config']['ignore'] ?? []));

        $add = function (string $subject, string $ref, string $level, string $observed,
                         string $evidence) use (&$found) {
            $found[] = [
                'kind'       => 'watchdog_strike',
                'subject'    => $subject,
                'pin'        => 'wd:' . $ref . '|' . $subject,
                'ref'        => $ref,
                'conf_key'   => '',
                'conf_file'  => '',
                'sys_level'  => $level,
                'observed'   => $observed,
                'evidence'   => $evidence,
                // Every watchdog logs through the orchestrator; none keeps a log of its own.
                'source_log' => 'Orchestrators/watchdog_orchestrator',
                'state'      => 'needs_operator',
            ];
        };

        // 1. Skip list — the terminal state. The watchdog tried its restart limit and stopped,
        //    which means nothing further is coming automatically. Always worth a record.
        foreach ((array)($st['skiplist'] ?? []) as $name) {
            if (isset($ignore[$name])) continue;
            $add($name, 'skiplist', 'error', 'on the skip list',
                 sprintf('%s is on the docker watchdog skip list on %s. It was restarted up to '
                       . 'the limit of %d and did not stay up, so the watchdog has stopped trying '
                       . 'and will not restart it again without intervention.',
                         $name, $id, (int)($cfg['restart_limit'] ?? 3)));
        }

        // 2. Container check strikes at or past their limit. Below the limit the watchdog is
        //    still working the problem and a finding would be premature.
        $lim = max(1, (int)($cfg['cpu_fail_lim'] ?? 2));
        foreach ((array)($st['ctr_strikes'] ?? []) as $key => $count) {
            $name = preg_replace('/_(cpu|http|api|docker)$/', '', (string)$key);
            if (isset($ignore[$name]) || (int)$count < $lim) continue;
            $check = preg_match('/_(cpu|http|api|docker)$/', (string)$key, $m) ? $m[1] : 'health';
            $add($name, 'strike:' . $check, 'warn', $count . ' / ' . $lim,
                 sprintf('The docker watchdog has counted %d consecutive %s failures for %s on %s, '
                       . 'against a limit of %d.', (int)$count, $check, $name, $id, $lim));
        }

        // 3. Repeated restarts inside the 24h window. Distinct from a strike: the container is
        //    coming back each time, which is why nothing has escalated, and is also why this is
        //    easy to never notice.
        $rc = [];
        foreach ((array)($st['restarts'] ?? []) as $r) {
            $n = (string)($r['name'] ?? '');
            if ($n !== '') $rc[$n] = ($rc[$n] ?? 0) + 1;
        }
        $rlim = max(2, (int)($cfg['restart_limit'] ?? 3));
        foreach ($rc as $name => $count) {
            if (isset($ignore[$name]) || $count < $rlim) continue;
            $add($name, 'restarts', 'warn', $count . ' in 24h',
                 sprintf('%s has been restarted %d times in the last 24 hours on %s. Each one '
                       . 'succeeded, so no strike escalated — but a container that needs restarting '
                       . 'this often is failing at something between restarts.', $name, $count, $id));
        }

        // 4. Any unattended reboot. Not thresholded: the machine restarting itself is worth
        //    saying once every time it happens, however normal the count is.
        $reboots = (array)($st['reboots'] ?? []);
        if ($reboots) {
            $add($id, 'reboots', 'error', count($reboots) . ' / ' . (int)($cfg['reboot_limit'] ?? 3),
                 sprintf('The stability watchdog has rebooted %s %d time%s in the last %d hours, '
                       . 'against a limit of %d before it stops trying.',
                         $id, count($reboots), count($reboots) === 1 ? '' : 's',
                         (int)($cfg['reboot_window'] ?? 12), (int)($cfg['reboot_limit'] ?? 3)));
        }

        // 5. Sustained memory pressure. Level 1 is soft and self-clears constantly; from level 2
        //    the watchdog has acted on containers, and that is a thing that happened.
        $lvl = (int)($st['rw_level'] ?? 0);
        if (isset(VV_AI_WD_PRESSURE_LEVELS[$lvl])) {
            $paused  = array_values(array_filter((array)($st['rw_paused']  ?? [])));
            $stopped = array_values(array_filter((array)($st['rw_stopped'] ?? [])));
            $acted   = array_merge(
                $paused  ? ['paused ' . implode(', ', $paused)]   : [],
                $stopped ? ['stopped ' . implode(', ', $stopped)] : []
            );
            $add($id, 'pressure', $lvl >= 3 ? 'error' : 'warn',
                 VV_AI_WD_PRESSURE_LEVELS[$lvl] . ' pressure',
                 sprintf('The resource watchdog is at %s pressure on %s%s.%s',
                         VV_AI_WD_PRESSURE_LEVELS[$lvl], $id,
                         $acted ? ' and has ' . implode(' and ', $acted) : '',
                         !empty($st['mem_shutdown']) ? ' The memory shutdown flag is set, so '
                             . 'docker_watchdog is deferring container restarts.' : ''));
        }
    }

    return $found;
}

// ── Reaching the operator ────────────────────────────────────────────────────────────────────
// A finding nobody is told about is a finding nobody has. The card on the AI tab shows them, but
// only to someone who opens the tab, and the point of this subsystem is that it works while
// nobody is looking.
//
// Only needs_operator is announced. An open finding may still be repaired by the next pass, and
// announcing one would be telling the operator about a problem that has already been handled by
// the time they read it. acknowledged and dismissed are the operator's own answers and are never
// announced at all.
//
// AI_REPAIR_NOTIFY_ENABLED defaults to true when absent, unlike the two switches above it. Those
// gate reading and writing, which are things to be trusted first; this gates telling someone,
// which is the point of having found anything. It still cannot fire unless AI_REPAIR_ENABLED is
// on, and notify() itself is subject to NOTIFY_UNRAID and the host's webhook.
function vv_ai_notify_enabled(): bool {
    if (!vv_ai_repair_enabled()) return false;
    return strtolower(trim((string)(vv_conf_vars()['AI_REPAIR_NOTIFY_ENABLED'] ?? 'true'))) !== 'false';
}

// What has to change before this finding is worth mentioning twice.
//
// The same pin as an acknowledgement, plus the severity. That means a finding is announced once
// and then stays quiet — through every fifteen-minute pass, however many times it is seen — until
// either the thing it is about changes or it gets worse. "Indexers unavailable: NzbNoob" becoming
// "NzbNoob, Miatrix" is news; the same sentence for the ninth time is not.
function vv_ai_finding_notify_pin(array $f): string {
    return vv_ai_finding_ack_pin($f) . '|' . (string)($f['severity'] ?? 'warn');
}

// One notification for everything that needs saying, not one per finding. Returns what it did so
// the sweep can log it and the tests can read it without a notification having to be sent.
function vv_ai_findings_announce(bool $dryRun = false): array {
    $out = ['sent' => false, 'count' => 0, 'subject' => '', 'message' => '', 'ids' => []];
    if (!vv_ai_notify_enabled()) return $out + ['skipped' => 'AI_REPAIR_NOTIFY_ENABLED is false'];

    // Nothing switched on to receive it. Checked before anything is composed, and reported as a
    // configuration state rather than a delivery failure: a failure is retried on the next pass
    // and logged loudly, and neither is the right response to "no channel has been set up".
    if (!vv_notify_available()) {
        return $out + ['skipped' => 'no notification channel — NOTIFY_UNRAID is false and no webhook is set'];
    }

    $due = [];
    foreach (vv_ai_findings_list(['needs_operator']) as $f) {
        if (($f['notified'] ?? null) === vv_ai_finding_notify_pin($f)) continue;
        $due[] = $f;
    }
    if (!$due) return $out;

    // Worst first: if the message is truncated, the part that survives is the part that matters.
    usort($due, fn($a, $b) => (($b['severity'] ?? '') === 'error' ? 1 : 0)
                            <=> (($a['severity'] ?? '') === 'error' ? 1 : 0));

    $lines = [];
    foreach (array_slice($due, 0, 3) as $f) {
        // Evidence is a log excerpt and routinely spans lines. Collapsed here rather than left
        // for vv_notify() to tidy on the way out: this string is also what gets logged and what
        // the sweep summary returns, and only one of those three readers strips newlines.
        // Collapsed before truncating, so 90 characters means 90 visible ones.
        $ev = trim(preg_replace('/\s+/u', ' ', (string)($f['evidence'] ?? '')));
        $lines[] = sprintf('%s - %s: %s', $f['subject'] ?? '?', $f['ref'] ?? '?',
                           mb_substr($ev, 0, 90));
    }
    if (count($due) > 3) $lines[] = sprintf('and %d more', count($due) - 3);

    $n       = count($due);
    $anyErr  = false;
    foreach ($due as $f) if (($f['severity'] ?? '') === 'error') $anyErr = true;

    $out['count']   = $n;
    $out['subject'] = sprintf('Varaverk repair - %d finding%s need%s you', $n, $n === 1 ? '' : 's',
                              $n === 1 ? 's' : '');
    // ' · ' rather than newlines: notify() builds its Discord payload by printf-ing into a JSON
    // string literal, and a raw newline there produces a body the webhook rejects.
    $out['message'] = implode(' | ', $lines);
    $out['ids']     = array_column($due, 'id');

    if ($dryRun) return $out;

    $out['sent'] = vv_notify($out['message'], $out['subject'], $anyErr ? 'alert' : 'warning');

    // Stamped only on a delivery that worked. A channel that is down should retry on the next
    // pass rather than mark these as told and go quiet about them forever.
    if ($out['sent']) {
        foreach ($due as $f) {
            $rec = vv_ai_finding_get($f['id']);
            if ($rec === null) continue;
            $rec['notified'] = vv_ai_finding_notify_pin($rec);
            $p = vv_ai_finding_path($f['id']);
            if ($p !== null) @file_put_contents($p, json_encode($rec, JSON_PRETTY_PRINT));
        }
    }
    return $out;
}

// What the assistant should raise when a page loads: things that need the operator, newest
// first. Repaired findings are deliberately not here — a fix that worked is a log entry, not a
// conversation, and opening every session with a list of things that already went right is how
// an operator learns to close the panel without reading it.
function vv_ai_findings_for_chat(int $limit = 3): array {
    return array_slice(vv_ai_findings_list(['needs_operator']), 0, max(1, $limit));
}

// ── The sweep ────────────────────────────────────────────────────────────────────────────────
// There is no post-run hook in Varaverk — nothing fires when a job finishes. Rather than add a
// call to forty scripts, this picks up run records that completed since the last pass. One entry
// in an orchestrator's list instead of forty edits, and it batches naturally.
//
// Runs that reported ok are read too. A container failing its HTTP check warns and leaves the
// watchdog exiting 0, so "only look at failures" would miss the whole class of fault this exists
// for: the job worked, and told you something is wrong.

function vv_ai_sweep_marker_path(): string {
    return STATE_DIR . '/ai_repair_sweep.db';
}

function vv_ai_sweep_last(): int {
    return (int)trim((string)@file_get_contents(vv_ai_sweep_marker_path()));
}

function vv_ai_sweep_mark(int $ts): void {
    if (!is_dir(STATE_DIR)) @mkdir(STATE_DIR, 0755, true);
    @file_put_contents(vv_ai_sweep_marker_path(), (string)$ts, LOCK_EX);
}

// Run records that finished after $since. A record still marked running is skipped rather than
// read half-written — it will be picked up on the pass after it finishes.
function vv_ai_recent_runs(int $since): array {
    $out  = [];
    $base = realpath(LOG_DIR);
    if ($base === false) return [];

    foreach ((array)@glob($base . '/{,*/,*/*/,*/*/*/}*.json', GLOB_BRACE) as $path) {
        $r = json_decode((string)@file_get_contents($path), true);
        if (!is_array($r) || empty($r['id']) || ($r['status'] ?? '') === 'running') continue;

        $end = (int)($r['end'] ?? 0);
        if ($end <= $since) continue;

        $log = preg_replace('/\.json$/', '.log', $path);
        if (!is_file($log)) continue;

        $out[] = ['id' => (string)$r['id'], 'status' => (string)($r['status'] ?? '?'),
                  'start' => (int)($r['start'] ?? 0), 'end' => $end, 'log' => $log];
    }
    usort($out, fn($a, $b) => $a['end'] <=> $b['end']);
    return $out;
}

// The lines one run wrote, and only those. Logs are appended across runs, so a tail alone would
// re-read the previous run's output and re-report faults that have already been dealt with.
// Filtering on the leading timestamp scopes the evidence to the run being examined.
function vv_ai_run_log_lines(string $logPath, int $startTs, int $maxLines = 2000): array {
    $out = []; $rc = 0;
    @exec('tail -n ' . (int)$maxLines . ' ' . escapeshellarg($logPath) . ' 2>/dev/null', $out, $rc);
    if ($rc !== 0) return [];

    $kept = [];
    foreach ($out as $line) {
        if (preg_match('/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/', $line, $m)) {
            // A line older than the run belongs to a previous one. Two seconds of slack because
            // the record's start is stamped by the runner, not by the first line the job writes.
            if (strtotime($m[1]) < $startTs - 2) continue;
        }
        $kept[] = $line;
    }
    return $kept;
}

// One pass. Returns a summary rather than logging it, so the caller decides what to record and
// the whole thing stays testable without a log to read afterwards.
//
// $dryRun does everything except write conf and move the marker — including probing, which is
// the point: it answers "what would this have done" with real evidence rather than a guess.
function vv_ai_repair_sweep(bool $dryRun = false): array {
    if (!vv_ai_repair_enabled()) {
        return ['ok' => false, 'error' => 'AI_REPAIR_ENABLED is not true', 'runs' => 0];
    }

    $started = time();
    $since   = vv_ai_sweep_last();
    $runs    = vv_ai_recent_runs($since);

    $sum = ['ok' => true, 'runs' => count($runs), 'findings' => 0, 'fixed' => 0,
            'needs_operator' => 0, 'resolved' => 0, 'quiet' => 0, 'details' => []];

    // Candidates from four sources. Log triage is bounded to runs that finished since the last
    // pass; the arrs are asked every time, because their health is a current state rather than
    // something that appeared in a log once. Asking costs three local HTTP calls.
    //
    // The system log is bounded the same way the run logs are — everything since the marker —
    // so a fault that has been shouting for an hour is counted once per pass rather than once
    // per line, and stops producing candidates the moment it stops being logged.
    //
    // The watchdog counters are asked every time for the same reason the arrs are: a strike count
    // is a current state, not something that appeared in a log once. It is also the cheapest of
    // the four — one cached file read — and gated separately, on AI_ASSIST_WATCHDOG.
    $candidates = vv_ai_arr_health_findings();
    foreach (vv_ai_syslog_findings($since) as $c) $candidates[] = $c;
    foreach (vv_ai_container_findings($since) as $c) $candidates[] = $c;
    foreach (vv_ai_watchdog_findings() as $c) $candidates[] = $c;

    foreach ($runs as $run) {
        $lines = vv_ai_run_log_lines($run['log'], $run['start']);
        if (!$lines) continue;

        $rel = ltrim(str_replace(realpath(LOG_DIR), '', $run['log']), '/');
        foreach (vv_ai_triage_log($lines, $rel) as $c) $candidates[] = $c;
    }

    foreach ($candidates as $cand) {
        $cand = vv_ai_probe_finding($cand);
        $sum['findings']++;

        // Write first, so a finding exists even if the repair below fails. A repair that
        // errored without leaving a record is the one failure mode there is no way back from.
        $w = vv_ai_finding_write($cand);
        if (!($w['ok'] ?? false)) continue;
        $id = $w['id'];

        // Already acknowledged or dismissed — the operator has spoken, and re-fixing behind
        // them would be the opposite of what an acknowledgement means.
        if (in_array($w['state'] ?? '', ['acknowledged', 'dismissed'], true)) {
            $sum['quiet']++;
            continue;
        }

        if (($cand['state'] ?? '') === 'resolved') {
            vv_ai_finding_close($id, (string)($cand['note'] ?? ''));
            $sum['resolved']++;
            continue;
        }

        if (vv_ai_finding_may_autofix($cand)) {
            if ($dryRun) { $sum['details'][] = "would fix {$cand['conf_key']} → {$cand['proposed']}"; continue; }
            $r = vv_ai_finding_apply_action($id, 'fix', 'Probed and written by the repair sweep.');
            if ($r['ok'] ?? false) { $sum['fixed']++; $sum['details'][] = "fixed {$cand['conf_key']}"; }
            else                   { $sum['needs_operator']++; vv_ai_finding_set_state($id, 'needs_operator', (string)($r['error'] ?? '')); }
            continue;
        }

        if (($cand['state'] ?? '') === 'needs_operator') $sum['needs_operator']++;
    }

    // Marked only on a completed pass, and to when the pass began — a job that finished while
    // this was running is then picked up next time instead of being skipped for having ended
    // before a marker written at the end.
    if (!$dryRun) vv_ai_sweep_mark($started);

    // Announced after the marker rather than before, and over the whole store rather than only
    // what this pass touched. A finding that reached needs_operator two passes ago and was never
    // successfully delivered is still owed to the operator, and the pin is what stops that from
    // meaning it is announced twice.
    $sum['announced'] = vv_ai_findings_announce($dryRun);

    return $sum;
}

// ── Answering a finding in words ─────────────────────────────────────────────────────────────
// The buttons are unambiguous by construction. This is for the other path — replying "yeah go
// ahead" in the chat that raised the finding — and it is matched here rather than asked of the
// model, because the model's answer would be a conf write and a wrong reading of "no, leave it"
// is not recoverable by apologising.
//
// Same shape as vv_ai_route_from_chat(): anchored patterns, most specific first, and anything
// unrecognised returns null so the assistant asks again instead of guessing. Two actions both
// matching is also null — "leave it, I know" and "leave it for now" differ by one clause and
// mean different things, so a phrase that supports both is not an instruction yet.
const VV_AI_ACTION_PATTERNS = [
    // Acknowledge — "this is deliberate, stop telling me".
    'ack' => [
        '/\b(i|we) know\b/u',
        '/\b(that|this|it)(?:\'s| is) (fine|expected|intentional|deliberate|on purpose)\b/u',
        '/\bon purpose\b/u',
        '/\b(aware|acknowledge|ack)\b/u',
        '/\bmeant to be\b/u',
    ],
    // Apply the proposed value.
    'fix' => [
        '/\bfix (it|that|this|them)?\b/u',
        '/\b(go ahead|do it|apply|make the change|change it|update it|correct it)\b/u',
        // A bare affirmative, as the whole message — "yes" answering "shall I fix it" is an
        // instruction, "yes it looks wrong" is agreement about the diagnosis and nothing more.
        // A trailing please is still bare.
        '/\b(yes|yeah|yep|yup|sure|ok|okay)\b(\s*,?\s*please)?[\s,.!]*$/u',
        '/\bplease do\b/u',
    ],
    // Not now — no state written, it comes back next sweep.
    'cancel' => [
        // "leave it" is matched bare, not only as "leave it alone" / "leave it for now". The
        // ambiguity this function is built to refuse — "leave it, I know" reading as both cancel
        // and ack — did not actually arise with the longer forms, so that sentence resolved to
        // ack and silenced the finding until the fault changed. The looser pattern is what makes
        // the two readings collide and sends it back to be restated.
        '/\b(not now|later|leave it\b|skip( it)?|cancel|ignore for now)\b/u',
        '/\b(no|nope|nah)\b[\s,.!]*$/u',
        '/\b(don\'?t|do not) (fix|touch|change|write|apply)\b/u',
    ],
    // Never a problem. Deliberately narrow: this is the one answer that cannot expire on its
    // own, so it is only read from a sentence that says so outright. Anything vaguer than these
    // is meant to land on ack, which comes back by itself when the fault changes.
    'dismiss' => [
        '/\bdismiss\b/u',
        '/\b(this|that|it)(?:\'s| is) not (a |an )?(problem|bug|issue|real)\b/u',
        '/\bnever (a problem|an issue|report this)\b/u',
    ],
    // Undo a close.
    'reopen' => [
        '/\breopen\b/u',
        '/\bun-?dismiss\b/u',
    ],
];

// Returns one of the keys in VV_AI_ACTION_PATTERNS, or null when the reply does not clearly mean
// exactly one of them.
//
// Only call this when a finding is actually pending. A bare "yes" means fix in answer to "shall
// I fix it" and means nothing at all on its own, and the difference is context this function
// cannot see.
function vv_ai_finding_action_from_text(string $text): ?string {
    $t = strtolower(trim($text));
    if ($t === '') return null;
    $t = preg_replace('/\s+/', ' ', $t);

    $matched = [];
    foreach (VV_AI_ACTION_PATTERNS as $action => $patterns) {
        foreach ($patterns as $re) {
            if (preg_match($re, $t)) { $matched[$action] = true; break; }
        }
    }

    // Exactly one reading, or none. "leave it, I know" hits both ack and cancel; that is a
    // sentence the operator should be asked to restate, not one to pick a winner from.
    return count($matched) === 1 ? array_key_first($matched) : null;
}

// Carry out an answered action against a stored finding.
//
// Fix goes through the same guarded write path as everything else, and is the one place a
// toggle may be written — because reaching here means the operator asked for it by name. The
// unattended sweep never calls this.
function vv_ai_finding_apply_action(string $id, string $action, string $note = ''): array {
    $f = vv_ai_finding_get($id);
    if ($f === null) return ['ok' => false, 'error' => 'no such finding'];

    // Only what this finding actually offers, in the state it is actually in. The page renders
    // its buttons from the same function, but a stale tab holds buttons the store has moved past
    // — a row acked in one window is still showing Fix in another — and the endpoint is reachable
    // without either. Checking here is what makes vv_ai_finding_actions() the authority rather
    // than a suggestion.
    if (!isset(vv_ai_finding_actions($f)[$action])) {
        return ['ok' => false, 'error' => 'not offered for this finding: ' . $action];
    }

    switch ($action) {
        case 'ack':
            return ['ok' => vv_ai_finding_ack($id, $note), 'action' => 'ack'];

        case 'dismiss':
            return ['ok' => vv_ai_finding_dismiss($id, $note), 'action' => 'dismiss'];

        // Back to open, never straight back to needs_operator: whether it still cannot be
        // repaired here is the next sweep's finding to make, not a state to restore.
        case 'reopen':
            return ['ok' => vv_ai_finding_set_state($id, 'open', $note), 'action' => 'reopen'];

        case 'cancel':
            // Deliberately writes nothing at all. "Not now" is not a state, it is the absence of
            // one — recording it would make the finding look decided when it is still open.
            return ['ok' => true, 'action' => 'cancel'];

        case 'fix':
            $proposed = $f['proposed'] ?? null;
            if ($proposed === null) return ['ok' => false, 'error' => 'nothing proposed to write'];

            $key = (string)$f['conf_key'];
            $ok  = vv_conf_write_changes([[
                'file'  => (string)($f['conf_file'] ?? 'master.conf'),
                'key'   => $key,
                'value' => (string)$proposed,
                'type'  => 'scalar',
            ]]);
            $wrote = !in_array(false, $ok, true);

            if ($wrote) {
                vv_ai_finding_set_state($id, 'fixed',
                    $note !== '' ? $note : 'Wrote ' . $key . ' at the operator\'s request.');
            }
            return ['ok' => $wrote, 'action' => 'fix',
                    'error' => $wrote ? null : 'conf write refused — see conf_changes.log'];
    }
    return ['ok' => false, 'error' => 'unknown action'];
}

// ── Resolving a log line back to a conf key ──────────────────────────────────────────────────
// By value wherever possible, by name only as a fallback.
//
// A log line usually contains the thing that failed — the URL that did not answer. That value
// came from a conf key, so searching the conf for which key holds it is an exact lookup with a
// definite answer. Guessing the key from the container's name is inference, and the failure mode
// is silent: HOST1_EMBY_URL and HOST1_EMBY_EXTERNAL_URL are both plausible for "Emby" and only
// one of them is the value that just failed.
//
// Same principle as resolve_tailscale_ip() refusing similarity matching for host identity: an
// exact match or an honest nothing.

// Every conf key whose value the given text starts with, longest first. Prefix rather than
// equality because a log reports the URL it actually called — the conf value plus an endpoint
// path — and the longest match is the most specific key that could have produced it.
function vv_ai_conf_keys_for_value(string $value): array {
    $value = trim($value);
    if ($value === '' || strlen($value) < 6) return [];

    $hits = [];
    foreach (vv_conf_vars() as $k => $v) {
        $v = trim((string)$v);
        if ($v === '' || strlen($v) < 6) continue;
        if ($v === $value || str_starts_with($value, $v)) $hits[$k] = strlen($v);
    }
    arsort($hits);
    return array_keys($hits);
}

// <HOSTID>_<SUBJECT>_<SUFFIX>, built literally and then checked for existence. Nothing is
// inferred: either the conf holds a key by exactly that name or this returns null.
function vv_ai_conf_key_for_subject(string $subject, string $suffix): ?string {
    $norm = strtoupper(preg_replace('/[^A-Za-z0-9]+/', '_', trim($subject)));
    if ($norm === '') return null;

    $key  = strtoupper(vv_detect_host()) . '_' . $norm . '_' . strtoupper($suffix);
    return array_key_exists($key, vv_conf_vars()) ? $key : null;
}

// Which conf file a key lives in. A finding has to name the file it would be edited in, and
// host keys are not in master.conf.
function vv_ai_conf_file_for_key(string $key): string {
    foreach (vv_get_conf_files() as $f) {
        if (preg_match('/^\s*' . preg_quote($key, '/') . '\s*=/m', vv_read_conf_raw($f))) return $f;
    }
    return 'master.conf';
}

// ── Deterministic triage ─────────────────────────────────────────────────────────────────────
// Patterns are written against log formats that exist in this repo today, taken from the emit
// sites rather than imagined. A pattern that stops matching because its log line was reworded
// produces no finding, which is the safe direction — the alternative is a pattern loose enough
// to match anything, which fills the store with rows nobody can act on.
//
// No model runs here. "Connection refused" is not a judgement call, and a 14B model invoked
// after every cron job to notice it would be slower, costlier and less reliable than a regex.
// The model's job starts where these stop: explaining a finding, and talking the operator
// through the ones that cannot be repaired automatically.
const VV_AI_TRIAGE_PATTERNS = [
    // docker_watchdog HTTP check — the richest signal, carrying both subject and failing URL.
    [
        're'      => '/^(?P<subject>\S+) — not responding at (?P<observed>\S+) \(strike/u',
        'kind'    => 'unreachable',
        'suffix'  => 'URL',
    ],
    // docker_watchdog API check, 401/403. The endpoint answered, so the address is right and
    // the credential is not.
    [
        're'      => '/^(?P<subject>\S+) — API check skipped \(HTTP (?:401|403)/u',
        'kind'    => 'auth_rejected',
        'suffix'  => 'API_KEY',
    ],
    // Configured for an API check with nothing to authenticate with.
    [
        're'      => '/^(?P<subject>\S+) — API check skipped \(no key configured\)/u',
        'kind'    => 'missing_value',
        'suffix'  => 'API_KEY',
    ],
    [
        're'      => '/^Skipping (?P<subject>\S+) — placeholder API key/u',
        'kind'    => 'missing_value',
        'suffix'  => 'API_KEY',
    ],
    [
        're'      => '/^(?P<subject>\S+) (?:—\s*)?API unreachable/u',
        'kind'    => 'unreachable',
        'suffix'  => 'URL',
    ],
];

// One log line in, at most one finding candidate out. Returns null for everything else, which is
// almost every line.
function vv_ai_triage_line(string $line): ?array {
    // Strip the timestamp and level decoration the logger adds, so patterns can anchor on ^.
    $body = preg_replace('/^\S+\s+\S+\s+(?:[^\[]*\[[A-Z]+\]\s*)?/u', '', rtrim($line));
    $body = trim((string)$body);
    if ($body === '') return null;

    foreach (VV_AI_TRIAGE_PATTERNS as $p) {
        if (!preg_match($p['re'], $body, $m)) continue;

        $subject  = trim($m['subject'] ?? '');
        $observed = trim($m['observed'] ?? '');
        if ($subject === '') continue;

        // Value first, name second. Only one candidate key is accepted — two keys holding the
        // same value means the log cannot say which one produced it, and picking either is the
        // guess this whole approach exists to avoid.
        $key = null;
        if ($observed !== '') {
            $byValue = vv_ai_conf_keys_for_value($observed);
            if (count($byValue) === 1) $key = $byValue[0];
        }
        if ($key === null) $key = vv_ai_conf_key_for_subject($subject, $p['suffix']);
        if ($key === null) continue;   // nothing actionable — no row

        return [
            'kind'      => $p['kind'],
            'subject'   => $subject,
            'conf_key'  => $key,
            'conf_file' => vv_ai_conf_file_for_key($key),
            'observed'  => $observed !== '' ? $observed : (string)(vv_conf_vars()[$key] ?? ''),
            'evidence'  => $body,
        ];
    }
    return null;
}

// A whole log tail in, one candidate per distinct fault out. A job that retried twelve times
// produces twelve identical lines, and the store's dedupe would collapse them anyway — doing it
// here keeps the sweep from writing the same file twelve times in a row.
function vv_ai_triage_log(array $lines, string $sourceLog = ''): array {
    $found = [];
    foreach ($lines as $line) {
        $c = vv_ai_triage_line((string)$line);
        if ($c === null) continue;
        $c['source_log'] = $sourceLog;
        $found[vv_ai_finding_id($c['kind'], $c['subject'], $c['conf_key'])] = $c;
    }
    return array_values($found);
}

// ── The phrasebook ───────────────────────────────────────────────────────────────────────────
// What the operator said, what it was taken to mean, and whether that was right.
//
// The point is not to fine-tune anything. It is that "daily", said three times and meaning
// daily_sync_maintenance.sh all three, stops being an inference and becomes a lookup. This file
// is how a term earns that promotion, and everything it promotes is exact — the model keeps the
// language, the resolution stays deterministic. Same division as the rest of this subsystem.
//
// The corrections are the rows that matter. A resolution that was right confirms what was
// already believed; a resolution that was wrong, with what it should have been, is the only
// record of a mistake that would otherwise be repeated indefinitely.
//
// JSON Lines rather than the pipe-delimited shape the token ledger uses: that file holds numbers
// and a hostname, this one holds whatever the operator typed, and a delimiter that occurs in the
// data is not a delimiter. Append-only, one object per line, so a truncated write costs the last
// row rather than the corpus.

function vv_ai_phrasebook_path(): string {
    return AI_DATA_DIR . '/ai_phrasebook.jsonl';
}

// $said      what the operator actually wrote, verbatim but redacted
// $term      the fragment that carried the meaning — "daily", "critical rsync", "emby key"
// $target    what it resolved to: a conf key, a script id, an array name
// $kind      conf_key | script | array | section | unknown
// $outcome   accepted | corrected | rejected
// $correctedTo  what it should have been, when the resolution was wrong
//
// $target and $correctedTo must be a canonical identifier — CONF_BACKUP_DIR, not
// "CONF_BACKUP_DIR=${DATA_DIR}/Backups/Confs" and not "the backups directory". Promotion works
// by counting how often a term resolved to the same thing, so a target described three different
// ways is three meanings, and the term never promotes. Learned the hard way while seeding this
// with real corrections: the same fix, written up three ways, taught nothing.
function vv_ai_phrase_record(string $said, string $term, string $target, string $kind,
                             string $outcome = 'accepted', string $correctedTo = ''): bool {
    $said = trim($said);
    $term = strtolower(trim($term));
    if ($said === '' || $term === '') return false;
    if (!in_array($outcome, ['accepted', 'corrected', 'rejected'], true)) return false;

    if (!is_dir(AI_DATA_DIR)) @mkdir(AI_DATA_DIR, 0755, true);

    // An operator asking to set a credential types the credential. This file is long-lived and
    // read back for years; it is the last place a key should be preserved verbatim.
    $row = [
        'ts'      => time(),
        'said'    => mb_substr(vv_ai_redact($said), 0, 500),
        'term'    => mb_substr($term, 0, 80),
        'target'  => mb_substr(trim($target), 0, 160),
        'kind'    => $kind,
        'outcome' => $outcome,
    ];
    if ($correctedTo !== '') $row['corrected_to'] = mb_substr(vv_ai_redact($correctedTo), 0, 160);

    return @file_put_contents(vv_ai_phrasebook_path(),
        json_encode($row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n",
        FILE_APPEND | LOCK_EX) !== false;
}

function vv_ai_phrase_all(): array {
    $out = [];
    foreach ((array)@file(vv_ai_phrasebook_path(), FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
        $r = json_decode($line, true);
        if (is_array($r) && isset($r['term'])) $out[] = $r;
    }
    return $out;
}

// Terms that have earned a deterministic mapping: seen at least $minSeen times, resolving to one
// target every time, and never corrected away from it.
//
// A single contradiction disqualifies the term outright rather than going with the majority. The
// whole value of promoting a term is that it stops being a guess — a term that meant two things
// is still a guess, and a confident wrong alias is worse than no alias, because nothing downstream
// will question it.
function vv_ai_phrase_aliases(int $minSeen = 3): array {
    $seen = [];
    foreach (vv_ai_phrase_all() as $r) {
        $term = (string)$r['term'];
        // A correction records both the wrong reading and the right one. The right one is what
        // the term means; the wrong one is what it must never be promoted to again.
        $target = ($r['outcome'] === 'corrected' && !empty($r['corrected_to']))
            ? (string)$r['corrected_to'] : (string)$r['target'];
        if ($r['outcome'] === 'rejected' || $target === '') continue;

        $seen[$term]['targets'][$target] = ($seen[$term]['targets'][$target] ?? 0) + 1;
        $seen[$term]['kind'] = (string)($r['kind'] ?? 'unknown');
        if ($r['outcome'] === 'corrected') $seen[$term]['wrong'][(string)$r['target']] = true;
    }

    $aliases = [];
    foreach ($seen as $term => $d) {
        if (count($d['targets']) !== 1) continue;              // meant two things — still a guess
        $target = array_key_first($d['targets']);
        if (isset($d['wrong'][$target])) continue;             // was itself corrected away from
        if ($d['targets'][$target] < $minSeen) continue;
        $aliases[$term] = ['target' => $target, 'kind' => $d['kind'], 'seen' => $d['targets'][$target]];
    }
    return $aliases;
}

// Exact alias hit for a term, or null. Deliberately not fuzzy: an alias exists precisely so that
// this lookup is certain, and a near-match would reintroduce the guessing it replaced.
function vv_ai_phrase_lookup(string $term, int $minSeen = 3): ?array {
    return vv_ai_phrase_aliases($minSeen)[strtolower(trim($term))] ?? null;
}

// ── What the repair profile is given before it answers ───────────────────────────────────────
// Two records of what has already happened, assembled as plain text for the prompt.
//
// The phrasebook half is the operator's vocabulary — and the corrections matter more than the
// settled terms, because a term that has been corrected is one the assistant has already got
// wrong once and would otherwise get wrong again.
//
// The closed-findings half is this installation's history: the same fault, and what actually
// ended it. "Emby stopped answering last month and the address had changed" is worth more at the
// start of a repair conversation than any amount of reasoning from first principles.
//
// Bounded hard. This goes into a 16k context that retrieval and a log tail are also competing
// for, and an unbounded history would crowd out the evidence for the fault actually being
// discussed.
function vv_ai_repair_context(int $maxAliases = 20, int $maxFixes = 8): string {
    $out = [];

    $aliases = vv_ai_phrase_aliases();
    if ($aliases) {
        $lines = [];
        foreach (array_slice($aliases, 0, $maxAliases, true) as $term => $a) {
            $lines[] = '  "' . $term . '" means ' . $a['target'];
        }
        $out[] = "What the operator calls things:\n" . implode("\n", $lines);
    }

    $unsettled = vv_ai_phrase_unsettled();
    if ($unsettled) {
        $lines = [];
        foreach (array_slice($unsettled, 0, $maxAliases, true) as $term => $c) {
            $last = end($c);
            $lines[] = '  "' . $term . '" was read as ' . ($last['took_it_as'] ?: '?')
                     . ' and meant ' . ($last['meant'] ?: '?');
        }
        $out[] = "Corrected before — do not repeat these:\n" . implode("\n", $lines);
    }

    $closed = vv_ai_findings_list(['fixed', 'resolved']);
    if ($closed) {
        $lines = [];
        foreach (array_slice($closed, 0, $maxFixes) as $f) {
            $lines[] = '  ' . ($f['subject'] ?? '?') . ' / ' . ($f['ref'] ?? $f['conf_key'] ?? '?')
                     . ' — ' . ($f['state'] ?? '?')
                     . (!empty($f['note']) ? ': ' . mb_substr((string)$f['note'], 0, 140) : '');
        }
        $out[] = "Already dealt with on this host:\n" . implode("\n", $lines);
    }

    $spellings = vv_ai_spellings();
    if ($spellings) {
        $lines = [];
        foreach (array_slice($spellings, 0, $maxAliases, true) as $typo => $meant) {
            $lines[] = '  "' . $typo . '" = "' . $meant . '"';
        }
        $out[] = "The operator types quickly; known shorthand:\n" . implode("\n", $lines);
    }

    return implode("\n\n", $out);
}

// ── Resolving what the operator named ────────────────────────────────────────────────────────
// "turn off the zfs scrub", "change the emby api key" — a phrase in, an exact target out, or an
// honest refusal with the candidates that were considered.
//
// Four layers, most certain first, and each either answers exactly or declines:
//
//   1. A learned alias. The operator has used this term before and it settled on one target.
//   2. The literal key. "HOST1_EMBY_URL" or "host1 emby url" is not a phrase to interpret.
//   3. Every conf key containing all of the significant words. Exactly one is an answer; more
//      than one is a question.
//   4. Script ids from the orchestrator arrays, matched the same way.
//
// What this deliberately does not do is score similarity. There is no closest match, no edit
// distance, no "did you mean". The same reasoning as resolve_tailscale_ip() refusing to guess at
// host identity: the failure mode of a near-match is silent and confident, and here it would
// write to a key the operator never named. Ambiguity is returned as ambiguity, and the assistant
// asks — which is also how the phrasebook learns, because the answer to that question is a
// correction worth recording.
//
// Stop words exist because "the", "for" and "in" appear in a conf key somewhere and would make
// every phrase match everything.
const VV_AI_RESOLVE_STOPWORDS = [
    'the','a','an','to','for','in','on','of','and','or','is','it','this','that','my','our',
    'please','can','you','set','change','turn','make','update','put','value','key','conf','config',
    'setting','settings','off','on_','now','back','again','me','we','let','lets',
];

// A key or script id broken into its own words. HOST1_EMBY_API_KEY is four words, and
// Tools/zfs_pool_scrub.sh is five — the path separator and the extension are word boundaries too.
function vv_ai_resolve_segments(string $name): array {
    $n = strtolower(preg_replace('/\.sh$/', '', $name));
    return array_values(array_filter(preg_split('/[^a-z0-9]+/', $n, -1, PREG_SPLIT_NO_EMPTY) ?: []));
}

function vv_ai_resolve_tokens(string $phrase): array {
    $p = strtolower(trim($phrase));
    $p = preg_replace('/[^a-z0-9_\s-]+/', ' ', $p);
    $words = preg_split('/[\s_-]+/', $p, -1, PREG_SPLIT_NO_EMPTY) ?: [];
    return array_values(array_filter($words,
        fn($w) => strlen($w) > 1 && !in_array($w, VV_AI_RESOLVE_STOPWORDS, true)));
}

// Script ids named in any *_SCRIPTS array, so "zfs scrub" can resolve to Tools/zfs_pool_scrub.sh
// — which is the shape of request that has no conf key at all.
function vv_ai_resolve_script_ids(): array {
    $ids = [];
    foreach (vv_get_conf_files() as $f) {
        if (preg_match_all('/^\s*#?\s*"([A-Za-z0-9_\/.-]+\.sh)(?:\s[^"]*)?"/m',
                           vv_read_conf_raw($f), $m)) {
            foreach ($m[1] as $id) $ids[$id] = true;
        }
    }
    return array_keys($ids);
}

// Returns:
//   ok=true  with target, kind and via     — one certain answer
//   ok=false with candidates               — several, and the caller must ask
//   ok=false with candidates empty         — nothing recognised
function vv_ai_resolve_target(string $phrase): array {
    $none   = ['ok' => false, 'target' => null, 'kind' => null, 'via' => 'none', 'candidates' => []];
    $tokens = vv_ai_resolve_tokens($phrase);
    if (!$tokens) return $none;

    // 1 — learned
    $alias = vv_ai_phrase_lookup(strtolower(trim($phrase)));
    if ($alias === null) {
        // Also try the significant words alone, since "turn off ai repair" and "ai repair" are
        // the same instruction with different framing.
        $alias = vv_ai_phrase_lookup(implode(' ', $tokens));
    }
    if ($alias !== null) {
        return ['ok' => true, 'target' => $alias['target'], 'kind' => $alias['kind'],
                'via' => 'alias', 'candidates' => []];
    }

    $vars = vv_conf_vars();

    // 2 — the literal key, however it was spaced or cased
    $literal = strtoupper(implode('_', $tokens));
    if (array_key_exists($literal, $vars)) {
        return ['ok' => true, 'target' => $literal, 'kind' => 'conf_key',
                'via' => 'exact', 'candidates' => []];
    }

    // 3 — conf keys whose own words include every significant word
    //
    // Whole segments, not substrings. Substring matching made "mov" resolve to
    // MOVER_STOP_TIMEOUT with full confidence, which is exactly the near-match this is supposed
    // to refuse: a short fragment that happens to be unique is not the operator naming a key.
    $keyHits = [];
    foreach (array_keys($vars) as $key) {
        $segs = vv_ai_resolve_segments($key);
        foreach ($tokens as $t) {
            if (!in_array($t, $segs, true)) continue 2;
        }
        $keyHits[] = $key;
    }
    if (count($keyHits) === 1) {
        return ['ok' => true, 'target' => $keyHits[0], 'kind' => 'conf_key',
                'via' => 'match', 'candidates' => []];
    }

    // 4 — script ids, same rule
    $scriptHits = [];
    foreach (vv_ai_resolve_script_ids() as $id) {
        $segs = vv_ai_resolve_segments($id);
        foreach ($tokens as $t) {
            if (!in_array($t, $segs, true)) continue 2;
        }
        $scriptHits[] = $id;
    }
    if (!$keyHits && count($scriptHits) === 1) {
        return ['ok' => true, 'target' => $scriptHits[0], 'kind' => 'script',
                'via' => 'match', 'candidates' => []];
    }

    $all = array_merge($keyHits, $scriptHits);
    if (!$all) return $none;

    // Several. Returned rather than ranked — picking one here is the guess this avoids.
    sort($all);
    return ['ok' => false, 'target' => null, 'kind' => null, 'via' => 'ambiguous',
            'candidates' => array_slice($all, 0, 12)];
}

// ── Spellings ────────────────────────────────────────────────────────────────────────────────
// The operator types quickly and knows it: "haversync" for "have rsync", "as it to the list" for
// "add it". Recorded when the meaning was obvious in context, so the next occurrence is read
// rather than puzzled over.
//
// Promoted on first sighting, unlike a term alias, and the difference is deliberate. A term alias
// decides what gets written to conf, so it has to be earned by repetition. A spelling decides how
// a sentence is read, costs a misreading at worst, and is rarely made identically three times —
// requiring repetition would mean never learning any of them.
//
// Recorded only when the intent was actually clear. A guess written down here is worse than
// leaving it out, because it will be applied silently every time afterwards.
function vv_ai_spelling_record(string $said, string $typo, string $meant): bool {
    $typo = strtolower(trim($typo));
    $meant = trim($meant);
    if ($typo === '' || $meant === '' || strcasecmp($typo, $meant) === 0) return false;
    return vv_ai_phrase_record($said, $typo, $meant, 'spelling');
}

// What the operator most likely meant by a word, or null. Last writing wins, so a correction to
// an earlier reading simply supersedes it.
function vv_ai_spelling_lookup(string $word): ?string {
    $word = strtolower(trim($word));
    $hit  = null;
    foreach (vv_ai_phrase_all() as $r) {
        if (($r['kind'] ?? '') !== 'spelling' || ($r['outcome'] ?? '') === 'rejected') continue;
        if (($r['term'] ?? '') === $word) $hit = (string)$r['target'];
    }
    return $hit;
}

function vv_ai_spellings(): array {
    $out = [];
    foreach (vv_ai_phrase_all() as $r) {
        if (($r['kind'] ?? '') !== 'spelling' || ($r['outcome'] ?? '') === 'rejected') continue;
        $out[(string)$r['term']] = (string)$r['target'];
    }
    ksort($out);
    return $out;
}

// Terms that have been corrected and have not yet earned promotion — what the assistant is still
// getting wrong, and the thing worth reading when asking why it keeps mistaking something.
function vv_ai_phrase_unsettled(): array {
    $out = [];
    foreach (vv_ai_phrase_all() as $r) {
        if (($r['outcome'] ?? '') !== 'corrected') continue;
        $out[(string)$r['term']][] = ['said' => $r['said'], 'took_it_as' => $r['target'],
                                      'meant' => $r['corrected_to'] ?? '', 'ts' => $r['ts'] ?? 0];
    }
    return $out;
}

// ── What the arrs say about themselves ───────────────────────────────────────────────────────
// Sonarr, Radarr and Lidarr each publish a health endpoint listing what they believe is wrong,
// already structured and already graded. No log parsing, no pattern that goes stale when a
// message is reworded, and no guessing at severity — the arr is the authority on whether its own
// condition is an error or a warning.
//
// This is the one source here that needs no triage at all. Everything else in this file exists
// because logs are prose; these arrive as records.
//
// Note the API version differs: Lidarr is v1 where Sonarr and Radarr are v3. vv_discover_arrs()
// already carries it per arr, which is why this reads it rather than assuming.
function vv_ai_arr_health_findings(): array {
    if (!function_exists('vv_discover_arrs')) {
        require_once __DIR__ . '/arrs.php';
    }

    $host  = vv_detect_host();
    $found = [];

    foreach (vv_discover_arrs() as $node) {
        if (($node['host'] ?? '') !== $host) continue;

        foreach ($node['arrs'] ?? [] as $arr) {
            $type = ucfirst((string)($arr['type'] ?? ''));
            $url  = (string)($arr['url'] ?? '');
            $key  = (string)($arr['key'] ?? '');
            $api  = (string)($arr['api'] ?? 'v3');
            if ($type === '' || $url === '' || $key === '') continue;

            $items = vv_arr_http($url, $key, "/api/$api/health", vv_ai_probe_timeout());
            // null is unreachable, which is a different finding and one the log triage already
            // raises. An empty array is the arr saying it is fine, and must not be confused with
            // not having been able to ask.
            if (!is_array($items)) continue;

            foreach ($items as $item) {
                $source  = trim((string)($item['source'] ?? ''));
                $message = trim((string)($item['message'] ?? ''));
                if ($source === '' || $message === '') continue;

                $found[] = [
                    'kind'       => 'arr_health',
                    'subject'    => $type,
                    'ref'        => $source,
                    'conf_key'   => '',
                    'conf_file'  => '',
                    'arr_type'   => strtolower((string)($item['type'] ?? 'warning')),
                    'observed'   => $message,
                    'evidence'   => $type . ' › ' . $source . ': ' . $message,
                    'source_log' => $type . ' /api/' . $api . '/health',
                    // The arr's own documentation for this check, which is the actual next step
                    // for most of them and costs nothing to carry.
                    'note'       => trim((string)($item['wikiUrl'] ?? '')),
                ];
            }
        }
        break;
    }
    return $found;
}

// ── Proving a candidate ──────────────────────────────────────────────────────────────────────
// The guard the whole unattended path rests on: nothing is written that has not answered.
//
// A model can be confident that a port should be 8686. A probe can report that 8686 answered.
// Only the second is a fact, and only facts get written to conf without being asked. Everything
// a probe cannot settle becomes a conversation instead — which is not a lesser outcome, it is
// the honest one for a value that cannot be derived from this machine.
//
// Deliberately narrow. These check reachability and identity, never correctness of behaviour:
// that Lidarr answers on 8686 does not prove 8686 is the port you meant, only that something is
// listening there and calling itself Lidarr. Proving intent is not a probe's job.

function vv_ai_probe_timeout(): int {
    return max(1, (int)(vv_conf_vars()['AI_PROBE_TIMEOUT'] ?? 4));
}

// Does this URL answer at all? Any HTTP status counts, including 401 — a refusal is proof that
// something is listening and speaking HTTP, which is exactly what an address probe is asking.
// Distinguishing "wrong address" from "wrong credential" is the point of having both kinds.
function vv_ai_probe_url(string $url): array {
    if (!preg_match('#^https?://[^\s/$.?\#][^\s]*$#i', $url)) {
        return ['ok' => false, 'reason' => 'not a url'];
    }

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_NOBODY         => true,
        CURLOPT_TIMEOUT        => vv_ai_probe_timeout(),
        CURLOPT_CONNECTTIMEOUT => vv_ai_probe_timeout(),
        CURLOPT_SSL_VERIFYPEER => false,   // these are LAN and Tailscale endpoints, often self-signed
        CURLOPT_SSL_VERIFYHOST => false,
    ]);
    curl_exec($ch);
    $code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    $err  = curl_error($ch);
    curl_close($ch);

    return $code > 0
        ? ['ok' => true,  'code' => $code]
        : ['ok' => false, 'reason' => $err !== '' ? $err : 'no response'];
}

// Every container on this host, by exact name. The membership test for unknown_target findings,
// and one docker call rather than one per candidate.
function vv_ai_container_names(): array {
    static $names = null;
    if ($names !== null) return $names;

    $out = [];
    @exec('timeout ' . vv_ai_probe_timeout() . " docker ps -a --format '{{.Names}}' 2>/dev/null", $out, $rc);
    return $names = ($rc === 0) ? array_values(array_filter(array_map('trim', $out))) : [];
}

// Candidate corrections for a URL whose host or port stopped answering.
//
// Only two transformations, both conservative: the same host on a port that some other conf key
// already uses, and the same port on a host some other conf key already names. Both draw
// exclusively from values already present in this installation's conf — nothing is invented, and
// a scan of the port range is deliberately not attempted. Finding *a* listening port is not the
// same as finding the right service, and a probe that accepts any answer would happily point
// Lidarr at Sonarr.
function vv_ai_url_candidates(string $observed): array {
    $parts = @parse_url($observed);
    if (!is_array($parts) || empty($parts['host'])) return [];

    $hosts = $ports = [];
    foreach (vv_conf_vars() as $k => $v) {
        $v = trim((string)$v);
        if (!preg_match('#^https?://#i', $v)) continue;
        $p = @parse_url($v);
        if (!is_array($p) || empty($p['host'])) continue;
        $hosts[$p['host']] = true;
        if (!empty($p['port'])) $ports[(int)$p['port']] = true;
    }

    $scheme = $parts['scheme'] ?? 'http';
    $path   = $parts['path'] ?? '';
    $out    = [];

    foreach (array_keys($ports) as $port) {
        $c = $scheme . '://' . $parts['host'] . ':' . $port . $path;
        if ($c !== $observed) $out[$c] = true;
    }
    foreach (array_keys($hosts) as $host) {
        $port = !empty($parts['port']) ? ':' . $parts['port'] : '';
        $c    = $scheme . '://' . $host . $port . $path;
        if ($c !== $observed) $out[$c] = true;
    }
    return array_keys($out);
}

// Try to prove a correction for one finding. Returns the finding with 'proposed' and 'proven'
// filled in, or unchanged when nothing could be proven — which is the common case and not a
// failure.
//
// auth_rejected and missing_value are never proven here on purpose. A credential cannot be
// derived from this host by definition: if it could be read from somewhere, it would not be a
// credential. Those go straight to the operator.
function vv_ai_probe_finding(array $f): array {
    $kind = (string)($f['kind'] ?? '');

    if ($kind === 'unknown_target') {
        $observed = (string)($f['observed'] ?? '');
        // Exact membership only. A container name is a literal, and "close to an existing name"
        // is how a repair renames the wrong thing.
        if (in_array($observed, vv_ai_container_names(), true)) {
            return $f;   // it exists after all — nothing to correct
        }
        return $f + ['state' => 'needs_operator'];
    }

    if ($kind !== 'unreachable') {
        // Nothing on this machine can supply a credential, so there is nothing to prove.
        $f['state'] = 'needs_operator';
        return $f;
    }

    $observed = (string)($f['observed'] ?? '');

    // If the observed address answers now, the fault has cleared on its own — a host that was
    // rebooting, most often. Recording a proposal here would repair something already working.
    if (vv_ai_probe_url($observed)['ok']) {
        $f['state'] = 'resolved';
        $f['note']  = 'Answered when probed — the address was reachable again by the time this ran.';
        return $f;
    }

    $answered = [];
    foreach (vv_ai_url_candidates($observed) as $candidate) {
        if (vv_ai_probe_url($candidate)['ok']) $answered[] = $candidate;
    }

    // Exactly one, or none. Two addresses answering means the probe cannot say which is the
    // right one, and picking either is the guess this exists to prevent.
    if (count($answered) === 1) {
        $f['proposed'] = $answered[0];
        $f['proven']   = true;
        $f['note']     = 'Probed ' . $answered[0] . ' and it answered; ' . $observed . ' did not.';
        return $f;
    }

    $f['proven'] = false;
    $f['state']  = 'needs_operator';
    $f['note']   = $answered
        ? 'Several addresses answered (' . implode(', ', $answered) . '), so none was written.'
        : 'Nothing answered at ' . $observed . ', and no address in the conf answered either.';
    return $f;
}
