<?php
/**
 * embed.php - Full server-side semantic CIE-10 search pipeline.
 *
 * POST {"query":"..."}  ->  SSE stream of ranked results
 *
 * Pipeline:
 *   1. Expand (Haiku) - returns terms + detected clinical triads
 *   2. Embed (OpenAI) - generates query vector
 *   3. Vector search (binary index, APCu-cached) - top-20 by dot product
 *   4. Inject forced candidates from triad diagnoses
 *   5. Rerank stream (Haiku) - clinical reordering with streaming SSE
 *
 * Index files: cie10_index.bin + cie10_meta.json (from build_binary_index.php)
 * API keys: /etc/cie10/openai.key + /etc/cie10/anthropic.key
 *
 * APCu cache keys:
 *   cie10_index_v1  - raw binary string of cie10_index.bin (~52 MB, ttl=0)
 *   cie10_meta_v1   - serialized PHP array of cie10_meta.json (~1.2 MB, ttl=0)
 */

declare(strict_types=1);

header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');

if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    header('Content-Type: application/json; charset=utf-8');
    http_response_code(405); echo json_encode(['error' => 'POST only']); exit;
}

// -------- Auth check --------
require_once __DIR__ . '/auth.php';
if (!auth_check()) {
    header('Content-Type: application/json; charset=utf-8');
    http_response_code(401); echo json_encode(['error' => 'unauthenticated']); exit;
}

// -------- Rate limit (120 req/min/IP) --------
$rateDir = '/tmp/cie10_ratelimit';
if (!is_dir($rateDir)) @mkdir($rateDir, 0700, true);
$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$rateFile = $rateDir . '/' . md5($ip);
$now = time();
$timestamps = [];
if (file_exists($rateFile)) {
    $timestamps = array_filter(
        array_map('intval', explode("\n", trim((string)file_get_contents($rateFile)))),
        fn($t) => $now - 60 < $t
    );
}
if (count($timestamps) >= 120) {
    header('Content-Type: application/json; charset=utf-8');
    http_response_code(429); echo json_encode(['error' => 'rate limit exceeded']); exit;
}
$timestamps[] = $now;
file_put_contents($rateFile, implode("\n", $timestamps));

// -------- Parse input --------
$input = json_decode((string)file_get_contents('php://input'), true);
if (!is_array($input)) {
    header('Content-Type: application/json; charset=utf-8');
    http_response_code(400); echo json_encode(['error' => 'invalid JSON']); exit;
}
$query             = trim((string)($input['query'] ?? ''));
$clinicalReasoning = (bool)($input['clinical_reasoning'] ?? false);
if ($query === '' || mb_strlen($query) > 500) {
    header('Content-Type: application/json; charset=utf-8');
    http_response_code(400); echo json_encode(['error' => 'query must be 1-500 chars']); exit;
}

// -------- SSE setup --------
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('X-Accel-Buffering: no');
@ini_set('output_buffering', '0');
@ini_set('zlib.output_compression', '0');
while (ob_get_level() > 0) ob_end_flush();

function sseEmit(string $event, $data): void {
    echo "event: {$event}\ndata: " . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n\n";
    @ob_flush(); @flush();
}
function sseFail(string $msg): void {
    sseEmit('error', ['error' => $msg]);
    echo "event: done\ndata: {}\n\n"; @ob_flush(); @flush(); exit;
}

// -------- Load keys --------
function loadKey(string $path): string {
    if (!is_readable($path)) sseFail("missing key file: $path");
    return trim((string)file_get_contents($path));
}
$anthropicKey = loadKey('/etc/cie10/anthropic.key');
$openaiKey    = loadKey('/etc/cie10/openai.key');

// -------- HTTP helper --------
function httpPost(string $url, array $headers, string $body, int $timeout = 30): array {
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => $body,
        CURLOPT_HTTPHEADER => $headers,
        CURLOPT_TIMEOUT => $timeout,
    ]);
    $response = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $err = curl_error($ch);
    curl_close($ch);
    return [$code, $response, $err];
}

// ============================================================
// STAGES 1+2: EXPAND (Haiku) + EMBED (OpenAI) — parallel via curl_multi
//
// Both requests fire simultaneously. We wait for both to finish.
// Embed uses the raw query while expand is running — same latency,
// but the expanded terms produce a better vector, so we re-embed
// the expanded text only if expand finishes before the embed timeout.
//
// Strategy:
//   - Fire expand + embed(raw query) in parallel
//   - When both finish: re-embed expanded terms if expand succeeded
//   - If expand fails: use embed(raw query) as fallback vector
//   - Net saving: ~200-400ms (embed no longer waits for expand)
// ============================================================
sseEmit('stage', ['stage' => 'expand', 'label' => 'Procesando consulta...']);

