<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
//   AI tab endpoint. Serves the status banner, starts a chat turn, and reports its progress —
//   the token-and-poll contract behind the AI page.
//
// OPERATIONAL MODEL
//   Generation takes 25-76 seconds on this hardware, so a turn is not answered in the request
//   that starts it. POST action=ask spawns Tools/ai_chat_worker.php detached and returns a
//   token immediately; the page polls action=poll until the job file reaches a terminal state.
//   This is the same shape api/manual_sync.php uses for long rsync runs, chosen over SSE so
//   the whole api layer keeps one response convention and one error path.
//
//   Conversation history is capped here, not in the worker. The model is fully offloaded only
//   at 16384 context, and retrieved chunks plus reasoning already consume several thousand
//   tokens — unbounded history would silently cross that ceiling mid-conversation and cost
//   roughly 4x throughput. One place owns that policy.
//
// DESIGN PRINCIPLES
//   Reads are GET, work is POST.
//       stats and poll change nothing and are safe to repeat. ask spawns a process, so it is
//       POST and therefore covered by Unraid's CSRF prepend, which inspects no GET at all.
//
//   The banner is a separate action from the chat.
//       It is polled on a slow cycle and must keep rendering while a turn is in flight, so it
//       shares no state with the job.
//
//   Tokens are minted here and never accepted from elsewhere.
//       random_bytes, hex, fixed length. A job's answer is readable by anyone who can guess
//       its token, so the token is not guessable.
//
// OPERATIONAL SAFEGUARDS
//   Every action is refused off HOST1, and ask is refused when AI is disabled.
//       AI_ENABLED gates the whole subsystem and vv_is_ai_host() gates the node; the tab is
//       hidden when either fails, but hiding a link is not access control and the endpoint is
//       reachable directly. The host check sits ahead of the dispatch and answers 404.
//
//   Every token is validated as hex before it composes a path.
//       vv_ai_job_path() returns null for anything else, and each caller checks. That pattern
//       is what confines reads and deletes to the job directory.
//
//   History is validated per message, not trusted as a blob.
//       Role must be user or assistant, content must be a non-empty string, and each is
//       truncated. A crafted history could otherwise inject a system role or push the context
//       past the offload ceiling. chat_save applies the same validation, because a stored
//       conversation is replayed into a later prompt when it is reopened — an unchecked role
//       written there is an injection that survives a reload rather than one turn.
//
//   The question is length-capped before it reaches a command line.
//       It is passed to the worker through escapeshellarg, but an unbounded string would still
//       consume the context budget the retrieved chunks need.
//
//   The worker is spawned detached with output discarded.
//       nohup, stdin from /dev/null, stdout and stderr to /dev/null — the job file is the only
//       channel. A worker holding the request's file descriptors would keep the connection
//       open, defeating the point of returning a token.
//
//   Stale job files are reaped on each ask.
//       /tmp is tmpfs so they vanish on reboot, but a long-lived host would accumulate one
//       file per question asked. Anything older than an hour is removed.
//
// REQUEST
//   GET  ?action=stats                    banner payload
//   GET  ?action=poll&token=<hex32>       job state
//   GET  ?action=memory_get               the operator memory file and its budget
//   GET  ?action=chats                    stored conversations, newest first, metadata only
//   GET  ?action=chat_get&id=<hex32>      one stored conversation with its transcript
//   GET  ?action=findings [all=1]         repair findings, open only unless all=1
//   POST action=finding_action id=<hex12> act=fix|ack|dismiss|reopen|cancel [note=…]
//   POST action=ask   question=…  [history=<JSON>] [kind=…] [think=0|1]
//   POST action=memory_set memory=…       replace the memory file
//   POST action=clear token=<hex32>       discard a finished job
//   POST action=chat_save  [id=<hex32>] profile=… messages=<JSON>
//   POST action=chat_delete id=<hex32>
//
// RESPONSE
//   stats  {"ok":true,"stats":{…}}
//   ask    {"ok":true,"token":"<hex32>"}
//   poll   {"ok":true,"job":{"status":"retrieving|generating|done|error",…}}
//   clear  {"ok":true}
//   chats  {"ok":true,"chats":[{id,ts,profile,title,turns}],"max":N}
//   findings {"ok":true,"repair":{enabled,autofix,last},"findings":[…],"counts":{…}}
//   finding_action {"ok":true,"action":"fix"}
//   chat_save {"ok":true,"id":"<hex32>","title":…}
//   {"ok":false,"error":…}
//
// DEPENDS ON
//   include/ai.php            vv_ai_stats(), vv_ai_config(), vv_ai_job_*()
//   include/ai_repair.php     the findings store — loaded only by the two actions that read it
//   Tools/ai_chat_worker.php  the detached worker
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// First executable statement, deliberately dependency-free. A request that is rejected by the
// CSRF prepend never reaches here and a request that dies inside the include never reaches the
// action log below, and those two look identical from outside — which is what made a POST that
// the browser demonstrably sent leave no trace anywhere on the server.
// Polls are excluded here for the same reason they are excluded from the action log below: one
// line per second per open tab buries every line worth reading.
if (strpos($_SERVER['REQUEST_URI'] ?? '', 'action=poll') === false) {
    @file_put_contents('/var/log/varaverk/ai.log',
        date('Y-m-d H:i:s') . ' ENTER ' . ($_SERVER['REQUEST_METHOD'] ?? '?')
        . ' ' . ($_SERVER['REQUEST_URI'] ?? '?')
        . ' ct=' . substr($_SERVER['CONTENT_TYPE'] ?? '-', 0, 40)
        . ' len=' . ($_SERVER['CONTENT_LENGTH'] ?? '-') . "\n", FILE_APPEND | LOCK_EX);
}

