// SQLite-backed persistence for projects, conversations, messages, and the
// per-project set of open file tabs. The on-disk project folder under
// .od/projects/<id>/ is still the single owner of the user's actual files
// (HTML artifacts, sketches, uploads); this database tracks the metadata
// that used to live in localStorage.
import path from 'node:path';
import fs from 'node:fs';
import { randomUUID } from 'node:crypto';
import { createRequire } from 'node:module';
import { migrateCritique } from './critique/persistence.js';
import { migrateMediaTasks } from './media-tasks.js';
import { createW3KitsMemoryDatabase } from './w3kits-memory-sqlite.js';
let dbInstance = null;
let dbFile = null;
const require = createRequire(import.meta.url);
function row(value) {
    return value && typeof value === 'object' ? value : null;
}
function rows(value) {
    return value.map((item) => row(item) ?? {});
}
export function openDatabase(projectRoot, { dataDir } = {}) {
    const dir = dataDir ? path.resolve(dataDir) : path.join(projectRoot, '.od');
    const file = path.join(dir, 'app.sqlite');
    if (dbInstance && dbFile === file)
        return dbInstance;
    if (dbInstance)
        closeDatabase();
    fs.mkdirSync(dir, { recursive: true });
    const db = process.env.W3KITS_WEBCONTAINER === '1'
        ? createW3KitsMemoryDatabase(file)
        : new (require('better-sqlite3'))(file);
    db.pragma?.('journal_mode = WAL');
    db.pragma?.('foreign_keys = ON');
    migrate(db);
    dbInstance = db;
    dbFile = file;
    return db;
}
export function closeDatabase() {
    if (!dbInstance)
        return;
    dbInstance.close();
    dbInstance = null;
    dbFile = null;
}
function migrate(db) {
    db.exec(`
    CREATE TABLE IF NOT EXISTS projects (
      id TEXT PRIMARY KEY,
      name TEXT NOT NULL,
      skill_id TEXT,
      design_system_id TEXT,
      pending_prompt TEXT,
      metadata_json TEXT,
      created_at INTEGER NOT NULL,
      updated_at INTEGER NOT NULL
    );

    CREATE TABLE IF NOT EXISTS templates (
      id TEXT PRIMARY KEY,
      name TEXT NOT NULL,
      description TEXT,
      source_project_id TEXT,
      files_json TEXT NOT NULL,
      created_at INTEGER NOT NULL
    );

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

    CREATE INDEX IF NOT EXISTS idx_conv_project
      ON conversations(project_id, updated_at DESC);

    CREATE TABLE IF NOT EXISTS messages (
      id TEXT PRIMARY KEY,
      conversation_id TEXT NOT NULL,
      role TEXT NOT NULL,
      content TEXT NOT NULL,
      agent_id TEXT,
      agent_name TEXT,
      events_json TEXT,
      attachments_json TEXT,
      produced_files_json TEXT,
      feedback_json TEXT,
      started_at INTEGER,
      ended_at INTEGER,
      position INTEGER NOT NULL,
      created_at INTEGER NOT NULL,
      FOREIGN KEY(conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
    );

    CREATE INDEX IF NOT EXISTS idx_messages_conv
      ON messages(conversation_id, position);

    CREATE TABLE IF NOT EXISTS preview_comments (
      id TEXT PRIMARY KEY,
      project_id TEXT NOT NULL,
      conversation_id TEXT NOT NULL,
      file_path TEXT NOT NULL,
      element_id TEXT NOT NULL,
      selector TEXT NOT NULL,
      label TEXT NOT NULL,
      text TEXT NOT NULL,
      position_json TEXT NOT NULL,
      html_hint TEXT NOT NULL,
      note TEXT NOT NULL,
      status TEXT NOT NULL,
      created_at INTEGER NOT NULL,
      updated_at INTEGER NOT NULL,
      UNIQUE(project_id, conversation_id, file_path, element_id),
      FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE,
      FOREIGN KEY(conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
    );

    CREATE INDEX IF NOT EXISTS idx_preview_comments_conversation
      ON preview_comments(project_id, conversation_id, updated_at DESC);

    CREATE TABLE IF NOT EXISTS tabs (
      project_id TEXT NOT NULL,
      name TEXT NOT NULL,
      position INTEGER NOT NULL,
      is_active INTEGER NOT NULL DEFAULT 0,
      PRIMARY KEY(project_id, name),
      FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
    );

    CREATE INDEX IF NOT EXISTS idx_tabs_project
      ON tabs(project_id, position);

    CREATE TABLE IF NOT EXISTS deployments (
      id TEXT PRIMARY KEY,
      project_id TEXT NOT NULL,
      file_name TEXT NOT NULL,
      provider_id TEXT NOT NULL,
      url TEXT NOT NULL,
      deployment_id TEXT,
      deployment_count INTEGER NOT NULL DEFAULT 1,
      target TEXT NOT NULL DEFAULT 'preview',
      status TEXT NOT NULL DEFAULT 'ready',
      status_message TEXT,
      reachable_at INTEGER,
      provider_metadata_json TEXT,
      created_at INTEGER NOT NULL,
      updated_at INTEGER NOT NULL,
      UNIQUE(project_id, file_name, provider_id),
      FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
    );

    CREATE INDEX IF NOT EXISTS idx_deployments_project
      ON deployments(project_id, updated_at DESC);

    CREATE TABLE IF NOT EXISTS routines (
      id TEXT PRIMARY KEY,
      name TEXT NOT NULL,
      prompt TEXT NOT NULL,
      schedule_kind TEXT NOT NULL,
      schedule_value TEXT NOT NULL,
      schedule_json TEXT,
      project_mode TEXT NOT NULL,
      project_id TEXT,
      skill_id TEXT,
      agent_id TEXT,
      enabled INTEGER NOT NULL DEFAULT 1,
      created_at INTEGER NOT NULL,
      updated_at INTEGER NOT NULL
    );

    CREATE TABLE IF NOT EXISTS routine_runs (
      id TEXT PRIMARY KEY,
      routine_id TEXT NOT NULL,
      trigger TEXT NOT NULL,
      status TEXT NOT NULL,
      project_id TEXT NOT NULL,
      conversation_id TEXT NOT NULL,
      agent_run_id TEXT NOT NULL,
      started_at INTEGER NOT NULL,
      completed_at INTEGER,
      summary TEXT,
      error TEXT,
      FOREIGN KEY(routine_id) REFERENCES routines(id) ON DELETE CASCADE
    );

    CREATE INDEX IF NOT EXISTS idx_routine_runs_routine
      ON routine_runs(routine_id, started_at DESC);
  `);
    // Forward-compatible column add for databases created before metadata_json.
    // SQLite has no IF NOT EXISTS for ALTER, so we check pragma_table_info.
    const cols = db.prepare(`PRAGMA table_info(projects)`).all();
    if (!cols.some((c) => c.name === 'metadata_json')) {
        db.exec(`ALTER TABLE projects ADD COLUMN metadata_json TEXT`);
    }
    if (!cols.some((c) => c.name === 'custom_instructions')) {
        db.exec(`ALTER TABLE projects ADD COLUMN custom_instructions TEXT`);
    }
    const messageCols = db.prepare(`PRAGMA table_info(messages)`).all();
    if (!messageCols.some((c) => c.name === 'agent_id')) {
        db.exec(`ALTER TABLE messages ADD COLUMN agent_id TEXT`);
    }
    if (!messageCols.some((c) => c.name === 'agent_name')) {
        db.exec(`ALTER TABLE messages ADD COLUMN agent_name TEXT`);
    }
    if (!messageCols.some((c) => c.name === 'run_id')) {
        db.exec(`ALTER TABLE messages ADD COLUMN run_id TEXT`);
    }
    if (!messageCols.some((c) => c.name === 'run_status')) {
        db.exec(`ALTER TABLE messages ADD COLUMN run_status TEXT`);
    }
    if (!messageCols.some((c) => c.name === 'last_run_event_id')) {
        db.exec(`ALTER TABLE messages ADD COLUMN last_run_event_id TEXT`);
    }
    if (!messageCols.some((c) => c.name === 'comment_attachments_json')) {
        db.exec(`ALTER TABLE messages ADD COLUMN comment_attachments_json TEXT`);
    }
    if (!messageCols.some((c) => c.name === 'feedback_json')) {
        db.exec(`ALTER TABLE messages ADD COLUMN feedback_json TEXT`);
    }
    const previewCommentCols = db.prepare(`PRAGMA table_info(preview_comments)`).all();
    if (!previewCommentCols.some((c) => c.name === 'selection_kind')) {
        db.exec(`ALTER TABLE preview_comments ADD COLUMN selection_kind TEXT`);
    }
    if (!previewCommentCols.some((c) => c.name === 'member_count')) {
        db.exec(`ALTER TABLE preview_comments ADD COLUMN member_count INTEGER`);
    }
    if (!previewCommentCols.some((c) => c.name === 'pod_members_json')) {
        db.exec(`ALTER TABLE preview_comments ADD COLUMN pod_members_json TEXT`);
    }
    const deploymentCols = db.prepare(`PRAGMA table_info(deployments)`).all();
    if (!deploymentCols.some((c) => c.name === 'status')) {
        db.exec(`ALTER TABLE deployments ADD COLUMN status TEXT NOT NULL DEFAULT 'ready'`);
    }
    if (!deploymentCols.some((c) => c.name === 'status_message')) {
        db.exec(`ALTER TABLE deployments ADD COLUMN status_message TEXT`);
    }
    if (!deploymentCols.some((c) => c.name === 'reachable_at')) {
        db.exec(`ALTER TABLE deployments ADD COLUMN reachable_at INTEGER`);
    }
    if (!deploymentCols.some((c) => c.name === 'provider_metadata_json')) {
        db.exec(`ALTER TABLE deployments ADD COLUMN provider_metadata_json TEXT`);
    }
    // schedule_json holds the full RoutineSchedule object (kind discriminator
    // plus kind-specific fields like time/timezone/weekday). The legacy
    // schedule_kind/schedule_value columns are kept populated for query
    // convenience and as a fallback when reading rows written before this
    // column existed.
    const routineCols = db.prepare(`PRAGMA table_info(routines)`).all();
    if (routineCols.length > 0 && !routineCols.some((c) => c.name === 'schedule_json')) {
        db.exec(`ALTER TABLE routines ADD COLUMN schedule_json TEXT`);
    }
    migrateCritique(db);
    migrateMediaTasks(db);
}
// ---------- deployments ----------
const DEPLOYMENT_COLS = `id, project_id AS projectId, file_name AS fileName,
  provider_id AS providerId, url, deployment_id AS deploymentId,
  deployment_count AS deploymentCount, target, status,
  status_message AS statusMessage, reachable_at AS reachableAt,
  provider_metadata_json AS providerMetadataJson,
  created_at AS createdAt, updated_at AS updatedAt`;
