import Database from 'better-sqlite3';
import { join } from 'node:path';
import { existsSync, mkdirSync, chmodSync } from 'node:fs';
import { randomBytes } from 'node:crypto';
import { looksLikeCellAnchor, isValidCellAnchor } from '../core/cell-anchor.js';
function generateCommentId() {
    // 9 bytes → 12-char base64url ≈ 72 bits of entropy; collision risk negligible at any realistic scale.
    return `cmt_${randomBytes(9).toString('base64url')}`;
}
/**
 * Bump when columns are added/removed/renamed. The boot path runs every
 * registered migration whose version is greater than the recorded
 * `schema_version` in the meta table; pattern mirrors
 * core/secrets/SqliteEncryptedStore.ts.
 */
const SCHEMA_VERSION = 1;
/** Largest comment body the service will accept. Above this the route
 *  returns 413 — Fastify bodyLimit is the outer 10MB wall, this is the
 *  per-comment policy ceiling. */
export const MAX_BODY_LENGTH = 8 * 1024;
export const MAX_ANCHOR_RELPOS_LENGTH = 4 * 1024;
/** Sentinel author_user_id used for agent-authored comments. The auth.db
 *  users table is AUTOINCREMENT (starts at 1), so id=0 can never collide
 *  with a real user. */
export const AGENT_AUTHOR_USER_ID = 0;
/** Thrown by create() when body or anchor_relpos exceeds the ceiling. */
export class CommentTooLargeError extends Error {
    constructor(message) {
        super(message);
        this.name = 'CommentTooLargeError';
    }
}
/** Thrown by create() when a cell (JSON) anchor is malformed, or when a JSON
 *  cell anchor is attached to a non-dataset entry (markdown comments only ever
 *  carry a base64 text relpos or a null entry-level anchor). The route maps it
 *  to HTTP 400. */