header('Content-Type: application/json');
header('Cache-Control: no-store, no-cache');
require_once dirname(__DIR__) . '/include/ai.php';

// History depth is per profile and still decided server-side rather than by the page — the page
// simply no longer carries a second copy of the numbers. include/ai_profiles.php holds them.
// Reasoning is not stored in history, so it does not compound.
const VV_AI_MAX_QUESTION = 4000;  // characters
const VV_AI_MAX_HIST_MSG = 4000;  // characters per retained message
const VV_AI_JOB_TTL      = 3600;  // seconds before a job file is reaped

$isPost = $_SERVER['REQUEST_METHOD'] === 'POST';
$action = trim($isPost ? ($_POST['action'] ?? '') : ($_GET['action'] ?? 'stats'));

// Request trace. There is no nginx access log on this host and the CSRF prepend exits with an
// empty body, so without this there is no way to tell "the request never arrived" from "the
// request arrived and failed" — which is exactly the ambiguity that made the first hang
// undiagnosable. Excludes poll, which would otherwise write a line per second per open tab.
function vv_ai_log(string $msg): void {
    if (!is_dir('/var/log/varaverk')) return;
    @file_put_contents('/var/log/varaverk/ai.log',
        date('Y-m-d H:i:s') . ' ' . $msg . "\n", FILE_APPEND | LOCK_EX);
}
if ($action !== 'poll') {
    vv_ai_log(sprintf('%s action=%s from=%s',
        $_SERVER['REQUEST_METHOD'] ?? '?', $action ?: '(none)',
        $_SERVER['REMOTE_ADDR'] ?? '?'));
}

// Host gate, ahead of the dispatch rather than inside each action. Varaverk.page omits the tab
// on any host but HOST1, but a hidden link is not access control and this endpoint is reachable
// directly. Every action is refused rather than just the expensive ones — there is no such thing
// as a read this host is entitled to, since the index and the model are not here.
if (!vv_is_ai_host()) {
    http_response_code(404);
    echo json_encode(['ok' => false, 'error' => 'AI is not available on this host']);
    exit;
}

// Master switch, on the same footing as the host gate rather than only in front of ask. With
// AI_ENABLED false the tab is not in the tab list and the scheduler dock is not rendered, so
// nothing in the UI can legitimately reach any action here — including the cheap reads, which
// would otherwise still answer with index and token figures for a subsystem the operator has
// turned off. Not a 404: the switch is a setting, and the message names the setting.
if (!vv_ai_enabled()) {
    echo json_encode(['ok' => false, 'error' => 'AI_ENABLED is false — AI features are off']);
    exit;
}

