// In-process HTTP server speaking the Hrana v2 protocol.
// Backed by the `libsql` npm package (better-sqlite3 API).
// Binds to the fixed lock port for single-instance enforcement.
//
// Serves POST /v2/pipeline (Hrana v2 JSON), GET /v2, and GET /health.
// The @libsql/client HTTP driver and @prisma/adapter-libsql connect here.
//
// Hrana v2 protocol spec ("Hrana over HTTP"):
//   https://github.com/tursodatabase/libsql/blob/main/docs/HTTP_V2_SPEC.md
//
// The protocol exposes stateful streams over HTTP. Each stream corresponds
// to a SQLite connection. Requests on the same stream are tied together
// via a "baton" — the server returns a baton in every response, and the
// client includes it in the next request. Stream-scoped state includes
// SQL text cached via store_sql (referenced by sql_id in later stmts).
//
// Request types implemented:
//   execute     — run a single SQL statement, return cols/rows/changes
//   batch       — run multiple steps with conditional execution (ok/not/and/or)
//   sequence    — split raw SQL by semicolons, execute each (no results)
//   store_sql   — cache SQL text under a numeric sql_id for the stream
//   close_sql   — remove a cached sql_id
//   close       — close the stream (baton becomes null)
//
// Value encoding (SQLite → Hrana JSON):
//   INTEGER → {"type":"integer","value":"42"}  (string, not number)
//   REAL    → {"type":"float","value":3.14}
//   TEXT    → {"type":"text","value":"hello"}
//   BLOB    → {"type":"blob","base64":"..."}
//   NULL    → {"type":"null"}
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import crypto from 'node:crypto';
import Database from 'libsql';
import * as errore from 'errore';
import { createLogger, LogPrefix } from './logger.js';
import { ServerStartError, FetchError } from './errors.js';
import { getLockPort } from './config.js';
import { store } from './store.js';
const hranaLogger = createLogger(LogPrefix.DB);
let db = null;
let server = null;
let hranaUrl = null;
let discordGatewayReady = false;
let readyWaiters = [];
export function markDiscordGatewayReady() {
    if (discordGatewayReady) {
        return;
    }
    discordGatewayReady = true;
    for (const resolve of readyWaiters) {
        resolve();
    }
    readyWaiters = [];
}
async function waitForDiscordGatewayReady({ timeoutMs }) {
    if (discordGatewayReady) {
        return true;
    }
    const readyPromise = new Promise((resolve) => {
        readyWaiters.push(() => {
            resolve(true);
        });
    });
    const timeoutPromise = new Promise((resolve) => {
        setTimeout(() => {
            resolve(false);
        }, timeoutMs);
    });
    return Promise.race([readyPromise, timeoutPromise]);
}
function getRequestAuthToken(req) {
    const authorizationHeader = req.headers.authorization;
    if (typeof authorizationHeader === 'string' && authorizationHeader.startsWith('Bearer ')) {
        return authorizationHeader.slice('Bearer '.length);
    }
    return null;
}
// Timing-safe comparison to prevent timing attacks when the hrana server
// is internet-facing (bindAll=true / KIMAKI_INTERNET_REACHABLE_URL set).
function isAuthorizedRequest(req) {
    const expectedToken = store.getState().gatewayToken;
    if (!expectedToken) {
        return false;
    }
    const providedToken = getRequestAuthToken(req);
    if (!providedToken) {
        return false;
    }
    const expectedBuf = Buffer.from(expectedToken, 'utf8');
    const providedBuf = Buffer.from(providedToken, 'utf8');
    if (expectedBuf.length !== providedBuf.length) {
        return false;
    }
    return crypto.timingSafeEqual(expectedBuf, providedBuf);
}
function ensureServiceAuthTokenInStore() {
    const existingToken = store.getState().gatewayToken;
    if (existingToken) {
        return existingToken;
    }
    const generatedToken = `${crypto.randomUUID()}:${crypto.randomBytes(32).toString('hex')}`;
    store.setState({ gatewayToken: generatedToken });
    return generatedToken;
}
/**
 * Get the Hrana HTTP URL for injecting into plugin child processes.
 * Returns null if the server hasn't been started yet.
 * Only used for KIMAKI_DB_URL env var in opencode.ts — the bot process
 * itself always uses direct file: access via Prisma.
 */
export function getHranaUrl() {
    return hranaUrl;
}
/**
 * Start the in-process Hrana v2 server on the fixed lock port.
 * Handles single-instance enforcement: if the port is occupied, kills the
 * existing process first.
 */
