import type Database from 'better-sqlite3';

/**
 * Run database migrations. Safe to call on a fresh DB or on an existing production DB
 * (idempotent throughout). On fresh DBs, prerequisite tables are bootstrapped by the
 * individual migrate* functions as needed.
 */
export function runMigrations(db: Database.Database): void {
  // Helper: check if a table exists in this DB.
  const tableExists = (name: string): boolean =>
    !!(db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name));

  // Add owner_id to jobs (if not exists)
  // Guard: table may not exist when runMigrations is called on a fresh DB
  // (schema.sql hasn't been applied yet); the ALTER is a no-op in that case.
  const jobsCols = db.prepare("PRAGMA table_info('jobs')").all() as Array<{ name: string }>;
  if (tableExists('jobs') && !jobsCols.some(c => c.name === 'owner_id')) {
    db.exec("ALTER TABLE jobs ADD COLUMN owner_id TEXT REFERENCES users(id)");
  }

  // Add owner_id to local_tasks (if not exists)
  const tasksCols = db.prepare("PRAGMA table_info('local_tasks')").all() as Array<{ name: string }>;
  if (tableExists('local_tasks') && !tasksCols.some(c => c.name === 'owner_id')) {
    db.exec("ALTER TABLE local_tasks ADD COLUMN owner_id TEXT REFERENCES users(id)");
  }

  // Audit 2026-06-08 fix: user_gitea_orgs was created ONLY in
  // Repository.initSchema(), so a DB built via the migration path alone lacked
  // it and the task-list display SELECT (correlated subquery on org_name in
  // repository.ts) crashed with "no such table". Create it here too so the
  // fresh path (schema.sql) and the migration path stay in sync.
  db.exec(`
    CREATE TABLE IF NOT EXISTS user_gitea_orgs (
      user_id    TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
      org_id     TEXT NOT NULL,
      org_name   TEXT NOT NULL,
      fetched_at TEXT NOT NULL DEFAULT (datetime('now')),
      PRIMARY KEY (user_id, org_id)
    );
    CREATE INDEX IF NOT EXISTS idx_user_gitea_orgs_org_id ON user_gitea_orgs(org_id);
  `);

  // Add context tracking columns to jobs (if not exists)
  // re-fetch after the owner_id ALTER above to reflect the updated schema
  const jobsColsAfter = db.prepare("PRAGMA table_info('jobs')").all() as Array<{ name: string }>;
  const existingCols = new Set(jobsColsAfter.map(c => c.name));
  if (tableExists('jobs') && !existingCols.has('context_prompt_tokens')) {
    db.exec("ALTER TABLE jobs ADD COLUMN context_prompt_tokens INTEGER");
  }
  if (tableExists('jobs') && !existingCols.has('context_limit_tokens')) {
    db.exec("ALTER TABLE jobs ADD COLUMN context_limit_tokens INTEGER");
  }
  if (tableExists('jobs') && !existingCols.has('context_updated_at')) {
    db.exec("ALTER TABLE jobs ADD COLUMN context_updated_at TEXT");
  }

  // Phase: piece handoff (Continue-with-another-piece feature).
  // continued_from_job_id links a continuation job to its predecessor on the
  // same local_task. NULL for normal jobs. SQLite's REFERENCES clause in
  // ALTER TABLE is informational only — integrity is enforced at the API
  // layer (POST /api/local/tasks/:id/continue).
  if (tableExists('jobs') && !existingCols.has('continued_from_job_id')) {
    db.exec("ALTER TABLE jobs ADD COLUMN continued_from_job_id TEXT REFERENCES jobs(id)");
  }

  // Phase A: multi-team GPU pool + node status
  // last_backend_id records the physical backend (LiteLLM deployment id)
  // that handled this job's LLM calls. NULL for direct workers; set to
  // the value of the proxy's x-litellm-model-id header on the FIRST LLM
  // call of the job and never overwritten (sticky-backend policy per
  // design Open Question #3).
  if (tableExists('jobs') && !existingCols.has('last_backend_id')) {
    db.exec("ALTER TABLE jobs ADD COLUMN last_backend_id TEXT");
  }

  // Mission Brief: per-task pinned memo (JSON blob).
  const tasksColsAfter = db.prepare("PRAGMA table_info('local_tasks')").all() as Array<{ name: string }>;
  if (tableExists('local_tasks') && !tasksColsAfter.some(c => c.name === 'mission_brief')) {
    db.exec("ALTER TABLE local_tasks ADD COLUMN mission_brief TEXT");
  }

  // Add injected_at to local_task_comments (interjection feature: tracks
  // when a user comment was injected into the running agent's conversation).
  addColumnIfMissing(db, 'local_task_comments', 'injected_at', () => {
    db.exec("ALTER TABLE local_task_comments ADD COLUMN injected_at TEXT");
  });

  // Per-task options (JSON blob): controls runtime toggles like mcpDisabled / skillsDisabled.
  addColumnIfMissing(db, 'local_tasks', 'options', () => {
    db.exec("ALTER TABLE local_tasks ADD COLUMN options TEXT DEFAULT '{}'");
  });

  // Title provenance: 'auto' (creation fallback) / 'agent' (derived from
  // Mission Brief goal) / 'user' (manual edit, never auto-overwritten).
  addColumnIfMissing(db, 'local_tasks', 'title_source', () => {
    db.exec("ALTER TABLE local_tasks ADD COLUMN title_source TEXT NOT NULL DEFAULT 'auto'");
  });

  migrateMcpTables(db);
  migrateSshTables(db);
  migrateNotesTables(db);
  migrateDashboardWidgets(db);
  migrateGatewayVirtualKeys(db);
  migratePushNotificationsTables(db);
  migrateLlmUsageDaily(db);
  migrateLlmUsageHourly(db);
}