export class InvalidCellAnchorError extends Error {
    constructor(message) {
        super(message);
        this.name = 'InvalidCellAnchorError';
    }
}
export class CommentService {
    db;
    constructor(workspacePath) {
        const sgDir = join(workspacePath, '.studiograph');
        if (!existsSync(sgDir))
            mkdirSync(sgDir, { recursive: true });
        const dbPath = join(sgDir, 'comments.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 comments (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        comment_id TEXT UNIQUE NOT NULL,
        repo TEXT NOT NULL,
        entry_type TEXT NOT NULL,
        entry_id TEXT NOT NULL,
        thread_id TEXT NOT NULL,
        parent_id TEXT,
        author_user_id INTEGER NOT NULL,
        author_display_name TEXT NOT NULL,
        author_role TEXT NOT NULL DEFAULT 'user' CHECK(author_role IN ('user', 'agent')),
        body TEXT NOT NULL,
        anchor_relpos TEXT,
        anchor_quote TEXT,
        mentions TEXT NOT NULL DEFAULT '[]',
        created_at TEXT NOT NULL DEFAULT (datetime('now')),
        updated_at TEXT,
        resolved_at TEXT,
        resolved_by_user_id INTEGER,
        edited INTEGER NOT NULL DEFAULT 0 CHECK(edited IN (0, 1)),
        edit_history TEXT NOT NULL DEFAULT '[]',
        deleted_at TEXT
      )
    `);
        this.db.exec(`CREATE INDEX IF NOT EXISTS idx_comments_entry ON comments(repo, entry_type, entry_id, resolved_at)`);
        this.db.exec(`CREATE INDEX IF NOT EXISTS idx_comments_thread ON comments(thread_id, created_at)`);
        this.db.exec(`CREATE INDEX IF NOT EXISTS idx_comments_unresolved ON comments(resolved_at) WHERE resolved_at IS NULL`);
        this.db.exec(`CREATE INDEX IF NOT EXISTS idx_comments_repo_unresolved ON comments(repo, entry_type, entry_id) WHERE resolved_at IS NULL AND deleted_at IS NULL`);
        // Schema-version scaffold so future column changes (reactions, etc.)
        // can run through an ALTER TABLE migration step instead of silently
        // skipping on databases created against an older shape. Pattern
        // mirrors core/secrets/SqliteEncryptedStore.ts.
        this.db.exec(`
      CREATE TABLE IF NOT EXISTS meta (
        key TEXT PRIMARY KEY,
        value TEXT NOT NULL
      );
    `);
        this.runMigrations();
    }
    /**
     * Idempotent: each migration step is guarded by the recorded
     * `schema_version` in `meta`. 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;
        // Future migrations live here:
        //   if (current < 2) { this.db.exec(`ALTER TABLE comments ADD COLUMN ...`); }
        //   if (current < 3) { ... }
        this.db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run('schema_version', String(SCHEMA_VERSION));
    }
    create(input) {
        // Resource bounds — Fastify bodyLimit is 10MB, but comment bodies
        // shouldn't approach that. A user with repo write access could
        // otherwise balloon comments.db with a few well-crafted POSTs.
        if (input.body.length > MAX_BODY_LENGTH) {
            throw new CommentTooLargeError(`Comment body exceeds ${MAX_BODY_LENGTH} characters`);
        }
        if (input.anchorRelpos && input.anchorRelpos.length > MAX_ANCHOR_RELPOS_LENGTH) {
            throw new CommentTooLargeError(`Anchor relpos exceeds ${MAX_ANCHOR_RELPOS_LENGTH} characters`);
        }
        // Cell (JSON) anchors are dataset-only. A markdown comment's anchor is a
        // base64 relpos blob (never starts with '{'); only datasets carry the
        // `{"kind":"cell",…}` shape, validated here so a malformed anchor can't
        // land in the column and a JSON anchor can't reach the text resolver.
        if (looksLikeCellAnchor(input.anchorRelpos)) {
            if (input.entryType !== 'dataset') {
                throw new InvalidCellAnchorError('Cell anchors are only valid on dataset entries');
            }
            let parsed;
            try {
                parsed = JSON.parse(input.anchorRelpos);
            }
            catch {
                throw new InvalidCellAnchorError('Cell anchor is not valid JSON');
            }
            if (!isValidCellAnchor(parsed)) {
                throw new InvalidCellAnchorError('Cell anchor is structurally invalid');
            }
        }
        const anchorQuote = input.anchorQuote
            ? (input.anchorQuote.length > 256 ? input.anchorQuote.slice(0, 256) : input.anchorQuote)
            : null;
        const insert = this.db.prepare(`
      INSERT INTO comments
        (comment_id, repo, entry_type, entry_id, thread_id, parent_id,
         author_user_id, author_display_name, author_role, body,
         anchor_relpos, anchor_quote, mentions)
      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    `);
        // Retry on UNIQUE collision. 72-bit IDs make collisions astronomically
        // unlikely, but a sane caller shouldn't see HTTP 500 on the off-chance.
        let commentId = '';
        let lastErr = null;
        for (let attempt = 0; attempt < 3; attempt++) {
            commentId = generateCommentId();
            const threadId = input.threadId ?? input.parentId ?? commentId;
            try {
                insert.run(commentId, input.repo, input.entryType, input.entryId, threadId, input.parentId ?? null, input.authorUserId, input.authorDisplayName, input.authorRole ?? 'user', input.body, input.anchorRelpos ?? null, anchorQuote, JSON.stringify(input.mentions ?? []));
                lastErr = null;
                break;
            }
            catch (err) {
                const msg = err.message ?? '';
                if (!msg.includes('UNIQUE constraint failed: comments.comment_id'))
                    throw err;
                lastErr = err;
            }
        }
        if (lastErr)
            throw lastErr;
        return this.getById(commentId);
    }
    getById(commentId) {
        const row = this.db.prepare(`
      SELECT * FROM comments WHERE comment_id = ?
    `).get(commentId);
        return row ? this.toComment(row) : null;
    }
    /** Batch lookup used by the notifications route to enrich each notification
     *  with its source comment's entry coords. Returns a map keyed by commentId
     *  so callers can do a single pass. Missing ids are absent from the map. */
    getByIds(commentIds) {
        const out = new Map();
        if (commentIds.length === 0)
            return out;
        const placeholders = commentIds.map(() => '?').join(',');
        const rows = this.db.prepare(`SELECT * FROM comments WHERE comment_id IN (${placeholders})`).all(...commentIds);
        for (const row of rows) {
            const c = this.toComment(row);
            out.set(c.commentId, c);
        }
        return out;
    }
    list(opts = {}) {
        const where = [];
        const args = [];
        if (opts.repo) {
            where.push('repo = ?');
            args.push(opts.repo);
        }
        if (opts.entryType) {
            where.push('entry_type = ?');
            args.push(opts.entryType);
        }
        if (opts.entryId) {
            where.push('entry_id = ?');
            args.push(opts.entryId);
        }
        if (opts.resolved === false)
            where.push('resolved_at IS NULL');
        if (opts.resolved === true)
            where.push('resolved_at IS NOT NULL');
        if (!opts.includeDeleted)
            where.push('deleted_at IS NULL');
        const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';
        const limit = Math.min(opts.limit ?? 500, 2000);
        const rows = this.db.prepare(`SELECT * FROM comments ${clause} ORDER BY thread_id, created_at LIMIT ?`).all(...args, limit);
        return rows.map(r => this.toComment(r));
    }
    edit(commentId, body, editorUserId) {
        // Wrap read-modify-write in a transaction so concurrent edits by the
        // same author don't both build edit_history from the same pre-image
        // and lose the first edit's snapshot.
        const txn = this.db.transaction(() => {
            const existing = this.getById(commentId);
            if (!existing || existing.deletedAt)
                return null;
            if (existing.authorUserId !== editorUserId) {
                throw new Error('Only the author can edit this comment');
            }
            const history = [...existing.editHistory, { at: existing.updatedAt ?? existing.createdAt, body: existing.body }];
            this.db.prepare(`
        UPDATE comments
        SET body = ?, updated_at = datetime('now'), edited = 1, edit_history = ?
        WHERE comment_id = ?
      `).run(body, JSON.stringify(history), commentId);
            return this.getById(commentId);
        });
        return txn();
    }
    resolve(commentId, resolverUserId) {
        const existing = this.getById(commentId);
        if (!existing || existing.deletedAt)
            return null;
        // Resolve the entire thread (Google Docs semantics — resolve from any reply).
        // Scope the UPDATE to the same repo/entry the caller is acting on so a
        // forged thread_id pointing at another folder can't cascade-resolve there.
        this.db.prepare(`
      UPDATE comments
      SET resolved_at = datetime('now'), resolved_by_user_id = ?
      WHERE thread_id = ?
        AND repo = ? AND entry_type = ? AND entry_id = ?
        AND resolved_at IS NULL
    `).run(resolverUserId, existing.threadId, existing.repo, existing.entryType, existing.entryId);
        return this.getById(commentId);
    }
    reopen(commentId) {
        const existing = this.getById(commentId);
        if (!existing || existing.deletedAt)
            return null;
        // Same repo/entry scoping as resolve(), plus a resolved_at guard so a
        // concurrent resolve+reopen race can't clobber a freshly-resolved thread
        // without us noticing.
        this.db.prepare(`
      UPDATE comments
      SET resolved_at = NULL, resolved_by_user_id = NULL
      WHERE thread_id = ?
        AND repo = ? AND entry_type = ? AND entry_id = ?
        AND resolved_at IS NOT NULL
    `).run(existing.threadId, existing.repo, existing.entryType, existing.entryId);
        return this.getById(commentId);
    }
    /** Soft-delete: keeps thread continuity, blanks the visible body but
     *  preserves the original in edit_history so a future audit/restore
     *  path has something to recover. */
    softDelete(commentId, requesterUserId) {
        // Atomic so the edit_history append + body blank happen together.
        const txn = this.db.transaction(() => {
            const existing = this.getById(commentId);
            if (!existing)
                return null;
            if (existing.authorUserId !== requesterUserId) {
                throw new Error('Only the author can delete this comment');
            }
            if (existing.deletedAt)
                return existing;
            const history = [
                ...existing.editHistory,
                { at: existing.updatedAt ?? existing.createdAt, body: existing.body },
            ];
            this.db.prepare(`
        UPDATE comments
        SET deleted_at = datetime('now'), body = '', edit_history = ?
        WHERE comment_id = ?
      `).run(JSON.stringify(history), commentId);
            return this.getById(commentId);
        });
        return txn();
    }
    close() {
        this.db.close();
    }
    toComment(row) {
        let mentions = [];
        try {
            mentions = JSON.parse(row.mentions ?? '[]');
        }
        catch { /* default [] */ }
        let editHistory = [];
        try {
            editHistory = JSON.parse(row.edit_history ?? '[]');
        }
        catch { /* default [] */ }
        return {
            commentId: row.comment_id,
            repo: row.repo,
            entryType: row.entry_type,
            entryId: row.entry_id,
            threadId: row.thread_id,
            parentId: row.parent_id ?? null,
            authorUserId: row.author_user_id,
            authorDisplayName: row.author_display_name,
            authorRole: row.author_role,
            body: row.body,
            anchorRelpos: row.anchor_relpos ?? null,
            anchorQuote: row.anchor_quote ?? null,
            mentions,
            createdAt: row.created_at,
            updatedAt: row.updated_at ?? null,
            resolvedAt: row.resolved_at ?? null,
            resolvedByUserId: row.resolved_by_user_id ?? null,
            edited: row.edited === 1 ? 1 : 0,
            editHistory,
            deletedAt: row.deleted_at ?? null,
        };
    }
}
//# sourceMappingURL=comment-service.js.map