import type Database from 'better-sqlite3';
import { randomUUID } from 'node:crypto';
import { logger } from '../logger.js';
import { normalizeCommentToIndexText } from '../engine/task-index/normalize.js';

/**
 * 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);
  `);

  // Tool requests: agent-declared (RequestTool) or passively-captured (a tool
  // call blocked because it was not available in the current movement). Surfaced
  // in task detail + aggregation so workspace tool-policy omissions become visible/fixable.
  // Keep in sync with schema.sql (fresh path) and Repository.initSchema.
  db.exec(`
    CREATE TABLE IF NOT EXISTS tool_requests (
      id            TEXT PRIMARY KEY,
      task_id       TEXT,
      job_id        TEXT,
      space_id      TEXT,
      piece_name    TEXT NOT NULL,
      movement_name TEXT NOT NULL,
      tool_name     TEXT NOT NULL,
      reason        TEXT,
      category      TEXT NOT NULL DEFAULT 'requested',
      status        TEXT NOT NULL DEFAULT 'pending',
      grant_scope   TEXT,
      decided_by    TEXT,
      created_at    TEXT NOT NULL DEFAULT (datetime('now')),
      decided_at    TEXT
    );
    CREATE INDEX IF NOT EXISTS idx_tool_requests_task ON tool_requests (task_id);
    CREATE INDEX IF NOT EXISTS idx_tool_requests_piece ON tool_requests (piece_name);
    CREATE INDEX IF NOT EXISTS idx_tool_requests_dedup ON tool_requests (job_id, movement_name, tool_name, status);
  `);

  // Package requests: agent-declared (RequestPackage) requests to install a
  // Python wheel into the task's space overlay, pending a task-write approver.
  // Keep in sync with schema.sql (fresh path) and Repository.initSchema.
  db.exec(`
    CREATE TABLE IF NOT EXISTS package_requests (
      id              TEXT PRIMARY KEY,
      task_id         TEXT,
      job_id          TEXT,
      space_id        TEXT,
      piece_name      TEXT,
      movement_name   TEXT,
      spec            TEXT NOT NULL,
      normalized_name TEXT NOT NULL,
      reason          TEXT,
      status          TEXT NOT NULL DEFAULT 'pending',
      decided_by      TEXT,
      created_at      TEXT NOT NULL DEFAULT (datetime('now')),
      decided_at      TEXT
    );
    CREATE INDEX IF NOT EXISTS idx_package_requests_task ON package_requests (task_id);
    CREATE INDEX IF NOT EXISTS idx_package_requests_dedup ON package_requests (job_id, normalized_name, status);
  `);

  // A2A: oidc-provider 永続化
  db.exec(`
    CREATE TABLE IF NOT EXISTS oidc_models (
      model       TEXT NOT NULL,
      id          TEXT NOT NULL,
      payload     TEXT NOT NULL,
      grant_id    TEXT,
      user_code   TEXT,
      uid         TEXT,
      expires_at  INTEGER,
      consumed_at INTEGER,
      PRIMARY KEY (model, id)
    );
    CREATE INDEX IF NOT EXISTS idx_oidc_models_grant    ON oidc_models (grant_id);
    CREATE INDEX IF NOT EXISTS idx_oidc_models_uid      ON oidc_models (uid);
    CREATE INDEX IF NOT EXISTS idx_oidc_models_usercode ON oidc_models (user_code);
    CREATE INDEX IF NOT EXISTS idx_oidc_models_expires  ON oidc_models (expires_at);
  `);

  // A2A: 外部クライアント登録
  db.exec(`
    CREATE TABLE IF NOT EXISTS a2a_clients (
      client_id                  TEXT PRIMARY KEY,
      name                       TEXT NOT NULL,
      redirect_uris              TEXT NOT NULL,
      secret_hash                TEXT,                    -- reserved for Plan 2 confidential clients; Plan 1 registers public clients only (always NULL). NOTE: oidc-provider compares client_secret in plaintext.
      token_endpoint_auth_method TEXT NOT NULL DEFAULT 'client_secret_basic',
      agent_card_url             TEXT,
      issuer                     TEXT,
      key_fingerprint            TEXT,
      status                     TEXT NOT NULL DEFAULT 'active',
      created_by                 TEXT,
      created_at                 TEXT NOT NULL DEFAULT (datetime('now'))
    );
  `);

  // A2A: ユーザー → 外部クライアントへのスコープ付き委任（OAuth Grant と grant_id で 1:1）
  db.exec(`
    CREATE TABLE IF NOT EXISTS a2a_delegations (
      id                TEXT PRIMARY KEY,
      user_id           TEXT NOT NULL,
      client_id         TEXT NOT NULL,
      grant_id          TEXT,
      granted_space_ids TEXT NOT NULL DEFAULT '[]',
      granted_skills    TEXT NOT NULL DEFAULT '[]',
      audience          TEXT,
      expires_at        TEXT,
      revoked_at        TEXT,
      created_at        TEXT NOT NULL DEFAULT (datetime('now'))
    );
    CREATE INDEX IF NOT EXISTS idx_a2a_deleg_grant ON a2a_delegations (grant_id);
    CREATE INDEX IF NOT EXISTS idx_a2a_deleg_user  ON a2a_delegations (user_id);
  `);

  // A2A: A2A タスク追跡（Plan 2B）
  db.exec(`
    CREATE TABLE IF NOT EXISTS a2a_tasks (
      id            TEXT PRIMARY KEY,
      context_id    TEXT,
      job_id        TEXT,
      local_task_id INTEGER,
      payload       TEXT NOT NULL,
      updated_at    TEXT NOT NULL DEFAULT (datetime('now'))
    );
    CREATE INDEX IF NOT EXISTS idx_a2a_tasks_job     ON a2a_tasks (job_id);
    CREATE INDEX IF NOT EXISTS idx_a2a_tasks_context ON a2a_tasks (context_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");
  });

  // Attachment filenames (JSON array) saved to the task's input/ dir for a
  // user comment, so the UI can show download links under the comment bubble.
  addColumnIfMissing(db, 'local_task_comments', 'attachments', () => {
    db.exec("ALTER TABLE local_task_comments ADD COLUMN attachments 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);
  migrateDashboardWidgets(db);
  migrateGatewayVirtualKeys(db);
  migratePushNotificationsTables(db);
  migrateLlmUsageDaily(db);
  migrateLlmUsageHourly(db);
  migrateSpaces(db);
  migrateSpaceMembers(db);
  migrateCalendarEvents(db);
  migrateSpaceInvites(db);
  migrateAppShareLinks(db);
  migratePrivatizeSpaceRows(db);
  migrateRenamePersonalSpaceTitle(db);
  migrateSpacesToolPolicy(db);
  migrateSpacesA2aSkills(db);
  migrateSpacesPythonPackages(db);
  migrateWorkspaceFileProvenance(db);
  migrateTaskCommentIndex(db);
  migrateBackfillTaskCommentIndex(db);
  migrateA2aTaskDelegationColumns(db);
}

/**
 * workspace_file_provenance テーブルを作成（ファイル来歴台帳）。
 * 永続ワークスペースの各ファイルがどのタスクに由来するかを追跡する。
 * 冪等: CREATE TABLE IF NOT EXISTS。
 * Mirrors schema.sql + Repository.initSchema (三重ミラー; memory: project_db_migration_dual_path).
 */