export async function startHranaServer({ dbPath, bindAll = false, }) {
    if (server && db && hranaUrl)
        return hranaUrl;
    const port = getLockPort();
    const bindHost = bindAll ? '0.0.0.0' : '127.0.0.1';
    const serviceAuthToken = ensureServiceAuthTokenInStore();
    process.env.KIMAKI_DB_AUTH_TOKEN = serviceAuthToken;
    fs.mkdirSync(path.dirname(dbPath), { recursive: true });
    await evictExistingInstance({ port });
    hranaLogger.log(`Starting hrana server on ${bindHost}:${port} with db: ${dbPath}`);
    const database = new Database(dbPath);
    database.exec('PRAGMA journal_mode = WAL');
    database.exec('PRAGMA busy_timeout = 5000');
    db = database;
    const hranaHandler = createHranaHandler(database);
    // Combined handler: all control/data routes require the same service auth token.
    const handler = async (req, res) => {
        const pathname = new URL(req.url || '/', 'http://localhost').pathname;
        if (pathname === '/kimaki/wake') {
            if (req.method !== 'POST') {
                res.writeHead(405, { 'content-type': 'application/json' });
                res.end(JSON.stringify({ error: 'method_not_allowed' }));
                return;
            }
            if (!isAuthorizedRequest(req)) {
                res.writeHead(401, { 'content-type': 'application/json' });
                res.end(JSON.stringify({ error: 'unauthorized' }));
                return;
            }
            const isReady = await waitForDiscordGatewayReady({ timeoutMs: 30_000 });
            if (!isReady) {
                res.writeHead(504, { 'content-type': 'application/json' });
                res.end(JSON.stringify({ ready: false, error: 'timeout_waiting_for_discord_ready' }));
                return;
            }
            res.writeHead(200, { 'content-type': 'application/json' });
            res.end(JSON.stringify({ ready: true }));
            return;
        }
        // Hrana routes: /health, /v2, /v2/pipeline
        if (pathname === '/health') {
            hranaHandler(req, res);
            return;
        }
        if (pathname === '/v2' || pathname === '/v2/pipeline') {
            if (!isAuthorizedRequest(req)) {
                res.writeHead(401, { 'content-type': 'application/json' });
                res.end(JSON.stringify({ error: 'unauthorized' }));
                return;
            }
            hranaHandler(req, res);
            return;
        }
        res.writeHead(404);
        res.end();
    };
    const started = await new Promise((resolve) => {
        const srv = http.createServer(handler);
        srv.on('error', (err) => {
            resolve(new ServerStartError({
                port,
                reason: err.code === 'EADDRINUSE'
                    ? `Port ${port} still in use after eviction`
                    : err.message,
            }));
        });
        srv.listen(port, bindHost, () => {
            server = srv;
            resolve(true);
        });
    });
    if (started instanceof Error) {
        database.close();
        db = null;
        return started;
    }
    hranaUrl = `http://127.0.0.1:${port}`;
    hranaLogger.log(`Hrana server ready at ${hranaUrl}`);
    return hranaUrl;
}
/**
 * Stop the Hrana server and close the database.
 */