// ── stats ─────────────────────────────────────────────────────────────────────
// Served from the shared 'ai' cache that Tools/api_cache_writer.sh refreshes every minute, on
// the same terms as the monitor and arrs payloads. This action is polled every 30 seconds by
// every open tab and used to pay a full collection each time — around a second, most of it spent
// waiting on Ollama and nvidia-smi — for numbers that only change when the writer next runs.
//
// ?live=1 bypasses it, for the case where something was just changed and the point is to see the
// result. A missing cache always falls back to collecting, so the cache can never be the reason
// the banner fails to render.
if ($action === 'stats') {
    echo json_encode(['ok' => true, 'stats' => vv_ai_stats_cached(isset($_GET['live']))]);
    exit;
}

// ── tokens ────────────────────────────────────────────────────────────────────
// Separate from stats rather than folded into it. stats is polled every 30 seconds by every
// open tab; this reads a file that grows without bound between prunes. The totals only move
// when a turn completes, and the page knows exactly when that happened, so it asks then.
if ($action === 'tokens') {
    echo json_encode(['ok' => true, 'tokens' => vv_ai_token_stats()]);
    exit;
}

// ── poll ──────────────────────────────────────────────────────────────────────
if ($action === 'poll') {
    $token = trim($_GET['token'] ?? '');
    if (vv_ai_job_path($token) === null) {
        echo json_encode(['ok' => false, 'error' => 'Invalid token']); exit;
    }
    $job = vv_ai_job_read($token);
    if ($job === null) {
        // The worker writes its first state after this request may already have arrived.
        echo json_encode(['ok' => true, 'job' => ['status' => 'pending']]); exit;
    }
    echo json_encode(['ok' => true, 'job' => $job]);
    exit;
}

// ── memory ────────────────────────────────────────────────────────────────────
if ($action === 'memory_get') {
    $m = vv_ai_memory_read();
    echo json_encode(['ok' => true, 'memory' => $m['text'], 'chars' => $m['chars'],
                      'max' => vv_ai_memory_max(), 'exists' => $m['exists'],
                      'path' => vv_ai_memory_path()]);
    exit;
}

if ($action === 'memory_set') {
    if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
    $r = vv_ai_memory_write((string)($_POST['memory'] ?? ''));
    vv_ai_log('memory_set ' . ($r['ok'] ? 'ok chars=' . $r['chars'] : 'FAILED: ' . $r['error']));
    echo json_encode($r + ['max' => vv_ai_memory_max()]);
    exit;
}

// ── learned-memory proposals ──────────────────────────────────────────────────
// The store the assistant files candidates into. Accepting is the only path by which model-written
// text reaches a prompt, and it is a POST so the CSRF prepend covers it.
if ($action === 'mem_proposals') {
    require_once __DIR__ . '/../include/ai_memory_learn.php';
    $m = vv_ai_memory_read('learned');
    echo json_encode([
        'ok'        => true,
        'enabled'   => vv_ai_mem_learn_enabled(),
        'auto'      => vv_ai_mem_learn_auto(),
        // The list states the gate as well as the rows: an empty list means "nothing proposed"
        // when learning is on and "nothing is looking" when it is off, and those are different.
        'open'      => vv_ai_mem_list('open'),
        'recent'    => array_slice(vv_ai_mem_list(), 0, 25),
        'learned'   => ['chars' => $m['chars'], 'max' => vv_ai_memory_learned_max()],
    ]);
    exit;
}

if ($action === 'mem_proposal_action') {
    if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
    require_once __DIR__ . '/../include/ai_memory_learn.php';
    $id  = trim($_POST['id']  ?? '');
    $act = trim($_POST['act'] ?? '');
    $r   = vv_ai_mem_action($id, $act);
    vv_ai_log(sprintf('mem_proposal id=%s act=%s %s', $id, $act,
        $r['ok'] ? 'ok' : ('FAILED: ' . ($r['error'] ?? '?'))));
    echo json_encode($r);
    exit;
}