function migrateWorkspaceFileProvenance(db: Database.Database): void {
  db.exec(`
    CREATE TABLE IF NOT EXISTS workspace_file_provenance (
      id                        INTEGER PRIMARY KEY AUTOINCREMENT,
      space_id                  TEXT,
      workspace_path            TEXT NOT NULL,
      rel_path                  TEXT NOT NULL,
      created_by_task_id        INTEGER,
      created_by_job_id         TEXT,
      created_by_piece          TEXT,
      created_by_movement       TEXT,
      source_kind               TEXT NOT NULL,
      first_seen_at             TEXT NOT NULL,
      last_modified_by_task_id  INTEGER,
      last_modified_by_job_id   TEXT,
      last_modified_at          TEXT,
      checksum                  TEXT,
      note                      TEXT,
      UNIQUE(workspace_path, rel_path)
    );
    CREATE INDEX IF NOT EXISTS idx_wsfile_prov_ws ON workspace_file_provenance (workspace_path);
    CREATE INDEX IF NOT EXISTS idx_wsfile_prov_created ON workspace_file_provenance (workspace_path, created_by_task_id);
  `);
}

/**
 * spaces.tool_policy カラムを追加（nullable JSON）。
 * スペース固有のツール制限ポリシーを保持する。
 * 冪等: カラムが既に存在する場合は何もしない。
 * Mirrors schema.sql; both paths must stay in sync (memory: project_db_migration_dual_path).
 */
function migrateSpacesToolPolicy(db: Database.Database): void {
  const exists = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='spaces'").get();
  if (!exists) return;
  const cols = db.prepare("PRAGMA table_info('spaces')").all() as Array<{ name: string }>;
  if (!cols.some(c => c.name === 'tool_policy')) {
    db.exec("ALTER TABLE spaces ADD COLUMN tool_policy TEXT");
  }
}

/**
 * spaces.a2a_skills カラムを追加（nullable JSON）。
 * A2A 公開する piece 名の JSON 配列。NULL/未設定 = 公開ゼロ（fail-closed）。
 * 冪等: カラムが既に存在する場合は何もしない。
 * Mirrors schema.sql; both paths must stay in sync (memory: project_db_migration_dual_path).
 */
function migrateSpacesA2aSkills(db: Database.Database): void {
  const exists = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='spaces'").get();
  if (!exists) return;
  const cols = db.prepare("PRAGMA table_info('spaces')").all() as Array<{ name: string }>;
  if (!cols.some(c => c.name === 'a2a_skills')) {
    db.exec("ALTER TABLE spaces ADD COLUMN a2a_skills TEXT");
  }
}

/**
 * spaces.python_packages カラムを追加（nullable JSON）。
 * admin が入れた Python パッケージの desired-state 配列を保持する。
 * 冪等: カラムが既に存在する場合は何もしない。
 * Mirrors schema.sql; both paths must stay in sync (memory: project_db_migration_dual_path).
 */
