<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
//   Detached worker for one AI chat turn. Retrieves grounding chunks, asks the generation
//   model, and writes progress and the final answer to a job file the AI tab polls.
//
// OPERATIONAL MODEL
//   Not an HTTP endpoint. Generation takes 25-76 seconds on this hardware — far past what a
//   page request should hold open — so api/ai.php spawns this detached and returns a token.
//   The job file is the only channel between the two, exactly as docker_pull_worker.php works
//   for container updates. It lives under api/'s sibling Tools/ because it is part of that
//   endpoint's implementation, not a scheduled script.
//
//   Writes a terminal state on every exit path. A worker that dies without one leaves the tab
//   polling forever, so the states are: retrieving -> generating -> done | error.
//
//   Retrieval happens here rather than in the endpoint so the tab gets a token immediately.
//   Embedding a query is fast but not free, and it is the first thing that would make the
//   "send" button feel slow.
//
// DESIGN PRINCIPLES
//   Context is assembled here, not by the model's own tooling.
//       The retrieved chunks go into a system message with explicit citation and refusal
//       instructions. That instruction is the difference between a grounded answer and the
//       model filling a gap from training data it does not have for a private project.
//
//   History arrives already trimmed.
//       The endpoint caps turns before spawning. The worker does not re-derive the policy,
//       so there is one place that decides how much context history may consume.
//
//   Thinking is captured separately, never discarded.
//       qwen3 emits reasoning that is often more useful than the answer for judgement calls.
//       It is stored in its own field so the page can collapse it rather than lose it.
//
// OPERATIONAL SAFEGUARDS
//   Refuses to run under a web server.
//       PHP_SAPI is checked first. Over HTTP there is no $argv, so every argument below would
//       be undefined — and this process talks to Ollama and writes job files.
//
//   The job file path is validated as hex before anything is written.
//       It is supplied on the command line; the pattern is what keeps writes inside the job
//       directory even if the caller is ever wrong.
//
//   The Ollama request is time-boxed.
//       AI_REQUEST_TIMEOUT bounds it, and a timeout is written as an error state rather than
//       leaving the job file at "generating" forever.
//
//   Retrieval failure ends the turn.
//       An empty or failed retrieval writes an error instead of asking the model anyway. A
//       generated answer with no grounding is exactly the confident hallucination this whole
//       subsystem exists to prevent.
//
//   Live state is attached only to diagnostic questions.
//       Failing health checks and recent log warnings are several thousand tokens. On a
//       16384 context that is budget taken directly from the retrieved passages, so it is
//       spent only when the question is asking why something broke. Log lines are marked as
//       evidence rather than citable sources, so the model cannot cite a log line as though
//       it were documentation.
//
//   Every failure path writes the job file.
//       Including the ones that would otherwise be silent — unreachable Ollama, unparseable
//       response, empty content — so the tab always converges on a state it can render.
//
// ARGUMENTS
//   1 jobFile   absolute path, hex-named, written by api/ai.php
//   2 question  the user's message
//   3 history   JSON array of {role, content}, already trimmed by the endpoint
//   4 kind      optional retrieval filter (header|readme|manual|template|doc)
//   5 think     "1" to allow the model's reasoning, "0" to suppress it
//
// JOB FILE STATES
//   {"status":"retrieving"}
//   {"status":"generating","sources":[…]}
//   {"status":"done","answer":…,"thinking":…,"sources":[…],"timing":{…}}
//   {"status":"error","error":…}
// ═══════════════════════════════════════════════════════════════════════════════════════════════

if (PHP_SAPI !== 'cli') {
    http_response_code(404);
    exit(1);
}

require_once dirname(__DIR__) . '/include/ai.php';

[$jobFile, $question, $historyJson, $kind, $think] = array_slice($argv, 1, 5) + array_fill(0, 5, '');

if ($jobFile === '' || $question === '') exit(1);
if (!preg_match('#/[0-9a-f]{32}\.json$#', $jobFile)) exit(1);

function jw(string $f, array $d): void {
    file_put_contents($f, json_encode($d));
}

$cfg = vv_ai_config();
$t0  = microtime(true);

jw($jobFile, ['status' => 'retrieving']);

$r = vv_ai_retrieve($question, $kind);
if (!$r['ok']) {
    jw($jobFile, ['status' => 'error', 'error' => $r['error'] ?? 'retrieval failed']);
    exit(1);
}
if (!$r['results']) {
    jw($jobFile, ['status' => 'error',
        'error' => 'No relevant documentation found. Try rephrasing, or use the readme filter '
                 . 'for questions about what something is.']);
    exit(0);
}

$tRetrieve = microtime(true) - $t0;

$sources = array_map(fn($x) => [
    'path' => $x['path'] ?? '', 'section' => $x['section'] ?? '',
    'heading' => $x['heading'] ?? '', 'score' => $x['score'] ?? 0,
], $r['results']);

jw($jobFile, ['status' => 'generating', 'sources' => $sources]);

