<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
//   The AI tab's library. Resolves AI configuration, reports the health of the retrieval
//   subsystem for the status banner, runs retrieval against the index, and owns the job-file
//   contract the chat worker and the polling endpoint share.
//
// OPERATIONAL MODEL
//   Retrieval is delegated, not reimplemented. AI/lib/search.js already embeds the query,
//   scans the index and applies intent routing; this file shells to that CLI with --json. A
//   second PHP implementation of vector search would be a second set of scoring bugs, and the
//   two would drift the first time the chunker changed.
//
//   Generation is not performed here. It takes 25-76 seconds and belongs in a detached worker;
//   this file only describes where that worker writes and what the states mean.
//
// DESIGN PRINCIPLES
//   Every reported statistic distinguishes "unknown" from "bad".
//       A banner that shows a healthy default when it cannot reach Ollama is worse than one
//       that shows nothing, because the operator stops checking. Each probe returns null when
//       it could not determine the answer.
//
//   Offload percentage is a first-class metric.
//       size_vram against size is the difference between 74 tok/s and 19 on this hardware, and
//       nothing else on the host surfaces it. It is computed here so the banner and any future
//       watchdog read the same number.
//
//   Staleness is measured against tracked files, not the filesystem.
//       The index only ever contains git-tracked content, so comparing to an untracked scratch
//       file would report permanent staleness. git ls-files is the same source the indexer uses.
//
//   Health checks compare configuration against reality, and carry their remedy.
//       "Retrieval failed" is not actionable; "HOST1_OLLAMA_MODEL names a tag that is not
//       installed" is. Every check names the setting to change, because the failures this
//       subsystem actually has are configuration drift — a model pulled, wired into conf, then
//       removed — rather than software faults.
//
//   Logs are read live, never indexed.
//       They churn constantly, would dominate a 3341-chunk corpus, and embedding similarity
//       retrieves log lines poorly compared with recency and a severity filter. Indexing them
//       would also mean re-embedding every few minutes for content that is stale immediately.
//
// OPERATIONAL SAFEGUARDS
//   Read-only. Nothing here writes to the index, the conf, or the job files — it reads state
//   and runs a retrieval query. The worker owns every write.
//
//   Every external call is time-boxed.
//       Retrieval and the Ollama probes carry explicit timeouts, because this library is
//       loaded by a page render and by a polling endpoint; a hung Ollama must degrade the
//       banner, not hang the tab.
//
//   Retrieval arguments are validated before they reach the shell.
//       kind is checked against the five real values and k is clamped, then every value is
//       escapeshellarg'd. An unknown kind would match zero rows in SQL and read as "the index
//       has no answer", which is the most misleading failure this subsystem can produce.
//
//   Tokens are validated as hex before they compose a path.
//       Job files live in a fixed directory and are named from a caller-supplied token; the
//       pattern is what keeps that token from escaping the directory.
//
//   A missing index is reported, never treated as empty.
//       Empty results and an absent index look identical to a caller that only counts rows,
//       and they need completely different responses from the user.
//
// EXPORTS
//   Config      vv_ai_enabled(), vv_ai_config()
//   Status      vv_ai_stats(), vv_ai_index_stats(), vv_ai_runtime_stats(), vv_ai_index_meta()
//   Models      vv_ai_models_available(), vv_ai_models_loaded()
//   Diagnosis   vv_ai_health(), vv_ai_recent_logs()
//   Retrieval   vv_ai_retrieve()
//   Jobs        vv_ai_job_dir(), vv_ai_job_path(), vv_ai_job_read()
//
// CONFIGURATION
//   master.conf   AI_ENABLED, AI_INDEX_DB, AI_SEARCH_K, AI_SEARCH_PER_FILE,
//                 AI_REQUEST_TIMEOUT, AI_CONNECT_TIMEOUT
//   host*.conf    HOST*_OLLAMA_URL, HOST*_OLLAMA_MODEL, HOST*_OLLAMA_EMBED_MODEL
// ═══════════════════════════════════════════════════════════════════════════════════════════════

require_once __DIR__ . '/config.php';

define('VV_AI_JOB_DIR', '/tmp/varaverk_ai_jobs');
const VV_AI_KINDS = ['header', 'readme', 'manual', 'template', 'doc'];

