// ADR-0013 D3/D8/D9 — DB lifecycle, factories, and the public UiStore wiring. The synchronous
// `node:sqlite` DatabaseSync drives both factories; the node adapter adds directory creation,
// 0o700/0o600 permission hardening (Unix), and reopen-safe migrations.
import { DatabaseSync } from "node:sqlite";
import { chmodSync, existsSync, mkdirSync, renameSync, statSync } from "node:fs";
import { dirname } from "node:path";
import { randomUUID } from "node:crypto";
import { runMigrations } from "./schema.js";
import { deleteProject as sqlDeleteProject, getProject as sqlGetProject, listProjects as sqlListProjects, updateProject as sqlUpdateProject, upsertProject as sqlUpsertProject, } from "./projects.js";
import { deleteChat as sqlDeleteChat, findChatById as sqlFindChatById, insertChat as sqlInsertChat, listChats as sqlListChats, listChatsLimited as sqlListChatsLimited, touchChat as sqlTouchChat, updateChat as sqlUpdateChat, } from "./chats.js";
import { findMessageById as sqlFindMessageById, insertMessage as sqlInsertMessage, listMessages as sqlListMessages, listMessagesLimited as sqlListMessagesLimited, updateMessage as sqlUpdateMessage, } from "./messages.js";
import { validateProjectPath } from "./validation.js";
import { basename } from "node:path";
import { invalidRequest } from "./errors.js";
const DEFAULT_REDACT = (s) => s;
// Returns whether a project's directory currently exists and is a directory. Derived availability
// (ADR-0013 D5): the store never deletes a row because the path went missing; the UI surfaces this.
export function isProjectAvailable(project) {
    try {
        return statSync(project.path).isDirectory();
    }
    catch {
        return false;
    }
}
function resolveOptions(opts) {
    return {
        now: opts?.now ?? (() => Date.now()),
        newId: opts?.newId ?? randomUUID,
        redactString: opts?.redactString ?? DEFAULT_REDACT,
    };
}
function deriveProjectName(explicit, path) {
    if (explicit === undefined)
        return basename(path);
    if (explicit.length === 0)
        throw invalidRequest("Name must not be empty.");
    return explicit;
}
function createChatRecord(db, options, projectPath, title, selectedModel, opts) {
    const project = sqlGetProject(db, projectPath);
    if (project !== undefined && !isProjectAvailable(project)) {
        throw invalidRequest("Project path is unavailable.");
    }
    return sqlInsertChat(db, {
        id: options.newId(),
        projectPath,
        title,
        selectedModel,
        opts,
        now: options.now(),
    });
}
function createMessageRecord(db, options, msg) {
    return sqlInsertMessage(db, options.newId(), msg, options.redactString);
}
function createProjectRecord(db, options, path, name) {
    const normalized = validateProjectPath(path, { mustExist: true });
    const resolvedName = deriveProjectName(name, normalized);
    return sqlUpsertProject(db, normalized, resolvedName, name !== undefined, options.now());
}
function updateProjectRecord(db, options, path, patch) {
    const normalized = validateProjectPath(path, { mustExist: false });
    return sqlUpdateProject(db, normalized, patch, options.now());
}
function deleteProjectRecord(db, path) {
    const normalized = validateProjectPath(path, { mustExist: false });
    sqlDeleteProject(db, normalized);
}
function createMessageBatch(db, options, messages) {
    if (messages.length === 0) {
        throw invalidRequest("At least one message is required.");
    }
    db.exec("BEGIN");
    try {
        const created = messages.map((msg) => createMessageRecord(db, options, msg));
        for (const chatId of new Set(messages.map((msg) => msg.chatId))) {
            sqlTouchChat(db, chatId, options.now());
        }
        db.exec("COMMIT");
        return created;
    }
    catch (error) {
        db.exec("ROLLBACK");
        throw error;
    }
}
function buildStore(db, options) {
    return {
        listProjects: () => sqlListProjects(db),
        createProject: (path, name) => createProjectRecord(db, options, path, name),
        updateProject: (path, patch) => updateProjectRecord(db, options, path, patch),
        deleteProject: (path) => {
            deleteProjectRecord(db, path);
        },
        listChats: (projectPath, limit) => limit === undefined ? sqlListChats(db, projectPath) : sqlListChatsLimited(db, projectPath, limit),
        findChatById: (id) => sqlFindChatById(db, id),
        createChat: (projectPath, title, selectedModel, opts) => createChatRecord(db, options, projectPath, title, selectedModel, opts),
        updateChat: (id, patch) => sqlUpdateChat(db, id, patch, options.now()),
        deleteChat: (id) => {
            sqlDeleteChat(db, id);
        },
        listMessages: (chatId, limit) => limit === undefined ? sqlListMessages(db, chatId) : sqlListMessagesLimited(db, chatId, limit),
        findMessageById: (id) => sqlFindMessageById(db, id),
        createMessage: (msg) => {
            const message = createMessageRecord(db, options, msg);
            sqlTouchChat(db, msg.chatId, options.now());
            return message;
        },
        createMessages: (messages) => createMessageBatch(db, options, messages),
        updateMessage: (id, patch) => sqlUpdateMessage(db, id, patch, options.redactString),
        close: () => {
            db.close();
        },
    };
}
function quarantineCorruptDb(target) {
    const ts = new Date().toISOString().replace(/[:.]/g, "-");
    renameSync(target, `${target}.corrupt.${ts}`);
    for (const sidecar of [`${target}-wal`, `${target}-shm`]) {
        if (existsSync(sidecar)) {
            renameSync(sidecar, `${sidecar}.corrupt.${ts}`);
        }
    }
}
// Issue #639 — bound the SQLITE_BUSY window so concurrent UI/BFF writers (chat writes,
// relationship writes, evidence-adjacent updates) wait for the writer lock for a short, bounded
// interval instead of failing immediately. 5_000ms matches the conservative default we want for
// the local single-writer desktop pattern; exported so the regression test can assert the value
// without re-deriving it.
export const UI_DB_BUSY_TIMEOUT_MS = 5_000;
function preparedDatabase(target) {
    const db = new DatabaseSync(target);
    db.exec("PRAGMA foreign_keys = ON");
    db.exec(`PRAGMA busy_timeout = ${String(UI_DB_BUSY_TIMEOUT_MS)}`);
    return db;
}
// ────────────────────────────────────────────────────────────────────────────
// In-memory factory (tests)
// ────────────────────────────────────────────────────────────────────────────
export function createInMemoryUiStore(opts) {
    const db = preparedDatabase(":memory:");
    runMigrations(db);
    return buildStore(db, resolveOptions(opts));
}
// ────────────────────────────────────────────────────────────────────────────
// Node on-disk factory
// ────────────────────────────────────────────────────────────────────────────
function ensureDirHardened(dir) {
    if (!existsSync(dir)) {
        mkdirSync(dir, { recursive: true, mode: 0o700 });
    }
    if (process.platform !== "win32") {
        try {
            chmodSync(dir, 0o700);
        }
        catch {
            // best-effort; leave existing perms if owner change is unavailable
        }
    }
}
function chmodIfPresent(path, mode) {
    if (process.platform === "win32")
        return;
    try {
        chmodSync(path, mode);
    }
    catch {
        // file may not exist yet (WAL/-shm sidecars); best-effort
    }
}
// Issue #539: deps.ts needs the raw DatabaseSync to compose the relationship-engine store on
// the same UI database file. The relationship V5 schema lives in this DB (schema.ts §V5);
// keeping a single connection avoids WAL-coordination overhead. `createNodeUiStore` stays a
// one-shot convenience for callers that do not need the underlying handle.
export function openNodeUiDatabase(dbPath) {
    ensureDirHardened(dirname(dbPath));
    let db = preparedDatabase(dbPath);
    try {
        db.exec("PRAGMA journal_mode = WAL");
        runMigrations(db);
    }
    catch {
        // Corrupt DB: quarantine (rename to .corrupt.<iso>) and open a fresh one.
        db.close();
        quarantineCorruptDb(dbPath);
        db = preparedDatabase(dbPath);
        db.exec("PRAGMA journal_mode = WAL");
        runMigrations(db);
    }
    chmodIfPresent(dbPath, 0o600);
    chmodIfPresent(`${dbPath}-wal`, 0o600);
    chmodIfPresent(`${dbPath}-shm`, 0o600);
    return db;
}
export function buildUiStoreOverDatabase(db, opts) {
    return buildStore(db, resolveOptions(opts));
}
export function createNodeUiStore(dbPath, opts) {
    return buildUiStoreOverDatabase(openNodeUiDatabase(dbPath), opts);
}