function migrateSpacesPythonPackages(db: Database.Database): void {
  const exists = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='spaces'").get();
  if (!exists) return;
  const cols = db.prepare("PRAGMA table_info('spaces')").all() as Array<{ name: string }>;
  if (!cols.some(c => c.name === 'python_packages')) {
    db.exec("ALTER TABLE spaces ADD COLUMN python_packages TEXT");
  }
}

/**
 * 用語統一(スペース→ワークスペース)に伴い、自動生成された個人スペースのタイトル
 * 「個人スペース」「{name} の個人スペース」を「個人ワークスペース」表記に置換する。
 * `個人ワークスペース` は `個人スペース` を部分文字列に含まないため REPLACE は冪等。
 * personal 種別のみ対象（案件スペースのユーザー命名タイトルは触らない）。
 */
function migrateRenamePersonalSpaceTitle(db: Database.Database): void {
  const exists = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='spaces'").get();
  if (!exists) return;
  const info = db
    .prepare(
      `UPDATE spaces SET title = REPLACE(title, '個人スペース', '個人ワークスペース') ` +
      `WHERE kind = 'personal' AND title LIKE '%個人スペース%'`,
    )
    .run();
  if (info.changes > 0) logger.info(`[migrate] rename personal space title: updated=${info.changes}`);
}

/**
 * 既存スペース行を private 化する一回限りの backfill（冪等）。
 *
 * shared-space access モデルでは「スペースの行はメンバー（+根オーナー）にだけ見える」
 * のが正。可視性は buildVisibilityWhere の membership OR ブランチが担保する。
 * しかし space_id 付きの行が visibility='org'/'public' のまま残っていると、
 * その org/public 経路から非メンバーに漏れてしまう。そこで space_id を持つ行を
 * すべて private + visibility_scope_org_id=NULL に倒す。
 *
 * 冪等: 既に private の行は UPDATE 対象外（WHERE visibility != 'private'）なので
 * 再実行は no-op。space_id IS NULL（非スペース行）は一切触らない。
 */
function migratePrivatizeSpaceRows(db: Database.Database): void {
  const tableExists = (name: string): boolean =>
    !!(db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name));
  let total = 0;
  for (const table of ['local_tasks', 'jobs', 'scheduled_tasks']) {
    if (!tableExists(table)) continue;
    const cols = db.prepare(`PRAGMA table_info('${table}')`).all() as Array<{ name: string }>;
    const names = new Set(cols.map(c => c.name));
    // space_id / visibility 列を持つ DB だけ対象（古い経路ガード）。
    if (!names.has('space_id') || !names.has('visibility')) continue;
    const hasScope = names.has('visibility_scope_org_id');
    const setScope = hasScope ? ', visibility_scope_org_id = NULL' : '';
    const info = db
      .prepare(
        `UPDATE ${table} SET visibility = 'private'${setScope} ` +
        `WHERE space_id IS NOT NULL AND visibility != 'private'`,
      )
      .run();
    total += info.changes;
  }
  if (total > 0) {
    logger.info(`[migrate] privatize space rows: updated=${total}`);
  }
}

/**
 * スペース基盤（計画1）。spaces テーブルと local_tasks の space_id /
 * workspace_mode 列を追加する。追加のみ・冪等。schema.sql と
 * Repository.initSchema にミラーがある（三重経路）。
 * spec: docs/superpowers/specs/2026-06-17-space-foundation-design.md §5.2
 *
 * 注: CHECK(kind/visibility/status IN ...) は schema.sql 側のみ。ここで CHECK を
 * 付けないのは意図的（既存 in-flight DB に enum 外の行があった場合に CREATE/ALTER が
 * 失敗するのを避けるため。他の migrate* と同方針）。後から CHECK を足さないこと。
 */