function vv_ai_config(): array {
    static $cfg = null;
    if ($cfg !== null) return $cfg;

    $vars = vv_conf_vars();
    $host = strtoupper(vv_detect_host());

    $cfg = [
        'enabled'     => strtolower(trim($vars['AI_ENABLED'] ?? 'false')) === 'true',
        'url'         => rtrim(trim($vars["{$host}_OLLAMA_URL"] ?? ''), '/'),
        'model'       => trim($vars["{$host}_OLLAMA_MODEL"] ?? ''),
        'embed_model' => trim($vars["{$host}_OLLAMA_EMBED_MODEL"] ?? 'nomic-embed-text'),
        'db'          => trim($vars['AI_INDEX_DB'] ?? '') ?: DATA_DIR . '/ai_index.db',
        'k'           => (int)($vars['AI_SEARCH_K'] ?? 8),
        'per_file'    => (int)($vars['AI_SEARCH_PER_FILE'] ?? 3),
        'timeout'     => (int)($vars['AI_REQUEST_TIMEOUT'] ?? 240),
        'connect'     => (int)($vars['AI_CONNECT_TIMEOUT'] ?? 5),
    ];
    // AI_INDEX_DB is written as "$DATA_DIR/ai_index.db" in conf; the shell expands it, PHP does
    // not. Left as a literal it would name a file that cannot exist.
    $cfg['db'] = str_replace(['$DATA_DIR', '${DATA_DIR}'], DATA_DIR, $cfg['db']);
    return $cfg;
}

function vv_ai_enabled(): bool {
    return vv_ai_config()['enabled'];
}

// Index size, coverage and staleness. Staleness compares the newest git-tracked file against
// the last build: the indexer only ever ingests tracked content, so anything else would report
// permanent drift.
function vv_ai_index_stats(): array {
    $cfg = vv_ai_config();
    $out = ['exists' => false, 'chunks' => 0, 'files' => 0, 'size' => 0,
            'built' => null, 'newest_source' => null, 'stale' => null, 'kinds' => []];

    if (!file_exists($cfg['db'])) return $out;
    $out['exists'] = true;
    $out['size']   = (int)@filesize($cfg['db']);

    $db = escapeshellarg($cfg['db']);
    $q  = fn(string $sql) => trim((string)@shell_exec('sqlite3 ' . $db . ' ' . escapeshellarg($sql) . ' 2>/dev/null'));

    $out['chunks'] = (int)$q('SELECT COUNT(*) FROM vv_chunks;');
    $out['files']  = (int)$q('SELECT COUNT(*) FROM vv_files;');
    $built         = (int)$q('SELECT MAX(indexed) FROM vv_files;');
    $out['built']  = $built ?: null;

    foreach (explode("\n", $q('SELECT kind, COUNT(*) FROM vv_chunks GROUP BY kind;')) as $line) {
        if (!$line) continue;
        [$k, $n] = array_pad(explode('|', $line, 2), 2, 0);
        if ($k !== '') $out['kinds'][$k] = (int)$n;
    }

    $newest = trim((string)@shell_exec(
        'cd ' . escapeshellarg(SCRIPTS_DIR) . ' && git ls-files -z 2>/dev/null'
        . ' | xargs -0 stat -c %Y 2>/dev/null | sort -rn | head -1'
    ));
    if ($newest !== '') {
        $out['newest_source'] = (int)$newest;
        if ($out['built'] !== null) $out['stale'] = (int)$newest > $out['built'];
    }
    return $out;
}