// ── stop ──────────────────────────────────────────────────────────────────────
// Cancels a generation in flight. Only ever signals ONE pid, verified to be the worker for this
// exact job — never a process group. Signalling a group is what took the WebGUI down on
// 2026-08-07, and no group kill is needed here: the worker is a single php process whose only
// child-like thing is an HTTP connection to Ollama, which dies with it.
//
// Whatever was already generated is kept. A turn stopped at 80% is usually stopped because the
// operator has seen enough, not because they want it discarded.
if ($action === 'stop') {
    if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }

    $token = trim($_POST['token'] ?? '');
    if (vv_ai_job_path($token) === null) {
        echo json_encode(['ok' => false, 'error' => 'Invalid token']); exit;
    }

    $job = vv_ai_job_read($token);
    if ($job === null) { echo json_encode(['ok' => false, 'error' => 'No such job']); exit; }

    $status = (string)($job['status'] ?? '');
    if ($status === 'done' || $status === 'error' || $status === 'stopped') {
        echo json_encode(['ok' => true, 'already' => true, 'status' => $status]); exit;
    }

    $pid = (int)($job['pid'] ?? 0);
    // Below 2 is init or nonsense. A pid we cannot verify is a pid we do not signal.
    $killed = false;
    if ($pid >= 2) {
        // Pid reuse is the reason for this: the recorded worker may have exited seconds ago and
        // the number been handed to something else entirely. The cmdline must name both this
        // worker and this job's own file before anything is signalled.
        $cmdline = @file_get_contents("/proc/$pid/cmdline");
        $cmdline = $cmdline === false ? '' : str_replace("\0", ' ', $cmdline);
        if (strpos($cmdline, 'ai_chat_worker.php') !== false && strpos($cmdline, $token) !== false) {
            $killed = @posix_kill($pid, SIGTERM);
            // No escalation ladder. The worker holds no lock and writes the job file atomically,
            // so there is no cleanup that a delay would protect — and a SIGKILL race could land
            // between the temp write and the rename.
        }
    }

    // The job file is rewritten either way. If the pid could not be verified the worker is
    // already gone, and the page still needs a terminal state instead of polling to its ceiling.
    $job['status']  = 'stopped';
    $job['stopped'] = true;
    $job['answer']  = trim((string)($job['partial'] ?? $job['answer'] ?? ''));
    unset($job['partial']);
    @file_put_contents(vv_ai_job_path($token), json_encode($job));

    vv_ai_log(sprintf('stop token=%s pid=%d signalled=%s kept=%d chars',
        substr($token, 0, 12), $pid, $killed ? 'yes' : 'no', strlen($job['answer'])));

    echo json_encode(['ok' => true, 'signalled' => $killed, 'kept' => strlen($job['answer'])]);
    exit;
}

// ── clear ─────────────────────────────────────────────────────────────────────
if ($action === 'clear') {
    if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
    $p = vv_ai_job_path(trim($_POST['token'] ?? ''));
    if ($p === null) { echo json_encode(['ok' => false, 'error' => 'Invalid token']); exit; }
    if (file_exists($p)) @unlink($p);
    echo json_encode(['ok' => true]);
    exit;
}

// ── chats ─────────────────────────────────────────────────────────────────────
// Stored conversations. Listing and reading are GET because they change nothing; saving and
// deleting are POST, so they ride Unraid's CSRF prepend like every other mutation here.
//
// Messages are validated per message on the way in, exactly as ask validates history and for
// the same reason: a stored chat is replayed into a later prompt when the operator reopens it,
// so a crafted role in the store would be an injection that survives a reload.
if ($action === 'chats') {
    echo json_encode(['ok' => true, 'chats' => vv_ai_chats_list(), 'max' => vv_ai_chats_max()]);
    exit;
}

if ($action === 'chat_get') {
    $chat = vv_ai_chat_read(trim($_GET['id'] ?? ''));
    if ($chat === null) { echo json_encode(['ok' => false, 'error' => 'No such chat']); exit; }
    echo json_encode(['ok' => true, 'chat' => $chat]);
    exit;
}

if ($action === 'chat_save') {
    if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }

    $profile = trim($_POST['profile'] ?? 'chat');
    if (!vv_ai_profile_ok($profile)) {
        echo json_encode(['ok' => false, 'error' => 'Unknown profile: ' . $profile]); exit;
    }

    $clean = [];
    $msgs  = json_decode($_POST['messages'] ?? '[]', true);
    if (is_array($msgs)) {
        foreach ($msgs as $m) {
            $role = $m['role'] ?? '';
            $text = trim((string)($m['content'] ?? ''));
            if (!in_array($role, ['user', 'assistant'], true) || $text === '') continue;
            $clean[] = ['role' => $role, 'content' => mb_substr($text, 0, VV_AI_MAX_HIST_MSG)];
        }
    }
    // Capped at the deepest profile's window rather than that of the profile in hand. A chat
    // saved under one profile can be reopened under another, and the reopened turn is trimmed
    // again on the way back out by ask — so storing a little more than any single profile will
    // send costs nothing and keeps the transcript readable.
    $cap = vv_ai_profiles_max_turns() * 2;
    if (count($clean) > $cap) $clean = array_slice($clean, -$cap);

    // Whitelisted exactly as ask's is, and for the same reason: a scope is only ever a name from
    // a page's own view state, it is stored and later replayed into a prompt, and anything
    // richer than a file name is an instruction-injection surface for no benefit.
    $scope = trim($_POST['scope'] ?? '');
    if ($scope !== '' && !vv_ai_scope_ok($scope)) $scope = '';

    $r = vv_ai_chat_save(trim($_POST['id'] ?? ''), $profile, $clean, $scope);
    vv_ai_log('chat_save ' . ($r['ok'] ? 'ok id=' . substr($r['id'], 0, 12)
                                       : 'FAILED: ' . $r['error']));
    echo json_encode($r);
    exit;
}