export function listDeployments(db, projectId) {
    return db
        .prepare(`SELECT ${DEPLOYMENT_COLS}
         FROM deployments
        WHERE project_id = ?
        ORDER BY updated_at DESC`)
        .all(projectId)
        .map(normalizeDeployment);
}
export function getDeployment(db, projectId, fileName, providerId) {
    const row = db
        .prepare(`SELECT ${DEPLOYMENT_COLS}
         FROM deployments
        WHERE project_id = ? AND file_name = ? AND provider_id = ?`)
        .get(projectId, fileName, providerId);
    return row ? normalizeDeployment(row) : null;
}
export function getDeploymentById(db, projectId, id) {
    const row = db
        .prepare(`SELECT ${DEPLOYMENT_COLS}
         FROM deployments
        WHERE project_id = ? AND id = ?`)
        .get(projectId, id);
    return row ? normalizeDeployment(row) : null;
}
export function upsertDeployment(db, deployment) {
    const existing = getDeployment(db, deployment.projectId, deployment.fileName, deployment.providerId);
    const now = Date.now();
    const inputProviderMetadata = deployment.providerMetadata === undefined
        ? existing?.providerMetadata
        : deployment.providerMetadata;
    const providerMetadata = deployment.cloudflarePages && typeof deployment.cloudflarePages === 'object'
        ? {
            ...(inputProviderMetadata && typeof inputProviderMetadata === 'object' && !Array.isArray(inputProviderMetadata)
                ? inputProviderMetadata
                : {}),
            cloudflarePages: deployment.cloudflarePages,
        }
        : inputProviderMetadata;
    const next = {
        id: existing?.id ?? deployment.id,
        projectId: deployment.projectId,
        fileName: deployment.fileName,
        providerId: deployment.providerId,
        url: deployment.url,
        deploymentId: deployment.deploymentId ?? null,
        deploymentCount: typeof deployment.deploymentCount === 'number'
            ? deployment.deploymentCount
            : (existing?.deploymentCount ?? 0) + 1,
        target: deployment.target ?? 'preview',
        status: deployment.status ?? existing?.status ?? 'ready',
        statusMessage: deployment.statusMessage ?? null,
        reachableAt: deployment.reachableAt ?? null,
        providerMetadata,
        createdAt: existing?.createdAt ?? deployment.createdAt ?? now,
        updatedAt: deployment.updatedAt ?? now,
    };
    const providerMetadataJson = stringifyJsonObjectOrNull(next.providerMetadata);
    db.prepare(`INSERT INTO deployments
       (id, project_id, file_name, provider_id, url, deployment_id,
        deployment_count, target, status, status_message, reachable_at,
        provider_metadata_json, created_at, updated_at)
     VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
     ON CONFLICT(project_id, file_name, provider_id) DO UPDATE SET
       url = excluded.url,
       deployment_id = excluded.deployment_id,
       deployment_count = excluded.deployment_count,
       target = excluded.target,
       status = excluded.status,
       status_message = excluded.status_message,
       reachable_at = excluded.reachable_at,
       provider_metadata_json = excluded.provider_metadata_json,
       updated_at = excluded.updated_at`).run(next.id, next.projectId, next.fileName, next.providerId, next.url, next.deploymentId, next.deploymentCount, next.target, next.status, next.statusMessage, next.reachableAt, providerMetadataJson, next.createdAt, next.updatedAt);
    return getDeployment(db, next.projectId, next.fileName, next.providerId);
}
function normalizeDeployment(row) {
    const providerMetadata = parseJsonOrUndef(row.providerMetadataJson);
    const normalizedProviderMetadata = providerMetadata && typeof providerMetadata === 'object' && !Array.isArray(providerMetadata)
        ? providerMetadata
        : undefined;
    return {
        id: row.id,
        projectId: row.projectId,
        fileName: row.fileName,
        providerId: row.providerId,
        url: row.url,
        deploymentId: row.deploymentId ?? undefined,
        deploymentCount: Number(row.deploymentCount ?? 1),
        target: 'preview',
        status: row.status || 'ready',
        statusMessage: row.statusMessage ?? undefined,
        reachableAt: row.reachableAt == null ? undefined : Number(row.reachableAt),
        cloudflarePages: normalizedProviderMetadata?.cloudflarePages &&
            typeof normalizedProviderMetadata.cloudflarePages === 'object' &&
            !Array.isArray(normalizedProviderMetadata.cloudflarePages)
            ? normalizedProviderMetadata.cloudflarePages
            : undefined,
        providerMetadata: normalizedProviderMetadata,
        createdAt: Number(row.createdAt),
        updatedAt: Number(row.updatedAt),
    };
}
function stringifyJsonObjectOrNull(value) {
    if (!value || typeof value !== 'object' || Array.isArray(value))
        return null;
    return Object.keys(value).length > 0 ? JSON.stringify(value) : null;
}
// ---------- projects ----------
const PROJECT_COLS = `id, name, skill_id AS skillId,
  design_system_id AS designSystemId,
  pending_prompt AS pendingPrompt,
  metadata_json AS metadataJson,
  custom_instructions AS customInstructions,
  created_at AS createdAt,
  updated_at AS updatedAt`;