// Ollama reachability and — the number that matters on this hardware — whether the generation
// model is fully resident on the GPU. size_vram below size means layers are on the CPU, which
// costs roughly 4x throughput and is invisible everywhere else.
function vv_ai_runtime_stats(): array {
    $cfg = vv_ai_config();
    $out = ['reachable' => false, 'loaded' => null, 'offload_pct' => null,
            'context' => null, 'vram_used' => null, 'vram_total' => null, 'gpu' => null];

    if ($cfg['url'] === '') return $out;

    $ctx = stream_context_create(['http' => [
        'method' => 'GET', 'timeout' => max(2, $cfg['connect']), 'ignore_errors' => true,
    ]]);
    $raw = @file_get_contents($cfg['url'] . '/api/ps', false, $ctx);
    if ($raw === false) return $out;
    $out['reachable'] = true;

    $ps = json_decode($raw, true);
    foreach ($ps['models'] ?? [] as $m) {
        if (($m['name'] ?? '') !== $cfg['model']) continue;
        $size = (int)($m['size'] ?? 0);
        $vram = (int)($m['size_vram'] ?? 0);
        $out['loaded']      = true;
        $out['context']     = $m['context_length'] ?? null;
        $out['vram_used']   = $vram;
        $out['vram_total']  = $size;
        $out['offload_pct'] = $size > 0 ? (int)round($vram / $size * 100) : null;
        break;
    }
    if ($out['loaded'] === null) $out['loaded'] = false;

    $gpu = trim((string)@shell_exec(
        'nvidia-smi --query-gpu=name,memory.used,memory.total,utilization.gpu '
        . '--format=csv,noheader,nounits 2>/dev/null | tail -1'
    ));
    if ($gpu !== '') {
        $p = array_map('trim', explode(',', $gpu));
        if (count($p) >= 4) {
            $out['gpu'] = ['name' => $p[0], 'mem_used' => (int)$p[1],
                           'mem_total' => (int)$p[2], 'util' => (int)$p[3]];
        }
    }
    return $out;
}

// Models Ollama actually has on disk. The configured model naming a tag that does not exist is
// the single most likely misconfiguration here — it is what happens whenever a model is pulled,
// wired into conf, and later removed.
function vv_ai_models_available(): ?array {
    $cfg = vv_ai_config();
    if ($cfg['url'] === '') return null;
    $ctx = stream_context_create(['http' => ['timeout' => max(2, $cfg['connect']), 'ignore_errors' => true]]);
    $raw = @file_get_contents($cfg['url'] . '/api/tags', false, $ctx);
    if ($raw === false) return null;
    $d = json_decode($raw, true);
    if (!isset($d['models'])) return null;
    return array_map(fn($m) => [
        'name' => $m['name'] ?? '',
        'size' => (int)($m['size'] ?? 0),
    ], $d['models']);
}

// Currently resident models with their offload split.
function vv_ai_models_loaded(): ?array {
    $cfg = vv_ai_config();
    if ($cfg['url'] === '') return null;
    $ctx = stream_context_create(['http' => ['timeout' => max(2, $cfg['connect']), 'ignore_errors' => true]]);
    $raw = @file_get_contents($cfg['url'] . '/api/ps', false, $ctx);
    if ($raw === false) return null;
    $d = json_decode($raw, true);
    if (!isset($d['models'])) return null;
    $out = [];
    foreach ($d['models'] as $m) {
        $size = (int)($m['size'] ?? 0);
        $vram = (int)($m['size_vram'] ?? 0);
        $out[] = [
            'name'    => $m['name'] ?? '',
            'context' => $m['context_length'] ?? null,
            'size'    => $size,
            'vram'    => $vram,
            'offload' => $size > 0 ? (int)round($vram / $size * 100) : null,
        ];
    }
    return $out;
}

// vv_meta records the embed model and dimensionality the index was built with.
function vv_ai_index_meta(): array {
    $cfg = vv_ai_config();
    if (!file_exists($cfg['db'])) return [];
    $raw = trim((string)@shell_exec(
        'sqlite3 ' . escapeshellarg($cfg['db']) . ' ' . escapeshellarg('SELECT key, value FROM vv_meta;') . ' 2>/dev/null'
    ));
    $out = [];
    foreach (explode("\n", $raw) as $line) {
        if ($line === '') continue;
        [$k, $v] = array_pad(explode('|', $line, 2), 2, '');
        if ($k !== '') $out[$k] = $v;
    }
    return $out;
}