/**
 * Per-user daily LLM usage aggregation (gateway + direct). Idempotent.
 * Mirrors schema.sql + Repository.initSchema (dual-path rule:
 * project_db_migration_dual_path). Additive table, no mixed-version risk.
 * Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md.
 */
function migrateLlmUsageDaily(db: Database.Database): void {
  db.exec(`
    CREATE TABLE IF NOT EXISTS llm_usage_daily (
      day             TEXT    NOT NULL,
      user_id         TEXT    NOT NULL,
      source          TEXT    NOT NULL,
      model           TEXT    NOT NULL,
      route           TEXT    NOT NULL,
      tokens_in       INTEGER NOT NULL DEFAULT 0,
      tokens_out      INTEGER NOT NULL DEFAULT 0,
      requests        INTEGER NOT NULL DEFAULT 0,
      last_updated_at TEXT    NOT NULL,
      PRIMARY KEY (day, user_id, source, model, route)
    );
    CREATE INDEX IF NOT EXISTS idx_llm_usage_daily_user_day
      ON llm_usage_daily (user_id, day);
  `);
}

/**
 * Usage dashboard v2: hour-grain ledger (supersedes llm_usage_daily as the
 * write target). Idempotent; mirrors schema.sql + Repository.initSchema.
 * Backfills the daily archive into the hourly table once (hour = day||'T00')
 * via INSERT OR IGNORE so re-running the migration never double-counts.
 * Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md.
 */
function migrateLlmUsageHourly(db: Database.Database): void {
  db.exec(`
    CREATE TABLE IF NOT EXISTS llm_usage_hourly (
      hour            TEXT    NOT NULL,
      user_id         TEXT    NOT NULL,
      source          TEXT    NOT NULL,
      model           TEXT    NOT NULL,
      route           TEXT    NOT NULL,
      tokens_in       INTEGER NOT NULL DEFAULT 0,
      tokens_out      INTEGER NOT NULL DEFAULT 0,
      requests        INTEGER NOT NULL DEFAULT 0,
      last_updated_at TEXT    NOT NULL,
      PRIMARY KEY (hour, user_id, source, model, route)
    );
    CREATE INDEX IF NOT EXISTS idx_llm_usage_hourly_user_hour
      ON llm_usage_hourly (user_id, hour);
    INSERT OR IGNORE INTO llm_usage_hourly
      (hour, user_id, source, model, route, tokens_in, tokens_out, requests, last_updated_at)
    SELECT day || 'T00', user_id, source, model, route,
           tokens_in, tokens_out, requests, last_updated_at
      FROM llm_usage_daily;
  `);
}