function migrateSpaces(db: Database.Database): void {
  db.exec(`
    CREATE TABLE IF NOT EXISTS spaces (
      id                      TEXT PRIMARY KEY,
      kind                    TEXT NOT NULL DEFAULT 'case',
      title                   TEXT NOT NULL,
      description             TEXT NOT NULL DEFAULT '',
      owner_id                TEXT,
      visibility              TEXT NOT NULL DEFAULT 'private',
      visibility_scope_org_id TEXT,
      status                  TEXT NOT NULL DEFAULT 'open',
      brand_color             TEXT,
      workspace_dir           TEXT,
      created_at              TEXT NOT NULL DEFAULT (datetime('now')),
      updated_at              TEXT NOT NULL DEFAULT (datetime('now'))
    );
    CREATE INDEX IF NOT EXISTS idx_spaces_owner ON spaces(owner_id);
    CREATE INDEX IF NOT EXISTS idx_spaces_visibility ON spaces(visibility, visibility_scope_org_id);
    CREATE UNIQUE INDEX IF NOT EXISTS idx_spaces_personal_owner ON spaces(owner_id) WHERE kind = 'personal';
  `);
  addColumnIfMissing(db, 'local_tasks', 'space_id', () => {
    db.exec("ALTER TABLE local_tasks ADD COLUMN space_id TEXT");
  });
  addColumnIfMissing(db, 'local_tasks', 'workspace_mode', () => {
    db.exec("ALTER TABLE local_tasks ADD COLUMN workspace_mode TEXT NOT NULL DEFAULT 'persistent'");
  });
  addColumnIfMissing(db, 'jobs', 'space_id', () => {
    db.exec("ALTER TABLE jobs ADD COLUMN space_id TEXT");
  });
  addColumnIfMissing(db, 'scheduled_tasks', 'space_id', () => {
    db.exec("ALTER TABLE scheduled_tasks ADD COLUMN space_id TEXT");
  });
  // 計画5: 実行ログ root。NULL = 後方互換で workspace_path/logs に解決。
  addColumnIfMissing(db, 'local_tasks', 'runtime_dir', () => {
    db.exec("ALTER TABLE local_tasks ADD COLUMN runtime_dir TEXT");
  });
  addColumnIfMissing(db, 'jobs', 'runtime_dir', () => {
    db.exec("ALTER TABLE jobs ADD COLUMN runtime_dir TEXT");
  });
  // Tool-request mechanism: per-task grant overlay (JSON string[] of approved
  // tool names), merged into every movement's effective allowed-tool set.
  addColumnIfMissing(db, 'local_tasks', 'granted_tools', () => {
    db.exec("ALTER TABLE local_tasks ADD COLUMN granted_tools TEXT");
  });

  // workstream 2: ブラウザセッションプロファイルを per-space にする。
  // space_id NULL = legacy/個人 or admin-global。owner_id は復号 DEK のため保持。
  // 注: テーブルが存在する DB だけ対象（古い経路ガード）。index も同条件。
  addColumnIfMissing(db, 'browser_session_profiles', 'space_id', () => {
    db.exec("ALTER TABLE browser_session_profiles ADD COLUMN space_id TEXT");
  });
  const bspExists = !!db
    .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='browser_session_profiles'")
    .get();
  if (bspExists) {
    db.exec("CREATE INDEX IF NOT EXISTS idx_bsp_space ON browser_session_profiles(space_id)");
  }

  backfillMcpServerSpaceIds(db);
  backfillSshConnectionSpaceIds(db);
  backfillBrowserSessionProfileSpaceIds(db);
}

/**
 * スペース・メンバー（協働者）テーブルを追加する。追加のみ・冪等。
 * owner は members 行を持たず owner_id で判定する追加協働者の表。
 * schema.sql と Repository.initSchema にミラーがある（三重経路）。
 * spec: docs/superpowers/specs/2026-06-19-shared-space-design.md §データモデル
 */
function migrateSpaceMembers(db: Database.Database): void {
  db.exec(`
    CREATE TABLE IF NOT EXISTS space_members (
      space_id   TEXT NOT NULL,
      user_id    TEXT NOT NULL,
      role       TEXT NOT NULL DEFAULT 'editor' CHECK (role IN ('owner','editor','viewer')),
      invited_by TEXT,
      created_at TEXT NOT NULL DEFAULT (datetime('now')),
      PRIMARY KEY (space_id, user_id),
      FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE,
      FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
    );
    CREATE INDEX IF NOT EXISTS idx_space_members_user ON space_members (user_id);
  `);
}

/**
 * スペース・カレンダー（予定）テーブルを追加する。追加のみ・冪等。
 * schema.sql と Repository.initSchema にミラーがある（三重経路）。
 * spec: docs/superpowers/specs/2026-06-19-space-calendar-design.md §データモデル
 */
function migrateCalendarEvents(db: Database.Database): void {
  db.exec(`
    CREATE TABLE IF NOT EXISTS calendar_events (
      id             INTEGER PRIMARY KEY AUTOINCREMENT,
      space_id       TEXT NOT NULL,
      owner_id       TEXT,
      date           TEXT NOT NULL,
      time           TEXT,
      title          TEXT NOT NULL,
      description    TEXT,
      created_by     TEXT NOT NULL DEFAULT 'user',
      source_task_id INTEGER,
      created_at     TEXT NOT NULL DEFAULT (datetime('now')),
      updated_at     TEXT NOT NULL DEFAULT (datetime('now'))
    );
    CREATE INDEX IF NOT EXISTS idx_calendar_events_space_date ON calendar_events (space_id, date);
  `);
  // 複数日予定: end_date（NULL=単日）。追加のみ・冪等。
  addColumnIfMissing(db, 'calendar_events', 'end_date', () => {
    db.exec("ALTER TABLE calendar_events ADD COLUMN end_date TEXT");
  });
  // 終了時刻: end_time（NULL=終了時刻なし。time が NULL なら常に NULL）。追加のみ・冪等。
  addColumnIfMissing(db, 'calendar_events', 'end_time', () => {
    db.exec("ALTER TABLE calendar_events ADD COLUMN end_time TEXT");
  });
}

/**
 * スペース招待リンク（再利用トークン）テーブルを追加する。追加のみ・冪等。
 * schema.sql / Repository.initSchema と三重ミラー。
 * spec: docs/superpowers/specs/2026-06-19-space-invite-links-design.md
 */