// Config checked against reality. Each check is ok | warn | bad, with the remedy attached —
// the point is to name the setting that is wrong, not merely report that something failed.
function vv_ai_health(): array {
    $cfg    = vv_ai_config();
    $checks = [];
    $add = function (string $id, string $label, string $state, string $detail, string $fix = '')
        use (&$checks) {
        $checks[] = ['id' => $id, 'label' => $label, 'state' => $state,
                     'detail' => $detail, 'fix' => $fix];
    };

    $add('enabled', 'AI enabled', $cfg['enabled'] ? 'ok' : 'bad',
         $cfg['enabled'] ? 'AI_ENABLED=true' : 'AI_ENABLED is false',
         $cfg['enabled'] ? '' : 'Set AI_ENABLED=true in master.conf');

    if (!command_exists_node()) {
        $add('node', 'node runtime', 'bad', 'node not found on PATH',
             'Retrieval shells to AI/lib/cli.js and cannot run without it');
    }

    if ($cfg['url'] === '') {
        $add('url', 'Ollama URL', 'bad', 'not configured',
             'Set ' . strtoupper(vv_detect_host()) . '_OLLAMA_URL in the host conf');
        return $checks;
    }

    $avail = vv_ai_models_available();
    if ($avail === null) {
        $add('reach', 'Ollama reachable', 'bad', 'no response from ' . $cfg['url'],
             'Check the Ollama container is running and the URL is correct');
        return $checks;
    }
    $add('reach', 'Ollama reachable', 'ok', $cfg['url']);

    $names = array_column($avail, 'name');

    // The configured tag not existing is the failure this whole section is for.
    if ($cfg['model'] === '') {
        $add('gen', 'Generation model', 'bad', 'not configured',
             'Set ' . strtoupper(vv_detect_host()) . '_OLLAMA_MODEL in the host conf');
    } elseif (!in_array($cfg['model'], $names, true)) {
        $add('gen', 'Generation model', 'bad', $cfg['model'] . ' is not installed',
             'Either `ollama pull` it, or point _OLLAMA_MODEL at one of: ' . implode(', ', array_slice($names, 0, 4)));
    } else {
        $add('gen', 'Generation model', 'ok', $cfg['model']);
    }

    if ($cfg['embed_model'] === '' || !in_array($cfg['embed_model'], $names, true)) {
        $add('embed', 'Embedding model', 'bad',
             ($cfg['embed_model'] ?: 'not configured') . ' is not installed',
             'Retrieval cannot embed a query without it — `ollama pull ' . ($cfg['embed_model'] ?: 'nomic-embed-text') . '`');
    } else {
        $add('embed', 'Embedding model', 'ok', $cfg['embed_model']);
    }

    $ix   = vv_ai_index_stats();
    $meta = vv_ai_index_meta();

    if (!$ix['exists']) {
        $add('index', 'Index', 'bad', 'not built', 'Run AI/ai_index.sh');
    } else {
        $add('index', 'Index', 'ok', number_format($ix['chunks']) . ' chunks from ' . $ix['files'] . ' files');

        // A vector built by one embedding model is meaningless to another. Changing the embed
        // model without reindexing does not error — it silently returns nonsense, scored
        // confidently, which is the hardest failure here to notice from the answers alone.
        $builtWith = $meta['embed_model'] ?? '';
        if ($builtWith !== '' && $cfg['embed_model'] !== '' && $builtWith !== $cfg['embed_model']) {
            $add('embedmatch', 'Index / embedder match', 'bad',
                 'index built with ' . $builtWith . ', conf says ' . $cfg['embed_model'],
                 'Vectors from different models are not comparable — rerun AI/ai_index.sh --force');
        } else {
            $add('embedmatch', 'Index / embedder match', 'ok', $builtWith ?: 'unrecorded');
        }

        if ($ix['stale'] === true) {
            $add('fresh', 'Index freshness', 'warn', 'tracked files are newer than the index',
                 'Answers may cite code that has changed — rerun AI/ai_index.sh');
        } else {
            $add('fresh', 'Index freshness', 'ok', 'current');
        }
    }

    $loaded = vv_ai_models_loaded();
    if ($loaded !== null && $cfg['model'] !== '') {
        $hit = null;
        foreach ($loaded as $m) if ($m['name'] === $cfg['model']) { $hit = $m; break; }
        if ($hit === null) {
            $add('resident', 'Model resident', 'warn', 'not loaded — first question will load it');
        } elseif ($hit['offload'] !== null && $hit['offload'] < 100) {
            $add('resident', 'GPU offload', 'bad', $hit['offload'] . '% on GPU — layers on CPU',
                 'Roughly 4x slower on this card. Lower num_ctx or use a smaller quant');
        } else {
            $add('resident', 'GPU offload', 'ok', '100% on GPU at ' . ($hit['context'] ?? '?') . ' ctx');
        }
    }

    return $checks;
}

function command_exists_node(): bool {
    static $has = null;
    if ($has !== null) return $has;
    return $has = trim((string)@shell_exec('command -v node 2>/dev/null')) !== '';
}