// Fast prompt: terms only
$expandPromptFast = "Eres un asistente medico experto. Recibes una consulta breve de un medico "
    . "(en lenguaje natural, jerga chilena, o terminos clinicos) "
    . "y debes responder UNICAMENTE con un JSON valido (sin markdown, sin ```) con esta estructura:\n"
    . "{\"terms\":\"lista de 10-20 terminos clinicos separados por comas\"}\n\n"
    . "REGLAS:\n"
    . "- Incluye terminos medicos formales, sinonimos, variantes anatomicas, etiologias comunes.\n"
    . "- Incluye la consulta original como primer termino.\n"
    . "- Todo en espanol. Solo el JSON, nada mas.";

// Clinical reasoning prompt: terms + triads + tips
$expandPromptClinical = "Eres un asistente medico experto. Recibes una consulta breve de un medico "
    . "(en lenguaje natural, jerga chilena, o terminos clinicos) "
    . "y debes generar un JSON valido (sin markdown, sin ```) con esta estructura exacta:\n"
    . "{\"terms\":\"lista de 10-20 terminos clinicos separados por comas\",\"triads\":[],\"tips\":[]}\n\n"
    . "REGLAS PARA terms:\n"
    . "- Incluye terminos medicos formales, sinonimos, variantes anatomicas, etiologias comunes.\n"
    . "- Incluye la consulta original como primer termino.\n"
    . "- Todo en espanol. Sin explicaciones, sin numeracion.\n\n"
    . "REGLAS PARA triads:\n"
    . "- Si los sintomas forman PARTE o la TOTALIDAD de un patron semiologico clasico, agrega un objeto al array triads.\n"
    . "- Cada objeto: {\"present\":\"sintomas presentes\",\"missing\":\"sintomas faltantes o vacio\",\"name\":\"nombre del patron\",\"diagnoses\":\"diagnosticos separados por comas\"}\n"
    . "- Detecta: Charcot (dolor abd+fiebre+ictericia->colangitis), Reynolds (Charcot+hipotension+confusion->colangitis supurada), "
    . "Beck (hipotension+ingurgitacion yugular+ruidos apagados->taponamiento), "
    . "Cushing (HTA+bradicardia+resp irregular->HTE), Virchow (estasis+lesion endotelial+hipercoag->TVP/TEP), "
    . "Mackler (vomito+dolor toracico+enfisema subcutaneo->rotura esofagica), y otros que reconozcas.\n"
    . "- Incluye diagnosticos de triadas TAMBIEN en terms. Si no hay triadas, deja [].\n\n"
    . "REGLAS PARA tips:\n"
    . "- Tips ACCIONABLES para medico de urgencias/SAR rural. Maximo 3. Si no aplica, deja [].\n"
    . "- Cada tip: {\"category\":\"ECG|Laboratorio|Imagen|Farmaco|Trampa|Conducta|Derivacion\",\"text\":\"1-2 oraciones\",\"urgency\":\"high|medium|low\"}\n"
    . "- urgency high=cambia manejo inmediato, medium=importante, low=perla util.\n"
    . "- Los tips indican QUE HACER, no repiten el diagnostico de las triadas.\n"
    . "Solo el JSON. Siempre en espanol.";

$activePrompt = $clinicalReasoning ? $expandPromptClinical : $expandPromptFast;
$maxTokens    = $clinicalReasoning ? 700 : 200;

// ── Build both curl handles ───────────────────────────────────────────────────

// Handle A: expand (Haiku)
$chExpand = curl_init('https://api.anthropic.com/v1/messages');
curl_setopt_array($chExpand, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => json_encode([
        'model'      => 'claude-haiku-4-5-20251001',
        'max_tokens' => $maxTokens,
        'system'     => [['type' => 'text', 'text' => $activePrompt, 'cache_control' => ['type' => 'ephemeral']]],
        'messages'   => [['role' => 'user', 'content' => $query]],
    ]),
    CURLOPT_HTTPHEADER => [
        'x-api-key: ' . $anthropicKey,
        'anthropic-version: 2023-06-01',
        'Content-Type: application/json',
    ],
    CURLOPT_TIMEOUT => 15,
]);

// Handle B: embed raw query (OpenAI) — fires in parallel with expand
$chEmbed = curl_init('https://api.openai.com/v1/embeddings');
curl_setopt_array($chEmbed, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => json_encode([
        'model'      => 'text-embedding-3-large',
        'input'      => $query,   // raw query — good enough for vector search
        'dimensions' => 1024,
    ]),
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . $openaiKey,
        'Content-Type: application/json',
    ],
    CURLOPT_TIMEOUT => 15,
]);

