/**
 * Message Service
 *
 * SQLite-backed persistence for both folder-scoped and workspace-scoped
 * messaging. Database lives at .studiograph/messages.db inside the workspace.
 * Follows the AuthService pattern (WAL mode, table creation on init).
 *
 * Scope model:
 *   - messages.folder = '<name>' → folder-scoped (team chat within a folder)
 *   - messages.folder = NULL     → workspace-scoped (shared home chat)
 *
 * Read receipts are split across two tables (folder vs. workspace) because
 * the folder read_receipts PK is (user_id, folder); allowing NULL there
 * would break uniqueness semantics.
 */
import Database from 'better-sqlite3';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
/**
 * Format a list of messages as a readable transcript for an LLM prompt.
 * Messages are sorted oldest-first for natural conversation flow.
 *
 * @param messages   Messages to format.
 * @param showScope  If true, annotate each line with the folder/workspace scope.
 *                   Use when the transcript may mix scopes (e.g. cross-folder search).
 *                   Default false — heartbeat and per-scope callers should leave this off.
 */
export function formatTranscript(messages, showScope = false) {
    if (messages.length === 0)
        return 'No messages found.';
    const sorted = [...messages].sort((a, b) => a.id - b.id);
    return sorted.map(m => {
        const role = m.role === 'agent' ? 'Agent' : m.userDisplayName;
        const prefix = showScope ? `${role} (${m.folder ?? 'workspace'})` : role;
        return `[${m.createdAt}] ${prefix}: ${m.content}`;
    }).join('\n\n');
}
export class MessageService {
    db;
    constructor(workspacePath) {
        const sgDir = join(workspacePath, '.studiograph');
        if (!existsSync(sgDir)) {
            mkdirSync(sgDir, { recursive: true });
        }
        const dbPath = join(sgDir, 'messages.db');
        this.db = new Database(dbPath);
        this.db.pragma('journal_mode = WAL');
        // messages.folder is nullable: NULL = workspace-scoped, string = folder-scoped.
        this.db.exec(`
      CREATE TABLE IF NOT EXISTS messages (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        folder TEXT,
        user_id INTEGER NOT NULL,
        user_display_name TEXT NOT NULL,
        role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('user', 'agent')),
        content TEXT NOT NULL,
        created_at TEXT NOT NULL DEFAULT (datetime('now'))
      )
    `);
        this.db.exec(`
      CREATE INDEX IF NOT EXISTS idx_messages_folder_id
      ON messages(folder, id)
    `);
        // Folder read receipts — keyed by (user_id, folder). For workspace
        // read state we use a separate table (workspace_read_receipts) because
        // SQLite does not enforce uniqueness over NULL in a composite PK.
        this.db.exec(`
      CREATE TABLE IF NOT EXISTS read_receipts (
        user_id INTEGER NOT NULL,
        folder TEXT NOT NULL,
        last_read_message_id INTEGER NOT NULL DEFAULT 0,
        updated_at TEXT NOT NULL DEFAULT (datetime('now')),
        PRIMARY KEY (user_id, folder)
      )
    `);
        this.db.exec(`
      CREATE TABLE IF NOT EXISTS workspace_read_receipts (
        user_id INTEGER PRIMARY KEY,
        last_read_message_id INTEGER NOT NULL DEFAULT 0,
        updated_at TEXT NOT NULL DEFAULT (datetime('now'))
      )
    `);
        // Legacy column additions (messages pre-dating reply_to_id/mentions)
        try {
            this.db.exec('ALTER TABLE messages ADD COLUMN reply_to_id INTEGER');
        }
        catch { }
        try {
            this.db.exec('ALTER TABLE messages ADD COLUMN mentions TEXT');
        }
        catch { }
        // Analysis markers — tracks the last message the proactive heartbeat
        // analyzed for each scope. Keys: 'folder:<name>' or 'workspace'.
        // Persisted so restarts don't drop the analysis window.
        this.db.exec(`
      CREATE TABLE IF NOT EXISTS analysis_markers (
        scope_key TEXT PRIMARY KEY,
        last_analyzed_message_id INTEGER NOT NULL DEFAULT 0,
        updated_at TEXT NOT NULL DEFAULT (datetime('now'))
      )
    `);
        // Notifications: folder nullable for workspace-wide mentions.
        this.db.exec(`
      CREATE TABLE IF NOT EXISTS notifications (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id INTEGER NOT NULL,
        folder TEXT,
        message_id INTEGER NOT NULL,
        mentioner_name TEXT NOT NULL,
        message_preview TEXT NOT NULL,
        read INTEGER NOT NULL DEFAULT 0,
        created_at TEXT NOT NULL DEFAULT (datetime('now'))
      )
    `);
        this.db.exec(`
      CREATE INDEX IF NOT EXISTS idx_notifications_user
      ON notifications(user_id, read, id DESC)
    `);
        // Ensure messages.db is gitignored
        this.ensureGitignore(sgDir);
    }
    /** Post a new message to a folder thread. */
    postMessage(folder, userId, displayName, role, content, replyToId) {
        const stmt = this.db.prepare('INSERT INTO messages (folder, user_id, user_display_name, role, content, reply_to_id) VALUES (?, ?, ?, ?, ?, ?)');
        const result = stmt.run(folder, userId, displayName, role, content, replyToId ?? null);
        const row = this.db.prepare('SELECT * FROM messages WHERE id = ?').get(result.lastInsertRowid);
        return this.toMessage(row);
    }
    /** Get messages for a folder with cursor-based pagination (newest first). */
    getMessages(folder, opts = {}) {
        const limit = Math.min(opts.limit ?? 50, 200);
        let rows;
        if (opts.before) {
            rows = this.db.prepare('SELECT * FROM messages WHERE folder = ? AND id < ? ORDER BY id DESC LIMIT ?').all(folder, opts.before, limit);
        }
        else {
            rows = this.db.prepare('SELECT * FROM messages WHERE folder = ? ORDER BY id DESC LIMIT ?').all(folder, limit);
        }
        return rows.map(r => this.toMessage(r));
    }
    /** Search messages by keyword, optionally scoped to a folder. */
    searchMessages(query, opts = {}) {
        const limit = Math.min(opts.limit ?? 20, 100);
        if (opts.folder) {
            const rows = this.db.prepare(`SELECT * FROM messages WHERE folder = ? AND content LIKE '%' || ? || '%' ORDER BY id DESC LIMIT ?`).all(opts.folder, query, limit);
            return rows.map(r => this.toMessage(r));
        }
        if (opts.folders && opts.folders.length > 0) {
            const placeholders = opts.folders.map(() => '?').join(', ');
            const rows = this.db.prepare(`SELECT * FROM messages WHERE folder IN (${placeholders}) AND content LIKE '%' || ? || '%' ORDER BY id DESC LIMIT ?`).all(...opts.folders, query, limit);
            return rows.map(r => this.toMessage(r));
        }
        const rows = this.db.prepare(`SELECT * FROM messages WHERE content LIKE '%' || ? || '%' ORDER BY id DESC LIMIT ?`).all(query, limit);
        return rows.map(r => this.toMessage(r));
    }
    /** Mark messages as read up to a given message ID (UPSERT). */
    markRead(userId, folder, messageId) {
        this.db.prepare(`
      INSERT INTO read_receipts (user_id, folder, last_read_message_id, updated_at)
      VALUES (?, ?, ?, datetime('now'))
      ON CONFLICT (user_id, folder)
      DO UPDATE SET
        last_read_message_id = MAX(excluded.last_read_message_id, read_receipts.last_read_message_id),
        updated_at = excluded.updated_at
    `).run(userId, folder, messageId);
    }
    /** Get unread message counts for a user across multiple folders. */
    getUnreadCounts(userId, folders) {
        if (folders.length === 0)
            return {};
        const counts = {};
        const stmt = this.db.prepare(`
      SELECT COUNT(*) as count FROM messages
      WHERE folder = ? AND id > COALESCE(
        (SELECT last_read_message_id FROM read_receipts WHERE user_id = ? AND folder = ?),
        0
      )
    `);
        for (const f of folders) {
            const row = stmt.get(f, userId, f);
            if (row.count > 0) {
                counts[f] = row.count;
            }
        }
        return counts;
    }
    /** Update a message's content (used to mark capture items as done). */
    updateMessageContent(id, content) {
        this.db.prepare('UPDATE messages SET content = ? WHERE id = ?').run(content, id);
    }
    /** Get a single message by ID. */
    getMessageById(id) {
        const row = this.db.prepare('SELECT * FROM messages WHERE id = ?').get(id);
        return row ? this.toMessage(row) : null;
    }
    /** Get the latest message ID for a folder (for markRead on load). */
    getLatestMessageId(folder) {
        const row = this.db.prepare('SELECT MAX(id) as max_id FROM messages WHERE folder = ?').get(folder);
        return row.max_id;
    }
    /** Update the mentions JSON array on a message (called after mention detection). */
    setMentions(messageId, userIds) {
        this.db.prepare('UPDATE messages SET mentions = ? WHERE id = ?').run(JSON.stringify(userIds), messageId);
    }
    // ── Workspace-scoped messaging (folder IS NULL) ──
    /** Post a new message to the workspace-wide chat. */
    postWorkspaceMessage(userId, displayName, role, content, replyToId) {
        const stmt = this.db.prepare('INSERT INTO messages (folder, user_id, user_display_name, role, content, reply_to_id) VALUES (NULL, ?, ?, ?, ?, ?)');
        const result = stmt.run(userId, displayName, role, content, replyToId ?? null);
        const row = this.db.prepare('SELECT * FROM messages WHERE id = ?').get(result.lastInsertRowid);
        return this.toMessage(row);
    }
    /** Get workspace-wide messages with cursor-based pagination (newest first). */
    getWorkspaceMessages(opts = {}) {
        const limit = Math.min(opts.limit ?? 50, 200);
        let rows;
        if (opts.before) {
            rows = this.db.prepare('SELECT * FROM messages WHERE folder IS NULL AND id < ? ORDER BY id DESC LIMIT ?').all(opts.before, limit);
        }
        else {
            rows = this.db.prepare('SELECT * FROM messages WHERE folder IS NULL ORDER BY id DESC LIMIT ?').all(limit);
        }
        return rows.map(r => this.toMessage(r));
    }
    /** Mark workspace messages as read up to a given message ID (UPSERT). */
    markWorkspaceRead(userId, messageId) {
        this.db.prepare(`
      INSERT INTO workspace_read_receipts (user_id, last_read_message_id, updated_at)
      VALUES (?, ?, datetime('now'))
      ON CONFLICT (user_id)
      DO UPDATE SET
        last_read_message_id = MAX(excluded.last_read_message_id, workspace_read_receipts.last_read_message_id),
        updated_at = excluded.updated_at
    `).run(userId, messageId);
    }
    /** Count unread workspace messages for a user. */
    getWorkspaceUnreadCount(userId) {
        const row = this.db.prepare(`
      SELECT COUNT(*) as count FROM messages
      WHERE folder IS NULL AND id > COALESCE(
        (SELECT last_read_message_id FROM workspace_read_receipts WHERE user_id = ?),
        0
      )
    `).get(userId);
        return row.count;
    }
    /** Latest workspace message ID (for mark-read on load). */
    getLatestWorkspaceMessageId() {
        const row = this.db.prepare('SELECT MAX(id) as max_id FROM messages WHERE folder IS NULL').get();
        return row.max_id;
    }
    // ── Notifications ──
    /**
     * Create a mention notification.
     * @param folder folder name, or `null` for workspace-wide mentions.
     */
    createNotification(userId, folder, messageId, mentionerName, messagePreview) {
        const info = this.db.prepare('INSERT INTO notifications (user_id, folder, message_id, mentioner_name, message_preview) VALUES (?, ?, ?, ?, ?)').run(userId, folder, messageId, mentionerName, messagePreview.slice(0, 200));
        return Number(info.lastInsertRowid);
    }
    getNotifications(userId, opts = {}) {
        const limit = opts.limit ?? 50;
        const where = opts.unreadOnly ? 'WHERE user_id = ? AND read = 0' : 'WHERE user_id = ?';
        const rows = this.db.prepare(`SELECT * FROM notifications ${where} ORDER BY id DESC LIMIT ?`).all(userId, limit);
        return rows.map(r => this.toNotification(r));
    }
    getUnreadNotificationCount(userId) {
        const row = this.db.prepare('SELECT COUNT(*) as count FROM notifications WHERE user_id = ? AND read = 0').get(userId);
        return row.count;
    }
    markNotificationRead(notificationId, userId) {
        this.db.prepare('UPDATE notifications SET read = 1 WHERE id = ? AND user_id = ?').run(notificationId, userId);
    }
    markAllNotificationsRead(userId) {
        this.db.prepare('UPDATE notifications SET read = 1 WHERE user_id = ? AND read = 0').run(userId);
    }
    // ── Analysis markers (proactive heartbeat state) ──
    /** Get the last-analyzed message ID for a scope, or 0 if never analyzed. */
    getAnalysisMarker(scopeKey) {
        const row = this.db.prepare('SELECT last_analyzed_message_id FROM analysis_markers WHERE scope_key = ?').get(scopeKey);
        return row?.last_analyzed_message_id ?? 0;
    }
    /** Set the last-analyzed message ID for a scope (UPSERT, monotonic). */
    setAnalysisMarker(scopeKey, messageId) {
        this.db.prepare(`
      INSERT INTO analysis_markers (scope_key, last_analyzed_message_id, updated_at)
      VALUES (?, ?, datetime('now'))
      ON CONFLICT (scope_key)
      DO UPDATE SET
        last_analyzed_message_id = MAX(excluded.last_analyzed_message_id, analysis_markers.last_analyzed_message_id),
        updated_at = excluded.updated_at
    `).run(scopeKey, messageId);
    }
    close() {
        this.db.close();
    }
    ensureGitignore(sgDir) {
        const gitignorePath = join(sgDir, '.gitignore');
        const entries = ['messages.db', 'messages.db-wal', 'messages.db-shm'];
        let content = '';
        if (existsSync(gitignorePath)) {
            content = readFileSync(gitignorePath, 'utf-8');
        }
        const missing = entries.filter(e => !content.includes(e));
        if (missing.length > 0) {
            const suffix = (content && !content.endsWith('\n') ? '\n' : '') +
                '# Messages database (ephemeral, not synced)\n' +
                missing.join('\n') + '\n';
            writeFileSync(gitignorePath, content + suffix, 'utf-8');
        }
    }
    toNotification(row) {
        return {
            id: row.id,
            userId: row.user_id,
            folder: row.folder,
            messageId: row.message_id,
            mentionerName: row.mentioner_name,
            messagePreview: row.message_preview,
            read: row.read === 1,
            createdAt: row.created_at,
        };
    }
    toMessage(row) {
        return {
            id: row.id,
            folder: row.folder,
            userId: row.user_id,
            userDisplayName: row.user_display_name,
            role: row.role,
            content: row.content,
            createdAt: row.created_at,
            replyToId: row.reply_to_id ?? null,
        };
    }
}
//# sourceMappingURL=message-service.js.map