if ($action === 'chat_delete') {
    if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
    $ok = vv_ai_chat_delete(trim($_POST['id'] ?? ''));
    echo json_encode(['ok' => $ok, 'error' => $ok ? null : 'No such chat']);
    exit;
}

// ── bugs / bug_close ──────────────────────────────────────────────────────────
// Reports the troubleshooter filed. Listing is a GET because it changes nothing; dismissing is
// a POST, like every other mutation in this plugin.
if ($action === 'bugs') {
    echo json_encode(['ok' => true, 'bugs' => vv_ai_bugs_list(($_GET['all'] ?? '') !== '1')]);
    exit;
}
if ($action === 'bug_close') {
    if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
    $ok = vv_ai_bug_set_open(trim($_POST['id'] ?? ''), ($_POST['open'] ?? '0') === '1');
    echo json_encode(['ok' => $ok]);
    exit;
}

// The report, rendered server-side. Read-only by design: what the operator reviews is byte for
// byte what gets sent, so approving one text and transmitting another is not possible. It is also
// the only renderer — the page used to build its own markdown, which is two formats to keep in
// step and one of them always losing.
if ($action === 'bug_report') {
    $id  = trim($_GET['id'] ?? '');
    $bug = null;
    foreach (vv_ai_bugs_list(false) as $b) if (($b['id'] ?? '') === $id) { $bug = $b; break; }
    if (!$bug) { echo json_encode(['ok' => false, 'error' => 'no such report']); exit; }

    $t     = vv_ai_bug_targets();
    $title = '[' . ($bug['component'] ?? '?') . '] ' . ($bug['summary'] ?? '');
    echo json_encode([
        'ok'       => true,
        'title'    => $title,
        'markdown' => vv_ai_bug_report($bug),
        'targets'  => $t,
        // Built here because the repo name lives here. Length is the caller's problem to notice:
        // GitHub truncates a very long query rather than refusing it, which would silently send a
        // half report — so the page checks and falls back to the copy box.
        'github'   => 'https://github.com/' . $t['github_repo'] . '/issues/new?title='
                    . rawurlencode($title) . '&body=' . rawurlencode(vv_ai_bug_report($bug)),
    ]);
    exit;
}

// Sends to the operator's own Gitea, and only there. Never falls back to GitHub on failure: the
// two destinations are different people, and a silent substitution is how a report meant for a
// private backlog ends up public.
if ($action === 'bug_send_local') {
    if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
    $id  = trim($_POST['id'] ?? '');
    $bug = null;
    foreach (vv_ai_bugs_list(false) as $b) if (($b['id'] ?? '') === $id) { $bug = $b; break; }
    if (!$bug) { echo json_encode(['ok' => false, 'error' => 'no such report']); exit; }

    // Re-rendered from the store rather than taken from the request. The browser showed this text
    // read-only; accepting a body from the page would make that guarantee decorative.
    $r = vv_ai_bug_send_local('[' . ($bug['component'] ?? '?') . '] ' . ($bug['summary'] ?? ''),
                              vv_ai_bug_report($bug));
    vv_ai_log(sprintf('bug_send_local id=%s %s', $id,
        $r['ok'] ? 'ok ' . ($r['url'] ?? '') : 'failed: ' . ($r['error'] ?? '?')));
    echo json_encode($r);
    exit;
}

