<?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
//   ask is refused when AI is disabled.
//       AI_ENABLED gates the whole subsystem; the tab is hidden when it is false, but hiding
//       a link is not access control and the endpoint is reachable directly.
//
//   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.
//
//   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
//   POST action=ask   question=…  [history=<JSON>] [kind=…] [think=0|1]
//   POST action=clear token=<hex32>       discard a finished job
//
// RESPONSE
//   stats  {"ok":true,"stats":{…}}
//   ask    {"ok":true,"token":"<hex32>"}
//   poll   {"ok":true,"job":{"status":"retrieving|generating|done|error",…}}
//   clear  {"ok":true}
//   {"ok":false,"error":…}
//
// DEPENDS ON
//   include/ai.php            vv_ai_stats(), vv_ai_config(), vv_ai_job_*()
//   Tools/ai_chat_worker.php  the detached worker
// ═══════════════════════════════════════════════════════════════════════════════════════════════
header('Content-Type: application/json');
header('Cache-Control: no-store, no-cache');
require_once dirname(__DIR__) . '/include/ai.php';

const VV_AI_MAX_TURNS    = 3;     // user+assistant pairs retained; see OPERATIONAL MODEL
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'));

// ── stats ─────────────────────────────────────────────────────────────────────
if ($action === 'stats') {
    echo json_encode(['ok' => true, 'stats' => vv_ai_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;
}

// ── 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;
}

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

    if (!vv_ai_enabled()) {
        echo json_encode(['ok' => false, 'error' => 'AI_ENABLED is false — AI features are off']); 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;
    }

    $kind = 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) > VV_AI_MAX_TURNS * 2) {
        $clean = array_slice($clean, -(VV_AI_MAX_TURNS * 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;
    }

    @file_put_contents($jobFile, json_encode(['status' => 'pending']));

    $cmd = 'nohup php ' . escapeshellarg($worker) . ' '
         . escapeshellarg($jobFile) . ' '
         . escapeshellarg($question) . ' '
         . escapeshellarg(json_encode($clean)) . ' '
         . escapeshellarg($kind) . ' '
         . escapeshellarg(($_POST['think'] ?? '1') === '1' ? '1' : '0')
         . ' >/dev/null 2>&1 </dev/null &';
    exec($cmd);

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

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