/**
 * Idempotent column addition helper. Checks PRAGMA table_info and runs the
 * callback only when the column is missing.
 */
function addColumnIfMissing(
  db: Database.Database,
  table: string,
  column: string,
  apply: () => void,
): void {
  const tableExists = !!(db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(table));
  if (!tableExists) return;
  const cols = db.prepare(`PRAGMA table_info('${table}')`).all() as Array<{ name: string }>;
  if (!cols.some(c => c.name === column)) {
    apply();
  }
}

/**
 * Ensure MCP (Model Context Protocol) tables exist.
 * Idempotent (uses CREATE TABLE IF NOT EXISTS). FK clauses are omitted here
 * vs. schema.sql to keep this migration safe for in-flight DBs where FK
 * enforcement may already be ON before the referenced tables have been
 * migrated to their final shape.
 */
function migrateMcpTables(db: Database.Database): void {
  db.exec(`
    CREATE TABLE IF NOT EXISTS mcp_servers (
      id                      TEXT PRIMARY KEY,
      name                    TEXT NOT NULL,
      url                     TEXT NOT NULL,
      oauth_client_id         TEXT NOT NULL,
      oauth_client_secret_enc BLOB NOT NULL,
      oauth_scopes            TEXT,
      issuer                  TEXT,
      authorization_endpoint  TEXT,
      token_endpoint          TEXT,
      discovery_fingerprint   TEXT,
      enabled                 INTEGER NOT NULL DEFAULT 1,
      created_by              TEXT,
      created_at              TEXT NOT NULL DEFAULT (datetime('now')),
      updated_at              TEXT NOT NULL DEFAULT (datetime('now'))
    );

    CREATE TABLE IF NOT EXISTS user_mcp_tokens (
      user_id            TEXT NOT NULL,
      server_id          TEXT NOT NULL,
      access_token_enc   BLOB NOT NULL,
      refresh_token_enc  BLOB,
      expires_at         TEXT,
      scope              TEXT,
      scope_type         TEXT NOT NULL DEFAULT 'user' CHECK(scope_type IN ('user', 'org')),
      scope_id           TEXT,
      connected_at       TEXT NOT NULL DEFAULT (datetime('now')),
      updated_at         TEXT NOT NULL DEFAULT (datetime('now')),
      PRIMARY KEY (user_id, server_id)
    );

    CREATE TABLE IF NOT EXISTS mcp_server_tools (
      server_id    TEXT NOT NULL,
      tool_name    TEXT NOT NULL,
      description  TEXT,
      input_schema TEXT,
      refreshed_at TEXT NOT NULL DEFAULT (datetime('now')),
      PRIMARY KEY (server_id, tool_name)
    );

    CREATE TABLE IF NOT EXISTS mcp_oauth_pending (
      state         TEXT PRIMARY KEY,
      user_id       TEXT NOT NULL,
      server_id     TEXT NOT NULL,
      code_verifier TEXT NOT NULL,
      created_at    TEXT NOT NULL DEFAULT (datetime('now'))
    );
    CREATE INDEX IF NOT EXISTS idx_mcp_oauth_pending_created ON mcp_oauth_pending(created_at);
  `);

  // Phase 8: API key auth + user-owned servers
  // ALTER TABLE additions are idempotent via PRAGMA table_info check.
  const mcpServerCols = db.prepare("PRAGMA table_info('mcp_servers')").all() as Array<{ name: string }>;
  const mcpServerColNames = new Set(mcpServerCols.map(c => c.name));

  if (!mcpServerColNames.has('auth_kind')) {
    db.exec("ALTER TABLE mcp_servers ADD COLUMN auth_kind TEXT NOT NULL DEFAULT 'oauth'");
  }
  if (!mcpServerColNames.has('static_token_enc')) {
    // Nullable BLOB: present for api_key servers, NULL for oauth servers.
    db.exec('ALTER TABLE mcp_servers ADD COLUMN static_token_enc BLOB');
  }
  if (!mcpServerColNames.has('owner_id')) {
    // NULL = global/admin-managed; NOT NULL = user-owned.
    db.exec('ALTER TABLE mcp_servers ADD COLUMN owner_id TEXT REFERENCES users(id) ON DELETE CASCADE');
  }
  if (!mcpServerColNames.has('auth_header_name')) {
    // Custom auth header name for api_key servers (e.g. 'xc-mcp-token').
    // NULL = default Authorization: Bearer.
    db.exec('ALTER TABLE mcp_servers ADD COLUMN auth_header_name TEXT');
  }

  db.exec('CREATE INDEX IF NOT EXISTS idx_mcp_servers_owner ON mcp_servers(owner_id);');
}