$context = '';
foreach ($r['results'] as $i => $x) {
    $label = implode(' › ', array_filter([$x['path'] ?? '', $x['section'] ?? '', $x['heading'] ?? '']));
    $context .= '[' . ($i + 1) . '] ' . $label . "\n" . trim($x['content'] ?? '') . "\n\n";
}

// Diagnostic questions get live state as well as documentation. The docs say what a script is
// supposed to do; only the logs and the current config say what it actually did. Attached only
// when the question is asking why something failed — otherwise it is a few thousand tokens of
// noise competing with the retrieved passages for a context budget that is already tight.
$diagnostic = (bool)preg_match(
    '/\b(why|fail(ed|ing|ure)?|error|broken?|not work|isn.t work|wrong|stuck|hang|'
    . 'never runs?|didn.t|won.t|debug|troubleshoot|diagnos)/i',
    $question
);

$diagBlock = '';
if ($diagnostic) {
    $bad = array_filter(vv_ai_health(), fn($c) => in_array($c['state'], ['bad', 'warn'], true));
    if ($bad) {
        $diagBlock .= "CURRENT CONFIGURATION PROBLEMS\n";
        foreach ($bad as $c) {
            $diagBlock .= '- [' . strtoupper($c['state']) . '] ' . $c['label'] . ': ' . $c['detail']
                        . ($c['fix'] !== '' ? ' — ' . $c['fix'] : '') . "\n";
        }
        $diagBlock .= "\n";
    }
    $logs = vv_ai_recent_logs(40);
    if ($logs) {
        $diagBlock .= "RECENT WARNINGS AND ERRORS (newest last)\n" . implode("\n", $logs) . "\n\n";
    }
}

$system = "You are Varaverk's documentation assistant. Varaverk is this user's private "
        . "two-server Unraid media ecosystem; it is not in your training data, so the material "
        . "below is the only thing you know about it.\n\n"
        . "Answer only from this material and cite the passages inline as [1], [2]. If it does "
        . "not contain the answer, say so plainly and name what is missing — do not fill the "
        . "gap from general knowledge. Prefer the user's own terminology.\n\n";

if ($diagBlock !== '') {
    $system .= "This is a diagnostic question, so live system state is included alongside the "
             . "documentation. Use the passages to explain how the thing is supposed to work, "
             . "and the live state to say what is actually wrong. When a configuration problem "
             . "is listed, name the specific setting and the file it lives in. Log lines are "
             . "evidence, not citations — cite only the numbered passages.\n\n"
             . $diagBlock;
}

$system .= "PASSAGES\n" . $context;

$messages = [['role' => 'system', 'content' => $system]];
$hist = json_decode($historyJson ?: '[]', 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 !== '') {
            $messages[] = ['role' => $role, 'content' => $text];
        }
    }
}
$messages[] = ['role' => 'user', 'content' => $question];

$payload = json_encode([
    'model'    => $cfg['model'],
    'messages' => $messages,
    'stream'   => false,
    'think'    => $think === '1',
    'options'  => ['num_ctx' => 16384],
]);

$t1  = microtime(true);
$ctx = stream_context_create(['http' => [
    'method'        => 'POST',
    'header'        => "Content-Type: application/json\r\n",
    'content'       => $payload,
    'timeout'       => max(30, $cfg['timeout']),
    'ignore_errors' => true,
]]);

$raw = @file_get_contents($cfg['url'] . '/api/chat', false, $ctx);
if ($raw === false) {
    jw($jobFile, ['status' => 'error',
        'error' => 'Ollama did not respond within ' . max(30, $cfg['timeout']) . 's at ' . $cfg['url'],
        'sources' => $sources]);
    exit(1);
}

$d = json_decode($raw, true);
if (!is_array($d) || !isset($d['message'])) {
    jw($jobFile, ['status' => 'error', 'error' => 'Unparseable response from Ollama',
                  'sources' => $sources]);
    exit(1);
}

$answer   = trim((string)($d['message']['content'] ?? ''));
$thinking = trim((string)($d['message']['thinking'] ?? ''));

if ($answer === '') {
    jw($jobFile, ['status' => 'error',
        'error' => 'The model returned no answer' . ($thinking !== '' ? ' (only reasoning)' : ''),
        'thinking' => $thinking, 'sources' => $sources]);
    exit(1);
}

$evalCount = (int)($d['eval_count'] ?? 0);
$evalNs    = (int)($d['eval_duration'] ?? 0);

jw($jobFile, [
    'status'   => 'done',
    'answer'   => $answer,
    'thinking' => $thinking,
    'sources'  => $sources,
    'timing'   => [
        'retrieve_ms' => (int)round($tRetrieve * 1000),
        'generate_ms' => (int)round((microtime(true) - $t1) * 1000),
        'tokens'      => $evalCount,
        'tok_s'       => $evalNs > 0 ? round($evalCount / ($evalNs / 1e9), 1) : null,
    ],
]);