function migrateSpaceInvites(db: Database.Database): void {
  db.exec(`
    CREATE TABLE IF NOT EXISTS space_invites (
      token       TEXT PRIMARY KEY,
      space_id    TEXT NOT NULL,
      role        TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('editor','viewer')),
      created_by  TEXT,
      created_at  TEXT NOT NULL DEFAULT (datetime('now')),
      expires_at  TEXT,
      revoked_at  TEXT,
      FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE
    );
    CREATE INDEX IF NOT EXISTS idx_space_invites_space ON space_invites (space_id);
  `);
}

function migrateAppShareLinks(db: Database.Database): void {
  db.exec(`
    CREATE TABLE IF NOT EXISTS app_share_links (
      token       TEXT PRIMARY KEY,
      space_id    TEXT NOT NULL,
      app_name    TEXT NOT NULL,
      created_by  TEXT,
      created_at  TEXT NOT NULL DEFAULT (datetime('now')),
      revoked_at  TEXT,
      FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE
    );
    CREATE INDEX IF NOT EXISTS idx_app_share_links_space_app
      ON app_share_links(space_id, app_name);
  `);
}

/**
 * Per-space MCP isolation migration (Phase 2). Idempotent.
 *
 * Approved model = fully independent (space-only): a job sees ONLY its space's
 * MCP servers. To preserve existing per-user MCP servers under that model, each
 * user-owned server with no space (owner_id = <real user> AND space_id IS NULL)
 * is migrated into that user's personal space (created if missing, mirroring
 * Repository.ensurePersonalSpace). Global/admin-managed servers (owner_id IS
 * NULL) are intentionally left with space_id NULL → invisible to spaces, only
 * reachable by legacy (no-space) jobs via the NULL-space fallback.
 *
 * Runs after migrateMcpTables (mcp_servers.space_id added) and after the spaces
 * table exists. Skips quietly when prerequisite tables are absent (fresh DB
 * bootstrapped only partially).
 */
function backfillMcpServerSpaceIds(db: Database.Database): void {
  const tableExists = (name: string): boolean =>
    !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name);
  if (!tableExists('mcp_servers') || !tableExists('spaces') || !tableExists('users')) return;

  const mcpCols = db.prepare("PRAGMA table_info('mcp_servers')").all() as Array<{ name: string }>;
  if (!mcpCols.some((c) => c.name === 'space_id')) return;

  // Candidates: user-owned servers with no space yet, whose owner is a real user.
  const candidates = db
    .prepare(
      `SELECT m.id AS server_id, m.owner_id AS owner_id
         FROM mcp_servers m
         JOIN users u ON u.id = m.owner_id
        WHERE m.owner_id IS NOT NULL AND m.space_id IS NULL`,
    )
    .all() as Array<{ server_id: string; owner_id: string }>;

  if (candidates.length === 0) {
    // Count what was intentionally left global for visibility.
    const globalLeft = (
      db.prepare('SELECT COUNT(*) AS n FROM mcp_servers WHERE owner_id IS NULL AND space_id IS NULL').get() as {
        n: number;
      }
    ).n;
    logger.info(`[migrate] mcp space backfill: migrated=0 globalLeft=${globalLeft}`);
    return;
  }

  const findPersonal = db.prepare(
    `SELECT id FROM spaces WHERE owner_id = ? AND kind = 'personal' LIMIT 1`,
  );
  const insertPersonal = db.prepare(
    `INSERT INTO spaces (id, kind, title, description, owner_id, visibility, status)
     VALUES (?, 'personal', '個人ワークスペース', '', ?, 'private', 'open')`,
  );
  const setSpace = db.prepare('UPDATE mcp_servers SET space_id = ?, updated_at = datetime(\'now\') WHERE id = ?');

  const personalCache = new Map<string, string>();
  const apply = db.transaction(() => {
    let migrated = 0;
    let createdSpaces = 0;
    for (const c of candidates) {
      let spaceId = personalCache.get(c.owner_id);
      if (!spaceId) {
        const row = findPersonal.get(c.owner_id) as { id: string } | undefined;
        if (row) {
          spaceId = row.id;
        } else {
          spaceId = randomUUID();
          insertPersonal.run(spaceId, c.owner_id);
          createdSpaces += 1;
        }
        personalCache.set(c.owner_id, spaceId);
      }
      setSpace.run(spaceId, c.server_id);
      migrated += 1;
    }
    return { migrated, createdSpaces };
  });

  const { migrated, createdSpaces } = apply();
  const globalLeft = (
    db.prepare('SELECT COUNT(*) AS n FROM mcp_servers WHERE owner_id IS NULL AND space_id IS NULL').get() as {
      n: number;
    }
  ).n;
  logger.info(
    `[migrate] mcp space backfill: migrated=${migrated} personalSpacesCreated=${createdSpaces} globalLeft=${globalLeft}`,
  );
}