/**
 * Ensure SSH tables exist.
 * Idempotent (uses CREATE TABLE IF NOT EXISTS).
 * Plan: docs/superpowers/plans/2026-05-12-ssh-tool-integration.md (Phase 1).
 *
 * FK clauses are omitted vs schema.sql to keep the migration safe when applied
 * to in-flight DBs where FK enforcement may already be ON.
 */
function migrateSshTables(db: Database.Database): void {
  db.exec(`
    CREATE TABLE IF NOT EXISTS system_deks (
      id            INTEGER PRIMARY KEY CHECK (id = 1),
      encrypted_dek BLOB NOT NULL,
      key_version   INTEGER NOT NULL DEFAULT 1,
      created_at    TEXT NOT NULL DEFAULT (datetime('now'))
    );

    CREATE TABLE IF NOT EXISTS ssh_user_deks (
      user_id       TEXT PRIMARY KEY,
      encrypted_dek BLOB NOT NULL,
      key_version   INTEGER NOT NULL DEFAULT 1,
      created_at    TEXT NOT NULL DEFAULT (datetime('now'))
    );

    CREATE TABLE IF NOT EXISTS ssh_connections (
      id                            TEXT PRIMARY KEY,
      owner_id                      TEXT,
      label                         TEXT NOT NULL,
      host                          TEXT NOT NULL,
      port                          INTEGER NOT NULL DEFAULT 22,
      username                      TEXT NOT NULL,

      private_key_enc               BLOB NOT NULL,
      passphrase_enc                BLOB,
      key_version                   INTEGER NOT NULL DEFAULT 1,
      key_fingerprint               TEXT,

      host_key_type                 TEXT,
      host_key_b64                  TEXT,
      host_key_fingerprint          TEXT,
      host_key_recorded_at          TEXT,
      host_key_verified_at          TEXT,
      host_key_pending              INTEGER NOT NULL DEFAULT 0,
      host_key_pending_b64          TEXT,
      host_key_pending_fingerprint  TEXT,
      host_key_pending_token        TEXT,
      host_key_pending_source       TEXT,

      command_deny_patterns         TEXT,
      command_allow_patterns        TEXT,
      remote_path_prefix            TEXT NOT NULL CHECK (LENGTH(remote_path_prefix) > 0),
      allow_remote_unrestricted     INTEGER NOT NULL DEFAULT 0,
      allow_private_addresses       INTEGER NOT NULL DEFAULT 0,

      enabled                       INTEGER NOT NULL DEFAULT 1,
      disabled_by_admin             INTEGER NOT NULL DEFAULT 0,
      disabled_by_admin_reason      TEXT,
      disabled_by_admin_at          TEXT,
      disabled_by_admin_user_id     TEXT,

      created_at                    TEXT NOT NULL,
      updated_at                    TEXT NOT NULL
    );

    CREATE INDEX IF NOT EXISTS idx_ssh_connections_owner   ON ssh_connections(owner_id);
    CREATE INDEX IF NOT EXISTS idx_ssh_connections_enabled ON ssh_connections(enabled, disabled_by_admin);

    CREATE TABLE IF NOT EXISTS ssh_connection_grants (
      id                       TEXT PRIMARY KEY,
      connection_id            TEXT NOT NULL,
      subject_type             TEXT NOT NULL CHECK (subject_type IN ('user','org')),
      subject_id               TEXT NOT NULL,
      piece_name               TEXT,
      applies_to_all_pieces    INTEGER NOT NULL DEFAULT 0,
      granted_by_user_id       TEXT NOT NULL,
      reason                   TEXT NOT NULL CHECK (LENGTH(reason) >= 8),
      expires_at               TEXT,
      created_at               TEXT NOT NULL,
      CHECK (
        (applies_to_all_pieces = 1 AND piece_name IS NULL) OR
        (applies_to_all_pieces = 0 AND piece_name IS NOT NULL)
      )
    );

    CREATE INDEX IF NOT EXISTS idx_ssh_grants_connection ON ssh_connection_grants(connection_id);
    CREATE INDEX IF NOT EXISTS idx_ssh_grants_subject    ON ssh_connection_grants(subject_type, subject_id);

    CREATE TABLE IF NOT EXISTS ssh_audit_log (
      id                INTEGER PRIMARY KEY AUTOINCREMENT,
      action            TEXT NOT NULL,
      entity_type       TEXT,
      entity_id         TEXT,
      connection_id     TEXT,
      owner_id          TEXT,
      acting_user_id    TEXT,
      job_id            TEXT,
      piece_name        TEXT,
      outcome           TEXT NOT NULL CHECK (outcome IN ('pending','success','failed','denied','aborted')),
      reason            TEXT,
      detail            TEXT,
      started_at        TEXT NOT NULL,
      completed_at      TEXT
    );

    CREATE INDEX IF NOT EXISTS idx_ssh_audit_action     ON ssh_audit_log(action, started_at);
    CREATE INDEX IF NOT EXISTS idx_ssh_audit_connection ON ssh_audit_log(connection_id, started_at);
    CREATE INDEX IF NOT EXISTS idx_ssh_audit_owner      ON ssh_audit_log(owner_id, started_at);
    CREATE INDEX IF NOT EXISTS idx_ssh_audit_outcome    ON ssh_audit_log(outcome, started_at);
    CREATE INDEX IF NOT EXISTS idx_ssh_audit_pending    ON ssh_audit_log(outcome) WHERE outcome = 'pending';

    CREATE TABLE IF NOT EXISTS ssh_abuse_counters (
      scope_key            TEXT PRIMARY KEY,
      scope_kind           TEXT NOT NULL CHECK (scope_kind IN ('conn','userhost','globalhost')),
      enforce_lock         INTEGER NOT NULL DEFAULT 1,
      failure_count        INTEGER NOT NULL DEFAULT 0,
      failure_window_start TEXT,
      lock_until           TEXT,
      updated_at           TEXT NOT NULL
    );

    CREATE INDEX IF NOT EXISTS idx_ssh_abuse_kind   ON ssh_abuse_counters(scope_kind);
    CREATE INDEX IF NOT EXISTS idx_ssh_abuse_locked ON ssh_abuse_counters(lock_until) WHERE lock_until IS NOT NULL;
  `);

  // Future ALTERs: add new columns to ssh_connections via PRAGMA table_info pattern,
  // matching the MCP migrations above.
}