export function listProjects(db) {
    const rows = db
        .prepare(`SELECT ${PROJECT_COLS}
         FROM projects
        ORDER BY updated_at DESC`)
        .all();
    return rows.map(normalizeProject);
}
export function listLatestProjectRunStatuses(db) {
    const rows = db
        .prepare(`SELECT c.project_id AS projectId,
              m.run_id AS runId,
              m.run_status AS status,
              COALESCE(m.ended_at, m.started_at, m.created_at) AS updatedAt
         FROM messages m
         JOIN conversations c ON c.id = m.conversation_id
        WHERE m.run_status IS NOT NULL
        ORDER BY updatedAt DESC`)
        .all();
    const latestByProject = new Map();
    for (const row of rows) {
        if (!latestByProject.has(row.projectId)) {
            latestByProject.set(row.projectId, {
                value: normalizeProjectRunStatus(row.status),
                updatedAt: Number(row.updatedAt),
                runId: row.runId ?? undefined,
            });
        }
    }
    return latestByProject;
}
export function listProjectsAwaitingInput(db) {
    const rows = db
        .prepare(`SELECT latest.projectId
         FROM (
           SELECT c.project_id AS projectId,
                  m.conversation_id AS conversationId,
                  m.created_at AS createdAt,
                  m.position AS position,
                  ROW_NUMBER() OVER (
                    PARTITION BY c.project_id
                    ORDER BY m.created_at DESC, m.position DESC
                  ) AS rowNum
             FROM messages m
             JOIN conversations c ON c.id = m.conversation_id
            WHERE m.role = 'assistant'
              AND LOWER(m.content) LIKE '%<question-form%'
         ) latest
        WHERE latest.rowNum = 1
          AND NOT EXISTS (
            SELECT 1
              FROM messages reply
             WHERE reply.conversation_id = latest.conversationId
               AND reply.role = 'user'
               AND (
                 reply.created_at > latest.createdAt
                 OR (reply.created_at = latest.createdAt AND reply.position > latest.position)
               )
          )`)
        .all();
    return new Set(rows.map((row) => row.projectId));
}
export function getProject(db, id) {
    const row = db
        .prepare(`SELECT ${PROJECT_COLS} FROM projects WHERE id = ?`)
        .get(id);
    return row ? normalizeProject(row) : null;
}
export function insertProject(db, p) {
    db.prepare(`INSERT INTO projects
       (id, name, skill_id, design_system_id, pending_prompt,
        metadata_json, custom_instructions, created_at, updated_at)
     VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(p.id, p.name, p.skillId ?? null, p.designSystemId ?? null, p.pendingPrompt ?? null, p.metadata ? JSON.stringify(p.metadata) : null, p.customInstructions ?? null, p.createdAt, p.updatedAt);
    return getProject(db, p.id);
}
export function updateProject(db, id, patch) {
    const existing = getProject(db, id);
    if (!existing)
        return null;
    const merged = {
        ...existing,
        ...patch,
        updatedAt: typeof patch.updatedAt === 'number' ? patch.updatedAt : Date.now(),
    };
    db.prepare(`UPDATE projects
        SET name = ?,
            skill_id = ?,
            design_system_id = ?,
            pending_prompt = ?,
            metadata_json = ?,
            custom_instructions = ?,
            updated_at = ?
      WHERE id = ?`).run(merged.name, merged.skillId ?? null, merged.designSystemId ?? null, merged.pendingPrompt ?? null, merged.metadata ? JSON.stringify(merged.metadata) : null, merged.customInstructions ?? null, merged.updatedAt, id);
    return getProject(db, id);
}
export function deleteProject(db, id) {
    db.prepare(`DELETE FROM projects WHERE id = ?`).run(id);
}
function normalizeProject(row) {
    let metadata;
    if (row.metadataJson) {
        try {
            metadata = JSON.parse(row.metadataJson);
        }
        catch {
            metadata = undefined;
        }
    }
    return {
        id: row.id,
        name: row.name,
        skillId: row.skillId,
        designSystemId: row.designSystemId,
        pendingPrompt: row.pendingPrompt ?? undefined,
        metadata,
        customInstructions: row.customInstructions ?? undefined,
        createdAt: Number(row.createdAt),
        updatedAt: Number(row.updatedAt),
    };
}
function normalizeProjectRunStatus(status) {
    if (status === 'starting')
        return 'running';
    if (status === 'cancelled')
        return 'canceled';
    if (status === 'queued' ||
        status === 'running' ||
        status === 'succeeded' ||
        status === 'failed' ||
        status === 'canceled') {
        return status;
    }
    return 'not_started';
}
// ---------- templates ----------
export function listTemplates(db) {
    return db
        .prepare(`SELECT id, name, description, source_project_id AS sourceProjectId,
              files_json AS filesJson, created_at AS createdAt
         FROM templates
        ORDER BY created_at DESC`)
        .all()
        .map(normalizeTemplate);
}
export function getTemplate(db, id) {
    const row = db
        .prepare(`SELECT id, name, description, source_project_id AS sourceProjectId,
              files_json AS filesJson, created_at AS createdAt
         FROM templates WHERE id = ?`)
        .get(id);
    return row ? normalizeTemplate(row) : null;
}
export function findTemplateByNameAndProject(db, name, sourceProjectId) {
    const row = db
        .prepare(`SELECT id, name, description, source_project_id AS sourceProjectId,
              files_json AS filesJson, created_at AS createdAt
         FROM templates
        WHERE name = ? AND source_project_id = ?`)
        .get(name, sourceProjectId);
    return row ? normalizeTemplate(row) : null;
}
export function insertTemplate(db, t) {
    db.prepare(`INSERT INTO templates (id, name, description, source_project_id, files_json, created_at)
     VALUES (?, ?, ?, ?, ?, ?)`).run(t.id, t.name, t.description ?? null, t.sourceProjectId ?? null, JSON.stringify(t.files ?? []), t.createdAt);
    return getTemplate(db, t.id);
}
export function updateTemplate(db, id, t) {
    db.prepare(`UPDATE templates SET description = ?, files_json = ? WHERE id = ?`).run(t.description, JSON.stringify(t.files), id);
    return getTemplate(db, id);
}
export function deleteTemplate(db, id) {
    db.prepare(`DELETE FROM templates WHERE id = ?`).run(id);
}
function normalizeTemplate(row) {
    let files = [];
    try {
        files = JSON.parse(row.filesJson || '[]');
    }
    catch {
        files = [];
    }
    return {
        id: row.id,
        name: row.name,
        description: row.description ?? undefined,
        sourceProjectId: row.sourceProjectId ?? undefined,
        files,
        createdAt: Number(row.createdAt),
    };
}
// ---------- conversations ----------
export function listConversations(db, projectId) {
    return rows(db
        .prepare(`WITH project_conversations AS (
          SELECT id, project_id AS projectId, title,
                 created_at AS createdAt, updated_at AS updatedAt
            FROM conversations
           WHERE project_id = ?
        ),
        latest_runs AS (
          SELECT conversation_id AS conversationId,
                 run_status AS latestRunStatus,
                 started_at AS latestRunStartedAt,
                 ended_at AS latestRunEndedAt,
                 events_json AS latestRunEventsJson
            FROM (
              SELECT m.conversation_id,
                     m.run_status,
                     m.started_at,
                     m.ended_at,
                     m.events_json,
                     ROW_NUMBER() OVER (
                       PARTITION BY m.conversation_id
                       ORDER BY m.position DESC
                     ) AS rn
                FROM messages m
                JOIN project_conversations c ON c.id = m.conversation_id
               WHERE m.role = 'assistant'
                 AND m.run_status IS NOT NULL
            )
           WHERE rn = 1
        )
        SELECT c.id, c.projectId, c.title, c.createdAt, c.updatedAt,
               lr.latestRunStatus, lr.latestRunStartedAt,
               lr.latestRunEndedAt, lr.latestRunEventsJson
          FROM project_conversations c
          LEFT JOIN latest_runs lr ON lr.conversationId = c.id
         ORDER BY c.updatedAt DESC`)
        .all(projectId)).map(normalizeConversation);
}
export function getConversation(db, id) {
    const r = db
        .prepare(`SELECT id, project_id AS projectId, title,
              created_at AS createdAt, updated_at AS updatedAt
         FROM conversations WHERE id = ?`)
        .get(id);
    if (!r)
        return null;
    return {
        ...normalizeConversation(r),
        latestRun: latestConversationRunSummary(db, r.id) ?? undefined,
    };
}
function normalizeConversation(r) {
    const latestRun = conversationRunSummaryFromRow({
        runStatus: r.latestRunStatus,
        startedAt: r.latestRunStartedAt,
        endedAt: r.latestRunEndedAt,
        eventsJson: r.latestRunEventsJson,
    });
    return {
        id: r.id,
        projectId: r.projectId,
        title: r.title ?? null,
        createdAt: Number(r.createdAt),
        updatedAt: Number(r.updatedAt),
        latestRun: latestRun ?? undefined,
    };
}
function latestConversationRunSummary(db, conversationId) {
    const row = db
        .prepare(`SELECT run_status AS runStatus,
              started_at AS startedAt,
              ended_at AS endedAt,
              events_json AS eventsJson
         FROM messages
        WHERE conversation_id = ?
          AND role = 'assistant'
          AND run_status IS NOT NULL
        ORDER BY position DESC
        LIMIT 1`)
        .get(conversationId);
    return conversationRunSummaryFromRow(row);
}
function conversationRunSummaryFromRow(row) {
    if (!row || typeof row.runStatus !== 'string')
        return null;
    const startedAt = row.startedAt == null ? undefined : Number(row.startedAt);
    const endedAt = row.endedAt == null ? undefined : Number(row.endedAt);
    const usageDurationMs = latestUsageDurationMs(row.eventsJson);
    const durationMs = Number.isFinite(startedAt) && Number.isFinite(endedAt)
        ? Math.max(0, endedAt - startedAt)
        : usageDurationMs;
    return {
        status: row.runStatus,
        ...(Number.isFinite(startedAt) ? { startedAt } : {}),
        ...(Number.isFinite(endedAt) ? { endedAt } : {}),
        ...(typeof durationMs === 'number' && Number.isFinite(durationMs)
            ? { durationMs }
            : {}),
    };
}
function latestUsageDurationMs(eventsJson) {
    if (typeof eventsJson !== 'string' || eventsJson.length === 0)
        return undefined;
    try {
        const events = JSON.parse(eventsJson);
        if (!Array.isArray(events))
            return undefined;
        for (let i = events.length - 1; i >= 0; i -= 1) {
            const event = events[i];
            if (event &&
                typeof event === 'object' &&
                event.kind === 'usage' &&
                typeof event.durationMs === 'number' &&
                Number.isFinite(event.durationMs)) {
                return Math.max(0, event.durationMs);
            }
        }
    }
    catch {
        return undefined;
    }
    return undefined;
}
export function insertConversation(db, c) {
    db.prepare(`INSERT INTO conversations
       (id, project_id, title, created_at, updated_at)
     VALUES (?, ?, ?, ?, ?)`).run(c.id, c.projectId, c.title ?? null, c.createdAt, c.updatedAt);
    return getConversation(db, c.id);
}
export function updateConversation(db, id, patch) {
    const existing = getConversation(db, id);
    if (!existing)
        return null;
    const merged = {
        ...existing,
        ...patch,
        updatedAt: typeof patch.updatedAt === 'number' ? patch.updatedAt : Date.now(),
    };
    db.prepare(`UPDATE conversations
        SET title = ?, updated_at = ? WHERE id = ?`).run(merged.title ?? null, merged.updatedAt, id);
    return getConversation(db, id);
}
export function deleteConversation(db, id) {
    db.prepare(`DELETE FROM conversations WHERE id = ?`).run(id);
}
// ---------- messages ----------
export function listMessages(db, conversationId) {
    return db
        .prepare(`SELECT id, role, content, agent_id AS agentId, agent_name AS agentName,
              run_id AS runId, run_status AS runStatus,
              last_run_event_id AS lastRunEventId,
              events_json AS eventsJson,
              attachments_json AS attachmentsJson,
              comment_attachments_json AS commentAttachmentsJson,
              produced_files_json AS producedFilesJson,
              feedback_json AS feedbackJson,
              created_at AS createdAt, started_at AS startedAt, ended_at AS endedAt,
              position
         FROM messages
        WHERE conversation_id = ?
        ORDER BY position ASC`)
        .all(conversationId)
        .map(normalizeMessage);
}
export function upsertMessage(db, conversationId, m) {
    const existing = db
        .prepare(`SELECT position FROM messages WHERE id = ?`)
        .get(m.id);
    const now = Date.now();
    if (existing) {
        db.prepare(`UPDATE messages
          SET role = ?, content = ?, agent_id = ?, agent_name = ?,
              run_id = ?, run_status = ?, last_run_event_id = ?,
              events_json = ?, attachments_json = ?, comment_attachments_json = ?,
              produced_files_json = ?, feedback_json = ?, started_at = ?, ended_at = ?
        WHERE id = ?`).run(m.role, m.content, m.agentId ?? null, m.agentName ?? null, m.runId ?? null, m.runStatus ?? null, m.lastRunEventId ?? null, m.events ? JSON.stringify(m.events) : null, m.attachments ? JSON.stringify(m.attachments) : null, m.commentAttachments ? JSON.stringify(m.commentAttachments) : null, m.producedFiles ? JSON.stringify(m.producedFiles) : null, m.feedback ? JSON.stringify(m.feedback) : null, m.startedAt ?? null, m.endedAt ?? null, m.id);
    }
    else {
        const max = db
            .prepare(`SELECT COALESCE(MAX(position), -1) AS m FROM messages WHERE conversation_id = ?`)
            .get(conversationId);
        const position = (max?.m ?? -1) + 1;
        // 18 values: id, conversation_id, role, content, agent_id, agent_name,
        // run_id, run_status, last_run_event_id, events_json, attachments_json,
        // comment_attachments_json, produced_files_json, feedback_json, started_at, ended_at,
        // position, created_at.
        db.prepare(`INSERT INTO messages
         (id, conversation_id, role, content, agent_id, agent_name,
          run_id, run_status, last_run_event_id, events_json,
          attachments_json, comment_attachments_json, produced_files_json,
          feedback_json, started_at, ended_at, position, created_at)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(m.id, conversationId, m.role, m.content, m.agentId ?? null, m.agentName ?? null, m.runId ?? null, m.runStatus ?? null, m.lastRunEventId ?? null, m.events ? JSON.stringify(m.events) : null, m.attachments ? JSON.stringify(m.attachments) : null, m.commentAttachments ? JSON.stringify(m.commentAttachments) : null, m.producedFiles ? JSON.stringify(m.producedFiles) : null, m.feedback ? JSON.stringify(m.feedback) : null, m.startedAt ?? null, m.endedAt ?? null, position, now);
    }
    // Bump conversation activity so the sidebar's recency sort works.
    db.prepare(`UPDATE conversations SET updated_at = ? WHERE id = ?`).run(now, conversationId);
    const row = db
        .prepare(`SELECT id, role, content, agent_id AS agentId, agent_name AS agentName,
              run_id AS runId, run_status AS runStatus,
              last_run_event_id AS lastRunEventId,
              events_json AS eventsJson,
              attachments_json AS attachmentsJson,
              comment_attachments_json AS commentAttachmentsJson,
              produced_files_json AS producedFilesJson,
              feedback_json AS feedbackJson,
              created_at AS createdAt, started_at AS startedAt, ended_at AS endedAt,
              position
         FROM messages WHERE id = ?`)
        .get(m.id);
    return row ? normalizeMessage(row) : null;
}
export function deleteMessage(db, id) {
    db.prepare(`DELETE FROM messages WHERE id = ?`).run(id);
}
// ---------- preview comments ----------
const PREVIEW_COMMENT_STATUSES = new Set([
    'open',
    'attached',
    'applying',
    'needs_review',
    'resolved',
    'failed',
]);
export function listPreviewComments(db, projectId, conversationId) {
    return db
        .prepare(`SELECT id, project_id AS projectId, conversation_id AS conversationId,
              file_path AS filePath, element_id AS elementId, selector, label,
              text, position_json AS positionJson, html_hint AS htmlHint,
              selection_kind AS selectionKind, member_count AS memberCount,
              pod_members_json AS podMembersJson,
              note, status, created_at AS createdAt, updated_at AS updatedAt
         FROM preview_comments
        WHERE project_id = ? AND conversation_id = ?
        ORDER BY updated_at DESC`)
        .all(projectId, conversationId)
        .map(normalizePreviewComment);
}
export function upsertPreviewComment(db, projectId, conversationId, input) {
    const target = input?.target ?? {};
    const note = typeof input?.note === 'string' ? input.note.trim() : '';
    if (!note)
        throw new Error('comment note required');
    const filePath = cleanRequiredString(target.filePath, 'filePath');
    const elementId = cleanRequiredString(target.elementId, 'elementId');
    const selector = cleanRequiredString(target.selector, 'selector');
    const label = cleanRequiredString(target.label, 'label');
    const text = typeof target.text === 'string' ? compactWhitespace(target.text).slice(0, 160) : '';
    const htmlHint = typeof target.htmlHint === 'string' ? compactWhitespace(target.htmlHint).slice(0, 180) : '';
    const position = normalizePosition(target.position);
    const selectionKind = target.selectionKind === 'pod' ? 'pod' : 'element';
    const podMembers = selectionKind === 'pod' ? normalizePodMembers(target.podMembers) : [];
    const memberCount = selectionKind === 'pod'
        ? (podMembers.length > 0
            ? podMembers.length
            : Number.isFinite(target.memberCount)
                ? Math.max(0, Math.round(target.memberCount))
                : 0)
        : 0;
    const now = Date.now();
    const existing = db
        .prepare(`SELECT id, created_at AS createdAt
         FROM preview_comments
        WHERE project_id = ? AND conversation_id = ? AND file_path = ? AND element_id = ?`)
        .get(projectId, conversationId, filePath, elementId);
    const id = existing?.id ?? randomCommentId();
    const createdAt = existing?.createdAt ?? now;
    db.prepare(`INSERT INTO preview_comments
       (id, project_id, conversation_id, file_path, element_id, selector, label,
        text, position_json, html_hint, selection_kind, member_count, pod_members_json,
        note, status, created_at, updated_at)
     VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
     ON CONFLICT(project_id, conversation_id, file_path, element_id) DO UPDATE SET
       selector = excluded.selector,
       label = excluded.label,
       text = excluded.text,
       position_json = excluded.position_json,
       html_hint = excluded.html_hint,
       selection_kind = excluded.selection_kind,
       member_count = excluded.member_count,
       pod_members_json = excluded.pod_members_json,
       note = excluded.note,
       status = 'open',
       updated_at = excluded.updated_at`).run(id, projectId, conversationId, filePath, elementId, selector, label, text, JSON.stringify(position), htmlHint, selectionKind, selectionKind === 'pod' ? memberCount : null, selectionKind === 'pod' ? JSON.stringify(podMembers) : null, note, 'open', createdAt, now);
    return getPreviewComment(db, projectId, conversationId, id);
}
export function updatePreviewCommentStatus(db, projectId, conversationId, id, status) {
    if (!PREVIEW_COMMENT_STATUSES.has(status))
        throw new Error('invalid comment status');
    const now = Date.now();
    db.prepare(`UPDATE preview_comments
        SET status = ?, updated_at = ?
      WHERE id = ? AND project_id = ? AND conversation_id = ?`).run(status, now, id, projectId, conversationId);
    return getPreviewComment(db, projectId, conversationId, id);
}
export function deletePreviewComment(db, projectId, conversationId, id) {
    const result = db
        .prepare(`DELETE FROM preview_comments
        WHERE id = ? AND project_id = ? AND conversation_id = ?`)
        .run(id, projectId, conversationId);
    return result.changes > 0;
}
function getPreviewComment(db, projectId, conversationId, id) {
    const row = db
        .prepare(`SELECT id, project_id AS projectId, conversation_id AS conversationId,
              file_path AS filePath, element_id AS elementId, selector, label,
              text, position_json AS positionJson, html_hint AS htmlHint,
              selection_kind AS selectionKind, member_count AS memberCount,
              pod_members_json AS podMembersJson,
              note, status, created_at AS createdAt, updated_at AS updatedAt
         FROM preview_comments
        WHERE id = ? AND project_id = ? AND conversation_id = ?`)
        .get(id, projectId, conversationId);
    return row ? normalizePreviewComment(row) : null;
}
function normalizePreviewComment(row) {
    const podMembers = parseJsonOrUndef(row.podMembersJson);
    const normalizedPodMembers = Array.isArray(podMembers) ? podMembers : undefined;
    return {
        id: row.id,
        projectId: row.projectId,
        conversationId: row.conversationId,
        filePath: row.filePath,
        elementId: row.elementId,
        selector: row.selector,
        label: row.label,
        text: row.text,
        position: parseJsonOrUndef(row.positionJson) ?? { x: 0, y: 0, width: 0, height: 0 },
        htmlHint: row.htmlHint,
        selectionKind: row.selectionKind === 'pod' ? 'pod' : 'element',
        memberCount: normalizedPodMembers && normalizedPodMembers.length > 0
            ? normalizedPodMembers.length
            : Number.isFinite(row.memberCount)
                ? row.memberCount
                : undefined,
        podMembers: normalizedPodMembers,
        note: row.note,
        status: row.status,
        createdAt: row.createdAt,
        updatedAt: row.updatedAt,
    };
}
function cleanRequiredString(value, name) {
    if (typeof value !== 'string' || !value.trim())
        throw new Error(`${name} required`);
    return value.trim();
}
function normalizePodMembers(input) {
    if (!Array.isArray(input))
        return [];
    return input
        .map((member) => {
        if (!member || typeof member !== 'object')
            return null;
        const elementId = cleanRequiredString(member.elementId, 'podMember.elementId');
        const selector = cleanRequiredString(member.selector, 'podMember.selector');
        const label = cleanRequiredString(member.label, 'podMember.label');
        return {
            elementId,
            selector,
            label,
            text: typeof member.text === 'string'
                ? compactWhitespace(member.text).slice(0, 160)
                : '',
            position: normalizePosition(member.position),
            htmlHint: typeof member.htmlHint === 'string'
                ? compactWhitespace(member.htmlHint).slice(0, 180)
                : '',
        };
    })
        .filter(Boolean);
}
function compactWhitespace(value) {
    return value.replace(/\s+/g, ' ').trim();
}
function normalizePosition(input) {
    const value = input && typeof input === 'object' ? input : {};
    return {
        x: finiteNumber(value.x),
        y: finiteNumber(value.y),
        width: finiteNumber(value.width),
        height: finiteNumber(value.height),
    };
}
function finiteNumber(value) {
    return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : 0;
}
function randomCommentId() {
    return `cmt_${randomUUID().slice(0, 8)}`;
}
function normalizeMessage(row) {
    return {
        id: row.id,
        role: row.role,
        content: row.content,
        agentId: row.agentId ?? undefined,
        agentName: row.agentName ?? undefined,
        runId: row.runId ?? undefined,
        runStatus: row.runStatus ?? undefined,
        lastRunEventId: row.lastRunEventId ?? undefined,
        events: parseJsonOrUndef(row.eventsJson),
        attachments: parseJsonOrUndef(row.attachmentsJson),
        commentAttachments: parseJsonOrUndef(row.commentAttachmentsJson),
        producedFiles: parseJsonOrUndef(row.producedFilesJson),
        feedback: parseJsonOrUndef(row.feedbackJson),
        createdAt: row.createdAt ?? undefined,
        startedAt: row.startedAt ?? undefined,
        endedAt: row.endedAt ?? undefined,
    };
}
function parseJsonOrUndef(s) {
    if (typeof s !== 'string' || !s)
        return undefined;
    try {
        return JSON.parse(s);
    }
    catch {
        return undefined;
    }
}
// ---------- routines ----------
const ROUTINE_COLS = `id, name, prompt,
  schedule_kind AS scheduleKind, schedule_value AS scheduleValue,
  schedule_json AS scheduleJson,
  project_mode AS projectMode, project_id AS projectId,
  skill_id AS skillId, agent_id AS agentId,
  enabled, created_at AS createdAt, updated_at AS updatedAt`;