// ── Fire both in parallel ─────────────────────────────────────────────────────
$mh = curl_multi_init();
curl_multi_add_handle($mh, $chExpand);
curl_multi_add_handle($mh, $chEmbed);

$running = null;
do {
    curl_multi_exec($mh, $running);
    curl_multi_select($mh, 0.01); // 10ms sleep to avoid busy-wait
} while ($running > 0);

$expandResponse = curl_multi_getcontent($chExpand);
$expandCode     = curl_getinfo($chExpand, CURLINFO_HTTP_CODE);
$embedResponse  = curl_multi_getcontent($chEmbed);
$embedCode      = curl_getinfo($chEmbed, CURLINFO_HTTP_CODE);

curl_multi_remove_handle($mh, $chExpand);
curl_multi_remove_handle($mh, $chEmbed);
curl_multi_close($mh);
curl_close($chExpand);
curl_close($chEmbed);

// ── Parse expand result ───────────────────────────────────────────────────────
$terms  = '';
$triads = [];
$tips   = [];

if ($expandCode === 200) {
    $expandDecoded = json_decode($expandResponse, true);
    $expandedRaw   = $expandDecoded['content'][0]['text'] ?? '';
    $expandedRaw   = preg_replace('/^```(?:json)?\s*|\s*```$/m', '', trim($expandedRaw));
    $expandData    = json_decode($expandedRaw, true);

    if (is_array($expandData) && isset($expandData['terms'])) {
        $terms  = $query . ', ' . trim((string)$expandData['terms']);
        $triads = is_array($expandData['triads'] ?? null) ? $expandData['triads'] : [];
        $tips   = is_array($expandData['tips']   ?? null) ? $expandData['tips']   : [];
    } else {
        $terms = $query . ', ' . trim($expandedRaw);
    }
} else {
    // Expand failed — fall back to raw query for vector search
    $terms = $query;
}

sseEmit('expanded', ['terms' => $terms, 'triads' => $triads, 'tips' => $tips]);

// ── Parse embed result ────────────────────────────────────────────────────────
// Use raw-query vector (already computed in parallel).
// If expand succeeded and produced meaningful new terms, re-embed the
// expanded text for a better vector. Skip re-embed if terms == query (no expansion).
sseEmit('stage', ['stage' => 'embed', 'label' => 'Generando embedding semántico...']);

$queryVec = null;

// Try to use expanded-text embed if terms differ significantly from raw query
$needsReembed = ($terms !== $query && strlen($terms) > strlen($query) + 10);

if (!$needsReembed && $embedCode === 200) {
    // Use the parallel raw-query embed — no extra round trip needed
    $embedDecoded = json_decode($embedResponse, true);
    $queryVec     = $embedDecoded['data'][0]['embedding'] ?? null;
}

if ($queryVec === null) {
    // Re-embed expanded terms (or fallback if parallel embed failed)
    $embedInput = $needsReembed ? $terms : $query;
    [$code, $response, $err] = httpPost(
        'https://api.openai.com/v1/embeddings',
        ['Authorization: Bearer ' . $openaiKey, 'Content-Type: application/json'],
        json_encode(['model' => 'text-embedding-3-large', 'input' => $embedInput, 'dimensions' => 1024]),
        15
    );
    if ($code !== 200) sseFail('embed failed: HTTP ' . $code);
    $decoded  = json_decode($response, true);
    $queryVec = $decoded['data'][0]['embedding'] ?? null;
}

if (!is_array($queryVec)) sseFail('embed: invalid response');

// ============================================================
// STAGE 3: VECTOR SEARCH (binary index — APCu cached)
// ============================================================
sseEmit('stage', ['stage' => 'search', 'label' => 'Buscando en 12.810 códigos...']);

$metaPath  = __DIR__ . '/cie10_meta.json';
$sockPath  = '/var/www/cie10/vector_search.sock';

// --- Load metadata from APCu or disk (needed for candidate lookup) ---
$metaCacheKey = 'cie10_meta_v1';
$meta = apcu_fetch($metaCacheKey, $metaCached);
if (!$metaCached) {
    if (!is_readable($metaPath)) sseFail('meta file not found — run: php build_binary_index.php');
    $meta = json_decode((string)file_get_contents($metaPath), true);
    if (!is_array($meta)) sseFail('failed to parse cie10_meta.json');
    apcu_store($metaCacheKey, $meta, 0);
}