/**
 * Per-space SSH isolation migration (Phase 3, spec §11). Idempotent.
 *
 * Approved model = fully independent (space-only): a job sees ONLY its space's
 * SSH connections. To preserve existing per-user connections under that model,
 * each user-owned connection with no space (owner_id = <real user> AND
 * space_id IS NULL) is migrated into that user's personal space (created if
 * missing, mirroring Repository.ensurePersonalSpace + backfillMcpServerSpaceIds).
 * System/global connections (owner_id IS NULL) are intentionally left with
 * space_id NULL → invisible to spaces, reachable only by legacy (no-space)
 * jobs via the access-gate NULL-space fallback.
 *
 * owner_id is preserved on every row — it drives the per-user DEK used to
 * decrypt the private key. No per-space DEK is introduced.
 *
 * Runs after ssh_connections.space_id is added (Phase 1) and after the spaces
 * table exists. Skips quietly when prerequisite tables/columns are absent.
 */
function backfillSshConnectionSpaceIds(db: Database.Database): void {
  const tableExists = (name: string): boolean =>
    !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name);
  if (!tableExists('ssh_connections') || !tableExists('spaces') || !tableExists('users')) return;

  const cols = db.prepare("PRAGMA table_info('ssh_connections')").all() as Array<{ name: string }>;
  if (!cols.some((c) => c.name === 'space_id')) return;

  // Candidates: user-owned connections with no space yet, whose owner is a real
  // user (the JOIN drops orphaned owner_ids).
  const candidates = db
    .prepare(
      `SELECT s.id AS conn_id, s.owner_id AS owner_id
         FROM ssh_connections s
         JOIN users u ON u.id = s.owner_id
        WHERE s.owner_id IS NOT NULL AND s.space_id IS NULL`,
    )
    .all() as Array<{ conn_id: string; owner_id: string }>;

  if (candidates.length === 0) {
    const systemLeft = (
      db.prepare('SELECT COUNT(*) AS n FROM ssh_connections WHERE owner_id IS NULL AND space_id IS NULL').get() as {
        n: number;
      }
    ).n;
    logger.info(`[migrate] ssh space backfill: migrated=0 systemLeft=${systemLeft}`);
    return;
  }

  const findPersonal = db.prepare(
    `SELECT id FROM spaces WHERE owner_id = ? AND kind = 'personal' LIMIT 1`,
  );
  const insertPersonal = db.prepare(
    `INSERT INTO spaces (id, kind, title, description, owner_id, visibility, status)
     VALUES (?, 'personal', '個人ワークスペース', '', ?, 'private', 'open')`,
  );
  const setSpace = db.prepare(
    "UPDATE ssh_connections SET space_id = ?, updated_at = datetime('now') WHERE id = ?",
  );

  const personalCache = new Map<string, string>();
  const apply = db.transaction(() => {
    let migrated = 0;
    let createdSpaces = 0;
    for (const c of candidates) {
      let spaceId = personalCache.get(c.owner_id);
      if (!spaceId) {
        const row = findPersonal.get(c.owner_id) as { id: string } | undefined;
        if (row) {
          spaceId = row.id;
        } else {
          spaceId = randomUUID();
          insertPersonal.run(spaceId, c.owner_id);
          createdSpaces += 1;
        }
        personalCache.set(c.owner_id, spaceId);
      }
      setSpace.run(spaceId, c.conn_id);
      migrated += 1;
    }
    return { migrated, createdSpaces };
  });

  const { migrated, createdSpaces } = apply();
  const systemLeft = (
    db.prepare('SELECT COUNT(*) AS n FROM ssh_connections WHERE owner_id IS NULL AND space_id IS NULL').get() as {
      n: number;
    }
  ).n;
  logger.info(
    `[migrate] ssh space backfill: migrated=${migrated} personalSpacesCreated=${createdSpaces} systemLeft=${systemLeft}`,
  );
}

/**
 * Per-space browser-session-profile migration (workstream 2). Idempotent.
 *
 * Each user-owned profile with no space (owner_id = <real user> AND space_id IS
 * NULL) is migrated into that user's personal space (created if missing,
 * mirroring Repository.ensurePersonalSpace + the mcp/ssh backfills). Admin-global
 * profiles (owner_id IS NULL) cannot exist today — owner_id is NOT NULL on the
 * table — but the WHERE clause guards anyway and leaves any such row's space_id
 * NULL.
 *
 * owner_id is preserved on every row — it drives the per-user DEK that decrypts
 * encrypted_state_blob. No per-space DEK is introduced; a profile created by user
 * A in a shared space remains decryptable only by A.
 *
 * Runs after browser_session_profiles.space_id is added and after the spaces
 * table exists. Skips quietly when prerequisite tables/columns are absent.
 * Safe to re-run: profiles already carrying a space_id are not candidates.
 */