/**
 * Ensure Shared Knowledge Notes tables exist.
 * Idempotent (uses CREATE TABLE IF NOT EXISTS / CREATE VIRTUAL TABLE IF NOT EXISTS).
 * Plan: docs/superpowers/plans/2026-05-15-shared-knowledge-notes.md (Task 1).
 *
 * FK clauses match schema.sql. Note that this function also creates the users
 * table as a prerequisite so it is safe to call on a fresh DB (e.g. in tests)
 * before schema.sql has been applied.
 */
function migrateNotesTables(db: Database.Database): void {
  // Ensure the users prerequisite table exists (no-op on production DBs where
  // schema.sql was already applied).
  db.exec(`
    CREATE TABLE IF NOT EXISTS users (
      id         TEXT PRIMARY KEY,
      email      TEXT UNIQUE NOT NULL,
      name       TEXT,
      avatar_url TEXT,
      role       TEXT NOT NULL DEFAULT 'user',
      status     TEXT NOT NULL DEFAULT 'pending',
      created_at TEXT NOT NULL DEFAULT (datetime('now')),
      updated_at TEXT NOT NULL DEFAULT (datetime('now'))
    );
  `);

  // Enable FK enforcement so CASCADE DELETE works (also set by Repository in production;
  // this covers test DBs that don't go through the Repository constructor).
  db.pragma('foreign_keys = ON');

  db.exec(`
    CREATE TABLE IF NOT EXISTS note_index (
      owner_id                TEXT NOT NULL,
      folder                  TEXT NOT NULL,
      file_name               TEXT NOT NULL,
      title                   TEXT,
      visibility              TEXT NOT NULL CHECK (visibility IN ('private','org','public')),
      visibility_scope_org_id TEXT,
      mode_hint               TEXT CHECK (mode_hint IS NULL OR mode_hint IN ('search','inject')),
      tags_json               TEXT,
      content_size            INTEGER NOT NULL DEFAULT 0,
      content_hash            TEXT NOT NULL DEFAULT '',
      body                    TEXT NOT NULL DEFAULT '',
      updated_at              INTEGER NOT NULL,
      PRIMARY KEY (owner_id, folder, file_name),
      FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE INDEX IF NOT EXISTS idx_note_index_visibility ON note_index(visibility, visibility_scope_org_id);
    CREATE INDEX IF NOT EXISTS idx_note_index_owner_folder ON note_index(owner_id, folder);

    CREATE TABLE IF NOT EXISTS note_subscriptions (
      consumer_user_id   TEXT NOT NULL,
      publisher_user_id  TEXT NOT NULL,
      folder             TEXT NOT NULL,
      mode               TEXT NOT NULL CHECK (mode IN ('search','inject')),
      enabled            INTEGER NOT NULL DEFAULT 1,
      created_at         INTEGER NOT NULL,
      PRIMARY KEY (consumer_user_id, publisher_user_id, folder),
      FOREIGN KEY (consumer_user_id) REFERENCES users(id) ON DELETE CASCADE,
      FOREIGN KEY (publisher_user_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE INDEX IF NOT EXISTS idx_note_subscriptions_consumer_mode ON note_subscriptions(consumer_user_id, mode, enabled);

    CREATE TABLE IF NOT EXISTS pending_reindex (
      owner_id   TEXT NOT NULL,
      folder     TEXT NOT NULL,
      file_name  TEXT NOT NULL,
      reason     TEXT,
      created_at INTEGER NOT NULL,
      PRIMARY KEY (owner_id, folder, file_name)
    );

    CREATE VIRTUAL TABLE IF NOT EXISTS note_index_fts USING fts5(
      owner_id UNINDEXED,
      folder UNINDEXED,
      file_name UNINDEXED,
      title,
      tags,
      body
    );

    CREATE TRIGGER IF NOT EXISTS note_index_ai AFTER INSERT ON note_index BEGIN
      INSERT INTO note_index_fts(owner_id, folder, file_name, title, tags, body)
      VALUES (new.owner_id, new.folder, new.file_name, new.title, new.tags_json, new.body);
    END;

    CREATE TRIGGER IF NOT EXISTS note_index_ad AFTER DELETE ON note_index BEGIN
      DELETE FROM note_index_fts WHERE owner_id = old.owner_id AND folder = old.folder AND file_name = old.file_name;
    END;

    CREATE TRIGGER IF NOT EXISTS note_index_au AFTER UPDATE ON note_index BEGIN
      DELETE FROM note_index_fts WHERE owner_id = old.owner_id AND folder = old.folder AND file_name = old.file_name;
      INSERT INTO note_index_fts(owner_id, folder, file_name, title, tags, body)
      VALUES (new.owner_id, new.folder, new.file_name, new.title, new.tags_json, new.body);
    END;
  `);
}