// --- Vector search via numpy sidecar (AVX2 + FMA via OpenBLAS) ---
// Falls back to PHP dot-product loop if sidecar is unavailable.
$topK     = 20;
$usedSock = false;
$candidateMap = [];

if (file_exists($sockPath)) {
    $sock = @stream_socket_client('unix://' . $sockPath, $errno, $errstr, 2.0);
    if ($sock) {
        // Send query vector as raw float32 bytes (1024 * 4 = 4096 bytes)
        $queryBin = pack('f*', ...$queryVec);
        fwrite($sock, $queryBin);

        // Receive 20 uint32 indices (20 * 4 = 80 bytes)
        $resultBin = '';
        while (strlen($resultBin) < 80) {
            $chunk = fread($sock, 80 - strlen($resultBin));
            if ($chunk === false || $chunk === '') break;
            $resultBin .= $chunk;
        }
        fclose($sock);

        if (strlen($resultBin) === 80) {
            $indices = array_values(unpack('V20', $resultBin));
            foreach ($indices as $i) {
                if (!isset($meta[$i])) continue;
                $c = $meta[$i]['codigo'];
                $candidateMap[$c] = ['codigo' => $c, 'descripcion' => $meta[$i]['descripcion']];
            }
            $usedSock = true;
        }
    }
}

if (!$usedSock) {
    // Fallback: PHP dot-product loop (APCu-cached binary index)
    $binPath      = __DIR__ . '/cie10_index.bin';
    $indexCacheKey = 'cie10_index_v1';
    $indexData = apcu_fetch($indexCacheKey, $indexCached);
    if (!$indexCached) {
        if (!is_readable($binPath)) sseFail('index files not found — run: php build_binary_index.php');
        $indexData = file_get_contents($binPath);
        if ($indexData === false) sseFail('failed to read binary index from disk');
        apcu_store($indexCacheKey, $indexData, 0);
    }
    $header        = unpack('Vcount/Vdims', substr($indexData, 0, 8));
    $entryCount    = (int)$header['count'];
    $dims          = (int)$header['dims'];
    $bytesPerEntry = $dims * 4;
    $queryFloats   = array_values($queryVec);
    $scores = [];
    for ($i = 0; $i < $entryCount; $i++) {
        $offset = 8 + $i * $bytesPerEntry;
        $floats = unpack('f*', substr($indexData, $offset, $bytesPerEntry));
        $dot = 0.0; $j = 0;
        foreach ($floats as $val) { $dot += $queryFloats[$j] * $val; $j++; }
        $scores[$i] = $dot;
    }
    unset($indexData, $queryFloats);
    arsort($scores);
    $rank = 0;
    foreach ($scores as $i => $score) {
        if ($rank >= $topK) break;
        $c = $meta[$i]['codigo'];
        $candidateMap[$c] = ['codigo' => $c, 'descripcion' => $meta[$i]['descripcion']];
        $rank++;
    }
    unset($scores);
}

// ============================================================
// INJECT FORCED CANDIDATES from triad diagnoses
// Ensures diagnoses identified by clinical pattern recognition
// reach the reranker even if they didn't make top-20 by vector similarity.
// ============================================================
if (!empty($triads)) {
    foreach ($triads as $triad) {
        $diagList  = (string)($triad['diagnoses'] ?? '');
        $diagTerms = array_map('trim', explode(',', $diagList));
        foreach ($diagTerms as $diagTerm) {
            if ($diagTerm === '') continue;
            $lower = mb_strtolower($diagTerm);
            foreach ($meta as $idx => $m) {
                if (isset($candidateMap[$m['codigo']])) continue;
                if (mb_stripos($m['descripcion'], $lower) !== false
                    || mb_stripos($lower, mb_strtolower($m['descripcion'])) !== false) {
                    $candidateMap[$m['codigo']] = ['codigo' => $m['codigo'], 'descripcion' => $m['descripcion']];
                    break;
                }
            }
        }
    }
}

$candidates = array_values($candidateMap);
unset($candidateMap);

// ============================================================
// STAGE 4: RERANK (Claude Haiku, streaming SSE)
// ============================================================
sseEmit('stage', ['stage' => 'rerank', 'label' => 'Reordenando por relevancia clínica...']);

$candidateList = '';
foreach ($candidates as $i => $c) {
    $candidateList .= ($i + 1) . ". [{$c['codigo']}] {$c['descripcion']}\n";
}