function backfillBrowserSessionProfileSpaceIds(db: Database.Database): void {
  const tableExists = (name: string): boolean =>
    !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name);
  if (!tableExists('browser_session_profiles') || !tableExists('spaces') || !tableExists('users')) return;

  const cols = db.prepare("PRAGMA table_info('browser_session_profiles')").all() as Array<{ name: string }>;
  if (!cols.some((c) => c.name === 'space_id')) return;

  // Candidates: user-owned profiles with no space yet, whose owner is a real
  // user (the JOIN drops orphaned owner_ids).
  const candidates = db
    .prepare(
      `SELECT p.id AS profile_id, p.owner_id AS owner_id
         FROM browser_session_profiles p
         JOIN users u ON u.id = p.owner_id
        WHERE p.owner_id IS NOT NULL AND p.space_id IS NULL`,
    )
    .all() as Array<{ profile_id: number; owner_id: string }>;

  if (candidates.length === 0) {
    const globalLeft = (
      db
        .prepare('SELECT COUNT(*) AS n FROM browser_session_profiles WHERE owner_id IS NULL AND space_id IS NULL')
        .get() as { n: number }
    ).n;
    logger.info(`[migrate] browser-profile space backfill: migrated=0 globalLeft=${globalLeft}`);
    return;
  }

  const findPersonal = db.prepare(
    `SELECT id FROM spaces WHERE owner_id = ? AND kind = 'personal' LIMIT 1`,
  );
  const insertPersonal = db.prepare(
    `INSERT INTO spaces (id, kind, title, description, owner_id, visibility, status)
     VALUES (?, 'personal', '個人ワークスペース', '', ?, 'private', 'open')`,
  );
  const setSpace = db.prepare(
    "UPDATE browser_session_profiles SET space_id = ?, updated_at = datetime('now') WHERE id = ?",
  );

  const personalCache = new Map<string, string>();
  const apply = db.transaction(() => {
    let migrated = 0;
    let createdSpaces = 0;
    for (const c of candidates) {
      let spaceId = personalCache.get(c.owner_id);
      if (!spaceId) {
        const row = findPersonal.get(c.owner_id) as { id: string } | undefined;
        if (row) {
          spaceId = row.id;
        } else {
          spaceId = randomUUID();
          insertPersonal.run(spaceId, c.owner_id);
          createdSpaces += 1;
        }
        personalCache.set(c.owner_id, spaceId);
      }
      setSpace.run(spaceId, c.profile_id);
      migrated += 1;
    }
    return { migrated, createdSpaces };
  });

  const { migrated, createdSpaces } = apply();
  const globalLeft = (
    db
      .prepare('SELECT COUNT(*) AS n FROM browser_session_profiles WHERE owner_id IS NULL AND space_id IS NULL')
      .get() as { n: number }
  ).n;
  logger.info(
    `[migrate] browser-profile space backfill: migrated=${migrated} personalSpacesCreated=${createdSpaces} globalLeft=${globalLeft}`,
  );
}

/**
 * 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;
  `);
}

/** この better-sqlite3 ビルドで FTS5 が使えるか（実 CREATE で判定してキャッシュはしない）。 */
export function isFts5Available(db: Database.Database): boolean {
  try {
    db.exec('CREATE VIRTUAL TABLE IF NOT EXISTS __fts5_probe USING fts5(x, tokenize=\'trigram\')');
    db.exec('DROP TABLE IF EXISTS __fts5_probe');
    return true;
  } catch {
    return false;
  }
}

/**
 * task_comment_index（他タスクの会話を検索するための索引テーブル）を作成する。
 * 通常テーブルは三重ミラー対象（schema.sql + Repository.initSchema + ここ）。
 * FTS5 仮想テーブル＋同期トリガは ensureTaskCommentFts() に委譲する。
 * Mirrors schema.sql + Repository.initSchema (三重ミラー; memory: project_db_migration_dual_path).
 */
function migrateTaskCommentIndex(db: Database.Database): void {
  db.exec(`
    CREATE TABLE IF NOT EXISTS task_comment_index (
      comment_id  INTEGER PRIMARY KEY,
      task_id     INTEGER NOT NULL,
      author      TEXT,
      kind        TEXT,
      created_at  TEXT,
      text        TEXT NOT NULL
    );
    CREATE INDEX IF NOT EXISTS idx_tci_task ON task_comment_index(task_id);
  `);
  ensureTaskCommentFts(db);
}

/**
 * task_comment_index が作られる前から存在していたコメントを一括索引する（バックフィル）。
 * 冪等: task_comment_index に既に存在する comment_id は LEFT JOIN で除外されるため、
 * 複数回 runMigrations を呼んでも再挿入されない。
 * Consumes: normalizeCommentToIndexText（Task 2）、task_comment_index（Task 1）。
 */
function migrateBackfillTaskCommentIndex(db: Database.Database): void {
  const hasComments = !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='local_task_comments'").get();
  const hasIndex = !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_comment_index'").get();
  if (!hasComments || !hasIndex) return;

  const insert = db.prepare(
    `INSERT OR IGNORE INTO task_comment_index (comment_id, task_id, author, kind, created_at, text)
     VALUES (?, ?, ?, ?, ?, ?)`,
  );
  // 未索引分だけを拾う（watermark: task_comment_index に無い comment_id）
  const rows = db.prepare(
    `SELECT c.id, c.task_id, c.author, c.kind, c.body, c.attachments, c.created_at
       FROM local_task_comments c
       LEFT JOIN task_comment_index i ON i.comment_id = c.id
      WHERE i.comment_id IS NULL`,
  ).all() as Array<{ id: number; task_id: number; author: string; kind: string; body: string; attachments: string | null; created_at: string }>;

  const tx = db.transaction((batch: typeof rows) => {
    for (const r of batch) {
      const text = normalizeCommentToIndexText(r.kind, r.body ?? '', r.attachments);
      if (text === null) continue;
      insert.run(r.id, r.task_id, r.author, r.kind, r.created_at, text);
    }
  });
  const BATCH = 1000;
  for (let i = 0; i < rows.length; i += BATCH) {
    tx(rows.slice(i, i + BATCH));
  }
  if (rows.length > 0) logger.info(`[task-index] backfilled ${rows.length} comment(s)`);
}