// Recent warnings and errors across the orchestrator logs. Read live rather than indexed:
// logs churn constantly, would dominate a 3341-chunk index, and embedding similarity retrieves
// them poorly compared with recency plus a severity filter.
function vv_ai_recent_logs(int $max = 40): array {
    $dir = '/var/log/varaverk';
    if (!is_dir($dir)) return [];

    $cmd = 'grep -rhE "\[(ERROR|WARN|CRITICAL|FAILED)\]|✗" ' . escapeshellarg($dir)
         . ' --include="*.log" 2>/dev/null | tail -' . max(1, min($max, 200));
    $raw = (string)@shell_exec($cmd);

    $out = [];
    foreach (explode("\n", $raw) as $line) {
        $line = trim(preg_replace('/\033\[[0-9;]*[mK]/', '', $line));
        if ($line === '') continue;
        $out[] = mb_substr($line, 0, 300);
    }
    return $out;
}

function vv_ai_stats(): array {
    $cfg = vv_ai_config();
    return [
        'enabled'     => $cfg['enabled'],
        'model'       => $cfg['model'],
        'embed_model' => $cfg['embed_model'],
        'url'         => $cfg['url'],
        'k'           => $cfg['k'],
        'index'       => vv_ai_index_stats(),
        'runtime'     => vv_ai_runtime_stats(),
        'loaded'      => vv_ai_models_loaded(),
        'health'      => vv_ai_health(),
        'ts'          => time(),
    ];
}

// Retrieval via AI/lib/cli.js. Returns ['ok'=>bool,'results'=>[],'intents'=>[],'error'=>?string].
function vv_ai_retrieve(string $query, string $kind = '', string $section = '', ?int $k = null): array {
    $cfg = vv_ai_config();

    if ($query === '')                 return ['ok' => false, 'error' => 'query is required', 'results' => []];
    if ($cfg['url'] === '')            return ['ok' => false, 'error' => 'Ollama URL is not configured', 'results' => []];
    if (!file_exists($cfg['db']))      return ['ok' => false, 'error' => 'No index — run AI/ai_index.sh', 'results' => []];
    if ($kind !== '' && !in_array($kind, VV_AI_KINDS, true)) {
        return ['ok' => false, 'error' => 'Unknown kind: ' . $kind, 'results' => []];
    }

    $cli = SCRIPTS_DIR . '/AI/lib/cli.js';
    if (!file_exists($cli)) return ['ok' => false, 'error' => 'AI/lib/cli.js not found', 'results' => []];

    $k = max(1, min($k ?? $cfg['k'], 25));

    $cmd = 'timeout ' . max(10, $cfg['connect'] * 6) . ' node --no-warnings '
         . escapeshellarg($cli) . ' search'
         . ' --db=' . escapeshellarg($cfg['db'])
         . ' --url=' . escapeshellarg($cfg['url'])
         . ' --model=' . escapeshellarg($cfg['embed_model'])
         . ' --query=' . escapeshellarg($query)
         . ' --k=' . $k
         . ' --per-file=' . max(1, $cfg['per_file'])
         . ($kind !== ''    ? ' --kind='    . escapeshellarg($kind)    : '')
         . ($section !== '' ? ' --section=' . escapeshellarg($section) : '')
         . ' --json 2>/dev/null';

    $raw = trim((string)@shell_exec($cmd));
    if ($raw === '') return ['ok' => false, 'error' => 'Retrieval produced no output', 'results' => []];

    $d = json_decode($raw, true);
    if (!is_array($d) || !isset($d['results'])) {
        return ['ok' => false, 'error' => 'Retrieval returned unparseable output', 'results' => []];
    }
    return ['ok' => true, 'results' => $d['results'], 'intents' => $d['intents'] ?? [],
            'scanned' => $d['scanned'] ?? 0];
}

function vv_ai_job_dir(): string {
    if (!is_dir(VV_AI_JOB_DIR)) @mkdir(VV_AI_JOB_DIR, 0700, true);
    return VV_AI_JOB_DIR;
}

// Hex-only, fixed length. The token composes a path, so the pattern is what confines it to the
// job directory.
function vv_ai_job_path(string $token): ?string {
    if (!preg_match('/^[0-9a-f]{32}$/', $token)) return null;
    return VV_AI_JOB_DIR . '/' . $token . '.json';
}

function vv_ai_job_read(string $token): ?array {
    $p = vv_ai_job_path($token);
    if ($p === null || !file_exists($p)) return null;
    $d = json_decode(@file_get_contents($p) ?: '', true);
    return is_array($d) ? $d : null;
}
