import { getErrorMessage } from "../../utils/error-message.js";
import { drizzle } from "drizzle-orm/better-sqlite3";
import Database from "better-sqlite3";
import * as schema from "./schema.js";
import fs from "fs";
import path from "path";
import { databaseLogger } from "../../utils/logger.js";
import { DatabaseFileEncryption } from "../../utils/database-file-encryption.js";
import { SystemCrypto } from "../../utils/system-crypto.js";
import { DatabaseMigration } from "../../utils/database-migration.js";
import {
  ensureSharedHostAuthOverrideProtocolSchema,
  migrateLegacySharedHostAuthOverrides,
} from "../../utils/shared-host-auth-override-migration.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { migrateAuditRetention } from "../../utils/audit-retention-migration.js";
import { createPerformanceIndexes } from "./performance-indexes.js";
import {
  assertDataDirIsNotMisconfigured,
  DataDirMisconfiguredError,
} from "../../utils/data-dir-guard.js";
import { getDefaultGuacdUrl } from "../../utils/guacd-config.js";
import { resolveDatabaseDialect, type DatabaseDialect } from "./dialect.js";
import { connectRemoteDatabase } from "./connect.js";
import { runRemoteMigrations } from "./migrate.js";
import type { PortableDatabase } from "../repositories/database-context.js";

const dataDir = process.env.DATA_DIR || "./db/data";
const dbDir = path.resolve(dataDir);
if (!fs.existsSync(dbDir)) {
  fs.mkdirSync(dbDir, { recursive: true });
}

const enableFileEncryption = process.env.DB_FILE_ENCRYPTION !== "false";
const dbPath = path.join(dataDir, "db.sqlite");
const encryptedDbPath = `${dbPath}.encrypted`;

const actualDbPath = ":memory:";
let memoryDatabase: Database.Database;
let isNewDatabase = false;
let sqlite: Database.Database;

function getRawSettingValue(key: string): string | null {
  const row = sqlite
    .prepare("SELECT value FROM settings WHERE key = ?")
    .get(key) as { value?: string } | undefined;

  return row?.value ?? null;
}

function setRawSettingValue(key: string, value: string): void {
  sqlite
    .prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
    .run(key, value);
}

function ensureRawSettingDefault(key: string, value: string): void {
  if (getRawSettingValue(key) === null) {
    sqlite
      .prepare("INSERT INTO settings (key, value) VALUES (?, ?)")
      .run(key, value);
  }
}

async function initializeDatabaseAsync(): Promise<void> {
  const systemCrypto = SystemCrypto.getInstance();

  await systemCrypto.getDatabaseKey();
  if (enableFileEncryption) {
    try {
      if (DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)) {
        const decryptedBuffer =
          await DatabaseFileEncryption.decryptDatabaseToBuffer(encryptedDbPath);

        memoryDatabase = new Database(decryptedBuffer);

        try {
          memoryDatabase
            .prepare("SELECT COUNT(*) as count FROM sessions")
            .get() as { count: number };
        } catch {
          // expected - sessions table may not exist yet
        }
      } else {
        const migration = new DatabaseMigration(dataDir);
        const migrationStatus = migration.checkMigrationStatus();

        if (migrationStatus.needsMigration) {
          const migrationResult = await migration.migrateDatabase();

          if (migrationResult.success) {
            migration.cleanupOldBackups();

            if (
              DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)
            ) {
              const decryptedBuffer =
                await DatabaseFileEncryption.decryptDatabaseToBuffer(
                  encryptedDbPath,
                );
              memoryDatabase = new Database(decryptedBuffer);
              isNewDatabase = false;
            } else {
              throw new Error(
                "Migration completed but encrypted database file not found",
              );
            }
          } else {
            databaseLogger.error("Automatic database migration failed", null, {
              operation: "auto_migration_failed",
              error: migrationResult.error,
              migratedTables: migrationResult.migratedTables,
              migratedRows: migrationResult.migratedRows,
              duration: migrationResult.duration,
              backupPath: migrationResult.backupPath,
            });
            throw new Error(
              `Database migration failed: ${migrationResult.error}. Backup available at: ${migrationResult.backupPath}`,
            );
          }
        } else {
          assertDataDirIsNotMisconfigured(dataDir);
          memoryDatabase = new Database(":memory:");
          isNewDatabase = true;
        }
      }
    } catch (error) {
      // Not a decryption problem: the database is fine, we are pointed at the
      // wrong directory. Surface that message as-is.
      if (error instanceof DataDirMisconfiguredError) throw error;

      databaseLogger.error("Failed to initialize memory database", error, {
        operation: "db_memory_init_failed",
        errorMessage: getErrorMessage(error),
        errorStack: error instanceof Error ? error.stack : undefined,
        encryptedDbExists:
          DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
        databaseKeyAvailable: !!process.env.DATABASE_KEY,
        databaseKeyLength: process.env.DATABASE_KEY?.length || 0,
      });

      try {
        const diagnosticInfo =
          DatabaseFileEncryption.getDiagnosticInfo(encryptedDbPath);
        databaseLogger.error(
          "Database encryption diagnostic completed - check logs above for details",
          null,
          {
            operation: "db_encryption_diagnostic_completed",
            filesConsistent: diagnosticInfo.validation.filesConsistent,
            sizeMismatch: diagnosticInfo.validation.sizeMismatch,
          },
        );
      } catch (diagError) {
        databaseLogger.warn("Failed to generate diagnostic information", {
          operation: "db_diagnostic_failed",
          error:
            getErrorMessage(diagError),
        });
      }

      throw new Error(
        `Database decryption failed: ${getErrorMessage(error)}. This prevents data loss.`,
        { cause: error },
      );
    }
  } else {
    assertDataDirIsNotMisconfigured(dataDir);

    // The database still lives in memory and is serialised out on every write;
    // turning encryption off only changes whether that file is ciphertext. It
    // has to be read back, or each restart starts empty and silently discards
    // everything the previous run saved.
    const existing = readPlainDatabaseFile();
    if (existing) {
      memoryDatabase = new Database(existing);
      databaseLogger.info("Loaded unencrypted database from disk", {
        operation: "db_load_plain",
        path: dbPath,
        bytes: existing.length,
      });
    } else {
      memoryDatabase = new Database(":memory:");
      isNewDatabase = true;
    }
  }
}

/** The plain database file, or null when there is nothing to restore. */
function readPlainDatabaseFile(): Buffer | null {
  try {
    const contents = fs.readFileSync(dbPath);
    return contents.length > 0 ? contents : null;
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
    throw error;
  }
}