export async function stopHranaServer() {
    if (server) {
        hranaLogger.log('Stopping hrana server...');
        await new Promise((resolve) => {
            server.close(() => {
                resolve();
            });
        });
        server = null;
    }
    if (db) {
        db.close();
        db = null;
    }
    hranaUrl = null;
    discordGatewayReady = false;
    readyWaiters = [];
    hranaLogger.log('Hrana server stopped');
}
// ── Value encoding/decoding ──────────────────────────────────────────────
function encodeValue(val) {
    if (val === null || val === undefined)
        return { type: 'null' };
    if (typeof val === 'bigint')
        return { type: 'integer', value: val.toString() };
    if (typeof val === 'number') {
        if (Number.isInteger(val))
            return { type: 'integer', value: val.toString() };
        return { type: 'float', value: val };
    }
    if (typeof val === 'string')
        return { type: 'text', value: val };
    if (Buffer.isBuffer(val))
        return { type: 'blob', base64: val.toString('base64') };
    if (val instanceof Uint8Array)
        return { type: 'blob', base64: Buffer.from(val).toString('base64') };
    return { type: 'text', value: String(val) };
}
function decodeValue(val) {
    if (val.type === 'null')
        return null;
    if (val.type === 'integer') {
        const n = Number(val.value);
        return Number.isSafeInteger(n) ? n : BigInt(val.value);
    }
    if (val.type === 'float')
        return val.value;
    if (val.type === 'text')
        return val.value;
    if (val.type === 'blob')
        return Buffer.from(val.base64, 'base64');
    return null;
}
// ── Statement execution ──────────────────────────────────────────────────
// SqliteError from libsql has a `code` property but catch gives Error.
function getSqliteErrorCode(err) {
    return err.code ?? 'SQLITE_ERROR';
}
function resolveStmtSql(stmt, sqlStore) {
    if (stmt.sql != null)
        return stmt.sql;
    if (stmt.sql_id != null)
        return sqlStore.get(stmt.sql_id) ?? '';
    return '';
}
function bindParams(stmt) {
    if (stmt.named_args && stmt.named_args.length > 0) {
        const named = {};
        for (const na of stmt.named_args) {
            named[na.name] = decodeValue(na.value);
        }
        return [named];
    }
    return (stmt.args ?? []).map(decodeValue);
}
function executeStmt(database, stmt, sqlStore) {
    const sql = resolveStmtSql(stmt, sqlStore);
    const prepared = database.prepare(sql);
    const params = bindParams(stmt);
    if (prepared.reader) {
        const cols = prepared.columns();
        const rows = prepared.all(...params);
        return {
            cols: cols.map((c) => ({ name: c.name, decltype: c.type })),
            rows: rows.map((row) => cols.map((c) => encodeValue(row[c.name]))),
            affected_row_count: 0,
            last_insert_rowid: null,
        };
    }
    const result = prepared.run(...params);
    return {
        cols: [],
        rows: [],
        affected_row_count: result.changes,
        last_insert_rowid: result.lastInsertRowid != null ? result.lastInsertRowid.toString() : null,
    };
}
// ── Batch condition evaluation ───────────────────────────────────────────
function evaluateCondition(cond, stepResults, stepErrors) {
    if (!cond)
        return true;
    if (cond.type === 'ok')
        return stepErrors[cond.step] === null && stepResults[cond.step] !== null;
    if (cond.type === 'not')
        return !evaluateCondition(cond.cond, stepResults, stepErrors);
    if (cond.type === 'and')
        return (cond.conds ?? []).every((c) => evaluateCondition(c, stepResults, stepErrors));
    if (cond.type === 'or')
        return (cond.conds ?? []).some((c) => evaluateCondition(c, stepResults, stepErrors));
    return true;
}
// ── Request handlers ─────────────────────────────────────────────────────
function handleExecute(database, req, sqlStore) {
    if (!req.stmt)
        return {
            type: 'error',
            error: { message: 'Missing stmt', code: 'HRANA_PROTO_ERROR' },
        };
    const result = errore.try({
        try: () => executeStmt(database, req.stmt, sqlStore),
        catch: (e) => e,
    });
    if (result instanceof Error) {
        return {
            type: 'error',
            error: { message: result.message, code: getSqliteErrorCode(result) },
        };
    }
    return { type: 'ok', response: { type: 'execute', result } };
}
function handleBatch(database, req, sqlStore) {
    const steps = req.batch?.steps ?? [];
    const stepResults = [];
    const stepErrors = [];
    for (const step of steps) {
        if (!evaluateCondition(step.condition, stepResults, stepErrors)) {
            stepResults.push(null);
            stepErrors.push(null);
            continue;
        }
        const result = errore.try({
            try: () => executeStmt(database, step.stmt, sqlStore),
            catch: (e) => e,
        });
        if (result instanceof Error) {
            stepResults.push(null);
            stepErrors.push({
                message: result.message,
                code: getSqliteErrorCode(result),
            });
        }
        else {
            stepResults.push(result);
            stepErrors.push(null);
        }
    }
    return {
        type: 'ok',
        response: {
            type: 'batch',
            result: { step_results: stepResults, step_errors: stepErrors },
        },
    };
}
function handleSequence(database, req, sqlStore) {
    const sql = req.sql ?? (req.sql_id != null ? sqlStore.get(req.sql_id) : null);
    if (!sql)
        return { type: 'ok', response: { type: 'sequence' } };
    const result = errore.try({
        try: () => {
            database.exec(sql);
        },
        catch: (e) => e,
    });
    if (result instanceof Error) {
        return {
            type: 'error',
            error: { message: result.message, code: getSqliteErrorCode(result) },
        };
    }
    return { type: 'ok', response: { type: 'sequence' } };
}
function processRequest(database, req, sqlStore) {
    if (req.type === 'execute')
        return handleExecute(database, req, sqlStore);
    if (req.type === 'batch')
        return handleBatch(database, req, sqlStore);
    if (req.type === 'sequence')
        return handleSequence(database, req, sqlStore);
    if (req.type === 'close')
        return { type: 'ok', response: { type: 'close' } };
    if (req.type === 'store_sql') {
        if (req.sql_id != null && req.sql != null)
            sqlStore.set(req.sql_id, req.sql);
        return { type: 'ok', response: { type: 'store_sql' } };
    }
    if (req.type === 'close_sql') {
        if (req.sql_id != null)
            sqlStore.delete(req.sql_id);
        return { type: 'ok', response: { type: 'close_sql' } };
    }
    return {
        type: 'error',
        error: {
            message: `Unknown request type: ${req.type}`,
            code: 'HRANA_PROTO_ERROR',
        },
    };
}
// ── HTTP handler ─────────────────────────────────────────────────────────
// @libsql/client HTTP driver uses batons to keep streams alive across
// pipeline requests (needed for interactive transactions). Each stream has
// its own SQL store for store_sql/close_sql scoping.
let batonCounter = 0;
const streamStores = new Map();
export function createHranaHandler(database) {
    return (req, res) => {
        const requestUrl = new URL(req.url || '/', 'http://127.0.0.1');
        if (req.method === 'GET' && requestUrl.pathname === '/health') {
            res.writeHead(200, { 'content-type': 'application/json' });
            res.end(JSON.stringify({ status: 'ok', pid: process.pid }));
            return;
        }
        if (req.method === 'GET' && requestUrl.pathname === '/v2') {
            res.writeHead(200, { 'content-type': 'application/json' });
            res.end('{"version":"hrana-v2"}');
            return;
        }
        if (req.method === 'POST' && requestUrl.pathname === '/v2/pipeline') {
            const chunks = [];
            let aborted = false;
            req.on('error', () => {
                aborted = true;
                res.destroy();
            });
            req.on('data', (chunk) => {
                chunks.push(chunk);
            });
            req.on('end', () => {
                if (aborted)
                    return;
                const parseResult = errore.try({
                    try: () => JSON.parse(Buffer.concat(chunks).toString()),
                    catch: (e) => e,
                });
                if (parseResult instanceof Error) {
                    res.writeHead(400, { 'content-type': 'application/json' });
                    res.end(JSON.stringify({
                        error: {
                            message: parseResult.message,
                            code: 'HRANA_PROTO_ERROR',
                        },
                    }));
                    return;
                }
                // Resolve or create per-stream SQL store keyed by baton
                const incoming = parseResult.baton;
                const sqlStore = (incoming ? streamStores.get(incoming) : undefined) ??
                    new Map();
                if (incoming)
                    streamStores.delete(incoming);
                const results = (parseResult.requests ?? []).map((r) => processRequest(database, r, sqlStore));
                const hasClose = (parseResult.requests ?? []).some((r) => r.type === 'close');
                const baton = hasClose ? null : `b${++batonCounter}`;
                if (baton)
                    streamStores.set(baton, sqlStore);
                res.writeHead(200, { 'content-type': 'application/json' });
                res.end(JSON.stringify({ baton, base_url: null, results }));
            });
            return;
        }
        res.writeHead(404);
        res.end();
    };
}
// ── Single-instance enforcement ──────────────────────────────────────────
/**
 * Evict a previous kimaki instance on the lock port.
 * Fetches /health to get the running process PID, then kills it directly.
 * No lsof/netstat/spawnSync needed — the PID comes from the health response.
 */
