import Database from 'better-sqlite3';
import { join } from 'node:path';
import { existsSync, mkdirSync, chmodSync } from 'node:fs';
/**
 * Server-side persistence for the agent rail's chat sessions (the conversation
 * history users see in the launcher pills / Chats view). The server is the SOLE
 * store — the web client keeps no localStorage copy.
 *
 * Storage is two tables (mirrors the CommentService layout: a dedicated
 * `chat-sessions.db` SQLite file under `.studiograph/`, WAL mode, a `meta`
 * schema-version scaffold, per-user scoping via a `user_id` column):
 *
 *   chat_sessions   — one row per chat, metadata only (NO messages column).
 *   chat_messages   — one row per message, ordered by `seq` within a session.
 *
 * Rows (not a JSON blob) mean a single chat has no size limit, the full history
 * is retained, and title/pin updates (the session row) are decoupled from
 * message appends (message rows) so a late stream completion can't clobber a
 * rename. `meta` carries the web `ChatMessage` extras (segments/suggestions/
 * error) as opaque JSON so the message schema can evolve without a migration.
 */
const SCHEMA_VERSION = 2;
/**
 * Abuse safety bound (DoS insurance, NOT a UX cap). A runaway client can't grow
 * the DB without limit: appends are rejected once a user is at the ceiling.
 * Generous enough to never bite real use — a heavy user with hundreds of long
 * chats sits orders of magnitude below this.
 */
const MAX_MESSAGES_PER_USER = 100_000;
/** Per-append byte budget across the appended rows' content + meta. Fastify's
 *  bodyLimit (10MB) is the outer wall; this is the inner, per-turn guard. */