async function initializeCompleteDatabase(): Promise<void> {
  await initializeDatabaseAsync();

  databaseLogger.info(`Initializing SQLite database`, {
    operation: "db_init",
    path: actualDbPath,
    encrypted:
      enableFileEncryption &&
      DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath),
    inMemory: true,
    isNewDatabase,
  });

  sqlite = memoryDatabase;

  sqlite.exec("PRAGMA foreign_keys = ON");

  db = drizzle(sqlite, { schema });

  sqlite.exec(`
    CREATE TABLE IF NOT EXISTS users (
        id TEXT PRIMARY KEY,
        username TEXT NOT NULL,
        password_hash TEXT NOT NULL,
        is_admin INTEGER NOT NULL DEFAULT 0,
        is_oidc INTEGER NOT NULL DEFAULT 0,
        oidc_identifier TEXT,
        client_id TEXT,
        client_secret TEXT,
        issuer_url TEXT,
        authorization_url TEXT,
        token_url TEXT,
        identifier_path TEXT,
        name_path TEXT,
        scopes TEXT DEFAULT 'openid email profile',
        totp_secret TEXT,
        totp_enabled INTEGER NOT NULL DEFAULT 0,
        totp_backup_codes TEXT,
        registered_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        donation_modal_dismissed INTEGER NOT NULL DEFAULT 0
    );

    CREATE TABLE IF NOT EXISTS settings (
        key TEXT PRIMARY KEY,
        value TEXT NOT NULL
    );

    CREATE TABLE IF NOT EXISTS sessions (
        id TEXT PRIMARY KEY,
        user_id TEXT NOT NULL,
        jwt_token TEXT NOT NULL,
        device_type TEXT NOT NULL,
        device_info TEXT NOT NULL,
        created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        expires_at TEXT NOT NULL,
        last_active_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
    );

    CREATE TABLE IF NOT EXISTS trusted_devices (
        id TEXT PRIMARY KEY,
        user_id TEXT NOT NULL,
        device_fingerprint TEXT NOT NULL,
        device_type TEXT NOT NULL,
        device_info TEXT NOT NULL,
        created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        expires_at TEXT NOT NULL,
        last_used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
    );

    CREATE TABLE IF NOT EXISTS webauthn_credentials (
        id TEXT PRIMARY KEY,
        user_id TEXT NOT NULL,
        name TEXT NOT NULL,
        credential_id TEXT NOT NULL UNIQUE,
        public_key TEXT NOT NULL,
        counter INTEGER NOT NULL DEFAULT 0,
        device_type TEXT,
        backed_up INTEGER NOT NULL DEFAULT 0,
        transports TEXT,
        user_verification TEXT NOT NULL DEFAULT 'preferred',
        created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        last_used_at TEXT,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
    );

    CREATE TABLE IF NOT EXISTS ssh_data (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id TEXT NOT NULL,
        name TEXT,
        ip TEXT NOT NULL,
        port INTEGER NOT NULL,
        username TEXT NOT NULL,
        folder TEXT,
        tags TEXT,
        pin INTEGER NOT NULL DEFAULT 0,
        sort_order INTEGER,
        auth_type TEXT NOT NULL,
        password TEXT,
        key TEXT,
        key_password TEXT,
        key_type TEXT,
        enable_terminal INTEGER NOT NULL DEFAULT 1,
        enable_tunnel INTEGER NOT NULL DEFAULT 1,
        tunnel_connections TEXT,
        enable_file_manager INTEGER NOT NULL DEFAULT 1,
        enable_docker INTEGER NOT NULL DEFAULT 0,
        default_path TEXT,
        autostart_password TEXT,
        autostart_key TEXT,
        autostart_key_password TEXT,
        force_keyboard_interactive TEXT,
        stats_config TEXT,
        docker_config TEXT,
        terminal_config TEXT,
        notes TEXT,
        use_socks5 INTEGER,
        socks5_host TEXT,
        socks5_port INTEGER,
        socks5_username TEXT,
        socks5_password TEXT,
        created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
    );

    CREATE TABLE IF NOT EXISTS file_manager_recent (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id TEXT NOT NULL,
        host_id INTEGER NOT NULL,
        name TEXT NOT NULL,
        path TEXT NOT NULL,
        last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
        FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
    );

    CREATE TABLE IF NOT EXISTS file_manager_pinned (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id TEXT NOT NULL,
        host_id INTEGER NOT NULL,
        name TEXT NOT NULL,
        path TEXT NOT NULL,
        pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
        FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
    );

    CREATE TABLE IF NOT EXISTS file_manager_shortcuts (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id TEXT NOT NULL,
        host_id INTEGER NOT NULL,
        name TEXT NOT NULL,
        path TEXT NOT NULL,
        created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
        FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
    );

    CREATE TABLE IF NOT EXISTS transfer_recent (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id TEXT NOT NULL,
        source_host_id INTEGER NOT NULL,
        dest_host_id INTEGER NOT NULL,
        dest_path TEXT NOT NULL,
        dest_path_label TEXT NOT NULL,
        last_used TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
        FOREIGN KEY (source_host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
        FOREIGN KEY (dest_host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
    );

    CREATE TABLE IF NOT EXISTS dismissed_alerts (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id TEXT NOT NULL,
        alert_id TEXT NOT NULL,
        dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
    );

    CREATE TABLE IF NOT EXISTS ssh_credentials (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id TEXT NOT NULL,
        name TEXT NOT NULL,
        description TEXT,
        folder TEXT,
        tags TEXT,
        auth_type TEXT NOT NULL,
        username TEXT,
        password TEXT,
        key TEXT,
        key_password TEXT,
        key_type TEXT,
        usage_count INTEGER NOT NULL DEFAULT 0,
        last_used TEXT,
        created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
    );

    CREATE TABLE IF NOT EXISTS ssh_credential_usage (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        credential_id INTEGER NOT NULL,
        host_id INTEGER NOT NULL,
        user_id TEXT NOT NULL,
        used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE,
        FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
    );

    CREATE TABLE IF NOT EXISTS snippets (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id TEXT NOT NULL,
        name TEXT NOT NULL,
        content TEXT NOT NULL,
        description TEXT,
        created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
    );

    CREATE TABLE IF NOT EXISTS c2s_tunnel_presets (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id TEXT NOT NULL,
        name TEXT NOT NULL,
        config TEXT NOT NULL,
        platform TEXT,
        computer_name TEXT,
        created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
    );

    CREATE TABLE IF NOT EXISTS ssh_folders (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id TEXT NOT NULL,
        name TEXT NOT NULL,
        color TEXT,
        icon TEXT,
        credential_id INTEGER,
        sort_order INTEGER,
        created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
        FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE SET NULL
    );

    CREATE TABLE IF NOT EXISTS recent_activity (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id TEXT NOT NULL,
        type TEXT NOT NULL,
        host_id INTEGER NOT NULL,
        host_name TEXT,
        timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
        FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
    );

    CREATE TABLE IF NOT EXISTS command_history (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id TEXT NOT NULL,
        host_id INTEGER NOT NULL,
        command TEXT NOT NULL,
        executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
        FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
    );

    CREATE TABLE IF NOT EXISTS host_access (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        host_id INTEGER NOT NULL,
        user_id TEXT,
        role_id INTEGER,
        granted_by TEXT NOT NULL,
        permission_level TEXT NOT NULL DEFAULT 'use',
        expires_at TEXT,
        created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        last_accessed_at TEXT,
        access_count INTEGER NOT NULL DEFAULT 0,
        FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
        FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
        FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
    );

    CREATE TABLE IF NOT EXISTS roles (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL UNIQUE,
        display_name TEXT NOT NULL,
        description TEXT,
        is_system INTEGER NOT NULL DEFAULT 0,
        permissions TEXT,
        created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
    );

    CREATE TABLE IF NOT EXISTS user_roles (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id TEXT NOT NULL,
        role_id INTEGER NOT NULL,
        granted_by TEXT,
        granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        UNIQUE(user_id, role_id),
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
        FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
        FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE SET NULL
    );

    CREATE TABLE IF NOT EXISTS audit_logs (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id TEXT,
        username TEXT NOT NULL,
        action TEXT NOT NULL,
        resource_type TEXT NOT NULL,
        resource_id TEXT,
        resource_name TEXT,
        details TEXT,
        ip_address TEXT,
        user_agent TEXT,
        success INTEGER NOT NULL,
        error_message TEXT,
        timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL
    );

    CREATE TABLE IF NOT EXISTS session_recordings (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        host_id INTEGER NOT NULL,
        user_id TEXT,
        username TEXT,
        access_id INTEGER,
        started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        ended_at TEXT,
        duration INTEGER,
        commands TEXT,
        dangerous_actions TEXT,
        recording_path TEXT,
        protocol TEXT NOT NULL DEFAULT 'ssh',
        format TEXT NOT NULL DEFAULT 'text',
        terminated_by_owner INTEGER DEFAULT 0,
        termination_reason TEXT,
        FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL,
        FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
    );

    CREATE TABLE IF NOT EXISTS session_shares (
        id TEXT PRIMARY KEY,
        host_id INTEGER NOT NULL,
        owner_user_id TEXT NOT NULL,
        protocol TEXT NOT NULL,
        session_id TEXT NOT NULL,
        tab_instance_id TEXT,
        share_type TEXT NOT NULL,
        target_user_id TEXT,
        link_token TEXT UNIQUE,
        permission_level TEXT NOT NULL DEFAULT 'read-only',
        created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        expires_at TEXT NOT NULL,
        revoked_at TEXT,
        last_joined_at TEXT,
        join_count INTEGER NOT NULL DEFAULT 0,
        FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
        FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE,
        FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE
    );

    CREATE TABLE IF NOT EXISTS session_share_participants (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        share_id TEXT NOT NULL,
        user_id TEXT,
        guest_label TEXT,
        joined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        left_at TEXT,
        FOREIGN KEY (share_id) REFERENCES session_shares (id) ON DELETE CASCADE,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
    );

    CREATE TABLE IF NOT EXISTS api_keys (
        id TEXT PRIMARY KEY,
        user_id TEXT NOT NULL,
        name TEXT NOT NULL,
        token_hash TEXT NOT NULL,
        token_prefix TEXT NOT NULL,
        created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        expires_at TEXT,
        last_used_at TEXT,
        is_active INTEGER NOT NULL DEFAULT 1,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
    );

    CREATE TABLE IF NOT EXISTS user_open_tabs (
        id TEXT PRIMARY KEY,
        user_id TEXT NOT NULL,
        tab_type TEXT NOT NULL,
        host_id INTEGER,
        label TEXT NOT NULL,
        tab_order INTEGER NOT NULL DEFAULT 0,
        backend_session_id TEXT,
        created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
        FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
    );

    CREATE TABLE IF NOT EXISTS user_preferences (
        user_id TEXT PRIMARY KEY,
        reopen_tabs_on_login INTEGER NOT NULL DEFAULT 0,
        theme TEXT,
        font_size TEXT,
        accent_color TEXT,
        language TEXT,
        updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
    );

    CREATE TABLE IF NOT EXISTS host_metrics_preferences (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id TEXT NOT NULL,
        host_id INTEGER NOT NULL,
        layout TEXT NOT NULL,
        created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
        FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
    );

    CREATE UNIQUE INDEX IF NOT EXISTS idx_host_metrics_prefs_user_host
        ON host_metrics_preferences (user_id, host_id);

    CREATE TABLE IF NOT EXISTS proxmox_stats_preferences (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id TEXT NOT NULL,
        host_id INTEGER NOT NULL,
        layout TEXT NOT NULL,
        created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
        FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
    );

    CREATE UNIQUE INDEX IF NOT EXISTS idx_proxmox_stats_prefs_user_host
        ON proxmox_stats_preferences (user_id, host_id);

    CREATE TABLE IF NOT EXISTS host_sidebar_preferences (
        user_id TEXT PRIMARY KEY,
        data TEXT NOT NULL,
        updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
    );

    CREATE TABLE IF NOT EXISTS credential_sidebar_preferences (
        user_id TEXT PRIMARY KEY,
        data TEXT NOT NULL,
        updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
    );

    CREATE TABLE IF NOT EXISTS ui_preferences (
        user_id TEXT PRIMARY KEY,
        data TEXT NOT NULL,
        updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
    );

    CREATE TABLE IF NOT EXISTS host_health_checks (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id TEXT NOT NULL,
        host_id INTEGER NOT NULL,
        checks TEXT NOT NULL,
        interval_seconds INTEGER NOT NULL DEFAULT 300,
        created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
        FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
    );

    CREATE UNIQUE INDEX IF NOT EXISTS idx_host_health_checks_user_host
        ON host_health_checks (user_id, host_id);

    CREATE TABLE IF NOT EXISTS host_health_history (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id TEXT NOT NULL,
        host_id INTEGER NOT NULL,
        check_id TEXT NOT NULL,
        ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        ok INTEGER NOT NULL,
        latency_ms INTEGER,
        detail TEXT,
        FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
        FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
    );

    CREATE INDEX IF NOT EXISTS idx_host_health_history_lookup
        ON host_health_history (user_id, host_id, check_id, ts);

    CREATE TABLE IF NOT EXISTS sso_providers (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        type TEXT NOT NULL,
        enabled INTEGER NOT NULL DEFAULT 1,
        display_order INTEGER NOT NULL DEFAULT 0,
        config TEXT NOT NULL,
        created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
        updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
    );

`);

  try {
    const timeoutRow = sqlite
      .prepare(
        "SELECT value FROM settings WHERE key = 'terminal_session_timeout_minutes'",
      )
      .get() as { value: string } | undefined;
    const timeoutMinutes = timeoutRow
      ? parseInt(timeoutRow.value, 10)
      : 30;
    const ttlMs =
      !isNaN(timeoutMinutes) && timeoutMinutes > 0
        ? timeoutMinutes * 60_000
        : 30 * 60_000;
    const cutoff = new Date(Date.now() - ttlMs).toISOString();
    const result = sqlite
      .prepare("DELETE FROM user_open_tabs WHERE updated_at <= ?")
      .run(cutoff);
    if (result.changes > 0) {
      databaseLogger.info("Expired open tabs cleared on startup", {
        operation: "db_init_open_tabs_cleanup",
        count: result.changes,
      });
    }
  } catch (e) {
    databaseLogger.warn("Could not clear expired open tabs on startup", {
      operation: "db_init_open_tabs_cleanup_failed",
      error: e,
    });
  }

  try {
    const result = sqlite
      .prepare("DELETE FROM sessions WHERE expires_at <= ?")
      .run(new Date().toISOString());
    if (result.changes > 0) {
      databaseLogger.info("Expired sessions cleaned up on startup", {
        operation: "db_init_session_cleanup",
        deletedSessions: result.changes,
      });
    }
  } catch (e) {
    databaseLogger.warn("Could not clear expired sessions on startup", {
      operation: "db_init_session_cleanup_failed",
      error: e,
    });
  }

  migrateSchema();
  vacuumIfFreelistBloated();

  try {
    ensureRawSettingDefault("allow_registration", "true");
  } catch (e) {
    databaseLogger.warn("Could not initialize default settings", {
      operation: "db_init",
      error: e,
    });
  }

  try {
    ensureRawSettingDefault("allow_password_login", "true");
  } catch (e) {
    databaseLogger.warn("Could not initialize allow_password_login setting", {
      operation: "db_init",
      error: e,
    });
  }

  try {
    ensureRawSettingDefault("guac_enabled", "true");
  } catch (e) {
    databaseLogger.warn("Could not initialize guac_enabled setting", {
      operation: "db_init",
      error: e,
    });
  }

  try {
    ensureRawSettingDefault("guac_url", getDefaultGuacdUrl());
  } catch (e) {
    databaseLogger.warn("Could not initialize guac_url setting", {
      operation: "db_init",
      error: e,
    });
  }
}