/**
 * Ensure user_dashboard_widgets table exists for the Side Info Panel feature.
 * Idempotent (CREATE TABLE IF NOT EXISTS).
 */
function migrateDashboardWidgets(db: Database.Database): void {
  db.exec(`
    CREATE TABLE IF NOT EXISTS user_dashboard_widgets (
      id               INTEGER PRIMARY KEY AUTOINCREMENT,
      user_id          TEXT NOT NULL,
      slug             TEXT NOT NULL,
      title            TEXT NOT NULL,
      kind             TEXT NOT NULL DEFAULT 'markdown',
      markdown_content TEXT NOT NULL DEFAULT '',
      sort_order       INTEGER NOT NULL DEFAULT 0,
      created_at       TEXT NOT NULL DEFAULT (datetime('now')),
      updated_at       TEXT NOT NULL DEFAULT (datetime('now')),
      UNIQUE(user_id, slug)
    );
    CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_user
      ON user_dashboard_widgets (user_id, sort_order);
  `);
  // Phase B (2026-05): existing deployments created the table without the
  // kind column. ALTER is idempotent because we check column existence first.
  const columns = db.prepare(`PRAGMA table_info(user_dashboard_widgets)`).all() as Array<{ name: string }>;
  if (!columns.some(c => c.name === 'kind')) {
    db.exec(`ALTER TABLE user_dashboard_widgets ADD COLUMN kind TEXT NOT NULL DEFAULT 'markdown'`);
  }
}