const ROUTINE_RUN_COLS = `id, routine_id AS routineId, trigger, status,
  project_id AS projectId, conversation_id AS conversationId,
  agent_run_id AS agentRunId, started_at AS startedAt,
  completed_at AS completedAt, summary, error`;
export function listRoutines(db) {
    return db
        .prepare(`SELECT ${ROUTINE_COLS} FROM routines ORDER BY created_at ASC`)
        .all()
        .map(normalizeRoutine);
}
export function getRoutine(db, id) {
    const r = db
        .prepare(`SELECT ${ROUTINE_COLS} FROM routines WHERE id = ?`)
        .get(id);
    return r ? normalizeRoutine(r) : null;
}
export function insertRoutine(db, r) {
    db.prepare(`INSERT INTO routines
       (id, name, prompt, schedule_kind, schedule_value, schedule_json,
        project_mode, project_id, skill_id, agent_id, enabled,
        created_at, updated_at)
     VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(r.id, r.name, r.prompt, r.scheduleKind, r.scheduleValue, r.scheduleJson ?? null, r.projectMode, r.projectId ?? null, r.skillId ?? null, r.agentId ?? null, r.enabled ? 1 : 0, r.createdAt, r.updatedAt);
    return getRoutine(db, r.id);
}
export function updateRoutine(db, id, patch) {
    const existing = getRoutine(db, id);
    if (!existing)
        return null;
    const merged = {
        ...existing,
        ...patch,
        updatedAt: typeof patch.updatedAt === 'number' ? patch.updatedAt : Date.now(),
    };
    db.prepare(`UPDATE routines
        SET name = ?, prompt = ?,
            schedule_kind = ?, schedule_value = ?, schedule_json = ?,
            project_mode = ?, project_id = ?,
            skill_id = ?, agent_id = ?,
            enabled = ?, updated_at = ?
      WHERE id = ?`).run(merged.name, merged.prompt, merged.scheduleKind, merged.scheduleValue, merged.scheduleJson ?? null, merged.projectMode, merged.projectId ?? null, merged.skillId ?? null, merged.agentId ?? null, merged.enabled ? 1 : 0, merged.updatedAt, id);
    return getRoutine(db, id);
}
export function deleteRoutine(db, id) {
    const result = db.prepare(`DELETE FROM routines WHERE id = ?`).run(id);
    return result.changes > 0;
}
function normalizeRoutine(row) {
    return {
        id: row.id,
        name: row.name,
        prompt: row.prompt,
        scheduleKind: row.scheduleKind,
        scheduleValue: row.scheduleValue,
        scheduleJson: row.scheduleJson ?? null,
        projectMode: row.projectMode,
        projectId: row.projectId ?? null,
        skillId: row.skillId ?? null,
        agentId: row.agentId ?? null,
        enabled: Number(row.enabled) === 1,
        createdAt: Number(row.createdAt),
        updatedAt: Number(row.updatedAt),
    };
}
export function listRoutineRuns(db, routineId, limit = 20) {
    return db
        .prepare(`SELECT ${ROUTINE_RUN_COLS}
         FROM routine_runs
        WHERE routine_id = ?
        ORDER BY started_at DESC
        LIMIT ?`)
        .all(routineId, limit)
        .map(normalizeRoutineRun);
}
export function getLatestRoutineRun(db, routineId) {
    const r = db
        .prepare(`SELECT ${ROUTINE_RUN_COLS}
         FROM routine_runs
        WHERE routine_id = ?
        ORDER BY started_at DESC
        LIMIT 1`)
        .get(routineId);
    return r ? normalizeRoutineRun(r) : null;
}
export function getRoutineRun(db, id) {
    const r = db
        .prepare(`SELECT ${ROUTINE_RUN_COLS} FROM routine_runs WHERE id = ?`)
        .get(id);
    return r ? normalizeRoutineRun(r) : null;
}
export function insertRoutineRun(db, r) {
    db.prepare(`INSERT INTO routine_runs
       (id, routine_id, trigger, status, project_id, conversation_id,
        agent_run_id, started_at, completed_at, summary, error)
     VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(r.id, r.routineId, r.trigger, r.status, r.projectId, r.conversationId, r.agentRunId, r.startedAt, r.completedAt ?? null, r.summary ?? null, r.error ?? null);
    return getRoutineRun(db, r.id);
}
export function updateRoutineRun(db, id, patch) {
    const existing = getRoutineRun(db, id);
    if (!existing)
        return null;
    const merged = {
        ...existing,
        ...patch,
    };
    db.prepare(`UPDATE routine_runs
        SET status = ?, completed_at = ?, summary = ?, error = ?
      WHERE id = ?`).run(merged.status, merged.completedAt ?? null, merged.summary ?? null, merged.error ?? null, id);
    return getRoutineRun(db, id);
}
function normalizeRoutineRun(row) {
    return {
        id: row.id,
        routineId: row.routineId,
        trigger: row.trigger,
        status: row.status,
        projectId: row.projectId,
        conversationId: row.conversationId,
        agentRunId: row.agentRunId,
        startedAt: Number(row.startedAt),
        completedAt: row.completedAt == null ? null : Number(row.completedAt),
        summary: row.summary ?? null,
        error: row.error ?? null,
    };
}
// ---------- tabs ----------
export function listTabs(db, projectId) {
    const rows = db
        .prepare(`SELECT name, position, is_active AS isActive
         FROM tabs WHERE project_id = ? ORDER BY position ASC`)
        .all(projectId);
    const active = rows.find((r) => r.isActive) ?? null;
    return {
        tabs: rows.map((r) => r.name),
        active: active ? active.name : null,
    };
}
export function setTabs(db, projectId, names, activeName) {
    const tx = db.transaction(() => {
        db.prepare(`DELETE FROM tabs WHERE project_id = ?`).run(projectId);
        const ins = db.prepare(`INSERT INTO tabs (project_id, name, position, is_active)
       VALUES (?, ?, ?, ?)`);
        names.forEach((name, i) => {
            ins.run(projectId, name, i, name === activeName ? 1 : 0);
        });
    });
    tx();
    return listTabs(db, projectId);
}
//# sourceMappingURL=db.js.map