const addColumnIfNotExists = (
  table: string,
  column: string,
  definition: string,
) => {
  try {
    sqlite
      .prepare(
        `SELECT "${column}"
                        FROM ${table} LIMIT 1`,
      )
      .get();
  } catch {
    try {
      sqlite.exec(`ALTER TABLE ${table}
                ADD COLUMN "${column}" ${definition};`);
    } catch (alterError) {
      const message =
        alterError instanceof Error ? alterError.message : String(alterError);
      databaseLogger.warn(
        `Failed to add column ${column} to ${table}: ${message}`,
        {
          operation: "schema_migration",
          table,
          column,
        },
      );
    }
  }
};

const migrateSchema = () => {
  addColumnIfNotExists(
    "session_recordings",
    "protocol",
    "TEXT NOT NULL DEFAULT 'ssh'",
  );
  addColumnIfNotExists(
    "session_recordings",
    "format",
    "TEXT NOT NULL DEFAULT 'text'",
  );

  addColumnIfNotExists("user_preferences", "theme", "TEXT");
  addColumnIfNotExists("user_preferences", "font_size", "TEXT");
  addColumnIfNotExists("user_preferences", "accent_color", "TEXT");
  addColumnIfNotExists("user_preferences", "language", "TEXT");
  addColumnIfNotExists("user_preferences", "storage_mode", "TEXT");
  addColumnIfNotExists("user_preferences", "command_autocomplete", "INTEGER");
  addColumnIfNotExists("user_preferences", "command_palette_enabled", "INTEGER");
  addColumnIfNotExists("user_preferences", "show_host_tags", "INTEGER");
  addColumnIfNotExists("user_preferences", "host_tray_on_click", "INTEGER");
  addColumnIfNotExists("user_preferences", "pin_app_rail", "INTEGER");
  addColumnIfNotExists(
    "user_preferences",
    "expand_app_rail_on_hover",
    "INTEGER",
  );
  addColumnIfNotExists("user_preferences", "folders_collapsed", "INTEGER");
  addColumnIfNotExists("user_preferences", "confirm_snippet_execution", "INTEGER");
  addColumnIfNotExists("user_preferences", "disable_update_check", "INTEGER");
  addColumnIfNotExists("user_preferences", "confirm_tab_close", "INTEGER");
  addColumnIfNotExists("user_preferences", "hidden_rail_tabs", "TEXT");
  addColumnIfNotExists("user_preferences", "ai_assistant_enabled", "INTEGER");
  addColumnIfNotExists("user_preferences", "ai_read_only_commands", "INTEGER");
  addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
  addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT");
  addColumnIfNotExists("user_preferences", "custom_themes", "TEXT");
  addColumnIfNotExists("user_preferences", "custom_keybindings", "TEXT");
  addColumnIfNotExists("user_preferences", "terminal_defaults", "TEXT");
  addColumnIfNotExists("user_preferences", "rdp_defaults", "TEXT");
  addColumnIfNotExists("user_preferences", "terminal_macros", "TEXT");

  sqlite.exec(`
    CREATE TABLE IF NOT EXISTS dashboard_service_links (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
      label TEXT NOT NULL,
      url TEXT NOT NULL,
      "order" INTEGER NOT NULL DEFAULT 0,
      created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
    )
  `);

  addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");

  addColumnIfNotExists("users", "is_oidc", "INTEGER NOT NULL DEFAULT 0");
  addColumnIfNotExists("users", "oidc_identifier", "TEXT");
  addColumnIfNotExists("users", "client_id", "TEXT");
  addColumnIfNotExists("users", "client_secret", "TEXT");
  addColumnIfNotExists("users", "issuer_url", "TEXT");
  addColumnIfNotExists("users", "authorization_url", "TEXT");
  addColumnIfNotExists("users", "token_url", "TEXT");

  addColumnIfNotExists("users", "identifier_path", "TEXT");
  addColumnIfNotExists("users", "name_path", "TEXT");
  addColumnIfNotExists("users", "scopes", "TEXT");

  addColumnIfNotExists("users", "totp_secret", "TEXT");
  addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0");
  addColumnIfNotExists("users", "totp_backup_codes", "TEXT");

  const hadRegisteredAtColumn = (() => {
    try {
      sqlite.prepare(`SELECT "registered_at" FROM users LIMIT 1`).get();
      return true;
    } catch {
      return false;
    }
  })();
  // SQLite's ALTER TABLE ADD COLUMN rejects non-constant defaults like
  // CURRENT_TIMESTAMP, so the column is added empty and backfilled below.
  addColumnIfNotExists("users", "registered_at", "TEXT");
  if (!hadRegisteredAtColumn) {
    // Pre-existing users are backdated past the 30 day mark so they see the
    // donation modal immediately on upgrade instead of waiting a fresh
    // 30 days as if they had just registered.
    try {
      sqlite.exec(
        `UPDATE users SET registered_at = datetime('now', '-31 days') WHERE registered_at IS NULL`,
      );
    } catch (backfillError) {
      databaseLogger.warn("Failed to backfill users.registered_at", {
        operation: "schema_migration",
        error:
          getErrorMessage(backfillError, String(backfillError)),
      });
    }
  } else {
    try {
      sqlite.exec(
        `UPDATE users SET registered_at = CURRENT_TIMESTAMP WHERE registered_at IS NULL`,
      );
    } catch (backfillError) {
      databaseLogger.warn(
        "Failed to backfill NULL users.registered_at values",
        {
          operation: "schema_migration",
          error:
            getErrorMessage(backfillError, String(backfillError)),
        },
      );
    }
  }
  addColumnIfNotExists(
    "users",
    "donation_modal_dismissed",
    "INTEGER NOT NULL DEFAULT 0",
  );

  addColumnIfNotExists("sessions", "oidc_sub", "TEXT");
  addColumnIfNotExists("sessions", "oidc_sid", "TEXT");
  addColumnIfNotExists("sessions", "sso_provider_id", "INTEGER");

  sqlite.exec(`
    CREATE TABLE IF NOT EXISTS webauthn_credentials (
      id TEXT PRIMARY KEY,
      user_id TEXT NOT NULL,
      name TEXT NOT NULL,
      credential_id TEXT NOT NULL UNIQUE,
      public_key TEXT NOT NULL,
      counter INTEGER NOT NULL DEFAULT 0,
      device_type TEXT,
      backed_up INTEGER NOT NULL DEFAULT 0,
      transports TEXT,
      user_verification TEXT NOT NULL DEFAULT 'preferred',
      created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
      last_used_at TEXT,
      FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
    )
  `);

  addColumnIfNotExists("ssh_data", "name", "TEXT");
  addColumnIfNotExists("ssh_data", "folder", "TEXT");
  addColumnIfNotExists("ssh_data", "tags", "TEXT");
  addColumnIfNotExists("ssh_data", "pin", "INTEGER NOT NULL DEFAULT 0");
  addColumnIfNotExists("ssh_data", "sort_order", "INTEGER");
  addColumnIfNotExists("ssh_folders", "sort_order", "INTEGER");
  addColumnIfNotExists(
    "ssh_data",
    "auth_type",
    'TEXT NOT NULL DEFAULT "password"',
  );
  addColumnIfNotExists("ssh_data", "password", "TEXT");
  addColumnIfNotExists("ssh_data", "key", "TEXT");
  addColumnIfNotExists("ssh_data", "key_password", "TEXT");
  addColumnIfNotExists("ssh_data", "key_type", "TEXT");
  addColumnIfNotExists(
    "ssh_data",
    "enable_terminal",
    "INTEGER NOT NULL DEFAULT 1",
  );
  addColumnIfNotExists(
    "ssh_data",
    "enable_session_logging",
    "INTEGER NOT NULL DEFAULT 1",
  );
  addColumnIfNotExists(
    "ssh_data",
    "enable_command_history",
    "INTEGER NOT NULL DEFAULT 1",
  );
  addColumnIfNotExists(
    "ssh_data",
    "enable_tunnel",
    "INTEGER NOT NULL DEFAULT 1",
  );
  addColumnIfNotExists("ssh_data", "tunnel_connections", "TEXT");
  addColumnIfNotExists("ssh_data", "jump_hosts", "TEXT");
  addColumnIfNotExists(
    "ssh_data",
    "enable_file_manager",
    "INTEGER NOT NULL DEFAULT 1",
  );
  addColumnIfNotExists(
    "ssh_data",
    "scp_legacy",
    "INTEGER NOT NULL DEFAULT 0",
  );
  addColumnIfNotExists("ssh_data", "default_path", "TEXT");
  addColumnIfNotExists(
    "ssh_data",
    "created_at",
    "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
  );
  addColumnIfNotExists(
    "ssh_data",
    "updated_at",
    "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
  );
  addColumnIfNotExists("ssh_data", "force_keyboard_interactive", "TEXT");
  addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
  addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
  addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
  addColumnIfNotExists(
    "ssh_data",
    "credential_id",
    "INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL",
  );
  addColumnIfNotExists(
    "ssh_data",
    "override_credential_username",
    "INTEGER",
  );
  addColumnIfNotExists(
    "ssh_data",
    "vault_profile_id",
    "INTEGER REFERENCES vault_profiles(id) ON DELETE SET NULL",
  );

  addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
  addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
  addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
  addColumnIfNotExists("ssh_data", "stats_config", "TEXT");
  addColumnIfNotExists("ssh_data", "terminal_config", "TEXT");
  addColumnIfNotExists("ssh_data", "quick_actions", "TEXT");
  addColumnIfNotExists(
    "ssh_data",
    "enable_docker",
    "INTEGER NOT NULL DEFAULT 0",
  );
  addColumnIfNotExists("ssh_data", "docker_config", "TEXT");
  addColumnIfNotExists(
    "ssh_data",
    "enable_proxmox",
    "INTEGER NOT NULL DEFAULT 0",
  );
  addColumnIfNotExists("ssh_data", "proxmox_config", "TEXT");
  addColumnIfNotExists(
    "ssh_data",
    "enable_proxmox_stats",
    "INTEGER NOT NULL DEFAULT 0",
  );
  addColumnIfNotExists("ssh_data", "proxmox_stats_config", "TEXT");
  addColumnIfNotExists(
    "ssh_data",
    "enable_tmux_monitor",
    "INTEGER NOT NULL DEFAULT 0",
  );
  addColumnIfNotExists(
    "ssh_data",
    "enable_terminal_toolbar",
    "INTEGER NOT NULL DEFAULT 1",
  );

  addColumnIfNotExists("ssh_data", "connection_type", 'TEXT NOT NULL DEFAULT "ssh"');
  addColumnIfNotExists("ssh_data", "domain", "TEXT");
  addColumnIfNotExists("ssh_data", "security", "TEXT");
  addColumnIfNotExists("ssh_data", "ignore_cert", "INTEGER NOT NULL DEFAULT 0");
  addColumnIfNotExists("ssh_data", "guacamole_config", "TEXT");
  addColumnIfNotExists("ssh_data", "notes", "TEXT");

  addColumnIfNotExists("ssh_data", "use_socks5", "INTEGER");
  addColumnIfNotExists("ssh_data", "socks5_host", "TEXT");
  addColumnIfNotExists("ssh_data", "socks5_port", "INTEGER");
  addColumnIfNotExists("ssh_data", "socks5_username", "TEXT");
  addColumnIfNotExists("ssh_data", "socks5_password", "TEXT");
  addColumnIfNotExists("ssh_data", "socks5_proxy_chain", "TEXT");

  addColumnIfNotExists("ssh_data", "host_key_fingerprint", "TEXT");
  addColumnIfNotExists("ssh_data", "host_key_type", "TEXT");
  addColumnIfNotExists("ssh_data", "host_key_algorithm", "TEXT DEFAULT 'sha256'");
  addColumnIfNotExists("ssh_data", "host_key_first_seen", "TEXT");
  addColumnIfNotExists("ssh_data", "host_key_last_verified", "TEXT");
  addColumnIfNotExists("ssh_data", "host_key_changed_count", "INTEGER DEFAULT 0");

  addColumnIfNotExists(
    "ssh_data",
    "show_terminal_in_sidebar",
    "INTEGER NOT NULL DEFAULT 1",
  );
  addColumnIfNotExists(
    "ssh_data",
    "show_file_manager_in_sidebar",
    "INTEGER NOT NULL DEFAULT 0",
  );
  addColumnIfNotExists(
    "ssh_data",
    "show_tunnel_in_sidebar",
    "INTEGER NOT NULL DEFAULT 0",
  );
  addColumnIfNotExists(
    "ssh_data",
    "show_docker_in_sidebar",
    "INTEGER NOT NULL DEFAULT 0",
  );
  addColumnIfNotExists(
    "ssh_data",
    "show_server_stats_in_sidebar",
    "INTEGER NOT NULL DEFAULT 0",
  );

  addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
  addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
  addColumnIfNotExists("ssh_credentials", "detected_key_type", "TEXT");

  addColumnIfNotExists("ssh_credentials", "cert_public_key", "TEXT");

  addColumnIfNotExists(
    "ssh_credentials",
    "pin",
    "INTEGER NOT NULL DEFAULT 0",
  );
  addColumnIfNotExists("ssh_credentials", "sort_order", "INTEGER");

  try {
    const tableInfo = sqlite.prepare("PRAGMA table_info(ssh_credentials)").all() as Array<{
      cid: number;
      name: string;
      type: string;
      notnull: number;
      dflt_value: string | null;
      pk: number;
    }>;
    const usernameCol = tableInfo.find((col) => col.name === "username");

    if (usernameCol && usernameCol.notnull === 1) {
      const tempTableName = "ssh_credentials_temp_migration";
      const allColumns = tableInfo.map((col) => `"${col.name}"`).join(", ");

      // Derive the replacement table from the live definition instead of
      // restating it here. The table keeps gaining columns (cert_public_key,
      // pin, sort_order, sync_id, ...), and a second copy of the column list
      // falls behind every time one is added — leaving the copy narrower than
      // the table, so the INSERT below fails and the constraint stays put.
      const createSql = sqlite
        .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'ssh_credentials'")
        .pluck()
        .get() as string | undefined;

      if (!createSql) {
        throw new Error("ssh_credentials has no stored table definition");
      }

      // Only the table name is rewritten; replace() stops at the first match,
      // and in a CREATE TABLE statement that is the table being defined.
      const renamedSql = createSql.replace("ssh_credentials", tempTableName);
      const tempCreateSql = renamedSql.replace(/(["`[]?username["`\]]?\s+TEXT)\s+NOT\s+NULL/i, "$1");

      if (tempCreateSql === renamedSql) {
        throw new Error("could not derive a nullable-username definition for ssh_credentials");
      }

      // DROP TABLE takes the table's indexes with it, so replay them afterwards.
      const indexDefs = sqlite
        .prepare(
          "SELECT sql FROM sqlite_master WHERE type = 'index' AND tbl_name = 'ssh_credentials' AND sql IS NOT NULL",
        )
        .pluck()
        .all() as string[];

      sqlite.exec(`PRAGMA foreign_keys = OFF`);
      sqlite.exec(`
        ${tempCreateSql};

        INSERT INTO ${tempTableName} (${allColumns}) SELECT ${allColumns} FROM ssh_credentials;

        DROP TABLE ssh_credentials;

        ALTER TABLE ${tempTableName} RENAME TO ssh_credentials;
      `);
      for (const indexSql of indexDefs) {
        sqlite.exec(indexSql);
      }
      sqlite.exec(`PRAGMA foreign_keys = ON`);

      databaseLogger.info("Successfully migrated ssh_credentials table to remove username NOT NULL constraint", {
        operation: "schema_migration_username_nullable",
        restoredIndexes: indexDefs.length,
      });
    }
  } catch (migrationError) {
    databaseLogger.warn("Failed to migrate ssh_credentials username column", {
      operation: "schema_migration",
      error: migrationError,
    });
  }

  try {
    const auditLogColumns = sqlite.prepare("PRAGMA table_info(audit_logs)").all() as Array<{
      name: string;
      notnull: number;
    }>;
    const auditUserIdCol = auditLogColumns.find((col) => col.name === "user_id");

    if (auditUserIdCol && auditUserIdCol.notnull === 1) {
      const tempTableName = "audit_logs_temp_migration";
      const columns = [
        "id",
        "user_id",
        "username",
        "action",
        "resource_type",
        "resource_id",
        "resource_name",
        "details",
        "ip_address",
        "user_agent",
        "success",
        "error_message",
        "timestamp",
      ].join(", ");

      sqlite.exec(`PRAGMA foreign_keys = OFF`);
      sqlite.exec(`
        CREATE TABLE ${tempTableName} (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          user_id TEXT,
          username TEXT NOT NULL,
          action TEXT NOT NULL,
          resource_type TEXT NOT NULL,
          resource_id TEXT,
          resource_name TEXT,
          details TEXT,
          ip_address TEXT,
          user_agent TEXT,
          success INTEGER NOT NULL,
          error_message TEXT,
          timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL
        );

        INSERT INTO ${tempTableName} (${columns}) SELECT ${columns} FROM audit_logs;

        DROP TABLE audit_logs;

        ALTER TABLE ${tempTableName} RENAME TO audit_logs;
      `);
      sqlite.exec(`PRAGMA foreign_keys = ON`);

      databaseLogger.info("Successfully migrated audit_logs table to remove user_id NOT NULL constraint", {
        operation: "schema_migration_audit_user_id_nullable",
      });
    }
  } catch (migrationError) {
    databaseLogger.warn("Failed to migrate audit_logs user_id column", {
      operation: "schema_migration",
      error: migrationError,
    });
  }

  addColumnIfNotExists("file_manager_recent", "host_id", "INTEGER NOT NULL");
  addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
  addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");

  addColumnIfNotExists("snippets", "folder", "TEXT");
  addColumnIfNotExists("snippets", "order", "INTEGER NOT NULL DEFAULT 0");
  addColumnIfNotExists("snippets", "host_filter", "TEXT");
  addColumnIfNotExists("snippets", "is_note", "INTEGER NOT NULL DEFAULT 0");

  try {
    sqlite
      .prepare("SELECT id FROM snippet_folders LIMIT 1")
      .get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS snippet_folders (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          user_id TEXT NOT NULL,
          name TEXT NOT NULL,
          color TEXT,
          icon TEXT,
          created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
        );
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create snippet_folders table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }

  try {
    sqlite.prepare("SELECT id FROM snippet_access LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS snippet_access (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          snippet_id INTEGER NOT NULL,
          user_id TEXT,
          role_id INTEGER,
          granted_by TEXT NOT NULL,
          permission_level TEXT NOT NULL DEFAULT 'view',
          expires_at TEXT,
          created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          FOREIGN KEY (snippet_id) REFERENCES snippets (id) ON DELETE CASCADE,
          FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
          FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
          FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
        );
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create snippet_access table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }

  try {
    sqlite.prepare("SELECT id FROM termix_identities LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS termix_identities (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          user_id TEXT NOT NULL UNIQUE,
          handle TEXT NOT NULL UNIQUE,
          description TEXT,
          created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
        );
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create termix_identities table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }

  // Enforce one-Termix-ID-per-user on databases where the table predates the
  // UNIQUE(user_id) constraint above.
  try {
    sqlite.exec(
      "CREATE UNIQUE INDEX IF NOT EXISTS idx_termix_identities_user ON termix_identities(user_id)",
    );
  } catch (indexError) {
    databaseLogger.warn("Failed to create termix_identities user_id unique index", {
      operation: "schema_migration",
      error: indexError,
    });
  }

  try {
    sqlite.prepare("SELECT id FROM termix_identity_keys LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS termix_identity_keys (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          identity_id INTEGER NOT NULL,
          user_id TEXT NOT NULL,
          public_key TEXT NOT NULL,
          key_type TEXT NOT NULL,
          algorithm TEXT NOT NULL,
          label TEXT,
          comment TEXT,
          source TEXT NOT NULL DEFAULT 'manual',
          credential_id INTEGER,
          enabled INTEGER NOT NULL DEFAULT 1,
          created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          FOREIGN KEY (identity_id) REFERENCES termix_identities (id) ON DELETE CASCADE,
          FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
          FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE SET NULL
        );
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create termix_identity_keys table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }

  // The public resolver fetches keys by identity_id on every request; index it.
  try {
    sqlite.exec(
      "CREATE INDEX IF NOT EXISTS idx_termix_identity_keys_identity ON termix_identity_keys(identity_id)",
    );
  } catch (indexError) {
    databaseLogger.warn("Failed to create termix_identity_keys identity index", {
      operation: "schema_migration",
      error: indexError,
    });
  }

  try {
    sqlite.prepare("SELECT id FROM termix_identity_ca LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS termix_identity_ca (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          identity_id INTEGER NOT NULL UNIQUE,
          user_id TEXT NOT NULL,
          public_key TEXT NOT NULL,
          private_key TEXT NOT NULL,
          validity_days INTEGER NOT NULL DEFAULT 90,
          created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          FOREIGN KEY (identity_id) REFERENCES termix_identities (id) ON DELETE CASCADE,
          FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
        );
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create termix_identity_ca table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }

  try {
    sqlite
      .prepare("SELECT id FROM network_topology LIMIT 1")
      .get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS network_topology (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          user_id TEXT NOT NULL,
          topology TEXT,
          created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
        );
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create network_topology table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }

  try {
    sqlite.prepare("SELECT role_id FROM host_access LIMIT 1").get();
  } catch {
    try {
      sqlite.exec("ALTER TABLE host_access ADD COLUMN role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE");
    } catch (alterError) {
      databaseLogger.warn("Failed to add role_id column", {
        operation: "schema_migration",
        error: alterError,
      });
    }
  }

  try {
    sqlite.prepare("SELECT override_credential_id FROM host_access LIMIT 1").get();
  } catch {
    try {
      sqlite.exec("ALTER TABLE host_access ADD COLUMN override_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL");
    } catch (alterError) {
      databaseLogger.warn("Failed to add override_credential_id column", {
        operation: "schema_migration",
        error: alterError,
      });
    }
  }

  try {
    ensureSharedHostAuthOverrideProtocolSchema(sqlite);
  } catch (schemaError) {
    databaseLogger.warn("Failed to prepare shared_host_auth_overrides table", {
      operation: "schema_migration",
      error: schemaError,
    });
  }

  try {
    migrateLegacySharedHostAuthOverrides(
      sqlite,
      getRawSettingValue,
      setRawSettingValue,
    );
  } catch (migrateError) {
    databaseLogger.warn("Failed to migrate shared host auth overrides", {
      operation: "schema_migration",
      error: migrateError,
    });
  }

  try {
    sqlite.prepare("SELECT credential_id FROM ssh_folders LIMIT 1").get();
  } catch {
    try {
      sqlite.exec("ALTER TABLE ssh_folders ADD COLUMN credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL");
    } catch (alterError) {
      databaseLogger.warn("Failed to add credential_id column to ssh_folders", {
        operation: "schema_migration",
        error: alterError,
      });
    }
  }

  try {
    sqlite.prepare("SELECT sudo_password FROM ssh_data LIMIT 1").get();
  } catch {
    try {
      sqlite.exec("ALTER TABLE ssh_data ADD COLUMN sudo_password TEXT");
    } catch (alterError) {
      databaseLogger.warn("Failed to add sudo_password column", {
        operation: "schema_migration",
        error: alterError,
      });
    }
  }

  const sshDataMigrations: Array<{ column: string; sql: string }> = [
    { column: "connection_type", sql: "ALTER TABLE ssh_data ADD COLUMN connection_type TEXT NOT NULL DEFAULT 'ssh'" },
    { column: "credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN credential_id INTEGER" },
    { column: "override_credential_username", sql: "ALTER TABLE ssh_data ADD COLUMN override_credential_username INTEGER" },
    { column: "share_ssh_auth", sql: "ALTER TABLE ssh_data ADD COLUMN share_ssh_auth INTEGER NOT NULL DEFAULT 0" },
    { column: "jump_hosts", sql: "ALTER TABLE ssh_data ADD COLUMN jump_hosts TEXT" },
    { column: "show_terminal_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_terminal_in_sidebar INTEGER NOT NULL DEFAULT 1" },
    { column: "show_file_manager_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_file_manager_in_sidebar INTEGER NOT NULL DEFAULT 0" },
    { column: "show_tunnel_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_tunnel_in_sidebar INTEGER NOT NULL DEFAULT 0" },
    { column: "show_docker_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0" },
    { column: "show_server_stats_in_sidebar", sql: "ALTER TABLE ssh_data ADD COLUMN show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0" },
    { column: "quick_actions", sql: "ALTER TABLE ssh_data ADD COLUMN quick_actions TEXT" },
    { column: "domain", sql: "ALTER TABLE ssh_data ADD COLUMN domain TEXT" },
    { column: "security", sql: "ALTER TABLE ssh_data ADD COLUMN security TEXT" },
    { column: "ignore_cert", sql: "ALTER TABLE ssh_data ADD COLUMN ignore_cert INTEGER NOT NULL DEFAULT 0" },
    { column: "guacamole_config", sql: "ALTER TABLE ssh_data ADD COLUMN guacamole_config TEXT" },
    { column: "socks5_proxy_chain", sql: "ALTER TABLE ssh_data ADD COLUMN socks5_proxy_chain TEXT" },
    { column: "host_key_fingerprint", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_fingerprint TEXT" },
    { column: "host_key_type", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_type TEXT" },
    { column: "host_key_algorithm", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_algorithm TEXT NOT NULL DEFAULT 'sha256'" },
    { column: "host_key_first_seen", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_first_seen TEXT" },
    { column: "host_key_last_verified", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_last_verified TEXT" },
    { column: "host_key_changed_count", sql: "ALTER TABLE ssh_data ADD COLUMN host_key_changed_count INTEGER NOT NULL DEFAULT 0" },
    { column: "mac_address", sql: "ALTER TABLE ssh_data ADD COLUMN mac_address TEXT" },
    { column: "port_knock_sequence", sql: "ALTER TABLE ssh_data ADD COLUMN port_knock_sequence TEXT" },
    { column: "enable_ssh", sql: "ALTER TABLE ssh_data ADD COLUMN enable_ssh INTEGER NOT NULL DEFAULT 1" },
    { column: "enable_rdp", sql: "ALTER TABLE ssh_data ADD COLUMN enable_rdp INTEGER NOT NULL DEFAULT 0" },
    { column: "enable_vnc", sql: "ALTER TABLE ssh_data ADD COLUMN enable_vnc INTEGER NOT NULL DEFAULT 0" },
    { column: "enable_telnet", sql: "ALTER TABLE ssh_data ADD COLUMN enable_telnet INTEGER NOT NULL DEFAULT 0" },
    { column: "ssh_port", sql: "ALTER TABLE ssh_data ADD COLUMN ssh_port INTEGER DEFAULT 22" },
    { column: "rdp_port", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_port INTEGER DEFAULT 3389" },
    { column: "vnc_port", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_port INTEGER DEFAULT 5900" },
    { column: "telnet_port", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_port INTEGER DEFAULT 23" },
    { column: "rdp_user", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_user TEXT" },
    { column: "rdp_password", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_password TEXT" },
    { column: "rdp_domain", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_domain TEXT" },
    { column: "rdp_security", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_security TEXT" },
    { column: "rdp_ignore_cert", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_ignore_cert INTEGER DEFAULT 0" },
    { column: "vnc_password", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_password TEXT" },
    { column: "vnc_user", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_user TEXT" },
    { column: "telnet_user", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_user TEXT" },
    { column: "telnet_password", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_password TEXT" },
    { column: "rdp_credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL" },
    { column: "vnc_credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL" },
    { column: "wol_broadcast_address", sql: "ALTER TABLE ssh_data ADD COLUMN wol_broadcast_address TEXT" },
    { column: "use_warpgate", sql: "ALTER TABLE ssh_data ADD COLUMN use_warpgate INTEGER NOT NULL DEFAULT 0" },
    { column: "telnet_credential_id", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL" },
    { column: "rdp_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_auth_type TEXT" },
    { column: "vnc_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_auth_type TEXT" },
    { column: "telnet_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_auth_type TEXT" },
    { column: "allow_session_sharing", sql: "ALTER TABLE ssh_data ADD COLUMN allow_session_sharing INTEGER NOT NULL DEFAULT 1" },
    { column: "connection_origin", sql: "ALTER TABLE ssh_data ADD COLUMN connection_origin TEXT" },
    { column: "parent_host_id", sql: "ALTER TABLE ssh_data ADD COLUMN parent_host_id INTEGER REFERENCES ssh_data(id) ON DELETE SET NULL" },
  ];

  for (const migration of sshDataMigrations) {
    try {
      sqlite.prepare(`SELECT ${migration.column} FROM ssh_data LIMIT 1`).get();
    } catch {
      try {
        sqlite.exec(migration.sql);
      } catch (alterError) {
        databaseLogger.warn(`Failed to add ${migration.column} column`, {
          operation: "schema_migration",
          error: alterError,
        });
      }
    }
  }

  // share_ssh_auth arrived with 2.6.1 and defaults to 0, but sharing a host
  // used to pass the owner's SSH authentication along unconditionally. Every
  // host shared before the upgrade therefore stopped supplying credentials to
  // its recipients the moment the column appeared, and they were left with
  // "No valid authentication method provided".
  //
  // Turn it on for hosts that are already shared, which is where the previous
  // behaviour was in effect and consented to. Hosts nobody has shared keep the
  // new default; the owner decides when they share one.
  try {
    if (getRawSettingValue("share_ssh_auth_backfill_v1") === null) {
      const backfilled = sqlite
        .prepare(
          `UPDATE ssh_data SET share_ssh_auth = 1
           WHERE share_ssh_auth = 0
             AND id IN (SELECT DISTINCT host_id FROM host_access)`,
        )
        .run();

      if (backfilled.changes > 0) {
        databaseLogger.info(
          `Restored shared SSH authentication for ${backfilled.changes} already-shared host(s)`,
          { operation: "share_ssh_auth_backfill_v1" },
        );
      }
      setRawSettingValue("share_ssh_auth_backfill_v1", "true");
    }
  } catch (e) {
    databaseLogger.warn("Failed to backfill share_ssh_auth", {
      operation: "share_ssh_auth_backfill_v1",
      error: e,
    });
  }

  // Migrate legacy authType="warpgate" hosts to useWarpgate=1 with authType="none"
  try {
    const result = sqlite
      .prepare("UPDATE ssh_data SET use_warpgate = 1, auth_type = 'none' WHERE auth_type = 'warpgate'")
      .run();
    if (result.changes > 0) {
      databaseLogger.info(`Migrated ${result.changes} host(s) from authType='warpgate' to useWarpgate=true`, {
        operation: "warpgate_auth_migration",
      });
    }
  } catch (e) {
    databaseLogger.warn("Failed to migrate legacy warpgate authType hosts", {
      operation: "warpgate_auth_migration",
      error: e,
    });
  }

  // Copy unencrypted username/domain into protocol-specific columns for old guac hosts.
  // Passwords are handled via the legacy field name fallback in lazy-field-encryption.ts.
  const usernameDomainBackfills = [
    {
      protocol: "rdp",
      sql: "UPDATE ssh_data SET rdp_user = username, rdp_password = password, rdp_domain = domain WHERE connection_type = 'rdp' AND rdp_user IS NULL AND rdp_password IS NULL",
    },
    {
      protocol: "vnc",
      sql: "UPDATE ssh_data SET vnc_user = username, vnc_password = password WHERE connection_type = 'vnc' AND vnc_user IS NULL AND vnc_password IS NULL",
    },
    {
      protocol: "telnet",
      sql: "UPDATE ssh_data SET telnet_user = username, telnet_password = password WHERE connection_type = 'telnet' AND telnet_user IS NULL AND telnet_password IS NULL",
    },
  ];
  for (const backfill of usernameDomainBackfills) {
    try {
      const result = sqlite.prepare(backfill.sql).run();
      if (result.changes > 0) {
        databaseLogger.info(
          `Backfilled ${result.changes} ${backfill.protocol} host credential(s)`,
          { operation: "guac_credential_backfill" },
        );
      }
    } catch (e) {
      databaseLogger.warn(`Failed to backfill ${backfill.protocol} host credentials`, {
        operation: "guac_credential_backfill",
        error: e,
      });
    }
  }

  // Rename the legacy "stats" tab type to "host-metrics" so previously saved
  // open tabs restore correctly after the Server Stats -> Host Metrics rename.
  try {
    sqlite.prepare("SELECT id FROM user_open_tabs LIMIT 1").get();
    const result = sqlite
      .prepare(
        "UPDATE user_open_tabs SET tab_type = 'host-metrics' WHERE tab_type = 'stats'",
      )
      .run();
    if (result.changes > 0) {
      databaseLogger.info(
        `Migrated ${result.changes} open tab(s) from 'stats' to 'host-metrics'`,
        { operation: "open_tabs_tab_type_migration" },
      );
    }
  } catch {
    // user_open_tabs table not present yet; nothing to migrate.
  }

  try {
    sqlite.prepare("SELECT id FROM shared_host_secrets LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS shared_host_secrets (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          host_access_id INTEGER NOT NULL,
          target_user_id TEXT NOT NULL,
          protocol TEXT NOT NULL DEFAULT 'ssh',
          source_type TEXT NOT NULL DEFAULT 'credential',
          original_credential_id INTEGER,
          encrypted_username TEXT,
          encrypted_auth_type TEXT,
          encrypted_password TEXT,
          encrypted_key TEXT,
          encrypted_key_password TEXT,
          encrypted_key_type TEXT,
          encrypted_domain TEXT,
          created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          UNIQUE(host_access_id, target_user_id, protocol),
          FOREIGN KEY (host_access_id) REFERENCES host_access (id) ON DELETE CASCADE,
          FOREIGN KEY (original_credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE,
          FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE
        );
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create shared_host_secrets table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }

  try {
    if (getRawSettingValue("rbac_permission_levels_v2") === null) {
      sqlite.exec(
        "UPDATE host_access SET permission_level = 'connect' WHERE permission_level = 'view'",
      );
      setRawSettingValue("rbac_permission_levels_v2", "done");
    }
  } catch (migrateError) {
    databaseLogger.warn("Failed to migrate legacy view permission level", {
      operation: "schema_migration",
      error: migrateError,
    });
  }

  try {
    sqlite.prepare("SELECT id FROM opkssh_tokens LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS opkssh_tokens (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          user_id TEXT NOT NULL,
          host_id INTEGER NOT NULL,
          ssh_cert TEXT NOT NULL,
          private_key TEXT NOT NULL,
          email TEXT,
          sub TEXT,
          issuer TEXT,
          audience TEXT,
          created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          expires_at TEXT NOT NULL,
          last_used TEXT,
          UNIQUE(user_id, host_id),
          FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
          FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
        );
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create opkssh_tokens table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }

  try {
    sqlite.prepare("SELECT id FROM vault_profiles LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS vault_profiles (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          user_id TEXT NOT NULL,
          name TEXT NOT NULL,
          description TEXT,
          folder TEXT,
          tags TEXT,
          vault_addr TEXT NOT NULL,
          vault_namespace TEXT,
          oidc_mount TEXT,
          oidc_role TEXT,
          ssh_mount TEXT,
          ssh_role TEXT NOT NULL,
          valid_principals TEXT,
          key_type TEXT,
          shared INTEGER NOT NULL DEFAULT 0,
          created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
        );
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create vault_profiles table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }

  try {
    sqlite.prepare("SELECT id FROM vault_tokens LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS vault_tokens (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          user_id TEXT NOT NULL,
          profile_id INTEGER NOT NULL,
          ssh_cert TEXT NOT NULL,
          private_key TEXT NOT NULL,
          created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          expires_at TEXT NOT NULL,
          last_used TEXT,
          UNIQUE(user_id, profile_id),
          FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
          FOREIGN KEY (profile_id) REFERENCES vault_profiles (id) ON DELETE CASCADE
        );
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create vault_tokens table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }

  // --- tmux-monitor begin ---
  try {
    sqlite.prepare("SELECT id FROM tmux_session_tags LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS tmux_session_tags (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          user_id TEXT NOT NULL,
          host_id INTEGER NOT NULL,
          session_name TEXT NOT NULL,
          tag TEXT NOT NULL,
          created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
          FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
        );
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create tmux_session_tags table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }

  // Rebuild pre-release tables created without the host FK so deleting a
  // host cascades to its tags (SQLite cannot add a FK via ALTER TABLE).
  try {
    const tagFks = sqlite
      .prepare("PRAGMA foreign_key_list(tmux_session_tags)")
      .all() as Array<{ table: string; from: string }>;
    const hasHostFk = tagFks.some(
      (fk) => fk.from === "host_id" && fk.table === "ssh_data",
    );
    if (!hasHostFk) {
      sqlite.exec(`
        BEGIN;
        CREATE TABLE tmux_session_tags_new (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          user_id TEXT NOT NULL,
          host_id INTEGER NOT NULL,
          session_name TEXT NOT NULL,
          tag TEXT NOT NULL,
          created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
          FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
        );
        INSERT INTO tmux_session_tags_new (id, user_id, host_id, session_name, tag, created_at)
          SELECT id, user_id, host_id, session_name, tag, created_at
          FROM tmux_session_tags
          WHERE user_id IN (SELECT id FROM users)
            AND host_id IN (SELECT id FROM ssh_data);
        DROP TABLE tmux_session_tags;
        ALTER TABLE tmux_session_tags_new RENAME TO tmux_session_tags;
        COMMIT;
      `);
      databaseLogger.info("Rebuilt tmux_session_tags with host FK", {
        operation: "schema_migration",
      });
    }
  } catch (rebuildError) {
    try {
      sqlite.exec("ROLLBACK;");
    } catch {
      // no transaction open
    }
    databaseLogger.warn("Failed to add host FK to tmux_session_tags", {
      operation: "schema_migration",
      error: rebuildError,
    });
  }
  // --- tmux-monitor end ---

  try {
    const existingRoles = sqlite.prepare("SELECT name, is_system FROM roles").all() as Array<{ name: string; is_system: number }>;

    try {
      const validSystemRoles = ['admin', 'user'];
      const unwantedRoleNames = ['superAdmin', 'powerUser', 'readonly', 'member'];
      const deleteByName = sqlite.prepare("DELETE FROM roles WHERE name = ?");
      for (const roleName of unwantedRoleNames) {
        deleteByName.run(roleName);
      }

      const deleteOldSystemRole = sqlite.prepare("DELETE FROM roles WHERE name = ? AND is_system = 1");
      for (const role of existingRoles) {
        if (role.is_system === 1 && !validSystemRoles.includes(role.name) && !unwantedRoleNames.includes(role.name)) {
          deleteOldSystemRole.run(role.name);
        }
      }
    } catch (cleanupError) {
      databaseLogger.warn("Failed to clean up old system roles", {
        operation: "schema_migration",
        error: cleanupError,
      });
    }

    const systemRoles = [
      {
        name: "admin",
        displayName: "rbac.roles.admin",
        description: "Administrator with full access",
        permissions: null,
      },
      {
        name: "user",
        displayName: "rbac.roles.user",
        description: "Regular user",
        permissions: null,
      },
    ];

    for (const role of systemRoles) {
      const existingRole = sqlite.prepare("SELECT id FROM roles WHERE name = ?").get(role.name);
      if (!existingRole) {
        try {
          sqlite.prepare(`
            INSERT INTO roles (name, display_name, description, is_system, permissions)
            VALUES (?, ?, ?, 1, ?)
          `).run(role.name, role.displayName, role.description, role.permissions);
        } catch (insertError) {
          databaseLogger.warn(`Failed to create system role: ${role.name}`, {
            operation: "schema_migration",
            error: insertError,
          });
        }
      }
    }

    try {
      const adminUsers = sqlite.prepare("SELECT id FROM users WHERE is_admin = 1").all() as { id: string }[];
      const normalUsers = sqlite.prepare("SELECT id FROM users WHERE is_admin = 0").all() as { id: string }[];

      const adminRole = sqlite.prepare("SELECT id FROM roles WHERE name = 'admin'").get() as { id: number } | undefined;
      const userRole = sqlite.prepare("SELECT id FROM roles WHERE name = 'user'").get() as { id: number } | undefined;

      if (adminRole) {
        const insertUserRole = sqlite.prepare(`
          INSERT OR IGNORE INTO user_roles (user_id, role_id, granted_at)
          VALUES (?, ?, CURRENT_TIMESTAMP)
        `);

        for (const admin of adminUsers) {
          try {
            insertUserRole.run(admin.id, adminRole.id);
          } catch {
            // Ignore duplicate errors
          }
        }
      }

      if (userRole) {
        const insertUserRole = sqlite.prepare(`
          INSERT OR IGNORE INTO user_roles (user_id, role_id, granted_at)
          VALUES (?, ?, CURRENT_TIMESTAMP)
        `);

        for (const user of normalUsers) {
          try {
            insertUserRole.run(user.id, userRole.id);
          } catch {
            // Ignore duplicate errors
          }
        }
      }
    } catch (migrationError) {
      databaseLogger.warn("Failed to migrate existing users to roles", {
        operation: "schema_migration",
        error: migrationError,
      });
    }
  } catch (seedError) {
    databaseLogger.warn("Failed to seed system roles", {
      operation: "schema_migration",
      error: seedError,
    });
  }

  addColumnIfNotExists("users", "sso_provider_id", "INTEGER");

  try {
    const usersTableInfo = sqlite.prepare("PRAGMA table_info(users)").all() as Array<{
      cid: number;
      name: string;
      type: string;
      notnull: number;
      dflt_value: string | null;
      pk: number;
    }>;
    const legacyNotNullColumns = new Set([
      "client_id",
      "client_secret",
      "issuer_url",
      "authorization_url",
      "token_url",
      "identifier_path",
      "name_path",
      "scopes",
    ]);
    const hasStaleNotNull = usersTableInfo.some(
      (col) => legacyNotNullColumns.has(col.name) && col.notnull === 1,
    );

    if (hasStaleNotNull) {
      const tempTableName = "users_temp_migration";
      const columnDefs = usersTableInfo
        .map((col) => {
          const parts = [`"${col.name}"`, col.type || "TEXT"];
          if (col.pk === 1) parts.push("PRIMARY KEY");
          if (col.notnull === 1 && !legacyNotNullColumns.has(col.name)) {
            parts.push("NOT NULL");
          }
          if (col.dflt_value !== null) {
            parts.push(`DEFAULT ${col.dflt_value}`);
          }
          return parts.join(" ");
        })
        .join(",\n          ");
      const allColumns = usersTableInfo.map((col) => `"${col.name}"`).join(", ");

      sqlite.exec(`PRAGMA foreign_keys = OFF`);
      sqlite.exec(`
        CREATE TABLE ${tempTableName} (
          ${columnDefs}
        );

        INSERT INTO ${tempTableName} SELECT ${allColumns} FROM users;

        DROP TABLE users;

        ALTER TABLE ${tempTableName} RENAME TO users;
      `);
      sqlite.exec(`PRAGMA foreign_keys = ON`);

      databaseLogger.info(
        "Successfully migrated users table to remove legacy OIDC NOT NULL constraints",
        {
          operation: "schema_migration_users_oidc_nullable",
        },
      );
    }
  } catch (migrationError) {
    databaseLogger.warn("Failed to migrate users table legacy OIDC columns", {
      operation: "schema_migration",
      error: migrationError,
    });
  }

  // Migrate legacy single oidc_config settings blob into sso_providers table
  try {
    const migrationDone = getRawSettingValue("sso_migration_v1");
    if (!migrationDone) {
      const providerCount = (
        sqlite.prepare("SELECT COUNT(*) as c FROM sso_providers").get() as {
          c: number;
        }
      ).c;
      if (providerCount === 0) {
        const legacyConfig = getRawSettingValue("oidc_config");
        if (legacyConfig) {
          sqlite
            .prepare(
              "INSERT INTO sso_providers (name, type, enabled, display_order, config) VALUES (?, 'oidc', 1, 0, ?)",
            )
            .run("OIDC", legacyConfig);
          databaseLogger.info(
            "Migrated legacy oidc_config into sso_providers table",
            {
              operation: "sso_migration_v1",
            },
          );
        }
      }
      setRawSettingValue("sso_migration_v1", "true");
    }
  } catch (e) {
    databaseLogger.warn("Failed to run SSO migration v1", {
      operation: "sso_migration_v1",
      error: e,
    });
  }

  // --- metrics-history begin ---
  try {
    sqlite.prepare("SELECT id FROM host_metrics_history LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS host_metrics_history (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
          ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          cpu_percent REAL,
          mem_percent REAL,
          disk_percent REAL,
          net_rx_bytes INTEGER,
          net_tx_bytes INTEGER
        );
        CREATE INDEX IF NOT EXISTS idx_host_metrics_history_host_ts
          ON host_metrics_history (host_id, ts DESC);
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create host_metrics_history table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }
  // --- metrics-history end ---

  // --- proxmox-node-history begin ---
  try {
    sqlite.prepare("SELECT id FROM proxmox_node_history LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS proxmox_node_history (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
          ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          cpu_percent REAL,
          mem_percent REAL,
          disk_percent REAL,
          net_rx_bytes INTEGER,
          net_tx_bytes INTEGER
        );
        CREATE INDEX IF NOT EXISTS idx_proxmox_node_history_host_ts
          ON proxmox_node_history (host_id, ts DESC);
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create proxmox_node_history table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }
  // --- proxmox-node-history end ---

  // --- alerts begin ---
  try {
    sqlite.prepare("SELECT id FROM alert_rules LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS alert_rules (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
          host_id INTEGER REFERENCES ssh_data(id) ON DELETE CASCADE,
          name TEXT NOT NULL,
          enabled INTEGER NOT NULL DEFAULT 1,
          trigger_type TEXT NOT NULL,
          threshold_value REAL,
          threshold_duration_seconds INTEGER,
          cooldown_minutes INTEGER NOT NULL DEFAULT 15,
          created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        );
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create alert_rules table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }

  try {
    sqlite.prepare("SELECT id FROM notification_channels LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS notification_channels (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
          name TEXT NOT NULL,
          type TEXT NOT NULL,
          config TEXT NOT NULL,
          enabled INTEGER NOT NULL DEFAULT 1,
          created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        );
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create notification_channels table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }

  try {
    sqlite.prepare("SELECT id FROM alert_rule_channels LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS alert_rule_channels (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          rule_id INTEGER NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
          channel_id INTEGER NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE
        );
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create alert_rule_channels table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }

  try {
    sqlite.prepare("SELECT id FROM alert_firings LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS alert_firings (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
          rule_id INTEGER NOT NULL REFERENCES alert_rules(id) ON DELETE CASCADE,
          host_id INTEGER NOT NULL,
          host_name TEXT NOT NULL,
          fired_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          resolved_at TEXT,
          value REAL,
          message TEXT NOT NULL,
          severity TEXT NOT NULL DEFAULT 'warning',
          acknowledged INTEGER NOT NULL DEFAULT 0
        );
        CREATE INDEX IF NOT EXISTS idx_alert_firings_user_fired
          ON alert_firings (user_id, fired_at DESC);
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create alert_firings table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }
  // --- alerts end ---

  // --- automations begin ---
  try {
    sqlite.prepare("SELECT id FROM automations LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS automations (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
          name TEXT NOT NULL,
          description TEXT,
          enabled INTEGER NOT NULL DEFAULT 1,
          definition TEXT NOT NULL,
          definition_version INTEGER NOT NULL DEFAULT 1,
          concurrency_policy TEXT NOT NULL DEFAULT 'skip',
          max_run_seconds INTEGER NOT NULL DEFAULT 300,
          dry_run INTEGER NOT NULL DEFAULT 0,
          last_run_at TEXT,
          last_run_status TEXT,
          created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        );
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create automations table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }

  try {
    sqlite.prepare("SELECT id FROM automation_trigger_state LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS automation_trigger_state (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
          state_key TEXT NOT NULL,
          breach_started_at TEXT,
          last_fired_at TEXT,
          last_value REAL,
          last_observed_state TEXT,
          updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        );
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create automation_trigger_state table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }

  try {
    sqlite.prepare("SELECT id FROM automation_schedules LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS automation_schedules (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
          cron TEXT,
          interval_seconds INTEGER,
          timezone TEXT,
          next_due_at TEXT,
          last_tick_at TEXT
        );
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create automation_schedules table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }

  try {
    sqlite.prepare("SELECT id FROM automation_runs LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS automation_runs (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
          user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
          trigger_type TEXT NOT NULL,
          trigger_context TEXT,
          status TEXT NOT NULL,
          started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          finished_at TEXT,
          duration_ms INTEGER,
          error TEXT,
          dry_run INTEGER NOT NULL DEFAULT 0,
          parent_run_id INTEGER
        );
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create automation_runs table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }

  try {
    sqlite.prepare("SELECT id FROM automation_run_steps LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS automation_run_steps (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          run_id INTEGER NOT NULL REFERENCES automation_runs(id) ON DELETE CASCADE,
          step_index INTEGER NOT NULL,
          step_id TEXT NOT NULL,
          step_type TEXT NOT NULL,
          status TEXT NOT NULL,
          started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          finished_at TEXT,
          output TEXT,
          error TEXT,
          truncated INTEGER NOT NULL DEFAULT 0
        );
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create automation_run_steps table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }

  try {
    sqlite.prepare("SELECT id FROM automation_channels LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS automation_channels (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
          channel_id INTEGER NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE
        );
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create automation_channels table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }
  // --- automations end ---

  // Seed default metrics history retention setting
  try {
    ensureRawSettingDefault("metrics_history_retention_days", "7");
  } catch (e) {
    databaseLogger.warn(
      "Could not initialize metrics_history_retention_days setting",
      {
        operation: "schema_migration",
        error: e,
      },
    );
  }

  // --- homepage begin ---
  try {
    sqlite.prepare("SELECT id FROM homepage_items LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS homepage_items (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
          type_id TEXT NOT NULL,
          title TEXT,
          config TEXT NOT NULL DEFAULT '{}',
          folder_id INTEGER,
          created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        );
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create homepage_items table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }

  try {
    sqlite.prepare("SELECT id FROM homepage_layouts LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS homepage_layouts (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          user_id TEXT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
          layout TEXT NOT NULL DEFAULT '{}',
          updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        );
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create homepage_layouts table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }
  // --- homepage end ---

  // --- fleets begin ---
  try {
    sqlite.prepare("SELECT id FROM fleets LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS fleets (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
          name TEXT NOT NULL,
          description TEXT,
          color TEXT,
          icon TEXT,
          tag_rules TEXT,
          sync_id TEXT UNIQUE,
          created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        );
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create fleets table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }

  try {
    sqlite.prepare("SELECT id FROM fleet_members LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS fleet_members (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          fleet_id INTEGER NOT NULL REFERENCES fleets(id) ON DELETE CASCADE,
          host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
          added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        );
      `);
      sqlite.exec(
        "CREATE UNIQUE INDEX IF NOT EXISTS idx_fleet_members_fleet_host ON fleet_members(fleet_id, host_id)",
      );
    } catch (createError) {
      databaseLogger.warn("Failed to create fleet_members table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }

  try {
    sqlite.prepare("SELECT id FROM fleet_inventory LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS fleet_inventory (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
          user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
          os_pretty_name TEXT,
          kernel TEXT,
          architecture TEXT,
          hostname TEXT,
          uptime_seconds INTEGER,
          ip TEXT,
          package_manager TEXT,
          collected_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        );
      `);
      sqlite.exec(
        "CREATE UNIQUE INDEX IF NOT EXISTS idx_fleet_inventory_host ON fleet_inventory(host_id, user_id)",
      );
    } catch (createError) {
      databaseLogger.warn("Failed to create fleet_inventory table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }
  // --- fleets end ---

  // --- workspaces begin ---
  try {
    sqlite.prepare("SELECT id FROM user_workspaces LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS user_workspaces (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
          name TEXT NOT NULL,
          color TEXT,
          icon TEXT,
          kind TEXT NOT NULL DEFAULT 'manual',
          is_default INTEGER NOT NULL DEFAULT 0,
          payload TEXT NOT NULL DEFAULT '{}',
          sync_id TEXT UNIQUE,
          created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          last_used_at TEXT
        );
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create user_workspaces table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }
  // --- workspaces end ---

  // --- sync begin ---
  // Stable per-row identity used to match rows across two independently-
  // seeded databases (the embedded desktop backend and a connected remote
  // server) during sync. Local autoincrement ids collide across instances,
  // so a randomly-generated id is the join key instead. SQLite refuses a
  // non-constant DEFAULT (e.g. randomblob()) on ALTER TABLE ADD COLUMN for
  // tables with existing constraints ("Cannot add a column with
  // non-constant default"), so the column is added as plain nullable TEXT;
  // repositories set syncId explicitly on insert going forward, and
  // existing rows are backfilled by the UPDATE loop below.
  addColumnIfNotExists("ssh_data", "sync_id", "TEXT");
  addColumnIfNotExists("ssh_credentials", "sync_id", "TEXT");
  addColumnIfNotExists("ssh_folders", "sync_id", "TEXT");
  addColumnIfNotExists("snippets", "sync_id", "TEXT");
  addColumnIfNotExists("snippet_folders", "sync_id", "TEXT");
  addColumnIfNotExists("vault_profiles", "sync_id", "TEXT");
  addColumnIfNotExists("dashboard_service_links", "sync_id", "TEXT");
  // SQLite also rejects NOT NULL DEFAULT CURRENT_TIMESTAMP here for the same
  // "non-constant default" reason -- add nullable, then backfill from
  // created_at below and rely on the repository layer to keep it current.
  addColumnIfNotExists("dashboard_service_links", "updated_at", "TEXT");
  try {
    sqlite.exec(
      "UPDATE dashboard_service_links SET updated_at = created_at WHERE updated_at IS NULL",
    );
  } catch (e) {
    const message = e instanceof Error ? e.message : String(e);
    databaseLogger.warn(
      `Failed to backfill dashboard_service_links.updated_at: ${message}`,
      { operation: "schema_migration", table: "dashboard_service_links" },
    );
  }
  addColumnIfNotExists("homepage_items", "sync_id", "TEXT");

  const syncIdTables = [
    "ssh_data",
    "ssh_credentials",
    "ssh_folders",
    "snippets",
    "snippet_folders",
    "vault_profiles",
    "dashboard_service_links",
    "homepage_items",
  ];

  for (const table of syncIdTables) {
    try {
      const result = sqlite
        .prepare(
          `UPDATE ${table} SET sync_id = lower(hex(randomblob(16))) WHERE sync_id IS NULL`,
        )
        .run();
      if (result.changes > 0) {
        databaseLogger.info(
          `Backfilled sync_id for ${result.changes} row(s) in ${table}`,
          { operation: "sync_id_backfill", table },
        );
      }
      sqlite.exec(
        `CREATE UNIQUE INDEX IF NOT EXISTS idx_${table}_sync_id ON ${table}(sync_id)`,
      );
    } catch (e) {
      const message = e instanceof Error ? e.message : String(e);
      databaseLogger.warn(
        `Failed to backfill sync_id for ${table}: ${message}`,
        {
          operation: "sync_id_backfill",
          table,
        },
      );
    }
  }

  try {
    sqlite.prepare("SELECT id FROM sync_tombstones LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS sync_tombstones (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
          entity_type TEXT NOT NULL,
          sync_id TEXT NOT NULL,
          deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        );
      `);
      sqlite.exec(
        "CREATE INDEX IF NOT EXISTS idx_sync_tombstones_user_entity ON sync_tombstones(user_id, entity_type)",
      );
    } catch (createError) {
      databaseLogger.warn("Failed to create sync_tombstones table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }
  // --- sync end ---

  // --- ai begin ---
  try {
    sqlite.prepare("SELECT id FROM ai_providers LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS ai_providers (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
          provider_type TEXT NOT NULL,
          label TEXT NOT NULL,
          base_url TEXT,
          api_key TEXT,
          api_key_prefix TEXT,
          default_model TEXT,
          enabled INTEGER NOT NULL DEFAULT 1,
          created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          UNIQUE(user_id, label)
        );
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create ai_providers table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }

  try {
    sqlite.prepare("SELECT id FROM ai_conversations LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS ai_conversations (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
          title TEXT,
          provider_id INTEGER,
          model TEXT,
          created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
          updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        );
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create ai_conversations table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }

  try {
    sqlite.prepare("SELECT id FROM ai_messages LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS ai_messages (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          conversation_id INTEGER NOT NULL REFERENCES ai_conversations(id) ON DELETE CASCADE,
          role TEXT NOT NULL,
          content TEXT NOT NULL DEFAULT '',
          tool_calls TEXT,
          created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        );
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create ai_messages table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }

  try {
    sqlite.prepare("SELECT id FROM ai_proposals LIMIT 1").get();
  } catch {
    try {
      sqlite.exec(`
        CREATE TABLE IF NOT EXISTS ai_proposals (
          id INTEGER PRIMARY KEY AUTOINCREMENT,
          conversation_id INTEGER NOT NULL REFERENCES ai_conversations(id) ON DELETE CASCADE,
          user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
          kind TEXT NOT NULL,
          summary TEXT,
          payload TEXT NOT NULL DEFAULT '{}',
          status TEXT NOT NULL DEFAULT 'pending',
          applied_at TEXT,
          result_summary TEXT,
          created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        );
      `);
    } catch (createError) {
      databaseLogger.warn("Failed to create ai_proposals table", {
        operation: "schema_migration",
        error: createError,
      });
    }
  }

  // --- ai end ---

  // Audit trails and session recordings used to be deleted along with the user
  // they referenced, which defeats the point of keeping them.
  migrateAuditRetention(sqlite);

  // Runs last so every table and column added above already exists.
  createPerformanceIndexes(sqlite);

  databaseLogger.success("Schema migration completed", {
    operation: "schema_migration",
  });
};

// A trivial telemetry write forces `serialize()` to rewrite every free page
// along with the live ones, so a database that has accumulated a large
// freelist (from years of unbounded metrics/audit growth before retention
// pruning existed) turns every future save into a multi-megabyte rewrite.
// Reclaiming that space once at startup keeps steady-state saves cheap.
const VACUUM_FREELIST_COUNT_THRESHOLD = 2000;
const VACUUM_FREELIST_RATIO_THRESHOLD = 0.5;

function vacuumIfFreelistBloated(): void {
  try {
    const pageCount = sqlite.pragma("page_count", { simple: true }) as number;
    const freelistCount = sqlite.pragma("freelist_count", {
      simple: true,
    }) as number;
    if (pageCount <= 0) return;

    const freelistRatio = freelistCount / pageCount;
    if (
      freelistCount < VACUUM_FREELIST_COUNT_THRESHOLD ||
      freelistRatio < VACUUM_FREELIST_RATIO_THRESHOLD
    ) {
      return;
    }

    databaseLogger.info("Reclaiming bloated SQLite freelist on startup", {
      operation: "db_startup_vacuum",
      pageCount,
      freelistCount,
      freelistRatio,
    });
    sqlite.exec("VACUUM");
  } catch (error) {
    databaseLogger.warn("Failed to vacuum database on startup", {
      operation: "db_startup_vacuum_failed",
      error: error instanceof Error ? error.message : String(error),
    });
  }
}

async function saveMemoryDatabaseToFile(): Promise<void> {
  if (!memoryDatabase) return;

  try {
    const buffer = memoryDatabase.serialize();

    if (!fs.existsSync(dataDir)) {
      fs.mkdirSync(dataDir, { recursive: true });
    }

    try {
      memoryDatabase
        .prepare("SELECT COUNT(*) as count FROM sessions")
        .get() as { count: number };
    } catch {
      // expected - sessions table may not exist yet
    }

    if (enableFileEncryption) {
      await DatabaseFileEncryption.encryptDatabaseFromBuffer(
        buffer,
        encryptedDbPath,
      );
    } else {
      fs.writeFileSync(dbPath, buffer);
    }

    DatabaseSaveTrigger.markClean();
  } catch (error) {
    databaseLogger.error("Failed to save in-memory database", error, {
      operation: "memory_db_save_failed",
      enableFileEncryption,
    });
  }
}

async function handlePostInitFileEncryption() {
  try {
    if (memoryDatabase) {
      DatabaseSaveTrigger.initialize(saveMemoryDatabaseToFile);

      if (enableFileEncryption) {
        await saveMemoryDatabaseToFile();
      }

      setInterval(() => {
        if (DatabaseSaveTrigger.isDirty) {
          saveMemoryDatabaseToFile();
        }
      }, 5 * 60 * 1000);
    }

    if (!enableFileEncryption) return;

    try {
      const migration = new DatabaseMigration(dataDir);
      migration.cleanupOldBackups();
    } catch (cleanupError) {
      databaseLogger.warn("Failed to cleanup old migration files", {
        operation: "migration_cleanup_startup_failed",
        error:
          getErrorMessage(cleanupError),
      });
    }
  } catch (error) {
    databaseLogger.error(
      "Failed to handle database file encryption setup",
      error,
      {
        operation: "db_encrypt_setup_failed",
      },
    );
  }
}

async function initializeDatabase(): Promise<void> {
  const dialect = resolveDatabaseDialect();

  if (dialect !== "sqlite") {
    await initializeRemoteDatabase(dialect);
    return;
  }

  await initializeCompleteDatabase();
  await handlePostInitFileEncryption();
}

/**
 * Startup against Postgres or MySQL.
 *
 * Shorter than the SQLite path because most of what that one does has no
 * counterpart here: there is no file to decrypt, no in-memory copy to keep in
 * step with disk, and the schema comes from drizzle-kit migrations instead of
 * the inline DDL below.
 *
 * What does carry over is the settings cache. 27 call sites read settings
 * synchronously, which better-sqlite3 allows and no remote driver does, so the
 * table is loaded once here before anything asks for it.
 */
async function initializeRemoteDatabase(
  dialect: Exclude<DatabaseDialect, "sqlite">,
): Promise<void> {
  databaseLogger.info(`Connecting to ${dialect} database`, {
    operation: "db_init",
    dialect,
  });

  db = await connectRemoteDatabase(dialect);
  await runRemoteMigrations(dialect, db);

  // Imported here rather than at the top: factory.ts imports getDb from this
  // module, and a static import would close the cycle at module-load time.
  const { primeCurrentSettingsCache, startSettingsCacheRefresh } = await import(
    "../repositories/factory.js"
  );
  await primeCurrentSettingsCache();
  startSettingsCacheRefresh();

  databaseLogger.info(`${dialect} database ready`, {
    operation: "db_init_complete",
    dialect,
  });
}

export { initializeDatabase };

async function cleanupDatabase() {
  if (memoryDatabase) {
    try {
      await saveMemoryDatabaseToFile();
    } catch (error) {
      databaseLogger.error(
        "Failed to save in-memory database before shutdown",
        error,
        {
          operation: "shutdown_save_failed",
        },
      );
    }
  }

  try {
    if (sqlite) {
      sqlite.close();
    }
  } catch (error) {
    databaseLogger.warn("Error closing database connection", {
      operation: "db_close_error",
      error: getErrorMessage(error),
    });
  }

  try {
    const tempDir = path.join(dataDir, ".temp");
    if (fs.existsSync(tempDir)) {
      const files = fs.readdirSync(tempDir);
      for (const file of files) {
        try {
          fs.unlinkSync(path.join(tempDir, file));
        } catch {
          // expected - file cleanup best effort
        }
      }

      try {
        fs.rmdirSync(tempDir);
      } catch {
        // expected - dir cleanup best effort
      }
    }
  } catch {
    // expected - temp dir cleanup best effort
  }
}

process.on("exit", () => {
  if (sqlite) {
    try {
      sqlite.close();
    } catch {
      // expected - database may already be closed
    }
  }
});

process.on("SIGINT", async () => {
  databaseLogger.info("Received SIGINT, cleaning up...", {
    operation: "shutdown",
  });
  await cleanupDatabase();
  process.exit(0);
});

process.on("SIGTERM", async () => {
  databaseLogger.info("Received SIGTERM, cleaning up...", {
    operation: "shutdown",
  });
  await cleanupDatabase();
  process.exit(0);
});

let db: PortableDatabase;

export function getDb(): PortableDatabase {
  if (!db) {
    throw new Error(
      "Database not initialized. Ensure initializeDatabase() is called before accessing db.",
    );
  }
  return db;
}

export function getSqlite(): Database.Database {
  if (!sqlite) {
    const dialect = resolveDatabaseDialect();
    if (dialect !== "sqlite") {
      throw new Error(
        `No SQLite handle: DATABASE_DIALECT is "${dialect}". This caller needs a ` +
          `synchronous query, which only SQLite offers — give it an async path instead.`,
      );
    }
    throw new Error(
      "SQLite not initialized. Ensure initializeDatabase() is called before accessing sqlite.",
    );
  }
  return sqlite;
}

export { db };
export { DatabaseFileEncryption };
export const databasePaths = {
  main: actualDbPath,
  encrypted: encryptedDbPath,
  directory: dbDir,
  inMemory: true,
};

export { saveMemoryDatabaseToFile };

export { DatabaseSaveTrigger };