export async function evictExistingInstance({ port }) {
    const url = `http://127.0.0.1:${port}/health`;
    const probe = await fetch(url, { signal: AbortSignal.timeout(1000) }).catch((e) => new FetchError({ url, cause: e }));
    if (probe instanceof Error)
        return;
    const body = await probe.json().catch((e) => new FetchError({ url, cause: e }));
    if (body instanceof Error)
        return;
    const targetPid = body.pid;
    if (!targetPid || targetPid === process.pid)
        return;
    hranaLogger.log(`Evicting existing kimaki process (PID: ${targetPid}) on port ${port}`);
    const killResult = errore.try({
        try: () => {
            process.kill(targetPid, 'SIGTERM');
        },
        catch: (e) => e,
    });
    if (killResult instanceof Error) {
        hranaLogger.log(`Failed to kill PID ${targetPid}: ${killResult.message}`);
        return;
    }
    await new Promise((resolve) => {
        setTimeout(resolve, 1000);
    });
    // Verify it's gone — if still alive, escalate to SIGKILL
    const secondProbe = await fetch(url, {
        signal: AbortSignal.timeout(500),
    }).catch((e) => new FetchError({ url, cause: e }));
    if (secondProbe instanceof Error)
        return;
    hranaLogger.log(`PID ${targetPid} still alive after SIGTERM, sending SIGKILL`);
    errore.try({
        try: () => {
            process.kill(targetPid, 'SIGKILL');
        },
        catch: (e) => e,
    });
    await new Promise((resolve) => {
        setTimeout(resolve, 1000);
    });
}