/**
 * task_comment_fts（FTS5 仮想テーブル）＋同期トリガを作成する。
 * FTS5 無効ビルドでは no-op（通常テーブルだけで動作させる）。
 * migrateTaskCommentIndex（migrate 経路）と Repository.initSchema（bare construction 経路）
 * の両方から呼ばれる唯一の DDL 定義箇所。呼び出し元は task_comment_index 本体を先に作成しておくこと。
 */
export function ensureTaskCommentFts(db: Database.Database): void {
  if (!isFts5Available(db)) return; // FTS5 無し環境: 通常テーブルだけ作り FTS はスキップ
  db.exec(`
    CREATE VIRTUAL TABLE IF NOT EXISTS task_comment_fts USING fts5(
      text, content='task_comment_index', content_rowid='comment_id', tokenize='trigram'
    );
    CREATE TRIGGER IF NOT EXISTS tci_ai AFTER INSERT ON task_comment_index BEGIN
      INSERT INTO task_comment_fts(rowid, text) VALUES (new.comment_id, new.text);
    END;
    CREATE TRIGGER IF NOT EXISTS tci_ad AFTER DELETE ON task_comment_index BEGIN
      INSERT INTO task_comment_fts(task_comment_fts, rowid, text) VALUES('delete', old.comment_id, old.text);
    END;
    CREATE TRIGGER IF NOT EXISTS tci_au AFTER UPDATE ON task_comment_index BEGIN
      INSERT INTO task_comment_fts(task_comment_fts, rowid, text) VALUES('delete', old.comment_id, old.text);
      INSERT INTO task_comment_fts(rowid, text) VALUES (new.comment_id, new.text);
    END;
  `);
}

/**
 * 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');
  }
  if (!mcpServerColNames.has('space_id')) {
    // Per-space isolation (spaces foundation). NULL = global/legacy
    // (resolved globally; Phase 1 is a no-op, no consumer reads this yet).
    db.exec('ALTER TABLE mcp_servers ADD COLUMN space_id 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'))
    );

    -- Space-as-Principal P2: per-SPACE DEK (env-master domain, shared by SSH/MCP).
    -- Mirrors ssh_user_deks but keyed by space_id so a space-owned credential
    -- survives member turnover (the secret is wrapped under the space DEK, not a
    -- departing user's DEK). Added unused in P2-1; wired in later slices.
    CREATE TABLE IF NOT EXISTS space_ssh_deks (
      space_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;
  `);

  // ALTERs for new ssh_connections columns via PRAGMA table_info pattern,
  // matching the MCP migrations above.
  const sshConnCols = db.prepare("PRAGMA table_info('ssh_connections')").all() as Array<{ name: string }>;
  const sshConnColNames = new Set(sshConnCols.map(c => c.name));
  if (!sshConnColNames.has('space_id')) {
    // Per-space isolation (spaces foundation). NULL = global/legacy
    // (resolved globally; Phase 1 is a no-op, no consumer reads this yet).
    db.exec('ALTER TABLE ssh_connections ADD COLUMN space_id TEXT');
  }
}

/**
 * Drop the legacy Side Info Panel widget table.
 *
 * The configurable-widget dashboard (markdown + node-status widgets, plus the
 * UpdateDashboardWidget agent tool) was removed in 2026-06 in favour of fixed
 * worker / node (GPU) status panels. Older DBs still carry user_dashboard_widgets;
 * drop it idempotently so the schema converges.
 */
function migrateDashboardWidgets(db: Database.Database): void {
  db.exec(`DROP TABLE IF EXISTS user_dashboard_widgets;`);
}

/**
 * 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);
  `);
}

/**
 * a2a_tasks に委任列を追加（Plan 2C-1 Task 2）。
 * dual-path: schema.sql の CREATE TABLE にも同じ列を追加済み。
 * Idempotent — PRAGMA table_info でカラム存在確認してから ALTER。
 */
function migrateA2aTaskDelegationColumns(db: Database.Database): void {
  addColumnIfMissing(db, 'a2a_tasks', 'delegation_id', () => {
    db.exec('ALTER TABLE a2a_tasks ADD COLUMN delegation_id TEXT');
  });
  addColumnIfMissing(db, 'a2a_tasks', 'grant_id', () => {
    db.exec('ALTER TABLE a2a_tasks ADD COLUMN grant_id TEXT');
  });
  addColumnIfMissing(db, 'a2a_tasks', 'acting_user_id', () => {
    db.exec('ALTER TABLE a2a_tasks ADD COLUMN acting_user_id TEXT');
  });
}