// ── findings / finding_action ─────────────────────────────────────────────────
// What the repair sweep found, and the operator's answer to it. include/ai_repair.php is pulled
// in here rather than at the top of the file: it is the largest include in the plugin and poll
// runs once a second per open tab, so it is loaded by the two actions that need it and by nothing
// else.
//
// Neither action is gated on AI_REPAIR_ENABLED. Findings filed while it was on do not stop being
// true when it goes off, and answering them — including saying "this was never a problem" — is
// exactly what an operator turning the feature off is likely to want to do first. The gate states
// are reported instead, so the card can say what is running rather than the endpoint pretending
// the store is empty.
if ($action === 'findings' || $action === 'finding_action') {
    require_once dirname(__DIR__) . '/include/ai_repair.php';

    if ($action === 'findings') {
        // Closed findings are the history — what was dismissed, what a fix actually fixed — and
        // they are asked for explicitly rather than shipped with every poll of the open list.
        $rows = [];
        $open = 0; $needs = 0;
        foreach (vv_ai_findings_list(($_GET['all'] ?? '') === '1' ? [] : ['open', 'needs_operator']) as $f) {
            $state = (string)($f['state'] ?? 'open');
            if ($state === 'open')                $open++;
            elseif ($state === 'needs_operator')  $needs++;
            // The three things the page must not decide for itself: which actions this row
            // offers, and what its state and kind mean in words.
            $f['actions']     = vv_ai_finding_actions($f);
            $f['state_label'] = VV_AI_FINDING_STATES[$state] ?? '';
            $f['kind_label']  = VV_AI_FINDING_KINDS[(string)($f['kind'] ?? '')] ?? '';
            $rows[] = $f;
        }
        echo json_encode(['ok' => true,
            'repair'   => ['enabled' => vv_ai_repair_enabled(),
                           'autofix' => vv_ai_repair_autofix_enabled(),
                           'last'    => vv_ai_sweep_last()],
            'findings' => $rows,
            'counts'   => ['open' => $open, 'needs_operator' => $needs, 'shown' => count($rows)]]);
        exit;
    }

    // POST, because fix writes conf through the guarded path and every other answer writes state.
    // Which actions are legal for a given row is vv_ai_finding_apply_action()'s call, not this
    // endpoint's — a tab left open overnight is holding buttons the store has moved past.
    if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }

    $fid = trim($_POST['id'] ?? '');
    $act = trim($_POST['act'] ?? '');
    $r   = vv_ai_finding_apply_action($fid, $act, trim($_POST['note'] ?? ''));
    vv_ai_log(sprintf('finding_action id=%s act=%s %s', $fid, $act,
        $r['ok'] ? 'ok' : 'FAILED: ' . ($r['error'] ?? '?')));
    echo json_encode($r);
    exit;
}

// ── incident_add ──────────────────────────────────────────────────────────────
// Appends one operator-written "this was the fix" note against a scope. POST only, and the
// scope is whitelisted the same way ask's is — it is written to a file that later rides in a
// prompt, so it gets the same treatment as anything else that reaches the model.
if ($action === 'incident_add') {
    if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }
    echo json_encode(vv_ai_incident_add(
        trim($_POST['scope'] ?? ''), trim($_POST['symptom'] ?? ''), trim($_POST['fix'] ?? '')));
    exit;
}