/**
 * Ensure gateway_virtual_keys table exists for AAO Gateway Phase 2a.
 * Idempotent (CREATE TABLE IF NOT EXISTS + partial / non-unique CREATE
 * INDEX). Mirrors the shape in src/db/schema.sql; both paths must stay in
 * sync (see memory: project_db_migration_dual_path).
 *
 * Plan: docs/superpowers/specs/2026-05-18-aao-gateway-mode-design.md (Phase 2a).
 */
function migrateGatewayVirtualKeys(db: Database.Database): void {
  db.exec(`
    CREATE TABLE IF NOT EXISTS gateway_virtual_keys (
      id             TEXT PRIMARY KEY,
      key_hash       TEXT NOT NULL UNIQUE,
      key_prefix     TEXT NOT NULL,
      team           TEXT NOT NULL,
      allowed_models TEXT,
      source         TEXT NOT NULL DEFAULT 'admin' CHECK (source IN ('admin','config-import')),
      created_at     TEXT NOT NULL,
      created_by     TEXT,
      revoked_at     TEXT,
      revoked_by     TEXT,
      last_used_at   TEXT
    );
    CREATE INDEX IF NOT EXISTS idx_gateway_keys_hash_active
      ON gateway_virtual_keys (key_hash)
      WHERE revoked_at IS NULL;
    CREATE INDEX IF NOT EXISTS idx_gateway_keys_team
      ON gateway_virtual_keys (team);
  `);

  // Phase 2b: per-key budget + rate limit columns. Idempotent via
  // PRAGMA table_info — repeated calls are safe and no-op once present.
  const cols = db.prepare("PRAGMA table_info('gateway_virtual_keys')").all() as Array<{ name: string }>;
  const colNames = new Set(cols.map(c => c.name));
  if (!colNames.has('tokens_budget')) {
    db.exec('ALTER TABLE gateway_virtual_keys ADD COLUMN tokens_budget INTEGER');
  }
  if (!colNames.has('rate_limit_rpm')) {
    db.exec('ALTER TABLE gateway_virtual_keys ADD COLUMN rate_limit_rpm INTEGER');
  }

  // Phase 2b: monthly usage tracker. CREATE TABLE IF NOT EXISTS is
  // idempotent. ON DELETE CASCADE ensures a hard-delete of a virtual key
  // wipes its usage rows too — but we still write the FK clause here
  // (matching schema.sql) because the gateway boot enables foreign_keys
  // pragma. Composite PK doubles as the lookup index for the hot-path
  // budget check (`getGatewayKeyUsage`).
  db.exec(`
    CREATE TABLE IF NOT EXISTS gateway_key_usage (
      key_id          TEXT NOT NULL REFERENCES gateway_virtual_keys(id) ON DELETE CASCADE,
      period_start    TEXT NOT NULL,
      tokens_in       INTEGER NOT NULL DEFAULT 0,
      tokens_out      INTEGER NOT NULL DEFAULT 0,
      requests        INTEGER NOT NULL DEFAULT 0,
      last_updated_at TEXT NOT NULL,
      PRIMARY KEY (key_id, period_start)
    );
    CREATE INDEX IF NOT EXISTS idx_gateway_usage_key
      ON gateway_key_usage (key_id);
  `);
}