$rerankPrompt = "Eres un medico clinico experto en codificacion CIE-10. Recibes una consulta de un medico (en lenguaje natural o jerga chilena) "
    . "y una lista numerada de codigos CIE-10 candidatos. Reordena los 15 mas relevantes por probabilidad clinica real. "
    . "REGLAS DE RANKING CLINICO: "
    . "1. Si la consulta describe un patron semiologico clasico (triada de Charcot, pentada de Reynolds, triada de Beck, triada de Cushing, criterios de Jones, etc.), "
    . "el diagnostico al que corresponde ese patron SIEMPRE va primero, por sobre diagnosticos diferenciales parciales. "
    . "2. Prioriza especificidad: un codigo que explica TODOS los sintomas descritos va antes que uno que explica solo parte. "
    . "3. Considera prevalencia y urgencia clinica: patologias agudas que requieren intervencion inmediata van antes que cronicas con presentacion similar. "
    . "4. Diferencia entre el diagnostico principal y los diagnosticos diferenciales; ordena primero el principal. "
    . "Respondes UNICAMENTE con un array JSON valido (sin markdown, sin ```), donde cada elemento tiene: "
    . "{\"codigo\":\"<cod>\",\"score\":<0-100>,\"reason\":\"<max 8 palabras o cadena vacia>\"}. "
    . "Incluye \"reason\" SOLO en los primeros 5 elementos; en los siguientes 10 deja \"reason\":\"\". "
    . "El score refleja probabilidad clinica, no similitud textual. Responde solo el array JSON.";

$codeMap = [];
foreach ($candidates as $c) $codeMap[$c['codigo']] = $c['descripcion'];

$jsonBuffer  = '';
$depth       = 0;
$inString    = false;
$escape      = false;
$objStart    = -1;
$emittedCount = 0;

$ch = curl_init('https://api.anthropic.com/v1/messages');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => json_encode([
        'model' => 'claude-haiku-4-5-20251001',
        'max_tokens' => 1200,
        'stream' => true,
        'system' => [['type' => 'text', 'text' => $rerankPrompt, 'cache_control' => ['type' => 'ephemeral']]],
        'messages' => [['role' => 'user', 'content' => "Consulta: \"$query\"\n\nCandidatos:\n$candidateList"]],
    ]),
    CURLOPT_HTTPHEADER => [
        'x-api-key: ' . $anthropicKey,
        'anthropic-version: 2023-06-01',
        'Content-Type: application/json',
        'Accept: text/event-stream',
    ],
    CURLOPT_TIMEOUT => 30,
    CURLOPT_WRITEFUNCTION => function ($curl, $chunk) use (
        &$jsonBuffer, &$depth, &$inString, &$escape, &$objStart, &$emittedCount, &$codeMap
    ) {
        static $sseBuffer = '';
        $sseBuffer .= $chunk;
        while (($pos = strpos($sseBuffer, "\n")) !== false) {
            $line      = substr($sseBuffer, 0, $pos);
            $sseBuffer = substr($sseBuffer, $pos + 1);
            if (strpos($line, 'data: ') !== 0) continue;
            $data = substr($line, 6);
            if ($data === '[DONE]') continue;
            $event = json_decode($data, true);
            if (!is_array($event)) continue;

            if (($event['type'] ?? '') === 'content_block_delta'
                && ($event['delta']['type'] ?? '') === 'text_delta') {
                $text     = $event['delta']['text'] ?? '';
                $startIdx = strlen($jsonBuffer);
                $jsonBuffer .= $text;

                $len = strlen($jsonBuffer);
                for ($i = $startIdx; $i < $len; $i++) {
                    $ch2 = $jsonBuffer[$i];
                    if ($escape) { $escape = false; continue; }
                    if ($ch2 === '\\' && $inString) { $escape = true; continue; }
                    if ($ch2 === '"') { $inString = !$inString; continue; }
                    if ($inString) continue;

                    if ($ch2 === '{') {
                        if ($depth === 0) $objStart = $i;
                        $depth++;
                    } elseif ($ch2 === '}') {
                        $depth--;
                        if ($depth === 0 && $objStart >= 0) {
                            $objStr = substr($jsonBuffer, $objStart, $i - $objStart + 1);
                            $obj    = json_decode($objStr, true);
                            if (is_array($obj) && isset($obj['codigo'])) {
                                $obj['descripcion'] = $codeMap[$obj['codigo']] ?? '';
                                sseEmit('result', $obj);
                                $emittedCount++;
                            }
                            $objStart = -1;
                        }
                    }
                }
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($httpCode !== 200 && $emittedCount === 0) {
    sseEmit('error', ['error' => 'rerank failed: HTTP ' . $httpCode]);
}

echo "event: done\ndata: {}\n\n";
@ob_flush(); @flush();