// @ts-nocheck — .cts via Node type-stripping; rich typing deferred (large dynamic DB/migration infra).
'use strict';

// The versioned migration list, extracted from db/database.js to keep that
// module manageable. A factory: each migration's run/check body closes over the
// live `db` connection and a handful of schema helpers, which are injected here
// so behavior is byte-identical to when the array lived inline. Consumed by
// runMigrations() in db/database.js.
module.exports = function buildVersionedMigrations(deps) {
  const {
    db,
    isValidColumnName,
    isValidSqlDefinition,
    ensureTransactionFoundationSchema,
    runSubscriptionCatalogMigration,
    runSubscriptionCatalogV2Migration,
    runAdvisoryFiltersMigration,
    runMerchantStoreMatchMigration,
  } = deps;
  return [
    {
      version: 'v0.2',
      dependsOn: [],
      description: 'payments: soft-delete column',
      run: function () {
        const paymentCols = db
          .prepare('PRAGMA table_info(payments)')
          .all()
          .map((c) => c.name);
        if (!paymentCols.includes('deleted_at')) {
          db.exec('ALTER TABLE payments ADD COLUMN deleted_at TEXT');
          // Index for fast filtering of live payments
          db.exec('CREATE INDEX IF NOT EXISTS idx_payments_deleted ON payments(deleted_at)');
          console.log('[migration] payments.deleted_at column added');
        }
      },
    },
    {
      version: 'v0.3',
      dependsOn: ['v0.2'],
      description: 'payments: compound index for tracker query',
      run: function () {
        // Supports: WHERE bill_id = ? AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL
        db.exec(
          'CREATE INDEX IF NOT EXISTS idx_payments_bill_date_del ON payments(bill_id, paid_date, deleted_at)',
        );
      },
    },
    {
      version: 'v0.4',
      dependsOn: ['v0.3'],
      description: 'monthly_bill_state: per-bill per-month overrides',
      run: function () {
        db.exec(`
          CREATE TABLE IF NOT EXISTS monthly_bill_state (
            id            INTEGER PRIMARY KEY AUTOINCREMENT,
            bill_id       INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
            year          INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
            month         INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
            actual_amount REAL,
            notes         TEXT,
            is_skipped    INTEGER NOT NULL DEFAULT 0,
            created_at    TEXT DEFAULT (datetime('now')),
            updated_at    TEXT DEFAULT (datetime('now')),
            UNIQUE(bill_id, year, month)
          )
        `);
        db.exec(
          'CREATE INDEX IF NOT EXISTS idx_monthly_bill_state_lookup ON monthly_bill_state(bill_id, year, month)',
        );
        console.log('[migration] monthly_bill_state table ensured');
      },
    },
    {
      version: 'v0.13',
      dependsOn: ['v0.4'],
      description: 'users: profile columns',
      run: function () {
        const userColsNow = db
          .prepare('PRAGMA table_info(users)')
          .all()
          .map((c) => c.name);
        const profileCols = [
          ['display_name', 'TEXT'],
          ['last_password_change_at', 'TEXT'],
        ];
        for (const [col, def] of profileCols) {
          if (!userColsNow.includes(col)) {
            // Security FIX (2026-05-08): Validate column name and definition to prevent SQL injection
            if (!isValidColumnName(col) || !isValidSqlDefinition(def)) {
              throw new Error(`Invalid migration: column '${col}' not in whitelist`);
            }
            db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`);
          }
        }
      },
    },
    {
      version: 'v0.14',
      dependsOn: ['v0.13'],
      description: 'bills: history visibility mode',
      run: function () {
        const billColsHist = db
          .prepare('PRAGMA table_info(bills)')
          .all()
          .map((c) => c.name);
        if (!billColsHist.includes('history_visibility')) {
          db.exec(
            "ALTER TABLE bills ADD COLUMN history_visibility TEXT NOT NULL DEFAULT 'default'",
          );
          console.log('[migration] bills.history_visibility column added');
        }
      },
    },
    {
      version: 'v0.14.4',
      dependsOn: ['v0.14'],
      description: 'bills: optional credit-card APR / interest rate',
      run: function () {
        const billColsInterest = db
          .prepare('PRAGMA table_info(bills)')
          .all()
          .map((c) => c.name);
        if (!billColsInterest.includes('interest_rate')) {
          db.exec('ALTER TABLE bills ADD COLUMN interest_rate REAL');
          console.log('[migration] bills.interest_rate column added');
        }
      },
    },
    {
      version: 'v0.15',
      dependsOn: ['v0.14.4'],
      description: 'import_sessions and import_history tables',
      run: function () {
        db.exec(`
          CREATE TABLE IF NOT EXISTS import_sessions (
            id           TEXT    PRIMARY KEY,
            user_id      INTEGER NOT NULL,
            created_at   TEXT    NOT NULL,
            expires_at   TEXT    NOT NULL,
            preview_json TEXT    NOT NULL
          )
        `);
        db.exec('CREATE INDEX IF NOT EXISTS idx_import_sessions_user ON import_sessions(user_id)');
        db.exec(
          'CREATE INDEX IF NOT EXISTS idx_import_sessions_expires ON import_sessions(expires_at)',
        );

        // ── import_history: per-user audit log (v0.38) ────────────────────────────
        db.exec(`
          CREATE TABLE IF NOT EXISTS import_history (
            id               INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id          INTEGER NOT NULL,
            imported_at      TEXT    NOT NULL,
            source_filename  TEXT,
            file_type        TEXT    DEFAULT 'xlsx',
            sheet_name       TEXT,
            rows_parsed      INTEGER DEFAULT 0,
            rows_created     INTEGER DEFAULT 0,
            rows_updated     INTEGER DEFAULT 0,
            rows_skipped     INTEGER DEFAULT 0,
            rows_ambiguous   INTEGER DEFAULT 0,
            rows_errored     INTEGER DEFAULT 0,
            options_json     TEXT,
            summary_json     TEXT
          )
        `);
        db.exec('CREATE INDEX IF NOT EXISTS idx_import_history_user ON import_history(user_id)');
      },
    },
    {
      version: 'v0.17',
      dependsOn: ['v0.15'],
      description: 'users: external identity / OIDC columns',
      run: function () {
        const userColsOidc = db
          .prepare('PRAGMA table_info(users)')
          .all()
          .map((c) => c.name);
        const oidcUserCols = [
          ['auth_provider', "TEXT NOT NULL DEFAULT 'local'"],
          ['external_subject', 'TEXT'],
          ['email', 'TEXT'],
          ['last_login_at', 'TEXT'],
        ];
        for (const [col, def] of oidcUserCols) {
          if (!userColsOidc.includes(col)) {
            // Security FIX (2026-05-08): Validate column name and definition to prevent SQL injection
            if (!isValidColumnName(col) || !isValidSqlDefinition(def)) {
              throw new Error(`Invalid migration: column '${col}' not in whitelist`);
            }
            db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`);
          }
        }

        // ── oidc_states: short-lived PKCE + nonce state for OIDC login (v0.17) ───
        db.exec(`
          CREATE TABLE IF NOT EXISTS oidc_states (
            id            TEXT PRIMARY KEY,
            nonce         TEXT NOT NULL,
            code_verifier TEXT NOT NULL,
            redirect_to   TEXT,
            created_at    TEXT NOT NULL,
            expires_at    TEXT NOT NULL
          )
        `);
        db.exec('CREATE INDEX IF NOT EXISTS idx_oidc_states_expires ON oidc_states(expires_at)');
      },
    },
    {
      version: 'v0.18.1',
      dependsOn: ['v0.17'],
      description: 'monthly_income: per-user monthly income for Summary planning',
      run: function () {
        db.exec(`
          CREATE TABLE IF NOT EXISTS monthly_income (
            id         INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
            year       INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
            month      INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
            label      TEXT NOT NULL DEFAULT 'Salary',
            amount     REAL NOT NULL DEFAULT 0,
            created_at TEXT DEFAULT (datetime('now')),
            updated_at TEXT DEFAULT (datetime('now')),
            UNIQUE(user_id, year, month)
          )
        `);
        db.exec(
          'CREATE INDEX IF NOT EXISTS idx_monthly_income_user_month ON monthly_income(user_id, year, month)',
        );
      },
    },
    {
      version: 'v0.18.2',
      dependsOn: ['v0.18.1'],
      description: 'monthly_starting_amounts: per-user monthly starting amounts for 1st and 15th',
      run: function () {
        db.exec(`
          CREATE TABLE IF NOT EXISTS monthly_starting_amounts (
            id               INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id          INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
            year             INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
            month            INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
            first_amount     REAL NOT NULL DEFAULT 0 CHECK(first_amount >= 0),
            fifteenth_amount REAL NOT NULL DEFAULT 0 CHECK(fifteenth_amount >= 0),
            other_amount     REAL NOT NULL DEFAULT 0 CHECK(other_amount >= 0),
            notes            TEXT,
            created_at       TEXT DEFAULT (datetime('now')),
            updated_at       TEXT DEFAULT (datetime('now')),
            UNIQUE(user_id, year, month)
          )
        `);
        db.exec(
          'CREATE INDEX IF NOT EXISTS idx_monthly_starting_amounts_user_month ON monthly_starting_amounts(user_id, year, month)',
        );
      },
    },
    {
      version: 'v0.18.3',
      dependsOn: ['v0.18.2'],
      description: 'monthly_starting_amounts: add other_amount column',
      run: function () {
        const startingCols = db
          .prepare('PRAGMA table_info(monthly_starting_amounts)')
          .all()
          .map((c) => c.name);
        if (!startingCols.includes('other_amount')) {
          // Security FIX (2026-05-08): Validate column name to prevent SQL injection
          if (!isValidColumnName('other_amount')) {
            throw new Error('Invalid migration: column other_amount not in whitelist');
          }
          db.exec(
            'ALTER TABLE monthly_starting_amounts ADD COLUMN other_amount REAL NOT NULL DEFAULT 0 CHECK(other_amount >= 0)',
          );
          console.log('[migration] monthly_starting_amounts.other_amount column added');
        }
      },
    },
    {
      version: 'v0.38',
      dependsOn: ['v0.18.3'],
      description: 'import_history: per-user audit log',
      run: function () {
        // This was already handled in v0.15, but keeping for completeness
      },
    },
    {
      version: 'v0.40',
      dependsOn: ['v0.38'],
      description: 'ownership: user-scoped bills/categories',
      run: function () {
        const billCols = db
          .prepare('PRAGMA table_info(bills)')
          .all()
          .map((c) => c.name);
        if (!billCols.includes('user_id')) {
          db.exec(
            'ALTER TABLE bills ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE',
          );
        }
        const categoryCols = db
          .prepare('PRAGMA table_info(categories)')
          .all()
          .map((c) => c.name);
        if (!categoryCols.includes('user_id')) {
          db.exec(
            'ALTER TABLE categories ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE',
          );
        }
        const categorySql =
          db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='categories'").get()
            ?.sql || '';
        if (/name\s+TEXT\s+NOT\s+NULL\s+UNIQUE/i.test(categorySql)) {
          db.exec('PRAGMA foreign_keys = OFF');
          db.exec(`
            CREATE TABLE IF NOT EXISTS categories_v040 (
              id INTEGER PRIMARY KEY AUTOINCREMENT,
              user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
              name TEXT NOT NULL,
              created_at TEXT DEFAULT (datetime('now')),
              updated_at TEXT DEFAULT (datetime('now'))
            )
          `);
          db.exec(
            'INSERT INTO categories_v040 (id, user_id, name, created_at, updated_at) SELECT id, user_id, name, created_at, updated_at FROM categories',
          );
          db.exec('DROP TABLE categories');
          db.exec('ALTER TABLE categories_v040 RENAME TO categories');
          db.exec('PRAGMA foreign_keys = ON');
        }

        const firstAdmin = db
          .prepare("SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1")
          .get();
        if (firstAdmin) {
          db.prepare('UPDATE bills SET user_id = ? WHERE user_id IS NULL').run(firstAdmin.id);
          // Drop any NULL-owner categories whose name already exists for this admin (case-insensitive)
          // to prevent a UNIQUE(user_id, name) violation when we assign them below.
          db.prepare(
            `
            DELETE FROM categories
            WHERE user_id IS NULL
              AND LOWER(name) IN (
                SELECT LOWER(name) FROM categories WHERE user_id = ?
              )
          `,
          ).run(firstAdmin.id);
          db.prepare('UPDATE categories SET user_id = ? WHERE user_id IS NULL').run(firstAdmin.id);
        }
        db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_active ON bills(user_id, active)');
        db.exec('CREATE INDEX IF NOT EXISTS idx_categories_user_name ON categories(user_id, name)');
        db.exec(
          'CREATE UNIQUE INDEX IF NOT EXISTS idx_categories_user_name_unique ON categories(user_id, name COLLATE NOCASE)',
        );
      },
    },
    {
      version: 'v0.41',
      dependsOn: ['v0.40'],
      description: 'bills and categories: is_seeded flag for demo data cleanup',
      run: function () {
        // ── bills: is_seeded flag for demo data cleanup (v0.41) ───────────────────
        const billColsSeeded = db
          .prepare('PRAGMA table_info(bills)')
          .all()
          .map((c) => c.name);
        if (!billColsSeeded.includes('is_seeded')) {
          db.exec('ALTER TABLE bills ADD COLUMN is_seeded INTEGER NOT NULL DEFAULT 0');
          console.log('[migration] bills.is_seeded column added');
        }

        // ── categories: is_seeded flag for demo data cleanup (v0.41) ──────────────
        const categoryColsSeeded = db
          .prepare('PRAGMA table_info(categories)')
          .all()
          .map((c) => c.name);
        if (!categoryColsSeeded.includes('is_seeded')) {
          db.exec('ALTER TABLE categories ADD COLUMN is_seeded INTEGER NOT NULL DEFAULT 0');
          console.log('[migration] categories.is_seeded column added');
        }
      },
    },
    {
      version: 'v0.42',
      dependsOn: ['v0.41'],
      description: 'bill_history_ranges: per-bill date ranges for history visibility',
      run: function () {
        db.exec(`
          CREATE TABLE IF NOT EXISTS bill_history_ranges (
            id          INTEGER PRIMARY KEY AUTOINCREMENT,
            bill_id     INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
            start_year  INTEGER NOT NULL,
            start_month INTEGER NOT NULL,
            end_year    INTEGER,
            end_month   INTEGER,
            label       TEXT,
            created_at  TEXT DEFAULT (datetime('now')),
            updated_at  TEXT DEFAULT (datetime('now'))
          )
        `);
        db.exec(
          'CREATE INDEX IF NOT EXISTS idx_bill_history_ranges_bill ON bill_history_ranges(bill_id)',
        );
      },
    },
    {
      version: 'v0.43',
      dependsOn: ['v0.42'],
      description: 'sessions: add created_at column',
      run: function () {
        const sessionCols = db
          .prepare('PRAGMA table_info(sessions)')
          .all()
          .map((c) => c.name);
        if (!sessionCols.includes('created_at')) {
          // Security FIX (2026-05-09): Validate column name to prevent SQL injection
          if (!isValidColumnName('created_at')) {
            throw new Error('Invalid migration: column created_at not in whitelist');
          }
          db.exec("ALTER TABLE sessions ADD COLUMN created_at TEXT DEFAULT (datetime('now'))");
          console.log('[migration] sessions.created_at column added');
        }
      },
    },
    {
      version: 'v0.44',
      dependsOn: ['v0.43'],
      description: 'performance: add missing indexes for frequently queried columns',
      run: function () {
        db.exec('CREATE INDEX IF NOT EXISTS idx_bills_user_name ON bills(user_id, name)');
        db.exec('CREATE INDEX IF NOT EXISTS idx_payments_method ON payments(method)');
        db.exec(
          'CREATE INDEX IF NOT EXISTS idx_monthly_starting_amounts_user ON monthly_starting_amounts(user_id)',
        );
        db.exec(
          'CREATE INDEX IF NOT EXISTS idx_import_history_imported_at ON import_history(imported_at)',
        );
        console.log('[migration] Added indexes for frequently queried columns');
      },
    },
    {
      version: 'v0.45',
      dependsOn: ['v0.44'],
      description: 'audit: add audit_log table for security event tracking',
      run: function () {
        db.exec(`
          CREATE TABLE IF NOT EXISTS audit_log (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id INTEGER,
            action TEXT NOT NULL,
            entity_type TEXT,
            entity_id INTEGER,
            details_json TEXT,
            ip_address TEXT,
            user_agent TEXT,
            created_at TEXT DEFAULT (datetime('now'))
          );
          CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id, created_at);
          CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action, created_at);
        `);
      },
    },
    {
      version: 'v0.46',
      description: 'billing: add cycle_type and cycle_day columns to bills',
      dependsOn: ['v0.45'],
      run: function () {
        const cols = db
          .prepare('PRAGMA table_info(bills)')
          .all()
          .map((c) => c.name);
        if (!cols.includes('cycle_type')) {
          db.exec(`ALTER TABLE bills ADD COLUMN cycle_type TEXT NOT NULL DEFAULT 'monthly'`);
        }
        if (!cols.includes('cycle_day')) {
          db.exec(`ALTER TABLE bills ADD COLUMN cycle_day TEXT`);
        }
      },
    },
    {
      version: 'v0.47',
      description: 'settings: reset backup_schedule_retention_count default from 14 to 2',
      dependsOn: ['v0.46'],
      run: function () {
        db.prepare(
          "UPDATE settings SET value = '2' WHERE key = 'backup_schedule_retention_count' AND value = '14'",
        ).run();
        console.log('[migration] backup_schedule_retention_count updated from 14 to 2');
      },
    },
    {
      version: 'v0.48',
      description:
        'bills: debt snowball fields (current_balance, minimum_payment, snowball_order, snowball_include)',
      dependsOn: ['v0.47'],
      run: function () {
        const cols = db
          .prepare('PRAGMA table_info(bills)')
          .all()
          .map((c) => c.name);
        if (!cols.includes('current_balance'))
          db.exec('ALTER TABLE bills ADD COLUMN current_balance REAL');
        if (!cols.includes('minimum_payment'))
          db.exec('ALTER TABLE bills ADD COLUMN minimum_payment REAL');
        if (!cols.includes('snowball_order'))
          db.exec('ALTER TABLE bills ADD COLUMN snowball_order INTEGER');
        if (!cols.includes('snowball_include'))
          db.exec('ALTER TABLE bills ADD COLUMN snowball_include INTEGER NOT NULL DEFAULT 0');
        console.log('[migration] bills: debt snowball columns added');
      },
    },
    {
      version: 'v0.49',
      description: 'users: snowball_extra_payment column',
      dependsOn: ['v0.48'],
      run: function () {
        const cols = db
          .prepare('PRAGMA table_info(users)')
          .all()
          .map((c) => c.name);
        if (!cols.includes('snowball_extra_payment')) {
          db.exec('ALTER TABLE users ADD COLUMN snowball_extra_payment REAL NOT NULL DEFAULT 0');
        }
        console.log('[migration] users: snowball_extra_payment column added');
      },
    },
    {
      version: 'v0.50',
      description: 'payments: balance_delta column for debt payoff tracking',
      dependsOn: ['v0.49'],
      run: function () {
        const cols = db
          .prepare('PRAGMA table_info(payments)')
          .all()
          .map((c) => c.name);
        if (!cols.includes('balance_delta')) {
          db.exec('ALTER TABLE payments ADD COLUMN balance_delta REAL');
        }
        console.log('[migration] payments: balance_delta column added');
      },
    },
    {
      version: 'v0.51',
      description: 'bills: snowball_exempt column for hiding debt-like bills',
      dependsOn: ['v0.50'],
      run: function () {
        const cols = db
          .prepare('PRAGMA table_info(bills)')
          .all()
          .map((c) => c.name);
        if (!cols.includes('snowball_exempt')) {
          db.exec('ALTER TABLE bills ADD COLUMN snowball_exempt INTEGER NOT NULL DEFAULT 0');
        }
        console.log('[migration] bills: snowball_exempt column added');
      },
    },
    {
      version: 'v0.52',
      description: 'users: last_seen_version for release-notes notifications',
      dependsOn: ['v0.51'],
      run: function () {
        const cols = db
          .prepare('PRAGMA table_info(users)')
          .all()
          .map((c) => c.name);
        if (!cols.includes('last_seen_version')) {
          db.exec('ALTER TABLE users ADD COLUMN last_seen_version TEXT');
        }
        console.log('[migration] users: last_seen_version column added');
      },
    },
    {
      version: 'v0.53',
      description: 'user_login_history: track last 3 logins per user',
      dependsOn: ['v0.52'],
      run: function () {
        db.exec(`
          CREATE TABLE IF NOT EXISTS user_login_history (
            id           INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id      INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
            logged_in_at TEXT    NOT NULL DEFAULT (datetime('now')),
            ip_address   TEXT,
            user_agent   TEXT
          )
        `);
        console.log('[migration] user_login_history table created');
      },
    },
    {
      version: 'v0.54',
      description: 'user_settings: per-user display and billing preferences',
      dependsOn: ['v0.53'],
      run: function () {
        db.exec(`
          CREATE TABLE IF NOT EXISTS user_settings (
            user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
            key TEXT NOT NULL,
            value TEXT NOT NULL,
            updated_at TEXT DEFAULT (datetime('now')),
            PRIMARY KEY (user_id, key)
          )
        `);

        const userSettingKeys = [
          'currency',
          'date_format',
          'grace_period_days',
          'notify_days_before',
        ];
        const users = db.prepare('SELECT id FROM users').all();
        const getCurrent = db.prepare('SELECT value FROM settings WHERE key = ?');
        const insert = db.prepare(`
          INSERT OR IGNORE INTO user_settings (user_id, key, value)
          VALUES (?, ?, ?)
        `);

        for (const user of users) {
          for (const key of userSettingKeys) {
            const row = getCurrent.get(key);
            if (row) insert.run(user.id, key, row.value);
          }
        }
        console.log(
          '[migration] user_settings table created and seeded from current global defaults',
        );
      },
    },
    {
      version: 'v0.55',
      description: 'user_login_history: parsed device metadata and fingerprint',
      dependsOn: ['v0.54'],
      run: function () {
        db.exec(`
          CREATE TABLE IF NOT EXISTS user_login_history (
            id           INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id      INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
            logged_in_at TEXT    NOT NULL DEFAULT (datetime('now')),
            ip_address   TEXT,
            user_agent   TEXT,
            browser      TEXT,
            os           TEXT,
            device_type  TEXT,
            device_fingerprint TEXT
          )
        `);

        const cols = db
          .prepare('PRAGMA table_info(user_login_history)')
          .all()
          .map((c) => c.name);
        const newCols = [
          ['browser', 'TEXT'],
          ['os', 'TEXT'],
          ['device_type', 'TEXT'],
          ['device_fingerprint', 'TEXT'],
        ];
        for (const [col, def] of newCols) {
          if (!cols.includes(col)) {
            db.exec(`ALTER TABLE user_login_history ADD COLUMN ${col} ${def}`);
          }
        }
        console.log('[migration] user_login_history device metadata columns ensured');
      },
    },
    {
      version: 'v0.56',
      description: 'bills/categories: soft-delete columns',
      dependsOn: ['v0.55'],
      run: function () {
        const billCols = db
          .prepare('PRAGMA table_info(bills)')
          .all()
          .map((c) => c.name);
        const catCols = db
          .prepare('PRAGMA table_info(categories)')
          .all()
          .map((c) => c.name);
        if (!billCols.includes('deleted_at')) {
          db.exec('ALTER TABLE bills ADD COLUMN deleted_at TEXT');
          db.exec('CREATE INDEX IF NOT EXISTS idx_bills_deleted ON bills(user_id, deleted_at)');
        }
        if (!catCols.includes('deleted_at')) {
          db.exec('ALTER TABLE categories ADD COLUMN deleted_at TEXT');
          db.exec(
            'CREATE INDEX IF NOT EXISTS idx_categories_deleted ON categories(user_id, deleted_at)',
          );
        }
        console.log('[migration] bills/categories deleted_at columns added');
      },
    },
    {
      version: 'v0.57',
      description: 'autopay: suggestions and auto-mark paid',
      dependsOn: ['v0.56'],
      run: function () {
        const billCols = db
          .prepare('PRAGMA table_info(bills)')
          .all()
          .map((c) => c.name);
        if (!billCols.includes('auto_mark_paid')) {
          db.exec('ALTER TABLE bills ADD COLUMN auto_mark_paid INTEGER NOT NULL DEFAULT 0');
        }
        db.exec(`
          CREATE TABLE IF NOT EXISTS autopay_suggestion_dismissals (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
            bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
            year INTEGER NOT NULL CHECK(year BETWEEN 2000 AND 2100),
            month INTEGER NOT NULL CHECK(month BETWEEN 1 AND 12),
            dismissed_at TEXT NOT NULL DEFAULT (datetime('now')),
            UNIQUE(user_id, bill_id, year, month)
          );
          CREATE INDEX IF NOT EXISTS idx_autopay_suggestion_dismissals_user_month
            ON autopay_suggestion_dismissals(user_id, year, month);
        `);
        console.log('[migration] autopay auto_mark_paid and suggestion dismissals ensured');
      },
    },
    {
      version: 'v0.58',
      description: 'bills: saved bill templates',
      dependsOn: ['v0.57'],
      run: function () {
        db.exec(`
          CREATE TABLE IF NOT EXISTS bill_templates (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
            name TEXT NOT NULL,
            data TEXT NOT NULL,
            created_at TEXT DEFAULT (datetime('now')),
            updated_at TEXT DEFAULT (datetime('now')),
            UNIQUE(user_id, name)
          );
          CREATE INDEX IF NOT EXISTS idx_bill_templates_user_name
            ON bill_templates(user_id, name);
        `);
        console.log('[migration] bill_templates table ensured');
      },
    },
    {
      version: 'v0.59',
      description: 'payments: source metadata for future transaction matching',
      dependsOn: ['v0.58'],
      run: function () {
        const cols = db
          .prepare('PRAGMA table_info(payments)')
          .all()
          .map((c) => c.name);
        if (!cols.includes('payment_source')) {
          db.exec("ALTER TABLE payments ADD COLUMN payment_source TEXT NOT NULL DEFAULT 'manual'");
        }
        if (!cols.includes('transaction_id')) {
          db.exec('ALTER TABLE payments ADD COLUMN transaction_id INTEGER');
        }
        console.log('[migration] payments: source metadata columns added');
      },
    },
    {
      version: 'v0.60',
      description: 'transactions: shared transaction foundation tables',
      dependsOn: ['v0.59'],
      run: function () {
        ensureTransactionFoundationSchema(db);
        console.log('[migration] transaction foundation tables ensured');
      },
    },
    {
      version: 'v0.61',
      description: 'payments: one active payment per linked transaction',
      dependsOn: ['v0.60'],
      run: function () {
        db.exec(`
          CREATE UNIQUE INDEX IF NOT EXISTS idx_payments_transaction_active
            ON payments(transaction_id)
            WHERE transaction_id IS NOT NULL AND deleted_at IS NULL
        `);
        console.log('[migration] payments: transaction active unique index ensured');
      },
    },
    {
      version: 'v0.62',
      description: 'matches: rejected transaction match suggestions',
      dependsOn: ['v0.61'],
      run: function () {
        db.exec(`
          CREATE TABLE IF NOT EXISTS match_suggestion_rejections (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
            transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
            bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
            rejected_at TEXT NOT NULL DEFAULT (datetime('now')),
            UNIQUE(user_id, transaction_id, bill_id)
          );
          CREATE INDEX IF NOT EXISTS idx_match_suggestion_rejections_user
            ON match_suggestion_rejections(user_id, transaction_id, bill_id);
        `);
        console.log('[migration] match suggestion rejections table ensured');
      },
    },
    {
      version: 'v0.63',
      description: 'bills: subscription metadata fields',
      dependsOn: ['v0.62'],
      run: function () {
        const cols = db
          .prepare('PRAGMA table_info(bills)')
          .all()
          .map((c) => c.name);
        if (!cols.includes('is_subscription'))
          db.exec('ALTER TABLE bills ADD COLUMN is_subscription INTEGER NOT NULL DEFAULT 0');
        if (!cols.includes('subscription_type'))
          db.exec('ALTER TABLE bills ADD COLUMN subscription_type TEXT');
        if (!cols.includes('reminder_days_before'))
          db.exec('ALTER TABLE bills ADD COLUMN reminder_days_before INTEGER NOT NULL DEFAULT 3');
        if (!cols.includes('subscription_source'))
          db.exec(
            "ALTER TABLE bills ADD COLUMN subscription_source TEXT NOT NULL DEFAULT 'manual'",
          );
        if (!cols.includes('subscription_detected_at'))
          db.exec('ALTER TABLE bills ADD COLUMN subscription_detected_at TEXT');
        db.exec(
          'CREATE INDEX IF NOT EXISTS idx_bills_user_subscription ON bills(user_id, is_subscription, active)',
        );
        console.log('[migration] bills: subscription metadata columns added');
      },
    },
    {
      version: 'v0.64',
      description: 'financial_accounts: monitored flag for bill matching',
      dependsOn: ['v0.63'],
      run: function () {
        const cols = db
          .prepare('PRAGMA table_info(financial_accounts)')
          .all()
          .map((c) => c.name);
        if (!cols.includes('monitored')) {
          db.exec('ALTER TABLE financial_accounts ADD COLUMN monitored INTEGER NOT NULL DEFAULT 1');
          console.log('[migration] financial_accounts: monitored column added');
        }
      },
    },
    {
      version: 'v0.65',
      description: 'subscription_catalog: top-200 known subscription services',
      dependsOn: ['v0.64'],
      run: function () {
        runSubscriptionCatalogMigration(db);
      },
    },
    {
      version: 'v0.66',
      description: 'declined_subscription_hints: per-user dismissed recommendation store',
      dependsOn: ['v0.65'],
      run: function () {
        db.exec(`
          CREATE TABLE IF NOT EXISTS declined_subscription_hints (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
            decline_key TEXT NOT NULL,
            declined_at TEXT NOT NULL DEFAULT (datetime('now')),
            UNIQUE(user_id, decline_key)
          );
          CREATE INDEX IF NOT EXISTS idx_declined_hints_user
            ON declined_subscription_hints(user_id);
        `);
      },
    },
    {
      version: 'v0.67',
      description: 'bill_merchant_rules: persistent merchant→bill auto-match rules',
      dependsOn: ['v0.66'],
      run: function () {
        db.exec(`
          CREATE TABLE IF NOT EXISTS bill_merchant_rules (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
            bill_id INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
            merchant TEXT NOT NULL,
            created_at TEXT NOT NULL DEFAULT (datetime('now')),
            UNIQUE(user_id, bill_id, merchant)
          );
          CREATE INDEX IF NOT EXISTS idx_bill_merchant_rules_user
            ON bill_merchant_rules(user_id);
        `);
      },
    },
    {
      version: 'v0.68',
      description: 'advisory_non_bill_filters: 5k advisory patterns + bill-like override terms',
      dependsOn: ['v0.67'],
      run: function () {
        runAdvisoryFiltersMigration(db);
      },
    },
    {
      version: 'v0.69',
      description: 'subscription_catalog v2: 90 new services + category fixes',
      dependsOn: ['v0.68'],
      run: function () {
        runSubscriptionCatalogV2Migration(db);
      },
    },
    {
      version: 'v0.70',
      description: 'monthly_bill_state: add snoozed_until for overdue command center',
      dependsOn: ['v0.69'],
      run: function () {
        const cols = db
          .prepare('PRAGMA table_info(monthly_bill_state)')
          .all()
          .map((c) => c.name);
        if (!cols.includes('snoozed_until'))
          db.exec('ALTER TABLE monthly_bill_state ADD COLUMN snoozed_until TEXT');
      },
    },
    {
      version: 'v0.71',
      description: 'bills: add drift_snoozed_until; users: add notify_amount_change',
      dependsOn: ['v0.70'],
      run: function () {
        const billCols = db
          .prepare('PRAGMA table_info(bills)')
          .all()
          .map((c) => c.name);
        const userCols = db
          .prepare('PRAGMA table_info(users)')
          .all()
          .map((c) => c.name);
        if (!billCols.includes('drift_snoozed_until'))
          db.exec('ALTER TABLE bills ADD COLUMN drift_snoozed_until TEXT');
        if (!userCols.includes('notify_amount_change'))
          db.exec('ALTER TABLE users ADD COLUMN notify_amount_change INTEGER NOT NULL DEFAULT 1');
      },
    },
    {
      version: 'v0.72',
      description: 'bills: persistent tracker sort order',
      dependsOn: ['v0.71'],
      run: function () {
        const cols = db
          .prepare('PRAGMA table_info(bills)')
          .all()
          .map((c) => c.name);
        if (!cols.includes('sort_order'))
          db.exec('ALTER TABLE bills ADD COLUMN sort_order INTEGER');
        db.exec(
          'CREATE INDEX IF NOT EXISTS idx_bills_user_sort ON bills(user_id, active, sort_order, due_day)',
        );
      },
    },
    {
      version: 'v0.73',
      description: 'add snowball_plans table for plan lifecycle + history',
      dependsOn: ['v0.72'],
      run: function () {
        db.exec(`
          CREATE TABLE IF NOT EXISTS snowball_plans (
            id            INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id       INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
            name          TEXT    NOT NULL DEFAULT 'Snowball Plan',
            method        TEXT    NOT NULL DEFAULT 'snowball',
            status        TEXT    NOT NULL DEFAULT 'active',
            started_at    TEXT    NOT NULL DEFAULT (datetime('now')),
            paused_at     TEXT,
            completed_at  TEXT,
            extra_payment REAL    NOT NULL DEFAULT 0,
            plan_snapshot TEXT    NOT NULL,
            notes         TEXT,
            created_at    TEXT    NOT NULL DEFAULT (datetime('now')),
            updated_at    TEXT    NOT NULL DEFAULT (datetime('now'))
          )
        `);
        db.exec(
          'CREATE INDEX IF NOT EXISTS idx_snowball_plans_user ON snowball_plans(user_id, status, created_at)',
        );
      },
    },
    {
      version: 'v0.74',
      description: 'subscription_catalog: Claude.ai Anthropic matching',
      dependsOn: ['v0.73'],
      run: function () {
        db.prepare(
          `
          UPDATE subscription_catalog
          SET name = 'Claude.ai',
              category = 'AI',
              subscription_type = 'software',
              website = 'https://claude.ai/upgrade',
              domain = 'anthropic.com'
          WHERE name IN ('Claude', 'Claude.ai')
             OR domain IN ('claude.ai', 'anthropic.com')
        `,
        ).run();

        db.prepare(
          `
          INSERT INTO subscription_catalog (rank, name, category, subscription_type, website, domain)
          SELECT 94, 'Claude.ai', 'AI', 'software', 'https://claude.ai/upgrade', 'anthropic.com'
          WHERE NOT EXISTS (
            SELECT 1 FROM subscription_catalog
            WHERE name = 'Claude.ai' OR domain IN ('claude.ai', 'anthropic.com')
          )
        `,
        ).run();
      },
    },
    {
      version: 'v0.75',
      description: 'categories: persistent sort order',
      dependsOn: ['v0.74'],
      run: function () {
        const cols = db
          .prepare('PRAGMA table_info(categories)')
          .all()
          .map((c) => c.name);
        if (!cols.includes('sort_order'))
          db.exec('ALTER TABLE categories ADD COLUMN sort_order INTEGER');
        db.exec(
          'CREATE INDEX IF NOT EXISTS idx_categories_user_sort ON categories(user_id, sort_order, name)',
        );
      },
    },
    {
      version: 'v0.76',
      description: 'bills: canonical billing schedule cleanup',
      dependsOn: ['v0.75'],
      run: function () {
        const cols = db
          .prepare('PRAGMA table_info(bills)')
          .all()
          .map((c) => c.name);
        if (
          !cols.includes('cycle_type') ||
          !cols.includes('cycle_day') ||
          !cols.includes('billing_cycle')
        )
          return;

        db.exec(`
          UPDATE bills
          SET cycle_type = CASE
            WHEN LOWER(COALESCE(cycle_type, '')) IN ('', 'monthly')
              AND LOWER(COALESCE(billing_cycle, '')) = 'quarterly'
              THEN 'quarterly'
            WHEN LOWER(COALESCE(cycle_type, '')) IN ('', 'monthly')
              AND LOWER(COALESCE(billing_cycle, '')) IN ('annually', 'annual')
              THEN 'annual'
            WHEN LOWER(COALESCE(cycle_type, '')) IN ('monthly', 'weekly', 'biweekly', 'quarterly', 'annual')
              THEN LOWER(cycle_type)
            ELSE 'monthly'
          END;

          UPDATE bills
          SET cycle_day = CASE
            WHEN cycle_type IN ('weekly', 'biweekly')
              AND LOWER(COALESCE(cycle_day, '')) IN ('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday')
              THEN LOWER(cycle_day)
            WHEN cycle_type IN ('weekly', 'biweekly')
              THEN 'monday'
            WHEN cycle_type IN ('quarterly', 'annual')
              AND CAST(cycle_day AS INTEGER) BETWEEN 1 AND 12
              THEN CAST(CAST(cycle_day AS INTEGER) AS TEXT)
            WHEN cycle_type IN ('quarterly', 'annual')
              THEN '1'
            WHEN cycle_type = 'monthly'
              AND CAST(cycle_day AS INTEGER) BETWEEN 1 AND 31
              THEN CAST(CAST(cycle_day AS INTEGER) AS TEXT)
            ELSE CAST(CASE WHEN due_day BETWEEN 1 AND 31 THEN due_day ELSE 1 END AS TEXT)
          END;

          UPDATE bills
          SET billing_cycle = CASE
            WHEN cycle_type = 'quarterly' THEN 'quarterly'
            WHEN cycle_type = 'annual' THEN 'annually'
            WHEN cycle_type IN ('weekly', 'biweekly') THEN 'irregular'
            ELSE 'monthly'
          END;
        `);
      },
    },
    {
      version: 'v0.77',
      description: 'encrypt SMTP password at rest',
      dependsOn: ['v0.76'],
      run: function () {
        try {
          const { decryptSecret, encryptSecret } = require('../../services/encryptionService.cts');
          const row = db
            .prepare("SELECT value FROM settings WHERE key = 'notify_smtp_password'")
            .get();
          if (row?.value) {
            try {
              decryptSecret(row.value); // already encrypted — skip
            } catch {
              // plaintext — encrypt it
              db.prepare(
                "UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'notify_smtp_password'",
              ).run(encryptSecret(row.value));
            }
          }
        } catch (err) {
          console.warn('[v0.77] SMTP password encryption migration failed:', err.message);
        }
      },
    },
    {
      version: 'v0.78',
      description: 're-encrypt secrets from SHA-256 to HKDF key derivation',
      dependsOn: ['v0.77'],
      run: function () {
        try {
          const { decryptSecret, encryptSecret } = require('../../services/encryptionService.cts');

          // Re-encrypt SimpleFIN tokens in data_sources
          const sources = db
            .prepare(
              "SELECT id, encrypted_secret FROM data_sources WHERE encrypted_secret IS NOT NULL AND encrypted_secret NOT LIKE 'v2:%'",
            )
            .all();
          const updateSource = db.prepare(
            'UPDATE data_sources SET encrypted_secret = ? WHERE id = ?',
          );
          for (const row of sources) {
            try {
              updateSource.run(encryptSecret(decryptSecret(row.encrypted_secret)), row.id);
            } catch (err) {
              console.warn(`[v0.78] Could not re-encrypt data_source id=${row.id}:`, err.message);
            }
          }

          // Re-encrypt SMTP password
          const smtp = db
            .prepare("SELECT value FROM settings WHERE key = 'notify_smtp_password'")
            .get();
          if (smtp?.value && !smtp.value.startsWith('v2:')) {
            try {
              db.prepare(
                "UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'notify_smtp_password'",
              ).run(encryptSecret(decryptSecret(smtp.value)));
            } catch (err) {
              console.warn('[v0.78] Could not re-encrypt SMTP password:', err.message);
            }
          }
        } catch (err) {
          console.warn('[v0.78] HKDF re-encryption migration failed:', err.message);
        }
      },
    },
    {
      version: 'v0.79',
      description: 'encrypt OIDC client secret at rest',
      dependsOn: ['v0.78'],
      run: function () {
        try {
          const { decryptSecret, encryptSecret } = require('../../services/encryptionService.cts');
          const row = db
            .prepare("SELECT value FROM settings WHERE key = 'oidc_client_secret'")
            .get();
          if (row?.value) {
            try {
              decryptSecret(row.value); // already encrypted — skip
            } catch {
              // plaintext — encrypt it
              db.prepare(
                "UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'oidc_client_secret'",
              ).run(encryptSecret(row.value));
            }
          }
        } catch (err) {
          console.warn('[v0.79] OIDC client secret encryption migration failed:', err.message);
        }
      },
    },
    {
      version: 'v0.80',
      description: 'users: push notification columns (ntfy / Gotify / Discord / Telegram)',
      dependsOn: ['v0.79'],
      run: function () {
        const cols = db
          .prepare('PRAGMA table_info(users)')
          .all()
          .map((c) => c.name);
        const add = (col, def) => {
          if (!cols.includes(col)) db.exec(`ALTER TABLE users ADD COLUMN ${col} ${def}`);
        };
        add('notify_push_enabled', 'INTEGER NOT NULL DEFAULT 0');
        add('push_channel', 'TEXT');
        add('push_url', 'TEXT');
        add('push_token', 'TEXT');
        add('push_chat_id', 'TEXT');
        console.log('[v0.80] push notification columns added');
      },
    },
    {
      version: 'v0.81',
      description:
        'bill_merchant_rules: composite index on (user_id, bill_id) for faster EXISTS lookups',
      dependsOn: ['v0.80'],
      run: function () {
        db.exec(`
          CREATE INDEX IF NOT EXISTS idx_bill_merchant_rules_user_bill
            ON bill_merchant_rules(user_id, bill_id)
        `);
        console.log('[v0.81] bill_merchant_rules composite index added');
      },
    },
    {
      version: 'v0.82',
      description: 'payments: normalise auto_match source to provider_sync',
      dependsOn: ['v0.81'],
      run: function () {
        const result = db
          .prepare(
            "UPDATE payments SET payment_source = 'provider_sync' WHERE payment_source = 'auto_match'",
          )
          .run();
        console.log(`[v0.82] Normalised ${result.changes} auto_match payment(s) to provider_sync`);
      },
    },
    {
      version: 'v0.83',
      description:
        'bill_merchant_rules: auto_attribute_late flag for bills that always post after month end',
      dependsOn: ['v0.82'],
      run: function () {
        const cols = db
          .prepare('PRAGMA table_info(bill_merchant_rules)')
          .all()
          .map((c) => c.name);
        if (!cols.includes('auto_attribute_late')) {
          db.exec(
            'ALTER TABLE bill_merchant_rules ADD COLUMN auto_attribute_late INTEGER NOT NULL DEFAULT 0',
          );
          console.log('[v0.83] bill_merchant_rules.auto_attribute_late added');
        }
      },
    },
    {
      version: 'v0.84',
      description:
        'user_login_history: encrypt ip/useragent at rest + add location + keep 10 records',
      dependsOn: ['v0.83'],
      run: function () {
        const { encryptSecret, decryptSecret } = require('../../services/encryptionService.cts');
        const cols = db
          .prepare('PRAGMA table_info(user_login_history)')
          .all()
          .map((c) => c.name);

        // Add location columns
        const newCols = ['location_city', 'location_country', 'location_region', 'location_isp'];
        for (const col of newCols) {
          if (!cols.includes(col)) db.exec(`ALTER TABLE user_login_history ADD COLUMN ${col} TEXT`);
        }

        // Encrypt existing plaintext ip_address and user_agent rows
        const rows = db.prepare('SELECT id, ip_address, user_agent FROM user_login_history').all();
        const updIp = db.prepare('UPDATE user_login_history SET ip_address=? WHERE id=?');
        const updUa = db.prepare('UPDATE user_login_history SET user_agent=? WHERE id=?');
        for (const row of rows) {
          if (row.ip_address && !row.ip_address.startsWith('v2:')) {
            try {
              updIp.run(encryptSecret(row.ip_address), row.id);
            } catch {}
          }
          if (row.user_agent && !row.user_agent.startsWith('v2:')) {
            try {
              updUa.run(encryptSecret(row.user_agent), row.id);
            } catch {}
          }
        }
        console.log(`[v0.84] login history: location columns added, ${rows.length} rows encrypted`);
      },
    },
    {
      version: 'v0.85',
      description:
        'user_login_history: failed attempt tracking + session fingerprint for current-session detection',
      dependsOn: ['v0.84'],
      run: function () {
        const cols = db
          .prepare('PRAGMA table_info(user_login_history)')
          .all()
          .map((c) => c.name);
        if (!cols.includes('success')) {
          db.exec('ALTER TABLE user_login_history ADD COLUMN success INTEGER NOT NULL DEFAULT 1');
        }
        if (!cols.includes('session_fingerprint')) {
          db.exec('ALTER TABLE user_login_history ADD COLUMN session_fingerprint TEXT');
        }
        console.log('[v0.85] user_login_history: success + session_fingerprint columns added');
      },
    },
    {
      version: 'v0.86',
      description: 'users: TOTP/authenticator 2FA columns + totp_challenges table',
      dependsOn: ['v0.85'],
      run: function () {
        const cols = db
          .prepare('PRAGMA table_info(users)')
          .all()
          .map((c) => c.name);
        if (!cols.includes('totp_enabled'))
          db.exec('ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0');
        if (!cols.includes('totp_secret')) db.exec('ALTER TABLE users ADD COLUMN totp_secret TEXT');
        if (!cols.includes('totp_recovery_codes'))
          db.exec('ALTER TABLE users ADD COLUMN totp_recovery_codes TEXT');

        db.exec(`
          CREATE TABLE IF NOT EXISTS totp_challenges (
            id         TEXT PRIMARY KEY,
            user_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
            expires_at TEXT    NOT NULL,
            created_at TEXT    NOT NULL DEFAULT (datetime('now'))
          )
        `);
        console.log('[v0.86] users: TOTP columns + totp_challenges table');
      },
    },
    {
      version: 'v0.87',
      description:
        'spending: category assignment on transactions + rules + budgets + default categories',
      dependsOn: ['v0.86'],
      run: function () {
        // spending_category_id on transactions
        const txCols = db
          .prepare('PRAGMA table_info(transactions)')
          .all()
          .map((c) => c.name);
        if (!txCols.includes('spending_category_id'))
          db.exec(
            'ALTER TABLE transactions ADD COLUMN spending_category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL',
          );

        // spending category rules (merchant → category)
        db.exec(`
          CREATE TABLE IF NOT EXISTS spending_category_rules (
            id          INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id     INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
            category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
            merchant    TEXT NOT NULL,
            created_at  TEXT NOT NULL DEFAULT (datetime('now')),
            UNIQUE(user_id, merchant)
          )
        `);

        // monthly spending budgets
        db.exec(`
          CREATE TABLE IF NOT EXISTS spending_budgets (
            id          INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id     INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
            category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
            year        INTEGER NOT NULL,
            month       INTEGER NOT NULL,
            amount      REAL    NOT NULL DEFAULT 0,
            updated_at  TEXT    NOT NULL DEFAULT (datetime('now')),
            UNIQUE(user_id, category_id, year, month)
          )
        `);

        // Seed default spending categories for each user that has none yet
        const DEFAULTS = [
          'Groceries',
          'Dining',
          'Fuel & Transport',
          'Shopping',
          'Entertainment',
          'Health',
          'Travel',
          'Other',
        ];
        const users = db.prepare("SELECT id FROM users WHERE role='user' AND active=1").all();
        const insert = db.prepare(
          'INSERT OR IGNORE INTO categories (user_id, name, sort_order, is_seeded) VALUES (?, ?, ?, 1)',
        );
        for (const user of users) {
          const existing = db
            .prepare('SELECT COUNT(*) AS n FROM categories WHERE user_id=? AND deleted_at IS NULL')
            .get(user.id);
          if ((existing?.n ?? 0) === 0) {
            DEFAULTS.forEach((name, i) => insert.run(user.id, name, 100 + i));
          }
        }

        console.log(
          '[v0.87] spending: transactions.spending_category_id, spending_category_rules, spending_budgets',
        );
      },
    },
    {
      version: 'v0.88',
      description: 'categories: spending_enabled flag to separate bill vs spending categories',
      dependsOn: ['v0.87'],
      run: function () {
        const cols = db
          .prepare('PRAGMA table_info(categories)')
          .all()
          .map((c) => c.name);
        if (!cols.includes('spending_enabled'))
          db.exec('ALTER TABLE categories ADD COLUMN spending_enabled INTEGER NOT NULL DEFAULT 0');

        // Mark the v0.87-seeded defaults as spending-enabled
        const SPENDING_DEFAULTS = [
          'Groceries',
          'Dining',
          'Fuel & Transport',
          'Shopping',
          'Entertainment',
          'Health',
          'Travel',
          'Other',
        ];
        const placeholder = SPENDING_DEFAULTS.map(() => '?').join(',');
        db.exec(
          `UPDATE categories SET spending_enabled=1 WHERE is_seeded=1 AND name IN (${SPENDING_DEFAULTS.map((n) => `'${n.replace("'", "''")}'`).join(',')})`,
        );

        // Mark any category already linked to a spending rule as spending-enabled
        try {
          db.exec(`
            UPDATE categories SET spending_enabled=1
            WHERE id IN (SELECT DISTINCT category_id FROM spending_category_rules)
          `);
        } catch {
          /* spending_category_rules may not exist on legacy paths */
        }

        console.log('[v0.88] categories.spending_enabled added, seeded defaults marked');
      },
    },
    {
      version: 'v0.89',
      description:
        'categories: seed spending defaults for users who had existing categories before v0.87',
      dependsOn: ['v0.88'],
      run: function () {
        const SPENDING_DEFAULTS = [
          'Groceries',
          'Dining',
          'Fuel & Transport',
          'Shopping',
          'Entertainment',
          'Health',
          'Travel',
          'Other',
        ];
        const users = db.prepare("SELECT id FROM users WHERE role='user' AND active=1").all();
        const insert = db.prepare(`
          INSERT OR IGNORE INTO categories (user_id, name, sort_order, is_seeded, spending_enabled)
          VALUES (?, ?, ?, 1, 1)
        `);
        let seeded = 0;
        for (const user of users) {
          const hasSpending = db
            .prepare(
              'SELECT 1 FROM categories WHERE user_id=? AND spending_enabled=1 AND deleted_at IS NULL LIMIT 1',
            )
            .get(user.id);
          if (!hasSpending) {
            SPENDING_DEFAULTS.forEach((name, i) => {
              insert.run(user.id, name, 200 + i);
              seeded++;
            });
          }
        }
        console.log(
          `[v0.89] spending defaults seeded for users missing them (${seeded} categories inserted)`,
        );
      },
    },
    {
      version: 'v0.90',
      description: 're-normalize merchant rules after & fix; ensure rejection expiry column',
      dependsOn: ['v0.89'],
      run: function () {
        const { normalizeMerchant } = require('../../services/subscriptionService.cts');

        // Re-normalize bill_merchant_rules stored under old normalization ("at t" → "att")
        let billFixed = 0;
        try {
          const rules = db.prepare('SELECT id, merchant FROM bill_merchant_rules').all();
          const updBill = db.prepare('UPDATE bill_merchant_rules SET merchant=? WHERE id=?');
          for (const r of rules) {
            try {
              const fixed = normalizeMerchant(r.merchant);
              if (fixed && fixed !== r.merchant) {
                updBill.run(fixed, r.id);
                billFixed++;
              }
            } catch {
              /* skip invalid entries */
            }
          }
        } catch (err) {
          console.warn('[v0.90] bill_merchant_rules re-normalize skipped:', err.message);
        }

        // Re-normalize spending_category_rules
        try {
          const srules = db.prepare('SELECT id, merchant FROM spending_category_rules').all();
          const updSpend = db.prepare('UPDATE spending_category_rules SET merchant=? WHERE id=?');
          let spendFixed = 0;
          for (const r of srules) {
            const fixed = normalizeMerchant(r.merchant);
            if (fixed !== r.merchant) {
              updSpend.run(fixed, r.id);
              spendFixed++;
            }
          }
          if (spendFixed)
            console.log(`[v0.90] spending_category_rules: ${spendFixed} re-normalized`);
        } catch {
          /* spending_category_rules may not exist on legacy DBs */
        }

        // Ensure match_suggestion_rejections has created_at for expiry queries
        const rejCols = db
          .prepare('PRAGMA table_info(match_suggestion_rejections)')
          .all()
          .map((c) => c.name);
        if (!rejCols.includes('created_at')) {
          // Static default — existing rejections get a past date so they expire immediately on next cleanup
          db.exec(
            "ALTER TABLE match_suggestion_rejections ADD COLUMN created_at TEXT NOT NULL DEFAULT '2000-01-01'",
          );
        }

        console.log(
          `[v0.90] merchant rules re-normalized (${billFixed} bill rules updated), rejection expiry column ensured`,
        );
      },
    },
    {
      version: 'v0.91',
      description:
        'performance: composite indexes on user_id+deleted_at for categories, bills, payments',
      dependsOn: ['v0.90'],
      run: function () {
        db.exec(`
          CREATE INDEX IF NOT EXISTS idx_categories_user_deleted ON categories(user_id, deleted_at);
          CREATE INDEX IF NOT EXISTS idx_bills_user_deleted      ON bills(user_id, deleted_at);
          CREATE INDEX IF NOT EXISTS idx_bills_user_active       ON bills(user_id, active, deleted_at);
          CREATE INDEX IF NOT EXISTS idx_payments_bill_deleted   ON payments(bill_id, deleted_at);
        `);
        console.log('[v0.91] composite indexes created on categories, bills, payments');
      },
    },
    {
      version: 'v0.92',
      description:
        'auth: WebAuthn/FIDO2 security key support — webauthn_credentials + webauthn_challenges tables',
      dependsOn: ['v0.91'],
      run: function () {
        const cols = db
          .prepare('PRAGMA table_info(users)')
          .all()
          .map((c) => c.name);
        if (!cols.includes('webauthn_enabled'))
          db.exec('ALTER TABLE users ADD COLUMN webauthn_enabled INTEGER NOT NULL DEFAULT 0');
        if (!cols.includes('webauthn_user_id'))
          db.exec('ALTER TABLE users ADD COLUMN webauthn_user_id TEXT');

        db.exec(`
          CREATE TABLE IF NOT EXISTS webauthn_credentials (
            id              INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id         INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
            credential_id   TEXT    NOT NULL UNIQUE,
            public_key      TEXT    NOT NULL,
            sign_count      INTEGER NOT NULL DEFAULT 0,
            transports      TEXT,
            backup_eligible INTEGER NOT NULL DEFAULT 0,
            backup_state    INTEGER NOT NULL DEFAULT 0,
            credential_name TEXT    NOT NULL DEFAULT 'Security Key',
            aaguid          TEXT,
            created_at      TEXT    NOT NULL DEFAULT (datetime('now')),
            updated_at      TEXT    NOT NULL DEFAULT (datetime('now'))
          );
          CREATE INDEX IF NOT EXISTS idx_webauthn_creds_user ON webauthn_credentials(user_id);

          CREATE TABLE IF NOT EXISTS webauthn_challenges (
            id             TEXT    PRIMARY KEY,
            user_id        INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
            challenge_type TEXT    NOT NULL CHECK(challenge_type IN ('registration','authentication','login')),
            challenge      TEXT    NOT NULL DEFAULT '',
            expires_at     TEXT    NOT NULL,
            created_at     TEXT    NOT NULL DEFAULT (datetime('now'))
          );
          CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_user    ON webauthn_challenges(user_id);
          CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at);
        `);
        console.log('[v0.92] WebAuthn tables + users columns added');
      },
    },
    {
      version: 'v0.93',
      description:
        'bills: interest_accrued_month; payments: interest_delta; transactions: stable provider key + dedupe index',
      dependsOn: ['v0.92'],
      run: function () {
        // 1. Track the calendar month when interest was last applied to a debt bill
        //    so computeBalanceDelta can skip interest if it was already charged this month.
        const billCols = db
          .prepare('PRAGMA table_info(bills)')
          .all()
          .map((c) => c.name);
        if (!billCols.includes('interest_accrued_month')) {
          db.exec('ALTER TABLE bills ADD COLUMN interest_accrued_month TEXT');
          console.log('[v0.93] bills.interest_accrued_month column added');
        }

        // 2. Track the interest component of each payment separately so delete/restore
        //    can handle it without double-charging interest.
        const paymentCols = db
          .prepare('PRAGMA table_info(payments)')
          .all()
          .map((c) => c.name);
        if (!paymentCols.includes('interest_delta')) {
          db.exec('ALTER TABLE payments ADD COLUMN interest_delta REAL');
          console.log('[v0.93] payments.interest_delta column added');
        }

        // 3. Strip the data_source_id from existing provider_transaction_id keys so
        //    they survive disconnect/reconnect. Old: "simplefin:{dsId}:{acctId}:{txId}"
        //    New: "simplefin:{acctId}:{txId}".
        //    Only rows where the segment after "simplefin:" is a numeric id are migrated.
        db.exec(`
          UPDATE transactions
          SET provider_transaction_id =
            'simplefin:' || SUBSTR(
              provider_transaction_id,
              INSTR(SUBSTR(provider_transaction_id, 11), ':') + 11
            )
          WHERE provider_transaction_id LIKE 'simplefin:%'
            AND CAST(
              SUBSTR(provider_transaction_id, 11,
                INSTR(SUBSTR(provider_transaction_id, 11), ':') - 1)
              AS INTEGER) > 0
        `);
        console.log('[v0.93] transactions: stripped data_source_id from provider_transaction_id');

        // 4. Dedup: after the key change, users who disconnected and reconnected now
        //    have duplicate (user_id, provider_transaction_id) pairs. Keep the best row
        //    (prefer linked rows; break ties by most-recent created_at).
        db.exec(`
          DELETE FROM transactions
          WHERE id IN (
            SELECT id FROM (
              SELECT id,
                ROW_NUMBER() OVER (
                  PARTITION BY user_id, provider_transaction_id
                  ORDER BY (data_source_id IS NULL) ASC, created_at DESC
                ) AS rn
              FROM transactions
              WHERE provider_transaction_id IS NOT NULL
            )
            WHERE rn > 1
          )
        `);
        console.log(
          '[v0.93] transactions: removed duplicate provider keys from disconnect/reconnect',
        );

        // 5. Replace the old dedupe index (data_source_id, provider_transaction_id)
        //    with a user-scoped one (user_id, provider_transaction_id) so reconnect
        //    with a new data_source_id still deduplicates correctly.
        db.exec(`
          DROP INDEX IF EXISTS idx_transactions_provider_dedupe;
          CREATE UNIQUE INDEX idx_transactions_provider_dedupe
            ON transactions (user_id, provider_transaction_id)
            WHERE provider_transaction_id IS NOT NULL;
        `);
        console.log(
          '[v0.93] transactions: dedupe index changed to (user_id, provider_transaction_id)',
        );
      },
    },
    {
      version: 'v0.94',
      description: 'security: session token hashing + geolocation opt-in setting',
      run() {
        // Seed the geolocation setting (default off) for existing installations
        db.prepare(
          "INSERT OR IGNORE INTO settings (key, value) VALUES ('geolocation_enabled', 'false')",
        ).run();
        console.log('[v0.94] geolocation_enabled setting seeded');

        // All existing plaintext session IDs are invalidated so everyone re-authenticates.
        // Going forward, sessions.id stores SHA-256(token); the raw token stays in the cookie.
        const count = db.prepare('SELECT COUNT(*) as n FROM sessions').get().n;
        db.exec('DELETE FROM sessions');
        console.log(
          `[v0.94] sessions: cleared ${count} existing plaintext sessions (re-login required)`,
        );
      },
    },
    {
      version: 'v0.95',
      description: 'subscription_catalog: bank descriptors + pricing from 2026 researched dataset',
      run() {
        // 1. Add new columns to subscription_catalog
        const cols = db
          .prepare('PRAGMA table_info(subscription_catalog)')
          .all()
          .map((c) => c.name);
        if (!cols.includes('subcategory'))
          db.exec('ALTER TABLE subscription_catalog ADD COLUMN subcategory TEXT');
        if (!cols.includes('starting_monthly_usd'))
          db.exec('ALTER TABLE subscription_catalog ADD COLUMN starting_monthly_usd REAL');
        if (!cols.includes('starting_annual_usd'))
          db.exec('ALTER TABLE subscription_catalog ADD COLUMN starting_annual_usd REAL');
        if (!cols.includes('price_notes'))
          db.exec('ALTER TABLE subscription_catalog ADD COLUMN price_notes TEXT');

        // 2. Create descriptors table (bank statement strings + slang/nicknames per service)
        db.exec(`
          CREATE TABLE IF NOT EXISTS subscription_catalog_descriptors (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            catalog_id INTEGER NOT NULL REFERENCES subscription_catalog(id) ON DELETE CASCADE,
            descriptor TEXT NOT NULL,
            descriptor_type TEXT NOT NULL CHECK(descriptor_type IN ('bank', 'slang'))
          );
          CREATE INDEX IF NOT EXISTS idx_scd_catalog_id ON subscription_catalog_descriptors(catalog_id);
        `);

        // 3. Load researched JSON — path is relative to this file's directory (db/)
        const path = require('path');
        // eslint-disable-next-line global-require
        const { subscriptions } = require(
          path.join(__dirname, '../../docs/top_200_us_subscriptions_researched_2026-06-06.json'),
        );

        // Map rich category labels from the JSON to our internal subscription_type values
        const CATEGORY_TYPE = {
          'Video Streaming': 'streaming',
          'Sports Streaming': 'streaming',
          'Live TV Streaming': 'streaming',
          'Sports Media': 'streaming',
          'Music & Audio': 'music',
          Podcasts: 'music',
          Gaming: 'gaming',
          'News & Magazines': 'news',
          'Fitness & Wellness': 'fitness',
          'Meditation & Wellness': 'fitness',
          'Sleep & Wellness': 'fitness',
          'Software & Productivity': 'software',
          'Software & Design': 'software',
          'Developer Tools': 'software',
          'Finance Software': 'software',
          AI: 'software',
          'Writing & AI': 'software',
          'Cloud & Storage': 'cloud',
          Security: 'security',
          'Food & Meal Kits': 'food',
          'Prepared Meals': 'food',
          'Food Delivery': 'food',
          'Food & Rides': 'food',
          'Coffee & Tea': 'food',
          Snacks: 'food',
          'Grocery & Delivery': 'shopping',
          'Shopping & Delivery': 'shopping',
          'Retail Memberships': 'shopping',
          'Warehouse Clubs': 'shopping',
          'Pet Retail': 'shopping',
          Education: 'education',
          Audiobooks: 'education',
          'Audiobooks & Ebooks': 'education',
          'Ebooks & Audiobooks': 'education',
          Ebooks: 'education',
          'Documents & Ebooks': 'education',
          'Books & Learning': 'education',
          'Books & Subscription Boxes': 'education',
          'Creator & Social': 'other',
          'Creator Media': 'other',
          Dating: 'other',
          'Career & Social': 'other',
        };

        const getByName = db.prepare('SELECT id FROM subscription_catalog WHERE name = ? LIMIT 1');
        const updateCatalog = db.prepare(`
          UPDATE subscription_catalog
          SET rank = ?, category = ?, subcategory = ?, subscription_type = ?,
              website = ?, domain = ?, starting_monthly_usd = ?, starting_annual_usd = ?, price_notes = ?
          WHERE id = ?
        `);
        const insertCatalog = db.prepare(`
          INSERT INTO subscription_catalog
            (rank, name, category, subcategory, subscription_type, website, domain, starting_monthly_usd, starting_annual_usd, price_notes)
          VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        `);
        const clearDescs = db.prepare(
          'DELETE FROM subscription_catalog_descriptors WHERE catalog_id = ?',
        );
        const insertDesc = db.prepare(
          'INSERT INTO subscription_catalog_descriptors (catalog_id, descriptor, descriptor_type) VALUES (?, ?, ?)',
        );

        let nUpdated = 0,
          nInserted = 0,
          nDescs = 0;

        db.transaction(() => {
          for (const sub of subscriptions) {
            const subType = CATEGORY_TYPE[sub.category] || 'other';

            let domain = null;
            try {
              domain = new URL(sub.website || '').hostname.replace(/^www\./, '');
            } catch {}

            const existing = getByName.get(sub.service);
            let catalogId;

            if (existing) {
              updateCatalog.run(
                sub.rank,
                sub.category,
                sub.subcategory || null,
                subType,
                sub.website || null,
                domain,
                sub.starting_monthly_usd ?? null,
                sub.starting_annual_usd ?? null,
                sub.price_notes || null,
                existing.id,
              );
              catalogId = existing.id;
              nUpdated++;
            } else {
              const r = insertCatalog.run(
                sub.rank,
                sub.service,
                sub.category,
                sub.subcategory || null,
                subType,
                sub.website || null,
                domain,
                sub.starting_monthly_usd ?? null,
                sub.starting_annual_usd ?? null,
                sub.price_notes || null,
              );
              catalogId = r.lastInsertRowid;
              nInserted++;
            }

            // Replace all descriptors for this entry
            clearDescs.run(catalogId);
            for (const d of sub.bank_statement_name_variables || []) {
              if (String(d).trim().length >= 3) {
                insertDesc.run(catalogId, String(d).trim(), 'bank');
                nDescs++;
              }
            }
            for (const d of sub.known_names_and_slang || []) {
              if (String(d).trim().length >= 2) {
                insertDesc.run(catalogId, String(d).trim(), 'slang');
                nDescs++;
              }
            }
          }
        })();

        console.log(
          `[v0.95] catalog: ${nUpdated} updated, ${nInserted} inserted, ${nDescs} descriptors added`,
        );
      },
    },
    {
      version: 'v0.96',
      description: 'bills: catalog_id FK; user_catalog_descriptors for custom bank descriptors',
      run() {
        // 1. Add catalog_id to bills
        const billCols = db
          .prepare('PRAGMA table_info(bills)')
          .all()
          .map((c) => c.name);
        if (!billCols.includes('catalog_id')) {
          db.exec(
            'ALTER TABLE bills ADD COLUMN catalog_id INTEGER REFERENCES subscription_catalog(id)',
          );
        }

        // 2. Create per-user custom descriptor table
        db.exec(`
          CREATE TABLE IF NOT EXISTS user_catalog_descriptors (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
            catalog_id INTEGER NOT NULL REFERENCES subscription_catalog(id) ON DELETE CASCADE,
            descriptor TEXT NOT NULL,
            created_at TEXT NOT NULL DEFAULT (datetime('now'))
          );
          CREATE INDEX IF NOT EXISTS idx_ucd_user_catalog ON user_catalog_descriptors(user_id, catalog_id);
        `);

        // 3. Backfill catalog_id for existing subscription bills using name normalization
        function normSimple(s) {
          return String(s || '')
            .toLowerCase()
            .replace(/[^a-z0-9]/g, '');
        }
        const catalogEntries = db.prepare('SELECT id, name FROM subscription_catalog').all();
        const subBills = db
          .prepare(
            'SELECT id, name FROM bills WHERE is_subscription = 1 AND deleted_at IS NULL AND catalog_id IS NULL',
          )
          .all();
        const updateBillCatalog = db.prepare('UPDATE bills SET catalog_id = ? WHERE id = ?');

        let backfilled = 0;
        db.transaction(() => {
          for (const bill of subBills) {
            const billNorm = normSimple(bill.name);
            if (billNorm.length < 3) continue;
            let best = null;
            let bestScore = 0;
            for (const cat of catalogEntries) {
              const catNorm = normSimple(cat.name);
              if (catNorm.length < 3) continue;
              let score = 0;
              if (billNorm === catNorm) score = 2000 + catNorm.length;
              else if (billNorm.includes(catNorm) || catNorm.includes(billNorm))
                score = 1000 + Math.min(billNorm.length, catNorm.length);
              if (score > bestScore) {
                best = cat;
                bestScore = score;
              }
            }
            if (best) {
              updateBillCatalog.run(best.id, bill.id);
              backfilled++;
            }
          }
        })();

        console.log(
          `[v0.96] catalog_id added to bills; ${backfilled}/${subBills.length} subscriptions backfilled`,
        );
      },
    },
    {
      version: 'v0.97',
      description: 'subscription recommendation feedback: per-user learning signals',
      run() {
        db.exec(`
          CREATE TABLE IF NOT EXISTS subscription_recommendation_feedback (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
            catalog_id INTEGER REFERENCES subscription_catalog(id) ON DELETE SET NULL,
            bill_id    INTEGER REFERENCES bills(id) ON DELETE SET NULL,
            merchant   TEXT,
            action     TEXT NOT NULL,
            confidence INTEGER,
            descriptor TEXT,
            metadata_json TEXT,
            created_at TEXT NOT NULL DEFAULT (datetime('now'))
          );
          CREATE INDEX IF NOT EXISTS idx_srf_user_catalog
            ON subscription_recommendation_feedback(user_id, catalog_id);
          CREATE INDEX IF NOT EXISTS idx_srf_user_merchant
            ON subscription_recommendation_feedback(user_id, merchant);
          CREATE INDEX IF NOT EXISTS idx_srf_user_action
            ON subscription_recommendation_feedback(user_id, action);
        `);
        console.log('[v0.97] subscription recommendation feedback table ensured');
      },
    },
    {
      version: 'v0.98',
      description: 'payments: bank override metadata for provisional manual payments',
      run() {
        const cols = db
          .prepare('PRAGMA table_info(payments)')
          .all()
          .map((c) => c.name);
        if (!cols.includes('accounting_excluded')) {
          db.exec('ALTER TABLE payments ADD COLUMN accounting_excluded INTEGER NOT NULL DEFAULT 0');
        }
        if (!cols.includes('exclusion_reason')) {
          db.exec('ALTER TABLE payments ADD COLUMN exclusion_reason TEXT');
        }
        if (!cols.includes('excluded_at')) {
          db.exec('ALTER TABLE payments ADD COLUMN excluded_at TEXT');
        }
        if (!cols.includes('overridden_by_payment_id')) {
          db.exec('ALTER TABLE payments ADD COLUMN overridden_by_payment_id INTEGER');
        }
        db.exec(`
          CREATE INDEX IF NOT EXISTS idx_payments_accounting_active
            ON payments(bill_id, paid_date, deleted_at, accounting_excluded);
          CREATE INDEX IF NOT EXISTS idx_payments_overridden_by
            ON payments(overridden_by_payment_id)
            WHERE overridden_by_payment_id IS NOT NULL;
        `);
        console.log('[v0.98] payment accounting override columns ensured');
      },
    },
    {
      version: 'v0.99',
      description:
        'bills: autopay trust indicators + lifecycle fields; payments: autopay failure flag',
      check() {
        const billCols = db
          .prepare('PRAGMA table_info(bills)')
          .all()
          .map((c) => c.name);
        return billCols.includes('autopay_verified_at') && billCols.includes('inactive_reason');
      },
      run() {
        const billCols = db
          .prepare('PRAGMA table_info(bills)')
          .all()
          .map((c) => c.name);
        const paymentCols = db
          .prepare('PRAGMA table_info(payments)')
          .all()
          .map((c) => c.name);
        if (!billCols.includes('autopay_verified_at')) {
          db.exec('ALTER TABLE bills ADD COLUMN autopay_verified_at TEXT');
        }
        if (!billCols.includes('inactive_reason')) {
          db.exec('ALTER TABLE bills ADD COLUMN inactive_reason TEXT');
        }
        if (!billCols.includes('inactivated_at')) {
          db.exec('ALTER TABLE bills ADD COLUMN inactivated_at TEXT');
        }
        if (!paymentCols.includes('autopay_failure')) {
          db.exec('ALTER TABLE payments ADD COLUMN autopay_failure INTEGER NOT NULL DEFAULT 0');
        }
        console.log('[v0.99] autopay trust indicators + lifecycle fields added');
      },
    },
    {
      version: 'v1.00',
      description: 'calendar feed subscription tokens',
      run() {
        db.exec(`
          CREATE TABLE IF NOT EXISTS calendar_tokens (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
            token TEXT NOT NULL UNIQUE,
            label TEXT,
            active INTEGER NOT NULL DEFAULT 1,
            last_used_at TEXT,
            created_at TEXT NOT NULL DEFAULT (datetime('now')),
            revoked_at TEXT
          );
          CREATE INDEX IF NOT EXISTS idx_calendar_tokens_token ON calendar_tokens(token) WHERE active = 1;
          CREATE INDEX IF NOT EXISTS idx_calendar_tokens_user_active ON calendar_tokens(user_id, active);
        `);
        console.log('[v1.00] calendar feed token table ensured');
      },
    },
    {
      version: 'v1.01',
      description: 'transactions: pending flag for SimpleFIN pending transactions',
      run() {
        const cols = db
          .prepare('PRAGMA table_info(transactions)')
          .all()
          .map((c) => c.name);
        if (!cols.includes('pending')) {
          db.exec('ALTER TABLE transactions ADD COLUMN pending INTEGER NOT NULL DEFAULT 0');
        }
        // Partial index speeds the per-account orphan prune that clears pending rows
        // which never posted (e.g. a pending charge that re-posted under a new id).
        db.exec(`CREATE INDEX IF NOT EXISTS idx_transactions_pending
                 ON transactions(account_id) WHERE pending = 1`);
        console.log('[v1.01] transactions.pending flag + partial index added');
      },
    },
    {
      version: 'v1.02',
      description: 'users: per-user geolocation opt-in (was global admin setting)',
      run() {
        const cols = db
          .prepare('PRAGMA table_info(users)')
          .all()
          .map((c) => c.name);
        if (!cols.includes('geolocation_enabled')) {
          db.exec('ALTER TABLE users ADD COLUMN geolocation_enabled INTEGER NOT NULL DEFAULT 0');
        }
        console.log('[v1.02] users.geolocation_enabled added');
      },
    },
    {
      version: 'v1.03',
      description: 'money columns: dollars (REAL) -> integer cents',
      run() {
        const conv = [
          ['bills', ['expected_amount', 'current_balance', 'minimum_payment']],
          ['payments', ['amount', 'balance_delta', 'interest_delta']],
          ['monthly_bill_state', ['actual_amount']],
          ['monthly_starting_amounts', ['first_amount', 'fifteenth_amount', 'other_amount']],
          ['monthly_income', ['amount']],
          ['spending_budgets', ['amount']],
          ['snowball_plans', ['extra_payment']],
          ['users', ['snowball_extra_payment']],
        ];
        for (const [table, cols] of conv) {
          for (const col of cols) {
            db.exec(
              `UPDATE ${table} SET ${col} = CAST(ROUND(${col} * 100) AS INTEGER) WHERE ${col} IS NOT NULL`,
            );
          }
        }
        console.log('[v1.03] money columns converted to integer cents');
      },
    },
    {
      version: 'v1.04',
      description: 'bill_templates.data JSON: money fields dollars -> integer cents',
      run() {
        // v1.03 converted table columns but not money values embedded in the
        // bill_templates.data JSON blob. Templates saved before v1.03 hold
        // dollars; the template code now reads cents (serializeTemplateData).
        for (const field of ['expected_amount', 'current_balance', 'minimum_payment']) {
          db.exec(`
            UPDATE bill_templates
            SET data = json_set(data, '$.${field}',
              CAST(ROUND(json_extract(data, '$.${field}') * 100) AS INTEGER))
            WHERE json_extract(data, '$.${field}') IS NOT NULL
          `);
        }
        console.log('[v1.04] bill_templates.data money fields converted to integer cents');
      },
    },
    {
      version: 'v1.05',
      description:
        'merchant_store_matches: 5k merchant/store matching pack for bank transaction categorization',
      dependsOn: ['v1.04'],
      run: function () {
        runMerchantStoreMatchMigration(db);
      },
    },
    {
      version: 'v1.06',
      description: 'category_groups: organize spending categories into named groups',
      dependsOn: ['v1.05'],
      run: function () {
        db.exec(`
          CREATE TABLE IF NOT EXISTS category_groups (
            id          INTEGER PRIMARY KEY AUTOINCREMENT,
            user_id     INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
            name        TEXT NOT NULL,
            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, name)
          )
        `);

        const cols = db
          .prepare('PRAGMA table_info(categories)')
          .all()
          .map((c) => c.name);
        if (!cols.includes('group_id'))
          db.exec(
            'ALTER TABLE categories ADD COLUMN group_id INTEGER REFERENCES category_groups(id) ON DELETE SET NULL',
          );

        console.log('[v1.06] category_groups table + categories.group_id added');
      },
    },
    {
      version: 'v1.07',
      description:
        'demo data: is_seeded marker on user-scoped tables the demo seeder touches (for surgical clear-demo removal)',
      dependsOn: ['v1.06'],
      run: function () {
        // Tables the demo seeder writes that are NOT bill-children (bill-children
        // cascade-delete with the seeded bill, so they need no marker). Marking
        // these lets clear-demo remove ONLY seeded rows without touching the
        // user's real planning / bank / spending / snowball data.
        const SEED_MARKER_TABLES = [
          'category_groups',
          'bill_templates',
          'snowball_plans',
          'spending_category_rules',
          'spending_budgets',
          'monthly_income',
          'monthly_starting_amounts',
          'data_sources',
          'financial_accounts',
          'transactions',
          'calendar_tokens',
          'declined_subscription_hints',
        ];
        let added = 0;
        for (const table of SEED_MARKER_TABLES) {
          const exists = db
            .prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name = ?")
            .get(table);
          if (!exists) continue; // defensive — all created by <= v1.06
          const cols = db
            .prepare(`PRAGMA table_info(${table})`)
            .all()
            .map((c) => c.name);
          if (!cols.includes('is_seeded')) {
            db.exec(`ALTER TABLE ${table} ADD COLUMN is_seeded INTEGER NOT NULL DEFAULT 0`);
            added++;
          }
        }
        console.log(`[v1.07] is_seeded marker ensured on ${added} demo table(s)`);
      },
    },
  ];
};
