/**
 * SQLite-backed project/session/message storage for `spectral serve`.
 *
 * Two-tier model:
 *   projects ─< sessions ─< messages
 *
 * Single-process, single-file. Using `better-sqlite3` because:
 *  - Synchronous API is a perfect fit for the per-WS-event write pattern.
 *  - No connection pool, no callback churn, transactions are trivial.
 *  - Native module ships prebuilt binaries for every Node version we support.
 *
 * Schema migrations:
 *   We track schema with the `user_version` PRAGMA. When opening a DB whose
 *   version does not match `SCHEMA_VERSION`, we DROP every known table and
 *   recreate. This is safe pre-1.0 because the agent UI is local-only and
 *   the data is conversation history we explicitly opted not to migrate
 *   for the project-tier rollout. Anything newer (real users with real
 *   data) needs a proper migration table.
 *
 * Foreign keys are enabled so DELETE FROM projects cascades to sessions
 * cascades to messages. The route layer must still tear down any in-flight
 * agent streams BEFORE deleting — see `SessionStreamManager.disposeProjectStreams`.
 *
 * The repo is intentionally a thin wrapper. We do NOT expose `Database`
 * itself — callers go through the typed methods so we can swap the backend
 * later (e.g. if we ever need to colocate with a remote service).
 */