const MAX_APPEND_BYTES = 1024 * 1024;
export class ChatSessionTooLargeError extends Error {
}
export class ChatSessionForbiddenError extends Error {
}
const PREVIEW_LEN = 80;
export class ChatSessionService {
    db;
    /** Injected external cleanup for a deleted chat (composer-vision E2a): delete
     *  the OWNED scratch file BLOBS (by exact key — only `source='upload'` rows;
     *  library-sourced rows reference permanent assets and are excluded by the
     *  caller, D9) and the agent-pool session JSON (which holds base64 copies of
     *  vision images). This SQLite service deletes its own rows; the callback
     *  handles the blob store + agent pool it has no handle to.
     *  `sessionId` = single delete; undefined = delete-all-for-user. */
    scratchCleanup;
    /** Wire the scratch blob + agent-pool cleanup (see field doc). */
    setScratchCleanup(fn) {
        this.scratchCleanup = fn;
    }
    constructor(workspacePath) {
        const sgDir = join(workspacePath, '.studiograph');
        if (!existsSync(sgDir))
            mkdirSync(sgDir, { recursive: true });
        const dbPath = join(sgDir, 'chat-sessions.db');
        this.db = new Database(dbPath);
        if (process.platform !== 'win32') {
            try {
                chmodSync(dbPath, 0o600);
            }
            catch { /* non-fatal */ }
        }
        this.db.pragma('journal_mode = WAL');
        this.db.exec(`
      CREATE TABLE IF NOT EXISTS chat_sessions (
        id TEXT PRIMARY KEY,
        user_id INTEGER NOT NULL,
        title TEXT NOT NULL,
        title_generated INTEGER NOT NULL DEFAULT 0 CHECK(title_generated IN (0, 1)),
        pinned INTEGER NOT NULL DEFAULT 0 CHECK(pinned IN (0, 1)),
        pinned_at TEXT,
        created_at TEXT NOT NULL DEFAULT (datetime('now')),
        updated_at TEXT NOT NULL DEFAULT (datetime('now'))
      )
    `);
        this.db.exec(`CREATE INDEX IF NOT EXISTS idx_chat_sessions_user ON chat_sessions(user_id, updated_at)`);
        this.db.exec(`
      CREATE TABLE IF NOT EXISTS chat_messages (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        session_id TEXT NOT NULL,
        user_id INTEGER NOT NULL,
        seq INTEGER NOT NULL,
        role TEXT NOT NULL,
        content TEXT NOT NULL DEFAULT '',
        meta TEXT,
        created_at TEXT NOT NULL DEFAULT (datetime('now'))
      )
    `);
        this.db.exec(`CREATE INDEX IF NOT EXISTS idx_chat_messages_session ON chat_messages(session_id, seq)`);
        // Conversation-scratch FILE metadata (composer-vision E2a, generalized in PR3
        // from images-only to any readable file). Holds NO bytes — the blob lives in
        // the asset backend under `blob_key`. Just enough to authorize (user_id +
        // session ownership), locate (blob_key), carry doc text, and clean up.
        //
        // ┌──────────────── scratch file lifecycle ─────────────────┐
        // │ upload/pick → row{kind, extracted_text?, source}        │
        // │   image  → bytes→vision each (warm) turn / re-attach     │
        // │   doc    → text injected (capped) EVERY turn (replay)    │
        // │ chat delete → delete blob (source='upload' only) + row   │
        // └─────────────────────────────────────────────────────────┘
        //
        // Migration (D14, additive — never drops data): a PR2 `chat_scratch_images`
        // table is renamed in place and the new columns added, preserving every row,
        // blob, and message-meta ref. Idempotent across fresh + already-migrated DBs.
        this.migrateScratchTable();
        this.db.exec(`CREATE INDEX IF NOT EXISTS idx_chat_scratch_user ON chat_scratch_files(user_id)`);
        this.db.exec(`
      CREATE TABLE IF NOT EXISTS meta (
        key TEXT PRIMARY KEY,
        value TEXT NOT NULL
      );
    `);
        this.runMigrations();
    }
    /**
     * Idempotent: guarded by the recorded `schema_version`. The undeployed blob
     * model (`a2ba23b6`, schema 1) never shipped, so there's no data to migrate —
     * a local dev DB that touched it simply keeps its orphaned `messages` column
     * (the new chat_sessions inserts don't reference it; it defaults). Add a new
     * branch when bumping `SCHEMA_VERSION`; never edit existing branches.
     */
    runMigrations() {
        const row = this.db.prepare("SELECT value FROM meta WHERE key='schema_version'").get();
        const current = row ? parseInt(row.value, 10) || 0 : 0;
        if (current >= SCHEMA_VERSION)
            return;
        // No data migration: schema 1 (blob) never deployed. Future migrations live here.
        this.db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run('schema_version', String(SCHEMA_VERSION));
    }
    /** True if a table with this name exists in the DB. */
    tableExists(name) {
        return !!this.db
            .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?")
            .get(name);
    }
    /**
     * Bring the conversation-scratch table to the PR3 `chat_scratch_files` shape.
     * Self-contained + idempotent (runs from the constructor, independent of
     * `schema_version` ordering since the constructor's CREATEs run first):
     *  - PR2 `chat_scratch_images` present, new table absent → rename in place +
     *    `ADD COLUMN` (preserves rows/blobs/refs; images get kind='image',
     *    extract_state='none', source='upload'). This is D14 — additive, no drop.
     *  - Neither present (fresh DB) → create `chat_scratch_files` directly.
     *  - New table already present → nothing to do.
     */
    migrateScratchTable() {
        // The PR2→PR3 rename (table + image_id column) is the one non-idempotent
        // step — wrap it in a transaction so an interrupted boot can't leave a
        // renamed-but-half-altered table. (better-sqlite3 DDL is transactional.)
        if (!this.tableExists('chat_scratch_files') && this.tableExists('chat_scratch_images')) {
            this.db.transaction(() => {
                this.db.exec('ALTER TABLE chat_scratch_images RENAME TO chat_scratch_files');
                this.db.exec('ALTER TABLE chat_scratch_files RENAME COLUMN image_id TO file_id');
            })();
        }
        if (!this.tableExists('chat_scratch_files')) {
            this.db.exec(`
        CREATE TABLE chat_scratch_files (
          session_id TEXT NOT NULL,
          file_id TEXT NOT NULL,
          user_id INTEGER NOT NULL,
          filename TEXT NOT NULL,
          content_type TEXT NOT NULL,
          size INTEGER,
          blob_key TEXT NOT NULL,
          kind TEXT NOT NULL DEFAULT 'image',
          extracted_text TEXT,
          extract_state TEXT NOT NULL DEFAULT 'none',
          source TEXT NOT NULL DEFAULT 'upload',
          source_repo TEXT,
          source_asset_id TEXT,
          created_at TEXT NOT NULL DEFAULT (datetime('now')),
          PRIMARY KEY (session_id, file_id)
        )
      `);
            return;
        }
        // Idempotent column backfill — adds any missing column whether the table
        // came from a PR2 rename, a partially-migrated (interrupted) boot, or an
        // earlier PR3 build without source_repo/source_asset_id. Re-running is a
        // no-op, so a half-migrated table self-heals on the next start.
        this.ensureScratchColumn('kind', "TEXT NOT NULL DEFAULT 'image'");
        this.ensureScratchColumn('extracted_text', 'TEXT');
        this.ensureScratchColumn('extract_state', "TEXT NOT NULL DEFAULT 'none'");
        this.ensureScratchColumn('source', "TEXT NOT NULL DEFAULT 'upload'");
        this.ensureScratchColumn('source_repo', 'TEXT');
        this.ensureScratchColumn('source_asset_id', 'TEXT');
    }
    /** Add a column to chat_scratch_files only if it's not already present. */
    ensureScratchColumn(name, ddl) {
        const cols = this.db.prepare('PRAGMA table_info(chat_scratch_files)').all();
        if (!cols.some((c) => c.name === name)) {
            this.db.exec(`ALTER TABLE chat_scratch_files ADD COLUMN ${name} ${ddl}`);
        }
    }
    rowToMeta(row, messageCount, preview) {
        return {
            id: row.id,
            title: row.title,
            titleGenerated: row.title_generated === 1,
            pinned: row.pinned === 1,
            pinnedAt: row.pinned_at,
            createdAt: row.created_at,
            updatedAt: row.updated_at,
            messageCount,
            preview,
        };
    }
    /**
     * A user's sessions as metadata (no messages), most-recently-updated first.
     * `messageCount` + `preview` (first user message) come from correlated
     * subqueries so the list stays a single query and never ships message bodies.
     */
    listMeta(userId) {
        const rows = this.db
            .prepare(`
        SELECT s.*,
          (SELECT COUNT(*) FROM chat_messages m WHERE m.session_id = s.id) AS message_count,
          (SELECT m.content FROM chat_messages m
             WHERE m.session_id = s.id AND m.role = 'user'
             ORDER BY m.seq LIMIT 1) AS preview
        FROM chat_sessions s
        WHERE s.user_id = ?
        ORDER BY s.updated_at DESC
      `)
            .all(userId);
        return rows.map(r => this.rowToMeta(r, r.message_count, (r.preview ?? '').slice(0, PREVIEW_LEN)));
    }
    /** A single session WITH its messages (seq order), or null if not owned/found. */
    get(userId, id) {
        const row = this.db
            .prepare('SELECT * FROM chat_sessions WHERE id = ? AND user_id = ?')
            .get(id, userId);
        if (!row)
            return null;
        const msgRows = this.db
            .prepare('SELECT role, content, meta FROM chat_messages WHERE session_id = ? ORDER BY seq')
            .all(id);
        const messages = msgRows.map(m => ({
            role: m.role === 'user' ? 'user' : 'agent',
            content: m.content,
            meta: m.meta ? this.safeParse(m.meta) : null,
        }));
        return {
            id: row.id,
            title: row.title,
            titleGenerated: row.title_generated === 1,
            pinned: row.pinned === 1,
            pinnedAt: row.pinned_at,
            createdAt: row.created_at,
            updatedAt: row.updated_at,
            messages,
        };
    }
    /**
     * Raw attachment-ref objects persisted on a session's USER messages, for the
     * server-side doc replay (D7). Returns the loosely-typed ref objects (from
     * `meta.attachments` (PR3) or legacy `meta.imageRefs` (PR2)); the caller
     * normalizes + re-authorizes them. Returns [] if the session isn't owned.
     * Kept here (not in the route) so the meta shape lives in one place, and
     * returns `unknown[]` to avoid importing the ref type (would cycle with
     * scratch-attachment-service, which imports this module).
     */
    getSessionAttachmentRefs(userId, id) {
        const owner = this.ownerOf(id);
        if (owner !== userId)
            return [];
        const rows = this.db
            .prepare("SELECT meta FROM chat_messages WHERE session_id = ? AND role = 'user' ORDER BY seq")
            .all(id);
        const refs = [];
        for (const r of rows) {
            if (!r.meta)
                continue;
            const meta = this.safeParse(r.meta);
            if (!meta)
                continue;
            const arr = Array.isArray(meta.attachments) ? meta.attachments
                : Array.isArray(meta.imageRefs) ? meta.imageRefs
                    : null;
            if (arr)
                refs.push(...arr);
        }
        return refs;
    }
    safeParse(s) {
        try {
            const v = JSON.parse(s);
            return v && typeof v === 'object' ? v : null;
        }
        catch {
            return null;
        }
    }
    ownerOf(id) {
        const r = this.db.prepare('SELECT user_id FROM chat_sessions WHERE id = ?').get(id);
        return r?.user_id;
    }
    /**
     * Create a session, or update its metadata in place. A session id is owned by
     * the first user who writes it; a write from a different user is rejected so
     * one account can't overwrite another's history. Only the provided fields are
     * updated (PATCH semantics) — messages are NEVER touched here, so a metadata
     * write (rename/pin) can't clobber an in-flight stream's appended messages.
     * `createdAt`/`updatedAt` are honored on create; on update, `createdAt` is
     * preserved and `updatedAt` only advances when supplied.
     */
    upsertMeta(userId, input) {
        const owner = this.ownerOf(input.id);
        if (owner !== undefined && owner !== userId) {
            throw new ChatSessionForbiddenError('Session belongs to another user');
        }
        const now = new Date().toISOString();
        if (owner === undefined) {
            // Create.
            this.db
                .prepare(`
          INSERT INTO chat_sessions (id, user_id, title, title_generated, pinned, pinned_at, created_at, updated_at)
          VALUES (@id, @user_id, @title, @title_generated, @pinned, @pinned_at, @created_at, @updated_at)
        `)
                .run({
                id: input.id,
                user_id: userId,
                title: input.title ?? 'New chat',
                title_generated: input.titleGenerated ? 1 : 0,
                pinned: input.pinned ? 1 : 0,
                pinned_at: input.pinnedAt ?? null,
                created_at: input.createdAt ?? now,
                updated_at: input.updatedAt ?? now,
            });
        }
        else {
            // Update only the provided fields (COALESCE keeps the stored value when null).
            this.db
                .prepare(`
          UPDATE chat_sessions SET
            title = COALESCE(@title, title),
            title_generated = COALESCE(@title_generated, title_generated),
            pinned = COALESCE(@pinned, pinned),
            pinned_at = CASE WHEN @pinned_at_set = 1 THEN @pinned_at ELSE pinned_at END,
            updated_at = COALESCE(@updated_at, updated_at)
          WHERE id = @id AND user_id = @user_id
        `)
                .run({
                id: input.id,
                user_id: userId,
                title: input.title ?? null,
                title_generated: input.titleGenerated === undefined ? null : input.titleGenerated ? 1 : 0,
                pinned: input.pinned === undefined ? null : input.pinned ? 1 : 0,
                // pinnedAt is meaningfully nullable (cleared on unpin), so a dedicated
                // "was it provided" flag distinguishes "set to null" from "leave alone".
                pinned_at_set: input.pinned === undefined ? 0 : 1,
                pinned_at: input.pinnedAt ?? null,
                updated_at: input.updatedAt ?? null,
            });
        }
        const row = this.db.prepare('SELECT * FROM chat_sessions WHERE id = ?').get(input.id);
        const count = this.db.prepare('SELECT COUNT(*) AS n FROM chat_messages WHERE session_id = ?').get(input.id).n;
        const preview = this.db
            .prepare("SELECT content FROM chat_messages WHERE session_id = ? AND role = 'user' ORDER BY seq LIMIT 1")
            .get(input.id)?.content ?? '';
        return this.rowToMeta(row, count, preview.slice(0, PREVIEW_LEN));
    }
    /**
     * Append messages to a session, in order, after the current max `seq`.
     * Create-on-demand: if the session row doesn't exist it's inserted (owned by
     * the caller) so a turn-completion append can't lose data to a create race.
     * Bumps the session's `updated_at` so active chats float to the top of the
     * list. Rejects another user's session id, an over-budget append, or one that
     * would push the user past the abuse ceiling.
     */
    appendMessages(userId, sessionId, messages, opts) {
        if (messages.length === 0)
            return { appended: 0 };
        let bytes = 0;
        for (const m of messages) {
            bytes += Buffer.byteLength(m.content ?? '', 'utf8');
            if (m.meta)
                bytes += Buffer.byteLength(JSON.stringify(m.meta), 'utf8');
        }
        if (bytes > MAX_APPEND_BYTES) {
            throw new ChatSessionTooLargeError(`Append exceeds ${MAX_APPEND_BYTES} bytes`);
        }
        const owner = this.ownerOf(sessionId);
        if (owner !== undefined && owner !== userId) {
            throw new ChatSessionForbiddenError('Session belongs to another user');
        }
        const total = this.db.prepare('SELECT COUNT(*) AS n FROM chat_messages WHERE user_id = ?').get(userId).n;
        if (total + messages.length > MAX_MESSAGES_PER_USER) {
            throw new ChatSessionTooLargeError(`User exceeds the ${MAX_MESSAGES_PER_USER}-message ceiling`);
        }
        const now = new Date().toISOString();
        const tx = this.db.transaction(() => {
            if (owner === undefined) {
                this.db
                    .prepare(`
            INSERT INTO chat_sessions (id, user_id, title, created_at, updated_at)
            VALUES (?, ?, ?, ?, ?)
          `)
                    .run(sessionId, userId, opts?.title ?? 'New chat', now, now);
            }
            const maxSeq = this.db.prepare('SELECT COALESCE(MAX(seq), -1) AS s FROM chat_messages WHERE session_id = ?').get(sessionId).s;
            const insert = this.db.prepare(`
        INSERT INTO chat_messages (session_id, user_id, seq, role, content, meta, created_at)
        VALUES (?, ?, ?, ?, ?, ?, ?)
      `);
            messages.forEach((m, i) => {
                insert.run(sessionId, userId, maxSeq + 1 + i, m.role === 'user' ? 'user' : 'agent', m.content ?? '', m.meta ? JSON.stringify(m.meta) : null, now);
            });
            this.db.prepare('UPDATE chat_sessions SET updated_at = ? WHERE id = ?').run(now, sessionId);
        });
        tx();
        return { appended: messages.length };
    }
    /** Owner of a session, or undefined if it doesn't exist (public for the
     *  composer-vision upload route's reservation check). */
    getSessionOwner(id) {
        return this.ownerOf(id);
    }
    /** Record a conversation-scratch file's metadata (no bytes). */
    insertScratchFile(input) {
        this.db.prepare(`
      INSERT INTO chat_scratch_files
        (session_id, file_id, user_id, filename, content_type, size, blob_key, kind, extracted_text, extract_state, source, source_repo, source_asset_id)
      VALUES
        (@sessionId, @fileId, @userId, @filename, @contentType, @size, @blobKey, @kind, @extractedText, @extractState, @source, @sourceRepo, @sourceAssetId)
    `).run({
            ...input,
            extractedText: input.extractedText ?? null,
            extractState: input.extractState ?? 'none',
            source: input.source ?? 'upload',
            sourceRepo: input.sourceRepo ?? null,
            sourceAssetId: input.sourceAssetId ?? null,
        });
    }
    /** Map a raw chat_scratch_files row to a ScratchFileRow. */
    static toScratchRow(r) {
        return {
            sessionId: r.session_id,
            fileId: r.file_id,
            userId: r.user_id,
            filename: r.filename,
            contentType: r.content_type,
            size: r.size ?? null,
            blobKey: r.blob_key,
            kind: (r.kind ?? 'image'),
            extractedText: r.extracted_text ?? null,
            extractState: (r.extract_state ?? 'none'),
            source: (r.source ?? 'upload'),
            sourceRepo: r.source_repo ?? null,
            sourceAssetId: r.source_asset_id ?? null,
            createdAt: r.created_at ?? undefined,
        };
    }
    /** Look up a scratch file's metadata row, or null. */
    getScratchFile(sessionId, fileId) {
        const r = this.db
            .prepare('SELECT * FROM chat_scratch_files WHERE session_id = ? AND file_id = ?')
            .get(sessionId, fileId);
        return r ? ChatSessionService.toScratchRow(r) : null;
    }
    /** A session's scratch files, most-recently-added first. Used by the agent's
     *  `save_file_to_assets` tool to resolve "this file" scoped to the CURRENT
     *  conversation, and by the server's per-turn doc replay (D7). */
    getScratchFilesBySession(sessionId) {
        const rows = this.db
            .prepare('SELECT * FROM chat_scratch_files WHERE session_id = ? ORDER BY created_at DESC, rowid DESC')
            .all(sessionId);
        return rows.map((r) => ChatSessionService.toScratchRow(r));
    }
    /** Delete a session and cascade its messages + scratch metadata, scoped to its
     *  owner (no-op if not owned). External blob + agent-pool cleanup runs via the
     *  injected scratchCleanup with the session's blob keys. */
    delete(userId, id) {
        const owner = this.ownerOf(id);
        if (owner !== userId)
            return;
        // D9: only delete blobs we OWN (source='upload'). Library-sourced rows point
        // at a permanent curated asset — deleting that blob would destroy the asset.
        const blobKeys = this.db
            .prepare("SELECT blob_key FROM chat_scratch_files WHERE session_id = ? AND source = 'upload'")
            .all(id).map(r => r.blob_key);
        const tx = this.db.transaction(() => {
            this.db.prepare('DELETE FROM chat_messages WHERE session_id = ?').run(id);
            this.db.prepare('DELETE FROM chat_scratch_files WHERE session_id = ?').run(id);
            this.db.prepare('DELETE FROM chat_sessions WHERE id = ? AND user_id = ?').run(id, userId);
        });
        tx();
        this.scratchCleanup?.(userId, id, blobKeys);
    }
    /** Delete every session + message + scratch row for a user (reset path). */
    deleteAllForUser(userId) {
        const blobKeys = this.db
            .prepare("SELECT blob_key FROM chat_scratch_files WHERE user_id = ? AND source = 'upload'")
            .all(userId).map(r => r.blob_key);
        const tx = this.db.transaction(() => {
            this.db.prepare('DELETE FROM chat_messages WHERE user_id = ?').run(userId);
            this.db.prepare('DELETE FROM chat_scratch_files WHERE user_id = ?').run(userId);
            this.db.prepare('DELETE FROM chat_sessions WHERE user_id = ?').run(userId);
        });
        tx();
        this.scratchCleanup?.(userId, undefined, blobKeys);
    }
    close() {
        try {
            this.db.close();
        }
        catch { /* already closed */ }
    }
}
//# sourceMappingURL=chat-session-service.js.map