// ── ask ───────────────────────────────────────────────────────────────────────
if ($action === 'ask') {
    if (!$isPost) { http_response_code(405); echo json_encode(['ok' => false, 'error' => 'POST only']); exit; }

    $cfg = vv_ai_config();
    if ($cfg['model'] === '') {
        echo json_encode(['ok' => false, 'error' => 'No generation model configured']); exit;
    }

    $question = trim($_POST['question'] ?? '');
    if ($question === '') { echo json_encode(['ok' => false, 'error' => 'question is required']); exit; }
    if (mb_strlen($question) > VV_AI_MAX_QUESTION) {
        echo json_encode(['ok' => false, 'error' => 'question exceeds ' . VV_AI_MAX_QUESTION . ' characters']); exit;
    }

    $profile = trim($_POST['profile'] ?? 'varaverk');
    if (!vv_ai_profile_ok($profile)) {
        echo json_encode(['ok' => false, 'error' => 'Unknown profile: ' . $profile]); exit;
    }
    $maxTurns = vv_ai_profile_turns($profile);

    // Where the caller is standing — "master.conf", "daily_sync_maintenance.sh", a log name.
    // The scheduler page sends it so a question can say "this setting" and mean something; the
    // AI tab sends nothing and the worker simply omits the location line.
    //
    // Whitelisted hard, not escaped and hoped for. It reaches the model as text, so anything
    // richer than a file name is an instruction-injection surface for no benefit — a scope is
    // only ever a name from this page's own view state.
    $scope = trim($_POST['scope'] ?? '');
    if ($scope !== '' && !vv_ai_scope_ok($scope)) $scope = '';

    // The retrieval filter only means anything to the profile that retrieves.
    $kind = vv_ai_profile_can($profile, 'kind_filter') ? trim($_POST['kind'] ?? '') : '';
    if ($kind !== '' && !in_array($kind, VV_AI_KINDS, true)) {
        echo json_encode(['ok' => false, 'error' => 'Unknown kind: ' . $kind]); exit;
    }

    // Validate per message rather than trusting the blob: a crafted history could otherwise
    // inject a system role, or push the context past the offload ceiling.
    $clean = [];
    $hist  = json_decode($_POST['history'] ?? '[]', true);
    if (is_array($hist)) {
        foreach ($hist as $m) {
            $role = $m['role'] ?? '';
            $text = trim((string)($m['content'] ?? ''));
            if (!in_array($role, ['user', 'assistant'], true) || $text === '') continue;
            $clean[] = ['role' => $role, 'content' => mb_substr($text, 0, VV_AI_MAX_HIST_MSG)];
        }
    }
    if (count($clean) > $maxTurns * 2) {
        $clean = array_slice($clean, -($maxTurns * 2));
    }

    $dir = vv_ai_job_dir();
    foreach (glob($dir . '/*.json') ?: [] as $old) {
        if (time() - (int)@filemtime($old) > VV_AI_JOB_TTL) @unlink($old);
    }

    $token   = bin2hex(random_bytes(16));
    $jobFile = vv_ai_job_path($token);
    $worker  = dirname(__DIR__) . '/Tools/ai_chat_worker.php';

    if (!file_exists($worker)) {
        echo json_encode(['ok' => false, 'error' => 'ai_chat_worker.php not found']); exit;
    }

    // Not suppressed: if the job file cannot be written the worker has nowhere to report and
    // the page polls a token that will never resolve — which looks exactly like a hang.
    if (file_put_contents($jobFile, json_encode(['status' => 'pending'])) === false) {
        vv_ai_log('ask FAILED — cannot write ' . $jobFile);
        echo json_encode(['ok' => false, 'error' => 'Cannot write job file to ' . VV_AI_JOB_DIR]);
        exit;
    }

    // setsid, not just nohup. nohup detaches from the terminal but leaves the child in the
    // caller's process group — php-fpm's. That is the arrangement that took the WebGUI down on
    // 2026-08-07 when a Stop signalled a group it did not own. Stop below signals one verified
    // pid and never a group, so this is belt and braces, but it also means a php-fpm restart no
    // longer takes a running generation with it.
    $cmd = 'setsid nohup php ' . escapeshellarg($worker) . ' '
         . escapeshellarg($jobFile) . ' '
         . escapeshellarg($question) . ' '
         . escapeshellarg(json_encode($clean)) . ' '
         . escapeshellarg($kind) . ' '
         . escapeshellarg(($_POST['think'] ?? '1') === '1' ? '1' : '0') . ' '
         . escapeshellarg($profile) . ' '
         . escapeshellarg($scope) . ' '
         // Asked for per turn. Only meaningful on a profile holding web_search — the worker
         // checks that, so a crafted web=1 against any other profile changes nothing.
         . escapeshellarg(($_POST['web'] ?? '') === '1' ? '1' : '0')
         . ' >/dev/null 2>&1 </dev/null &';
    $out = []; $rc = 0;
    exec($cmd, $out, $rc);
    // Redacted before it is logged, for the same reason the stored transcript is: asking the
    // assistant to set a credential means typing one, and ai.log is neither 0600 nor pruned.
    vv_ai_log(sprintf('ask token=%s rc=%d profile=%s kind=%s q=%s',
        substr($token, 0, 12), $rc, $profile, $kind ?: '-',
        mb_substr(vv_ai_redact($question), 0, 80)));

    echo json_encode(['ok' => true, 'token' => $token]);
    exit;
}

echo json_encode(['ok' => false, 'error' => 'Unknown action']);