import { createSqliteAdapter } from "./sqlite-adapter.js";
import { randomUUID } from "node:crypto";
import { mkdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { getConfigDir } from "../config.js";
import { stripJsoncComments } from "../studio-binding.js";
/**
 * Schema version — informational, stamped into every new database.
 *
 * ALL schema changes are additive (ALTER TABLE ADD COLUMN).  The version is
 * never used to trigger destructive drops; it is purely for troubleshooting.
 */
const SCHEMA_VERSION = 4;
const SCHEMA_SQL = `
PRAGMA foreign_keys = ON;

CREATE TABLE IF NOT EXISTS projects (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  path TEXT NOT NULL,
  created_at INTEGER NOT NULL,
  updated_at INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS sessions (
  id TEXT PRIMARY KEY,
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  title TEXT NOT NULL,
  created_at INTEGER NOT NULL,
  updated_at INTEGER NOT NULL,
  model_id TEXT,
  reasoning_effort TEXT
);

CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id, updated_at DESC);

CREATE TABLE IF NOT EXISTS messages (
  id TEXT PRIMARY KEY,
  session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
  role TEXT NOT NULL CHECK (role IN ('user','assistant','system')),
  content TEXT NOT NULL,
  events_jsonl TEXT NOT NULL DEFAULT '',
  images_json TEXT NOT NULL DEFAULT '',
  created_at INTEGER NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);

CREATE TABLE IF NOT EXISTS session_memory_snapshots (
  session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
  summary TEXT NOT NULL,
  details_json TEXT NOT NULL,
  covered_source_count INTEGER NOT NULL,
  updated_at INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS project_observations (
  id TEXT PRIMARY KEY,
  project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  session_id TEXT NOT NULL,
  content TEXT NOT NULL,
  relevance TEXT NOT NULL CHECK (relevance IN ('low','medium','high','critical')),
  created_at INTEGER NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_project_obs_project ON project_observations(project_id, created_at DESC);

CREATE TABLE IF NOT EXISTS prompt_queue (
  id TEXT PRIMARY KEY,
  session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
  content TEXT NOT NULL,
  position INTEGER NOT NULL,
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  UNIQUE(session_id, position)
);

CREATE INDEX IF NOT EXISTS idx_prompt_queue_session ON prompt_queue(session_id, position);
`;
/**
 * Synchronous binding-file reader for a project at the given filesystem path.
 * Returns null when no `.aexol/aexol.jsonc` exists at that path.
 */
function readBindingSync(projectPath) {
    try {
        const raw = readFileSync(join(projectPath, ".aexol", "aexol.jsonc"), "utf8");
        return JSON.parse(stripJsoncComments(raw));
    }
    catch (err) {
        const code = err.code;
        if (code === "ENOENT" || code === "ENOTDIR")
            return null;
        // Rethrow on unexpected I/O errors (permissions, etc.).
        throw err;
    }
}
/** Augment a WireProject row with Studio binding fields when present. */
function applyBindingFields(project) {
    const binding = readBindingSync(project.path);
    if (!binding)
        return project;
    return {
        ...project,
        studioProjectId: binding.projectId,
        studioProjectName: binding.name || binding.projectId,
    };
}
/** Tables we own — used by the migration drop step. */
/** Best-effort parse of the `images_json` column into `ImageAttachment[]`. */
function parseImagesJson(raw) {
    if (!raw || raw === "")
        return undefined;
    try {
        const parsed = JSON.parse(raw);
        if (!Array.isArray(parsed))
            return undefined;
        const out = [];
        for (const i of parsed) {
            if (i !== null &&
                typeof i === "object" &&
                typeof i.data === "string" &&
                typeof i.mimeType === "string") {
                out.push({
                    data: i.data,
                    mimeType: i.mimeType,
                    width: typeof i.width === "number" ? i.width : undefined,
                    height: typeof i.height === "number" ? i.height : undefined,
                });
            }
        }
        return out.length > 0 ? out : undefined;
    }
    catch {
        return undefined;
    }
}
export class SessionStore {
    path;
    db;
    closed = false;
    // Project statements
    stmtListProjects;
    stmtGetProject;
    stmtCreateProject;
    stmtUpdateProject;
    stmtDeleteProject;
    stmtListSessionsByProject;
    stmtListSessionIdsByProject;
    // Session statements
    stmtGetSession;
    stmtCreateSession;
    stmtDeleteSession;
    stmtListMessages;
    stmtAppendMessage;
    stmtDeleteMessage;
    stmtDeleteMessagesBySession;
    stmtTouchSession;
    stmtRenameSession;
    stmtSessionWithCount;
    stmtGetSessionMemorySnapshot;
    stmtUpsertSessionMemorySnapshot;
    stmtDeleteSessionMemorySnapshot;
    // Phase 3: per-session sticky model.
    stmtGetSessionModel;
    stmtSetSessionModel;
    stmtGetSessionReasoningEffort;
    stmtSetSessionReasoningEffort;
    // Fork & Compact: flag + per-session source tracking.
    stmtSetForkCompactSource;
    stmtGetForkCompactSource;
    // Project observations: cross-session durable memory.
    stmtInsertProjectObs;
    stmtSearchProjectObs;
    stmtGetProjectByCwd;
    // Prompt queue statements
    stmtEnqueuePrompt;
    stmtGetPromptQueue;
    stmtDequeuePrompt;
    stmtDeleteQueueItem;
    stmtClearSessionQueue;
    stmtShiftPositions;
    constructor(path) {
        this.path = path;
        // Make sure the parent directory exists. mkdirSync with recursive is a
        // no-op when the dir already exists.
        mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
        this.db = createSqliteAdapter(path);
        this.db.pragma("journal_mode = WAL");
        this.db.pragma("foreign_keys = ON");
        // ---- schema creation -------------------------------------------------
        // SCHEMA_SQL uses CREATE TABLE IF NOT EXISTS for every table, so it is
        // always safe to run — on a fresh database all tables are created;
        // on an existing one every statement is a no-op.  Schema changes that
        // add columns are handled by the additive migration block below.
        //
        // We NO LONGER drop tables when the stored user_version differs from
        // SCHEMA_VERSION.  The previous destructive behaviour caused permanent
        // data loss on version bumps.  SCHEMA_VERSION is now informational — it
        // is stamped into every new database purely for troubleshooting.
        this.db.exec(SCHEMA_SQL);
        this.db.pragma(`user_version = ${SCHEMA_VERSION}`);
        // ---- additive column migration ---------------------------------------
        // Phase 3 (Available Models): per-session sticky model selection lives in
        // a new `sessions.model_id` column. We do this as an idempotent ALTER
        // (not a SCHEMA_VERSION bump + drop) because the column is purely
        // additive — older rows have NULL model_id and fall back to spectral's own
        // settings file, which is the pre-Phase-3 behaviour. Bumping
        // SCHEMA_VERSION would also nuke conversation history we now want to
        // preserve. The check is cheap (one PRAGMA per startup) and safe to run
        // every time.
        const sessionCols = this.db
            .prepare(`PRAGMA table_info(sessions)`)
            .all();
        if (!sessionCols.some((c) => c.name === "model_id")) {
            this.db.exec(`ALTER TABLE sessions ADD COLUMN model_id TEXT`);
        }
        if (!sessionCols.some((c) => c.name === "reasoning_effort")) {
            this.db.exec(`ALTER TABLE sessions ADD COLUMN reasoning_effort TEXT`);
        }
        // Additive migration: images_json column for base64 image attachments on
        // user messages. Older rows default to '' (no images).
        const msgCols = this.db
            .prepare(`PRAGMA table_info(messages)`)
            .all();
        if (!msgCols.some((c) => c.name === "images_json")) {
            this.db.exec(`ALTER TABLE messages ADD COLUMN images_json TEXT NOT NULL DEFAULT ''`);
        }
        // Fork & Compact: fork_compact_source_id tracks which session a fork was
        // created from. Set during forkSession(), cleared after first compaction.
        // NULL means no fork-compact pending (normal session).
        if (!sessionCols.some((c) => c.name === "fork_compact_source_id")) {
            this.db.exec(`ALTER TABLE sessions ADD COLUMN fork_compact_source_id TEXT`);
        }
        // ---- one-time cleanup -------------------------------------------------
        // Historical garbage from prior runs of the bridge that persisted every
        // intermediate `message_end` spectral emitted, including pure-framing rows
        // with no visible content. Those rows were silently dropped by the
        // client's hydration path, but they polluted message counts and were
        // surfaced by `SELECT * FROM messages`. The bridge no longer emits
        // these going forward — this DELETE catches the backlog. Conservative
        // pattern: only rows with empty content, tiny JSONL, AND none of the
        // four meaningful wire-event substrings. Idempotent and cheap; safe to
        // run on every startup.
        try {
            const cleanup = this.db
                .prepare(`DELETE FROM messages
           WHERE role = 'assistant'
             AND length(content) = 0
             AND length(events_jsonl) < 200
             AND events_jsonl NOT LIKE '%text_delta%'
             AND events_jsonl NOT LIKE '%thinking_delta%'
             AND events_jsonl NOT LIKE '%tool_call%'
             AND events_jsonl NOT LIKE '%tool_result%'`)
                .run();
            if (cleanup.changes > 0) {
                console.log(`[storage] Cleaned ${cleanup.changes} empty assistant message(s) from prior runs`);
            }
        }
        catch (err) {
            // Defensive: cleanup must never block startup. A failure here just
            // leaves the rows in place — they were already harmless to the
            // client (they hydrate to no segments and are skipped).
            console.warn(`[storage] cleanup of empty assistant messages failed: ${err instanceof Error ? err.message : String(err)}`);
        }
        // ---- project statements ----------------------------------------------
        this.stmtListProjects = this.db.prepare(`
      SELECT p.id, p.name, p.path, p.created_at, p.updated_at,
             (SELECT COUNT(*) FROM sessions s WHERE s.project_id = p.id) AS session_count
      FROM projects p
      ORDER BY p.updated_at DESC
    `);
        this.stmtGetProject = this.db.prepare(`SELECT id, name, path, created_at, updated_at FROM projects WHERE id = ?`);
        this.stmtCreateProject = this.db.prepare(`INSERT INTO projects (id, name, path, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`);
        this.stmtUpdateProject = this.db.prepare(`UPDATE projects SET name = ?, path = ?, updated_at = ? WHERE id = ?`);
        this.stmtDeleteProject = this.db.prepare(`DELETE FROM projects WHERE id = ?`);
        this.stmtListSessionsByProject = this.db.prepare(`
      SELECT s.id, s.project_id, s.title, s.created_at, s.updated_at,
             (SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id) AS message_count
      FROM sessions s
      WHERE s.project_id = ?
      ORDER BY s.updated_at DESC
    `);
        this.stmtListSessionIdsByProject = this.db.prepare(`SELECT id FROM sessions WHERE project_id = ?`);
        // ---- session statements ----------------------------------------------
        this.stmtGetSession = this.db.prepare(`SELECT id, project_id, title, created_at, updated_at FROM sessions WHERE id = ?`);
        this.stmtCreateSession = this.db.prepare(`INSERT INTO sessions (id, project_id, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`);
        this.stmtDeleteSession = this.db.prepare(`DELETE FROM sessions WHERE id = ?`);
        this.stmtListMessages = this.db.prepare(`SELECT id, session_id, role, content, events_jsonl, images_json, created_at
       FROM messages WHERE session_id = ? ORDER BY created_at ASC, id ASC`);
        this.stmtAppendMessage = this.db.prepare(`INSERT OR REPLACE INTO messages (id, session_id, role, content, events_jsonl, images_json, created_at)
       VALUES (?, ?, ?, ?, ?, ?, ?)`);
        this.stmtDeleteMessage = this.db.prepare(`DELETE FROM messages WHERE id = ?`);
        this.stmtDeleteMessagesBySession = this.db.prepare(`DELETE FROM messages WHERE session_id = ?`);
        this.stmtTouchSession = this.db.prepare(`UPDATE sessions SET updated_at = ? WHERE id = ?`);
        this.stmtRenameSession = this.db.prepare(`UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?`);
        this.stmtSessionWithCount = this.db.prepare(`
      SELECT s.id, s.project_id, s.title, s.created_at, s.updated_at,
             (SELECT COUNT(*) FROM messages m WHERE m.session_id = s.id) AS message_count
      FROM sessions s
      WHERE s.id = ?
    `);
        this.stmtGetSessionMemorySnapshot = this.db.prepare(`SELECT session_id, summary, details_json, covered_source_count, updated_at
       FROM session_memory_snapshots WHERE session_id = ?`);
        this.stmtUpsertSessionMemorySnapshot = this.db.prepare(`INSERT OR REPLACE INTO session_memory_snapshots
       (session_id, summary, details_json, covered_source_count, updated_at)
       VALUES (?, ?, ?, ?, ?)`);
        this.stmtDeleteSessionMemorySnapshot = this.db.prepare(`DELETE FROM session_memory_snapshots WHERE session_id = ?`);
        this.stmtGetSessionModel = this.db.prepare(`SELECT model_id FROM sessions WHERE id = ?`);
        this.stmtSetSessionModel = this.db.prepare(`UPDATE sessions SET model_id = ? WHERE id = ?`);
        this.stmtGetSessionReasoningEffort = this.db.prepare(`SELECT reasoning_effort FROM sessions WHERE id = ?`);
        this.stmtSetSessionReasoningEffort = this.db.prepare(`UPDATE sessions SET reasoning_effort = ? WHERE id = ?`);
        this.stmtSetForkCompactSource = this.db.prepare(`UPDATE sessions SET fork_compact_source_id = ? WHERE id = ?`);
        this.stmtGetForkCompactSource = this.db.prepare(`SELECT fork_compact_source_id FROM sessions WHERE id = ?`);
        this.stmtInsertProjectObs = this.db.prepare(`INSERT OR REPLACE INTO project_observations (id, project_id, session_id, content, relevance, created_at)
       VALUES (?, ?, ?, ?, ?, ?)`);
        this.stmtSearchProjectObs = this.db.prepare(`SELECT id, project_id, session_id, content, relevance, created_at
       FROM project_observations
       WHERE project_id = ? AND content LIKE ?
       ORDER BY created_at DESC
       LIMIT 20`);
        this.stmtGetProjectByCwd = this.db.prepare(`SELECT id FROM projects WHERE path = ? LIMIT 1`);
        // ---- prompt queue statements ----------------------------------------
        this.stmtEnqueuePrompt = this.db.prepare(`INSERT INTO prompt_queue (id, session_id, content, position, created_at)
       VALUES (?, ?, ?, ?, ?)`);
        this.stmtGetPromptQueue = this.db.prepare(`SELECT id, session_id, content, position, created_at
       FROM prompt_queue WHERE session_id = ?
       ORDER BY position ASC`);
        this.stmtDequeuePrompt = this.db.prepare(`SELECT id, session_id, content, position, created_at
       FROM prompt_queue WHERE session_id = ?
       ORDER BY position ASC LIMIT 1`);
        this.stmtDeleteQueueItem = this.db.prepare(`DELETE FROM prompt_queue WHERE id = ? AND session_id = ?`);
        this.stmtClearSessionQueue = this.db.prepare(`DELETE FROM prompt_queue WHERE session_id = ?`);
        this.stmtShiftPositions = this.db.prepare(`UPDATE prompt_queue SET position = position + ?
       WHERE session_id = ? AND position >= ?`);
    }
    /** Smoke check: returns the names of the tables in the DB. */
    listTables() {
        const rows = this.db
            .prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`)
            .all();
        return rows.map((r) => r.name);
    }
    // ----------------------------------------------------------------------
    // Projects
    // ----------------------------------------------------------------------
    listProjects() {
        return this.stmtListProjects.all().map((r) => {
            const project = {
                id: r.id,
                name: r.name,
                path: r.path,
                createdAt: r.created_at,
                updatedAt: r.updated_at,
                sessionCount: r.session_count,
            };
            return applyBindingFields(project);
        });
    }
    createProject(input) {
        const id = input.id ?? randomUUID();
        const name = input.name.trim() || "Untitled project";
        const path = input.path;
        const now = Date.now();
        this.stmtCreateProject.run(id, name, path, now, now);
        return {
            id,
            name,
            path,
            createdAt: now,
            updatedAt: now,
            sessionCount: 0,
        };
    }
    /** Returns null if not found. */
    getProject(id) {
        const row = this.stmtGetProject.get(id);
        if (!row)
            return null;
        // Re-fetch with count to keep the wire shape consistent. Cheap.
        const withCount = this.stmtListProjects.all().find((r) => r.id === id);
        const project = {
            id: row.id,
            name: row.name,
            path: row.path,
            createdAt: row.created_at,
            updatedAt: row.updated_at,
            sessionCount: withCount?.session_count ?? 0,
        };
        return applyBindingFields(project);
    }
    /**
     * Update a project's name and/or path. At least one must be provided.
     * Returns the updated project, or null if not found. Empty `name` falls
     * back to "Untitled project" for parity with createProject.
     */
    updateProject(id, input) {
        const existing = this.stmtGetProject.get(id);
        if (!existing)
            return null;
        const nextName = input.name !== undefined
            ? (input.name.trim() || "Untitled project")
            : existing.name;
        const nextPath = input.path !== undefined ? input.path : existing.path;
        const now = Date.now();
        this.stmtUpdateProject.run(nextName, nextPath, now, id);
        return this.getProject(id);
    }
    /**
     * Delete a project. Cascades to sessions and messages via FK.
     * Caller MUST tear down any active SessionStream subscribers / spectral
     * processes for sessions in this project BEFORE invoking this.
     * Returns the list of session ids that belonged to the project (so
     * the caller can include them in the stream-teardown call).
     */
    deleteProject(id) {
        const sessionIds = this.stmtListSessionIdsByProject.all(id).map((r) => r.id);
        const info = this.stmtDeleteProject.run(id);
        return { deleted: info.changes > 0, sessionIds };
    }
    /** Sessions belonging to a single project, newest-first. */
    listSessionsByProject(projectId) {
        return this.stmtListSessionsByProject.all(projectId).map((r) => ({
            id: r.id,
            projectId: r.project_id,
            title: r.title,
            createdAt: r.created_at,
            updatedAt: r.updated_at,
            messageCount: r.message_count,
        }));
    }
    // ----------------------------------------------------------------------
    // Sessions
    // ----------------------------------------------------------------------
    createSession(input) {
        if (!input.projectId) {
            throw new Error("createSession requires projectId");
        }
        // Verify the project exists so we get a clean error rather than a raw
        // FK violation at INSERT time.
        const project = this.stmtGetProject.get(input.projectId);
        if (!project)
            throw new Error(`Unknown projectId: ${input.projectId}`);
        const id = input.id ?? randomUUID();
        const title = input.title?.trim() || "New conversation";
        const now = Date.now();
        this.stmtCreateSession.run(id, input.projectId, title, now, now);
        // Touch the project so it floats to the top of the project list.
        this.stmtUpdateProject.run(project.name, project.path, now, project.id);
        return {
            id,
            projectId: input.projectId,
            title,
            createdAt: now,
            updatedAt: now,
            messageCount: 0,
        };
    }
    /** Returns null if not found. */
    getSession(id) {
        const row = this.stmtGetSession.get(id);
        if (!row)
            return null;
        const messages = this.getMessages(id);
        const detail = {
            id: row.id,
            projectId: row.project_id,
            title: row.title,
            createdAt: row.created_at,
            updatedAt: row.updated_at,
            messages,
        };
        // Expose the fork-compact flag so the UI can show a special send
        // button for the first message of a forked session.
        if (this.getForkCompactSource(id)) {
            detail.forkCompactPending = true;
        }
        return detail;
    }
    /** Convenience for routes/managers that just need the projectId. */
    getSessionProjectId(id) {
        const row = this.stmtGetSession.get(id);
        return row ? row.project_id : null;
    }
    getMessages(sessionId) {
        return this.stmtListMessages.all(sessionId).map((r) => {
            const images = parseImagesJson(r.images_json);
            return {
                id: r.id,
                role: r.role,
                content: r.content,
                events: r.events_jsonl,
                createdAt: r.created_at,
                ...(images ? { images } : {}),
            };
        });
    }
    /**
     * Append a message and bump the session's updated_at in one transaction.
     * Throws if the session doesn't exist (FK violation).
     */
    /** Delete a message by id. No-op if the message doesn't exist. */
    deleteMessage(id) {
        try {
            this.stmtDeleteMessage.run(id);
        }
        catch (err) {
            console.error(`[spectral] error: deleteMessage failed for ${id}: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    /** Delete all persisted messages for a session. */
    deleteMessagesBySession(sessionId) {
        try {
            this.stmtDeleteMessagesBySession.run(sessionId);
        }
        catch (err) {
            console.error(`[spectral] error: deleteMessagesBySession failed for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
        }
    }
    appendMessage(sessionId, msg) {
        const id = msg.id ?? randomUUID();
        const createdAt = msg.createdAt ?? Date.now();
        const eventsJsonl = msg.eventsJsonl ?? "";
        const imagesJson = msg.images && msg.images.length > 0 ? JSON.stringify(msg.images) : "";
        const tx = this.db.transaction(() => {
            this.stmtAppendMessage.run(id, sessionId, msg.role, msg.content, eventsJsonl, imagesJson, createdAt);
            this.stmtTouchSession.run(createdAt, sessionId);
        });
        tx();
        return {
            id,
            role: msg.role,
            content: msg.content,
            events: eventsJsonl,
            createdAt,
            ...(msg.images && msg.images.length > 0 ? { images: msg.images } : {}),
        };
    }
    /**
     * Rename a session. Bumps `updated_at`. Returns the updated summary, or
     * null if the session doesn't exist. Title is trimmed; an empty title falls
     * back to "New conversation" for parity with createSession().
     */
    renameSession(id, title) {
        const trimmed = title.trim() || "New conversation";
        const now = Date.now();
        const info = this.stmtRenameSession.run(trimmed, now, id);
        if (info.changes === 0)
            return null;
        const row = this.stmtSessionWithCount.get(id);
        if (!row)
            return null;
        return {
            id: row.id,
            projectId: row.project_id,
            title: row.title,
            createdAt: row.created_at,
            updatedAt: row.updated_at,
            messageCount: row.message_count,
        };
    }
    /** Returns true if a row was deleted. Cascades to messages. */
    deleteSession(id) {
        const info = this.stmtDeleteSession.run(id);
        return info.changes > 0;
    }
    // ----------------------------------------------------------------------
    // Observational memory snapshot persistence
    // ----------------------------------------------------------------------
    getSessionMemorySnapshot(sessionId) {
        const row = this.stmtGetSessionMemorySnapshot.get(sessionId);
        if (!row)
            return null;
        try {
            return {
                sessionId: row.session_id,
                summary: row.summary,
                details: JSON.parse(row.details_json),
                coveredSourceCount: row.covered_source_count,
                updatedAt: row.updated_at,
            };
        }
        catch (err) {
            console.warn(`[storage] invalid memory snapshot for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
            return null;
        }
    }
    upsertSessionMemorySnapshot(sessionId, snapshot) {
        const now = Date.now();
        this.stmtUpsertSessionMemorySnapshot.run(sessionId, snapshot.summary, JSON.stringify(snapshot.details), snapshot.coveredSourceCount, now);
    }
    deleteSessionMemorySnapshot(sessionId) {
        this.stmtDeleteSessionMemorySnapshot.run(sessionId);
    }
    // ----------------------------------------------------------------------
    // Per-session sticky model (Phase 3 — Available Models whitelist)
    // ----------------------------------------------------------------------
    /**
     * Get the persisted modelId for a session, or null if none was ever set or
     * the session does not exist. The CLI uses this for cross-restart recovery:
     * when an envelope arrives WITHOUT a `modelId` but SQLite has a value
     * persisted from an earlier turn, we apply the persisted value before
     * forwarding to spectral. When neither envelope nor SQLite have a value, we
     * leave model selection to spectral's own settings file (pre-Phase-3 behaviour).
     */
    getSessionModel(sessionId) {
        const row = this.stmtGetSessionModel.get(sessionId);
        return row?.model_id ?? null;
    }
    /**
     * Persist the modelId for a session. Pass `null` to clear. Does NOT bump
     * `updated_at` — model selection is metadata, not user activity, so it
     * shouldn't promote a session to the top of the sidebar. Silently no-ops
     * when the session does not exist (the caller has already validated the
     * sessionId via attach/getSession in the normal flow).
     */
    setSessionModel(sessionId, modelId) {
        this.stmtSetSessionModel.run(modelId, sessionId);
    }
    /**
     * Get the persisted reasoningEffort for a session, or null if none
     * was ever set. Returns null for unknown sessions (consistent with
     * getSessionModel's contract).
     */
    getSessionReasoningEffort(sessionId) {
        const row = this.stmtGetSessionReasoningEffort.get(sessionId);
        return row?.reasoning_effort ?? null;
    }
    /**
     * Persist the reasoningEffort for a session. Pass `null` to clear.
     * Same semantics as setSessionModel — best-effort metadata, does
     * not bump `updated_at`.
     */
    setSessionReasoningEffort(sessionId, effort) {
        this.stmtSetSessionReasoningEffort.run(effort, sessionId);
    }
    // ----------------------------------------------------------------------
    // Fork & Compact
    // ----------------------------------------------------------------------
    /**
     * Fork a session: create a new session in the same project, copy all
     * messages from the source, and set the `fork_compact_source_id` flag
     * so SessionStreamManager compacts the context after the first assistant
     * turn completes.
     */
    forkSession(sourceId, opts) {
        const source = this.stmtGetSession.get(sourceId);
        if (!source)
            throw new Error(`Unknown sessionId: ${sourceId}`);
        // Capture project details before the transaction (needed for the touch).
        const project = this.stmtGetProject.get(source.project_id);
        if (!project)
            throw new Error(`Unknown projectId: ${source.project_id}`);
        const newId = opts?.newSessionId ?? randomUUID();
        const title = opts?.title?.trim() || `${source.title} (fork)`;
        const now = Date.now();
        let messageCount = 0;
        const tx = this.db.transaction(() => {
            // Create the new session row.
            this.stmtCreateSession.run(newId, source.project_id, title, now, now);
            this.stmtSetSessionModel.run(null, newId);
            // Copy every message, preserving created_at ordering.
            const messages = this.stmtListMessages.all(sourceId);
            messageCount = messages.length;
            for (const m of messages) {
                this.stmtAppendMessage.run(randomUUID(), newId, m.role, m.content, m.events_jsonl, m.images_json, m.created_at);
            }
            // Set the fork-compact flag — SessionStreamManager reads this to
            // trigger compaction after the first assistant turn.
            this.stmtSetForkCompactSource.run(sourceId, newId);
            // Touch the project so the new session appears at the top.
            this.stmtUpdateProject.run(project.name, project.path, now, project.id);
        });
        tx();
        return {
            id: newId,
            projectId: source.project_id,
            title,
            createdAt: now,
            updatedAt: now,
            messageCount,
        };
    }
    /**
     * Read the fork-compact source id for a session, or null if the session
     * was not forked or has already been compacted.
     */
    getForkCompactSource(sessionId) {
        const row = this.stmtGetForkCompactSource.get(sessionId);
        return row?.fork_compact_source_id ?? null;
    }
    /**
     * Clear the fork-compact flag after compaction completes (or fails).
     * Idempotent-safe.
     */
    clearForkCompactSource(sessionId) {
        this.stmtSetForkCompactSource.run(null, sessionId);
    }
    // ----------------------------------------------------------------------
    // Project observations (cross-session durable memory)
    // ----------------------------------------------------------------------
    /**
     * Insert multiple project observations in a single transaction.
     * Uses INSERT OR REPLACE so re-running after the same compaction is idempotent.
     */
    insertProjectObservations(projectId, sessionId, observations, createdAt) {
        if (observations.length === 0)
            return;
        const tx = this.db.transaction(() => {
            for (const obs of observations) {
                this.stmtInsertProjectObs.run(obs.id, projectId, sessionId, obs.content, obs.relevance, createdAt);
            }
        });
        tx();
    }
    /**
     * Search project observations by substring match. Returns up to 20 most recent.
     */
    searchProjectObservations(projectId, query, limit = 20) {
        const pattern = `%${query}%`;
        const rows = this.stmtSearchProjectObs.all(projectId, pattern);
        return rows.slice(0, limit).map((r) => ({
            content: r.content,
            relevance: r.relevance,
            createdAt: r.created_at,
            sessionId: r.session_id,
        }));
    }
    /**
     * Look up a project by its absolute filesystem path.
     * Returns null when no project has been registered for the given path.
     */
    getProjectByCwd(cwd) {
        const row = this.stmtGetProjectByCwd.get(cwd);
        return row?.id ?? null;
    }
    // ----------------------------------------------------------------------
    // Prompt Queue
    // ----------------------------------------------------------------------
    /**
     * Enqueue a prompt for a session. Returns the created queue item.
     * Position is auto-assigned as (max existing position + 1).
     */
    enqueuePrompt(sessionId, content) {
        const id = randomUUID();
        const items = this.stmtGetPromptQueue.all(sessionId);
        const nextPosition = items.length > 0
            ? Math.max(...items.map((r) => r.position)) + 1
            : 0;
        const createdAt = new Date().toISOString();
        this.stmtEnqueuePrompt.run(id, sessionId, content, nextPosition, createdAt);
        return { id, session_id: sessionId, content, position: nextPosition, created_at: createdAt };
    }
    /**
     * Dequeue (remove and return) the first prompt for a session.
     * Returns null if the queue is empty.
     * Renumbers remaining items so positions stay contiguous from 0.
     */
    dequeuePrompt(sessionId) {
        const row = this.stmtDequeuePrompt.get(sessionId);
        if (!row)
            return null;
        const tx = this.db.transaction(() => {
            this.stmtDeleteQueueItem.run(row.id, sessionId);
            // Shift remaining positions down by 1
            this.stmtShiftPositions.run(sessionId, -1, row.position);
        });
        tx();
        return row;
    }
    /**
     * Get the full prompt queue for a session, ordered by position.
     */
    getPromptQueue(sessionId) {
        return this.stmtGetPromptQueue.all(sessionId);
    }
    /**
     * Remove a specific prompt from the queue by id.
     * Renumbers remaining items so positions stay contiguous.
     */
    removePrompt(sessionId, itemId) {
        const row = this.stmtGetPromptQueue.all(sessionId).find((r) => r.id === itemId);
        if (!row)
            return;
        const tx = this.db.transaction(() => {
            this.stmtDeleteQueueItem.run(itemId, sessionId);
            this.stmtShiftPositions.run(sessionId, -1, row.position);
        });
        tx();
    }
    /**
     * Clear the entire prompt queue for a session.
     */
    clearPromptQueue(sessionId) {
        this.stmtClearSessionQueue.run(sessionId);
    }
    close() {
        if (this.closed)
            return;
        this.closed = true;
        this.db.close();
    }
}
export function preflightSqlite(dbPath) {
    try {
        const store = new SessionStore(dbPath);
        // Touch the schema check to make sure the DB is readable.
        store.listTables();
        store.close();
        return { ok: true };
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        return {
            ok: false,
            error: `Failed to load native sqlite module (${msg}).\n` +
                `  This usually means the native binary couldn't be built for your Node.js version.\n` +
                `  Try: cd ${getConfigDir()} && npm rebuild better-sqlite3\n` +
                `  Or reinstall: npm install -g @aexol/spectral`,
        };
    }
}