/**
 * Browser Notifications V2 (Web Push) tables. Idempotent.
 * Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md.
 * Mirrors schema.sql; both paths must stay in sync (memory:
 * project_db_migration_dual_path).
 */
function migratePushNotificationsTables(db: Database.Database): void {
  db.exec(`
    CREATE TABLE IF NOT EXISTS push_subscriptions (
      id              TEXT PRIMARY KEY,
      user_id         TEXT NOT NULL,
      endpoint        TEXT NOT NULL UNIQUE,
      p256dh          TEXT NOT NULL,
      auth            TEXT NOT NULL,
      user_agent      TEXT,
      vapid_key_id    TEXT NOT NULL,
      created_at      TEXT NOT NULL DEFAULT (datetime('now')),
      last_success_at TEXT,
      last_failure_at TEXT,
      failure_count   INTEGER NOT NULL DEFAULT 0
    );
    CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id
      ON push_subscriptions(user_id);

    CREATE TABLE IF NOT EXISTS user_notification_prefs (
      user_id              TEXT PRIMARY KEY,
      enabled              INTEGER NOT NULL DEFAULT 1,
      event_running        INTEGER NOT NULL DEFAULT 1,
      event_succeeded      INTEGER NOT NULL DEFAULT 1,
      event_failed         INTEGER NOT NULL DEFAULT 1,
      event_waiting_human  INTEGER NOT NULL DEFAULT 1,
      include_details      INTEGER NOT NULL DEFAULT 0,
      v1_migrated          INTEGER NOT NULL DEFAULT 0,
      updated_at           TEXT NOT NULL DEFAULT (datetime('now'))
    );

    -- Local-auth credentials (email + password accounts). Idempotent add so
    -- an existing no-auth / OAuth-only deployment gains local login on upgrade.
    CREATE TABLE IF NOT EXISTS local_credentials (
      user_id       TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
      password_hash TEXT NOT NULL,
      salt          TEXT NOT NULL,
      updated_at    TEXT NOT NULL DEFAULT (datetime('now'))
    );

    -- Local organizations + membership (provider-agnostic 'org' visibility for
    -- local accounts). Idempotent add.
    CREATE TABLE IF NOT EXISTS local_orgs (
      id         TEXT PRIMARY KEY,
      name       TEXT NOT NULL,
      created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
      created_at TEXT NOT NULL DEFAULT (datetime('now'))
    );
    CREATE TABLE IF NOT EXISTS local_org_members (
      org_id   TEXT NOT NULL REFERENCES local_orgs(id) ON DELETE CASCADE,
      user_id  TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
      role     TEXT NOT NULL DEFAULT 'member',
      added_at TEXT NOT NULL DEFAULT (datetime('now')),
      PRIMARY KEY (org_id, user_id)
    );
    CREATE INDEX IF NOT EXISTS idx_local_org_members_user ON local_org_members(user_id);
  `);
}
