import 'dotenv/config'
import { getDb } from './client.js'

const sql = getDb()

const migrate = async () => {
  await sql`
    CREATE EXTENSION IF NOT EXISTS "pgcrypto"
  `

  await sql`
    CREATE TABLE IF NOT EXISTS roles (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      name VARCHAR(100) NOT NULL UNIQUE,
      permissions JSONB NOT NULL DEFAULT '{}',
      created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
    )
  `

  await sql`
    CREATE TABLE IF NOT EXISTS customers (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      firebase_uid VARCHAR(255) UNIQUE,
      phone VARCHAR(20) UNIQUE,
      display_name VARCHAR(100) NOT NULL,
      is_anonymous BOOLEAN NOT NULL DEFAULT TRUE,
      created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
    )
  `

  await sql`
    CREATE TABLE IF NOT EXISTS mitras (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      firebase_uid VARCHAR(255) UNIQUE,
      phone VARCHAR(20) NOT NULL UNIQUE,
      display_name VARCHAR(100) NOT NULL,
      is_active BOOLEAN NOT NULL DEFAULT FALSE,
      created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
    )
  `

  await sql`
    CREATE TABLE IF NOT EXISTS control_center_users (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      firebase_uid VARCHAR(255) NOT NULL UNIQUE,
      email VARCHAR(255) NOT NULL UNIQUE,
      display_name VARCHAR(100) NOT NULL,
      role_id UUID NOT NULL REFERENCES roles(id),
      created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
    )
  `

  await sql`
    CREATE TABLE IF NOT EXISTS app_config (
      key VARCHAR(100) PRIMARY KEY,
      value JSONB NOT NULL,
      updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
    )
  `

  await sql`
    INSERT INTO app_config (key, value)
    VALUES ('anonymity', '{"enabled": false}')
    ON CONFLICT (key) DO NOTHING
  `

  // --- Phase 2: Mitra Online Status & Pairing ---

  await sql`
    CREATE TABLE IF NOT EXISTS mitra_online_status (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      mitra_id UUID NOT NULL UNIQUE REFERENCES mitras(id),
      is_online BOOLEAN NOT NULL DEFAULT FALSE,
      last_online_at TIMESTAMPTZ,
      last_offline_at TIMESTAMPTZ,
      last_heartbeat_at TIMESTAMPTZ,
      updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
    )
  `

  await sql`
    CREATE TABLE IF NOT EXISTS mitra_online_logs (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      mitra_id UUID NOT NULL REFERENCES mitras(id),
      status VARCHAR(10) NOT NULL,
      timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
    )
  `

  await sql`
    CREATE INDEX IF NOT EXISTS idx_mitra_online_logs_mitra_id
    ON mitra_online_logs (mitra_id)
  `

  await sql`
    CREATE TABLE IF NOT EXISTS chat_sessions (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      customer_id UUID NOT NULL REFERENCES customers(id),
      mitra_id UUID REFERENCES mitras(id),
      status VARCHAR(30) NOT NULL DEFAULT 'searching',
      created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
      paired_at TIMESTAMPTZ,
      ended_at TIMESTAMPTZ,
      ended_by VARCHAR(20)
    )
  `

  await sql`
    CREATE INDEX IF NOT EXISTS idx_chat_sessions_customer_id
    ON chat_sessions (customer_id)
  `

  await sql`
    CREATE INDEX IF NOT EXISTS idx_chat_sessions_mitra_id
    ON chat_sessions (mitra_id)
  `

  await sql`
    CREATE INDEX IF NOT EXISTS idx_chat_sessions_status
    ON chat_sessions (status)
  `

  // Composite index for the per-mitra active-session count subquery used by the
  // 5s availability poll and the per-blast capacity filter.
  await sql`
    CREATE INDEX IF NOT EXISTS idx_chat_sessions_mitra_status
    ON chat_sessions (mitra_id, status)
  `

  await sql`
    CREATE TABLE IF NOT EXISTS chat_request_notifications (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      session_id UUID NOT NULL REFERENCES chat_sessions(id),
      mitra_id UUID NOT NULL REFERENCES mitras(id),
      notified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
      response VARCHAR(20),
      responded_at TIMESTAMPTZ
    )
  `

  await sql`
    CREATE INDEX IF NOT EXISTS idx_chat_request_notifications_session_id
    ON chat_request_notifications (session_id)
  `

  await sql`
    INSERT INTO app_config (key, value)
    VALUES ('max_customers_per_mitra', '{"value": 3}')
    ON CONFLICT (key) DO NOTHING
  `

  // --- Phase 3: Chat Engine ---

  // Add session duration/pricing columns to chat_sessions
  await sql`
    ALTER TABLE chat_sessions
    ADD COLUMN IF NOT EXISTS duration_minutes INT,
    ADD COLUMN IF NOT EXISTS price INT DEFAULT 0,
    ADD COLUMN IF NOT EXISTS is_free_trial BOOLEAN NOT NULL DEFAULT FALSE,
    ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ,
    ADD COLUMN IF NOT EXISTS extended_minutes INT NOT NULL DEFAULT 0
  `

  // Add FCM token columns
  await sql`
    ALTER TABLE customers
    ADD COLUMN IF NOT EXISTS fcm_token VARCHAR(255)
  `

  await sql`
    ALTER TABLE mitras
    ADD COLUMN IF NOT EXISTS fcm_token VARCHAR(255)
  `

  await sql`
    CREATE TABLE IF NOT EXISTS chat_messages (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      session_id UUID NOT NULL REFERENCES chat_sessions(id),
      sender_type VARCHAR(10) NOT NULL,
      sender_id UUID NOT NULL,
      type VARCHAR(20) NOT NULL DEFAULT 'text',
      content TEXT NOT NULL,
      metadata JSONB,
      status VARCHAR(20) NOT NULL DEFAULT 'sent',
      delivered_at TIMESTAMPTZ,
      read_at TIMESTAMPTZ,
      created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
    )
  `

  await sql`
    CREATE INDEX IF NOT EXISTS idx_chat_messages_session_created
    ON chat_messages (session_id, created_at)
  `

  await sql`
    CREATE INDEX IF NOT EXISTS idx_chat_messages_session_status
    ON chat_messages (session_id, status)
  `

  await sql`
    CREATE TABLE IF NOT EXISTS session_closures (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      session_id UUID NOT NULL REFERENCES chat_sessions(id),
      user_type VARCHAR(10) NOT NULL,
      user_id UUID NOT NULL,
      message TEXT NOT NULL,
      created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
    )
  `

  await sql`
    CREATE TABLE IF NOT EXISTS session_extensions (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      session_id UUID NOT NULL REFERENCES chat_sessions(id),
      requested_duration_minutes INT NOT NULL,
      requested_price INT NOT NULL,
      status VARCHAR(20) NOT NULL DEFAULT 'pending',
      requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
      responded_at TIMESTAMPTZ
    )
  `

  await sql`
    CREATE TABLE IF NOT EXISTS customer_transactions (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      customer_id UUID NOT NULL REFERENCES customers(id),
      session_id UUID NOT NULL REFERENCES chat_sessions(id),
      type VARCHAR(20) NOT NULL,
      amount INT NOT NULL DEFAULT 0,
      created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
    )
  `

  await sql`
    CREATE INDEX IF NOT EXISTS idx_customer_transactions_customer_id
    ON customer_transactions (customer_id)
  `

  // Phase 3 config keys
  await sql`
    INSERT INTO app_config (key, value)
    VALUES ('free_trial_enabled', '{"value": true}')
    ON CONFLICT (key) DO NOTHING
  `

  await sql`
    INSERT INTO app_config (key, value)
    VALUES ('free_trial_duration_minutes', '{"value": 5}')
    ON CONFLICT (key) DO NOTHING
  `

  await sql`
    INSERT INTO app_config (key, value)
    VALUES ('extension_timeout_seconds', '{"value": 60}')
    ON CONFLICT (key) DO NOTHING
  `

  await sql`
    INSERT INTO app_config (key, value)
    VALUES ('early_end_mitra_enabled', '{"value": false}')
    ON CONFLICT (key) DO NOTHING
  `

  await sql`
    INSERT INTO app_config (key, value)
    VALUES ('early_end_customer_enabled', '{"value": false}')
    ON CONFLICT (key) DO NOTHING
  `

  await sql`
    INSERT INTO app_config (key, value)
    VALUES ('price_tiers', ${sql.json({ tiers: [
      { duration_minutes: 1, price: 5000, label: '1 Menit (Test)' },
      { duration_minutes: 15, price: 30000, label: '15 Menit' },
      { duration_minutes: 30, price: 60000, label: '30 Menit' },
      { duration_minutes: 45, price: 100000, label: '45 Menit' },
      { duration_minutes: 60, price: 150000, label: '60 Menit' },
      { duration_minutes: 1440, price: 250000, label: '24 Jam' },
    ]})})
    ON CONFLICT (key) DO NOTHING
  `

  // --- Phase 3.1: Mitra Ping Config ---

  await sql`
    INSERT INTO app_config (key, value)
    VALUES ('require_mitra_ping', '{"value": true}')
    ON CONFLICT (key) DO NOTHING
  `

  await sql`
    INSERT INTO app_config (key, value)
    VALUES ('mitra_ping_interval_seconds', '{"value": 15}')
    ON CONFLICT (key) DO NOTHING
  `

  // Mitra reachability — replaces the implicit `ping_interval * 3` grace
  // window with an operator-facing "max heartbeat age" knob. The companion
  // heartbeat cadence lives in env (MITRA_HEARTBEAT_CADENCE_SECONDS, default
  // 30s). Default 45s keeps the same effective grace as the old 15s ping × 3.
  // `mitra_ping_interval_seconds` is left in place (vestigial) — no live code
  // path reads it anymore; safe to drop after one release.
  await sql`
    INSERT INTO app_config (key, value)
    VALUES ('mitra_stale_after_seconds', '{"value": 45}')
    ON CONFLICT (key) DO NOTHING
  `

  // --- Phase 3.2: Mitra Request Activity Log ---

  await sql`
    ALTER TABLE chat_request_notifications
    ADD COLUMN IF NOT EXISTS active_session_count INT NOT NULL DEFAULT 0
  `

  await sql`
    CREATE INDEX IF NOT EXISTS idx_chat_request_notifications_mitra_notified
    ON chat_request_notifications (mitra_id, notified_at)
  `

  // --- Phase 3.3: Session Topic Sensitivity ---

  await sql`
    ALTER TABLE chat_sessions
    ADD COLUMN IF NOT EXISTS topic_sensitivity VARCHAR(16) NOT NULL DEFAULT 'regular'
  `

  await sql`
    CREATE INDEX IF NOT EXISTS idx_chat_sessions_topic_sensitivity
    ON chat_sessions (topic_sensitivity)
  `

  await sql`
    CREATE TABLE IF NOT EXISTS session_sensitivity_log (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      session_id UUID NOT NULL REFERENCES chat_sessions(id) ON DELETE CASCADE,
      changed_by_mitra_id UUID NOT NULL REFERENCES mitras(id),
      from_value VARCHAR(16) NOT NULL,
      to_value VARCHAR(16) NOT NULL,
      created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
    )
  `

  await sql`
    CREATE INDEX IF NOT EXISTS idx_session_sensitivity_log_session
    ON session_sensitivity_log (session_id)
  `

  await sql`
    INSERT INTO app_config (key, value)
    VALUES ('sensitive_flip_confirmation_enabled', '{"value": true}')
    ON CONFLICT (key) DO NOTHING
  `

  await sql`
    INSERT INTO app_config (key, value)
    VALUES ('sensitive_flag_one_way_latch', '{"value": false}')
    ON CONFLICT (key) DO NOTHING
  `

  // --- Phase 3.4: Self-Managed Auth ---

  // Customers: add social identity columns
  await sql`
    ALTER TABLE customers
      ADD COLUMN IF NOT EXISTS email VARCHAR(255),
      ADD COLUMN IF NOT EXISTS google_sub VARCHAR(255),
      ADD COLUMN IF NOT EXISTS apple_sub VARCHAR(255)
  `
  await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_google_sub ON customers (google_sub) WHERE google_sub IS NOT NULL`
  await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_apple_sub ON customers (apple_sub) WHERE apple_sub IS NOT NULL`

  // display_name is set after sign-in via the set-display-name screen for
  // direct phone/Google/Apple sign-ups (no anonymous bootstrap). Allow null.
  await sql`ALTER TABLE customers ALTER COLUMN display_name DROP NOT NULL`

  // Control center users: password-based auth columns
  // firebase_uid stays for backward compat during migration; will be dropped in a later cleanup migration
  await sql`ALTER TABLE control_center_users ALTER COLUMN firebase_uid DROP NOT NULL`
  await sql`
    ALTER TABLE control_center_users
      ADD COLUMN IF NOT EXISTS password_hash VARCHAR(60) NOT NULL DEFAULT '',
      ADD COLUMN IF NOT EXISTS failed_login_count INT NOT NULL DEFAULT 0,
      ADD COLUMN IF NOT EXISTS lockout_until TIMESTAMPTZ
  `

  // Auth sessions (refresh tokens + multi-device)
  await sql`
    CREATE TABLE IF NOT EXISTS auth_sessions (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      user_type VARCHAR(16) NOT NULL,
      user_id UUID NOT NULL,
      refresh_token_hash VARCHAR(60) NOT NULL,
      device_info JSONB,
      created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
      last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
      expires_at TIMESTAMPTZ NOT NULL,
      revoked_at TIMESTAMPTZ
    )
  `
  await sql`CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions (user_type, user_id)`
  await sql`CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions (expires_at) WHERE revoked_at IS NULL`

  // OTP requests (Fazpass reference + rate-limit tracking)
  await sql`
    CREATE TABLE IF NOT EXISTS otp_requests (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      phone VARCHAR(20) NOT NULL,
      ip_address VARCHAR(45),
      user_type VARCHAR(16) NOT NULL,
      fazpass_reference VARCHAR(255),
      channel VARCHAR(16),
      attempts INT NOT NULL DEFAULT 0,
      used_at TIMESTAMPTZ,
      created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
      expires_at TIMESTAMPTZ NOT NULL
    )
  `
  await sql`CREATE INDEX IF NOT EXISTS idx_otp_requests_phone_created ON otp_requests (phone, created_at)`
  await sql`CREATE INDEX IF NOT EXISTS idx_otp_requests_ip_created ON otp_requests (ip_address, created_at)`

  // Auth-related app_config defaults
  await sql`
    INSERT INTO app_config (key, value) VALUES
      ('otp_max_per_phone_per_hour', '{"value": 3}'),
      ('otp_max_per_ip_per_hour', '{"value": 10}'),
      ('otp_resend_cooldown_seconds', '{"value": 60}'),
      ('otp_verify_max_attempts', '{"value": 5}'),
      ('cc_login_max_attempts', '{"value": 5}'),
      ('cc_login_lockout_minutes', '{"value": 15}')
    ON CONFLICT (key) DO NOTHING
  `

  // --- Phase 3.7: Paid Pairing Flow + Returning-Chat + Extension Flip ---

  // Phase 5 rename — must run BEFORE the original CREATE TABLE so we don't end up
  // with both payment_sessions (old) and payment_requests (newly created from
  // IF NOT EXISTS) coexisting in the same schema. Schema-anchored via
  // current_schema() so the test schema's rename works even after the dev
  // schema already has payment_requests.
  await sql`
    DO $$
    BEGIN
      IF to_regclass(current_schema() || '.payment_sessions') IS NOT NULL
         AND to_regclass(current_schema() || '.payment_requests') IS NULL THEN
        EXECUTE 'ALTER TABLE ' || quote_ident(current_schema()) || '.payment_sessions RENAME TO payment_requests';
      END IF;
    END
    $$
  `

  // payment_requests: customer-initiated payment intents that gate pairing.
  // (Phase 5 rename — was `payment_sessions` in Phase 3.7. The rename block
  //  immediately above + the Phase 5 block at the end of this file together
  //  handle every state: fresh DB, pre-Phase-5 dev DB, post-Phase-5 dev DB.)
  await sql`
    CREATE TABLE IF NOT EXISTS payment_requests (
      id                 UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      customer_id        UUID NOT NULL REFERENCES customers(id),
      amount             INTEGER NOT NULL DEFAULT 0,
      duration_minutes   INTEGER NOT NULL,
      is_free_trial      BOOLEAN NOT NULL DEFAULT false,
      is_extension       BOOLEAN NOT NULL DEFAULT false,
      status             TEXT NOT NULL DEFAULT 'pending'
                         CHECK (status IN ('pending','confirmed','consumed','failed_delivery','abandoned','expired','failed')),
      targeted_mitra_id  UUID REFERENCES mitras(id),
      created_at         TIMESTAMPTZ NOT NULL DEFAULT NOW(),
      confirmed_at       TIMESTAMPTZ,
      consumed_at        TIMESTAMPTZ,
      expires_at         TIMESTAMPTZ NOT NULL
    )
  `

  await sql`
    CREATE INDEX IF NOT EXISTS idx_payment_requests_customer
    ON payment_requests (customer_id)
  `

  await sql`
    CREATE INDEX IF NOT EXISTS idx_payment_requests_status_expires
    ON payment_requests (status, expires_at)
  `

  // pairing_failures: cause-tagged audit rows for confirmed payments that did not yield a chat
  await sql`
    CREATE TABLE IF NOT EXISTS pairing_failures (
      id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      payment_request_id  UUID NOT NULL REFERENCES payment_requests(id) ON DELETE CASCADE,
      customer_id         UUID NOT NULL REFERENCES customers(id),
      targeted_mitra_id   UUID REFERENCES mitras(id),
      cause_tag           TEXT NOT NULL
                          CHECK (cause_tag IN (
                            'no_mitra_available',
                            'all_mitras_rejected',
                            'targeted_mitra_offline',
                            'targeted_mitra_rejected',
                            'targeted_mitra_timeout',
                            'payment_request_expired',
                            'customer_cancelled'
                          )),
      amount              INTEGER NOT NULL,
      operator_action     TEXT
                          CHECK (operator_action IS NULL OR operator_action IN ('refunded','credited','no_action')),
      actioned_by         UUID REFERENCES control_center_users(id),
      actioned_at         TIMESTAMPTZ,
      created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW()
    )
  `

  // Phase 3.7 follow-up: extend the pairing_failures.cause_tag CHECK to include the two
  // extension-specific tags. Idempotent: drop the existing check (whatever its exact name) and
  // re-add the expanded list. Postgres auto-names CHECK constraints `<table>_<column>_check`
  // unless we name them explicitly; the original DDL above relies on that default.
  await sql`
    DO $$
    BEGIN
      IF EXISTS (
        SELECT 1 FROM pg_constraint
        WHERE conrelid = 'pairing_failures'::regclass
          AND conname  = 'pairing_failures_cause_tag_check'
      ) THEN
        ALTER TABLE pairing_failures DROP CONSTRAINT pairing_failures_cause_tag_check;
      END IF;
      ALTER TABLE pairing_failures ADD CONSTRAINT pairing_failures_cause_tag_check
        CHECK (cause_tag IN (
          'no_mitra_available',
          'all_mitras_rejected',
          'targeted_mitra_offline',
          'targeted_mitra_rejected',
          'targeted_mitra_timeout',
          -- Phase 5 rename: payment_session_expired → payment_request_expired
          'payment_request_expired',
          'customer_cancelled',
          'extension_rejected',
          'extension_safeguard_tripped'
        ));
    END
    $$
  `

  await sql`
    CREATE INDEX IF NOT EXISTS idx_pairing_failures_created_at
    ON pairing_failures (created_at DESC)
  `

  await sql`
    CREATE INDEX IF NOT EXISTS idx_pairing_failures_cause
    ON pairing_failures (cause_tag)
  `

  await sql`
    CREATE INDEX IF NOT EXISTS idx_pairing_failures_unactioned
    ON pairing_failures (created_at DESC) WHERE operator_action IS NULL
  `

  // chat_sessions FK to payment_requests (nullable for backward compat with pre-3.7 rows)
  await sql`
    ALTER TABLE chat_sessions
    ADD COLUMN IF NOT EXISTS payment_request_id UUID REFERENCES payment_requests(id)
  `

  await sql`
    CREATE INDEX IF NOT EXISTS idx_chat_sessions_payment_request
    ON chat_sessions (payment_request_id)
  `

  // session_extensions FK to payment_requests (extensions also have their own payment request)
  await sql`
    ALTER TABLE session_extensions
    ADD COLUMN IF NOT EXISTS payment_request_id UUID REFERENCES payment_requests(id)
  `

  // Phase 3.7 config keys (idempotent — existing dev DBs need a manual update for extension_timeout_seconds → 10)
  await sql`
    INSERT INTO app_config (key, value) VALUES
      ('payment_request_timeout_minutes', '{"value": 20}'),
      ('returning_chat_confirmation_timeout_seconds', '{"value": 20}'),
      ('extension_default_action_on_timeout', '{"value": "auto_approve"}'),
      ('pairing_blast_timeout_seconds', '{"value": 60}')
    ON CONFLICT (key) DO NOTHING
  `

  // --- Phase 4 — Customer Flow Redesign ---

  // 1. payment_requests + chat_sessions: replace is_free_trial with is_first_session_discount.
  //    Phase 3.7 was the first ship of is_free_trial and never went live with real users
  //    (per project memory), so we copy whatever values exist and drop the old column.
  //    Idempotent: ADD/DROP both use IF [NOT] EXISTS, and each UPDATE is gated on the
  //    old column still existing.
  await sql`
    ALTER TABLE payment_requests
    ADD COLUMN IF NOT EXISTS is_first_session_discount BOOLEAN NOT NULL DEFAULT false
  `
  await sql`
    ALTER TABLE chat_sessions
    ADD COLUMN IF NOT EXISTS is_first_session_discount BOOLEAN NOT NULL DEFAULT false
  `

  // Copy values from the legacy column to the new one. We must use dynamic SQL
  // (EXECUTE) inside the DO block — a static reference to is_free_trial would fail
  // to parse when the column has already been dropped on a previous re-run.
  //
  // The IF EXISTS check resolves the column against the *current* search_path so
  // test schemas don't false-positive on the dev `public` schema's leftover columns.
  // We use to_regclass + pg_attribute (which is search_path-aware) instead of
  // information_schema.columns (which lists every schema).
  await sql`
    DO $$
    BEGIN
      IF EXISTS (
        SELECT 1 FROM pg_attribute
        WHERE attrelid = to_regclass('payment_requests')
          AND attname  = 'is_free_trial'
          AND NOT attisdropped
      ) THEN
        EXECUTE 'UPDATE payment_requests
                    SET is_first_session_discount = is_free_trial
                  WHERE is_free_trial = true
                    AND is_first_session_discount = false';
      END IF;
      IF EXISTS (
        SELECT 1 FROM pg_attribute
        WHERE attrelid = to_regclass('chat_sessions')
          AND attname  = 'is_free_trial'
          AND NOT attisdropped
      ) THEN
        EXECUTE 'UPDATE chat_sessions
                    SET is_first_session_discount = is_free_trial
                  WHERE is_free_trial = true
                    AND is_first_session_discount = false';
      END IF;
    END
    $$
  `

  await sql`ALTER TABLE payment_requests DROP COLUMN IF EXISTS is_free_trial`
  await sql`ALTER TABLE chat_sessions DROP COLUMN IF EXISTS is_free_trial`

  // 2. payment_requests.mode — chat (default) vs voice call. Voice call is just chat
  //    with a different price group + a header badge; no extra media handling.
  await sql`
    ALTER TABLE payment_requests
    ADD COLUMN IF NOT EXISTS mode TEXT NOT NULL DEFAULT 'chat'
      CHECK (mode IN ('chat', 'call'))
  `

  // 3. chat_sessions.topics — ESP picks persisted for info-only display to mitra.
  //    Does NOT affect matching, pricing, or routing.
  await sql`
    ALTER TABLE chat_sessions
    ADD COLUMN IF NOT EXISTS topics TEXT[]
  `

  // 4. Phase 4 app_config rows. Use ON CONFLICT (key) DO NOTHING so re-runs don't
  //    clobber operator edits, and the migration is idempotent against partially
  //    populated DBs.
  await sql`
    INSERT INTO app_config (key, value) VALUES
      ('payment_method_qris_first',                 ${sql.json({ value: true })}),
      ('searching_timeout_minutes',                 ${sql.json({ value: 5 })}),
      ('end_session_two_step_confirm',              ${sql.json({ value: true })}),
      ('three_minute_warning_enabled',              ${sql.json({ value: true })}),
      ('first_session_discount_enabled',            ${sql.json({ value: true })}),
      ('first_session_discount_actual_price_idr',   ${sql.json({ value: 2000 })}),
      ('first_session_discount_gimmick_price_idr',  ${sql.json({ value: 12000 })}),
      ('first_session_discount_duration_minutes',   ${sql.json({ value: 12 })}),
      ('first_session_discount_modes',              ${sql.json({ value: ['chat'] })}),
      ('pricing_chat_tiers_json',                   ${sql.json({ tiers: [
        { id: '5',   minutes: 5,   price_idr: 5000,  tag: null },
        { id: '12',  minutes: 12,  price_idr: 12000, tag: 'paling pas' },
        { id: '30',  minutes: 30,  price_idr: 25000, tag: 'hemat' },
        { id: '60',  minutes: 60,  price_idr: 45000, tag: null },
        { id: '120', minutes: 120, price_idr: 80000, tag: 'best deal' },
      ]})}),
      ('pricing_call_tiers_json',                   ${sql.json({ tiers: [
        { id: '10',  minutes: 10, price_idr: 9000,  tag: null },
        { id: '20',  minutes: 20, price_idr: 17000, tag: 'paling pas' },
        { id: '45',  minutes: 45, price_idr: 35000, tag: null },
        { id: '60',  minutes: 60, price_idr: 45000, tag: 'hemat' },
      ]})}),
      ('support_handles_json',                      ${sql.json({
        wa: { label: 'WhatsApp', deeplink: 'https://wa.me/6285173310010' },
        telegram: { label: 'Telegram', deeplink: 'https://t.me/halobestie' },
      })})
    ON CONFLICT (key) DO NOTHING
  `

  // 5. Phase 4 USP one-time gate. Customers see S5b USP at most once; this flag
  //    is the cross-device source of truth, OR-merged with a local
  //    SharedPreferences flag on the client. Existing customers come out as
  //    false and will see USP one more time on next "aku mau curhat" — business
  //    accepted this backfill cost.
  await sql`
    ALTER TABLE customers
    ADD COLUMN IF NOT EXISTS usp_seen BOOLEAN NOT NULL DEFAULT FALSE
  `

  // --- Phase 4 §2.1: Anonymous → existing-user merge breadcrumb ---
  //
  // When an anonymous customer verifies a phone that already belongs to a
  // different (existing) customer row, we don't 409 the OTP and we don't
  // delete the anon row (which would orphan its chat_sessions /
  // customer_transactions). Instead we stamp account_belongs_to on the anon
  // row pointing at the existing customer's id, then log the app in as the
  // existing user. Actual data reconciliation (moving FKs onto the existing
  // row) is deferred to a later phase — this column is the breadcrumb that
  // makes the merge replayable.
  await sql`
    ALTER TABLE customers
    ADD COLUMN IF NOT EXISTS account_belongs_to UUID REFERENCES customers(id) ON DELETE SET NULL
  `

  // --- Pricing relational migration — Stage 1 (schema + backfill only) ---
  //
  // Moves the pricing_chat_tiers_json / pricing_call_tiers_json /
  // first_session_discount_* rows from app_config into dedicated relational
  // tables. Stage 1 is schema-only: the live read paths in
  // pricing.service.js continue to read app_config until Stage 3 cuts them
  // over. The seven legacy app_config rows are NOT deleted here — that's
  // Stage 5.
  //
  // Design notes:
  //   - PK is UUID (gen_random_uuid()) instead of the doc's TEXT prefix
  //     scheme ("chat-60"). Backend lookups go through (mode, minutes),
  //     not the id, so the id is purely internal — there is no benefit to
  //     a human-readable surrogate key, and UUIDs match the convention
  //     used by every other table in this schema.
  //   - UNIQUE (mode, minutes) on pricing_tiers and UNIQUE (eligibility)
  //     on pricing_promotions enforce the natural keys at the DB level.
  //   - History tables reference the live row via tier_id / promotion_id
  //     (UUID) — column rename from the doc's `id` to avoid shadowing the
  //     history row's own pk.
  //   - original_price_idr is shipped as schema-only — not exposed in
  //     GET /api/client/pricing in this Stage. Surfacing it to clients is
  //     a separate out-of-scope follow-up.
  //   - sort_order: operator-controlled ordering distinct from minutes.
  //     Backfill seeds it as the array index from the existing JSON so any
  //     curated order is preserved.

  await sql`
    CREATE TABLE IF NOT EXISTS pricing_tiers (
      id                 UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      mode               TEXT NOT NULL CHECK (mode IN ('chat', 'call')),
      minutes            INTEGER NOT NULL CHECK (minutes > 0),
      price_idr          INTEGER NOT NULL CHECK (price_idr >= 0),
      original_price_idr INTEGER CHECK (original_price_idr IS NULL OR original_price_idr >= price_idr),
      tag                TEXT,
      sort_order         INTEGER NOT NULL DEFAULT 0,
      is_active          BOOLEAN NOT NULL DEFAULT true,
      created_at         TIMESTAMPTZ NOT NULL DEFAULT NOW(),
      updated_at         TIMESTAMPTZ NOT NULL DEFAULT NOW(),
      UNIQUE (mode, minutes)
    )
  `

  await sql`
    CREATE INDEX IF NOT EXISTS idx_pricing_tiers_mode_active_sort
    ON pricing_tiers (mode, is_active, sort_order)
  `

  await sql`
    CREATE TABLE IF NOT EXISTS pricing_promotions (
      id                UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      enabled           BOOLEAN NOT NULL DEFAULT true,
      eligibility       TEXT NOT NULL UNIQUE
                        CHECK (eligibility IN ('first_session')),
      actual_price_idr  INTEGER NOT NULL CHECK (actual_price_idr >= 0),
      gimmick_price_idr INTEGER CHECK (gimmick_price_idr IS NULL OR gimmick_price_idr >= actual_price_idr),
      duration_minutes  INTEGER NOT NULL CHECK (duration_minutes > 0),
      modes             TEXT[] NOT NULL CHECK (
                          array_length(modes, 1) >= 1
                          AND modes <@ ARRAY['chat', 'call']::TEXT[]
                        ),
      starts_at         TIMESTAMPTZ,
      ends_at           TIMESTAMPTZ,
      created_at        TIMESTAMPTZ NOT NULL DEFAULT NOW(),
      updated_at        TIMESTAMPTZ NOT NULL DEFAULT NOW()
    )
  `

  await sql`
    CREATE TABLE IF NOT EXISTS pricing_tiers_history (
      history_id         BIGSERIAL PRIMARY KEY,
      tier_id            UUID NOT NULL,
      mode               TEXT NOT NULL,
      minutes            INTEGER NOT NULL,
      price_idr          INTEGER NOT NULL,
      original_price_idr INTEGER,
      tag                TEXT,
      sort_order         INTEGER NOT NULL,
      is_active          BOOLEAN NOT NULL,
      changed_by         UUID,
      change_kind        TEXT NOT NULL CHECK (change_kind IN ('create', 'update', 'delete')),
      changed_at         TIMESTAMPTZ NOT NULL DEFAULT NOW()
    )
  `

  await sql`
    CREATE INDEX IF NOT EXISTS idx_pricing_tiers_history_tier_time
    ON pricing_tiers_history (tier_id, changed_at DESC)
  `

  await sql`
    CREATE TABLE IF NOT EXISTS pricing_promotions_history (
      history_id        BIGSERIAL PRIMARY KEY,
      promotion_id      UUID NOT NULL,
      enabled           BOOLEAN NOT NULL,
      eligibility       TEXT NOT NULL,
      actual_price_idr  INTEGER NOT NULL,
      gimmick_price_idr INTEGER,
      duration_minutes  INTEGER NOT NULL,
      modes             TEXT[] NOT NULL,
      starts_at         TIMESTAMPTZ,
      ends_at           TIMESTAMPTZ,
      changed_by        UUID,
      change_kind       TEXT NOT NULL CHECK (change_kind IN ('create', 'update', 'delete')),
      changed_at        TIMESTAMPTZ NOT NULL DEFAULT NOW()
    )
  `

  await sql`
    CREATE INDEX IF NOT EXISTS idx_pricing_promotions_history_promo_time
    ON pricing_promotions_history (promotion_id, changed_at DESC)
  `

  // --- Backfill: pricing_tiers ---
  //
  // Only seeds when the table is empty so re-runs don't clobber operator
  // edits or insert duplicates. The unique(mode, minutes) constraint is a
  // belt-and-suspenders backstop in case a half-finished run is retried.
  //
  // Source preference order per mode:
  //   1. app_config.pricing_{chat,call}_tiers_json (if the row exists)
  //   2. Hardcoded defaults that match DEFAULT_{CHAT,CALL}_TIERS in
  //      pricing.service.js. We duplicate them here rather than importing
  //      because migrate.js is a standalone script.

  const DEFAULT_CHAT_TIERS_BACKFILL = [
    { minutes: 5,   price_idr: 5000,  tag: null },
    { minutes: 12,  price_idr: 12000, tag: 'paling pas' },
    { minutes: 30,  price_idr: 25000, tag: 'hemat' },
    { minutes: 60,  price_idr: 45000, tag: null },
    { minutes: 120, price_idr: 80000, tag: 'best deal' },
  ]
  const DEFAULT_CALL_TIERS_BACKFILL = [
    { minutes: 10, price_idr: 9000,  tag: null },
    { minutes: 20, price_idr: 17000, tag: 'paling pas' },
    { minutes: 45, price_idr: 35000, tag: null },
    { minutes: 60, price_idr: 45000, tag: 'hemat' },
  ]

  const [{ n: tierCount }] = await sql`SELECT COUNT(*)::int AS n FROM pricing_tiers`
  if (tierCount === 0) {
    const [chatRow] = await sql`SELECT value FROM app_config WHERE key = 'pricing_chat_tiers_json'`
    const [callRow] = await sql`SELECT value FROM app_config WHERE key = 'pricing_call_tiers_json'`

    const chatTiers = Array.isArray(chatRow?.value?.tiers) ? chatRow.value.tiers : DEFAULT_CHAT_TIERS_BACKFILL
    const callTiers = Array.isArray(callRow?.value?.tiers) ? callRow.value.tiers : DEFAULT_CALL_TIERS_BACKFILL

    for (const [mode, tiers] of [['chat', chatTiers], ['call', callTiers]]) {
      let order = 0
      for (const t of tiers) {
        await sql`
          INSERT INTO pricing_tiers (mode, minutes, price_idr, tag, sort_order, is_active)
          VALUES (
            ${mode},
            ${t.minutes},
            ${t.price_idr},
            ${t.tag ?? null},
            ${order++},
            true
          )
          ON CONFLICT (mode, minutes) DO NOTHING
        `
      }
    }
  }

  // --- Backfill: pricing_promotions (single 'first_session' row) ---
  //
  // Defaults below match DEFAULT_DISCOUNT in pricing.service.js. The five
  // legacy app_config keys override these if present.

  const [{ n: promoCount }] = await sql`
    SELECT COUNT(*)::int AS n FROM pricing_promotions WHERE eligibility = 'first_session'
  `
  if (promoCount === 0) {
    const keys = [
      'first_session_discount_enabled',
      'first_session_discount_actual_price_idr',
      'first_session_discount_gimmick_price_idr',
      'first_session_discount_duration_minutes',
      'first_session_discount_modes',
    ]
    const rows = await sql`SELECT key, value FROM app_config WHERE key IN ${sql(keys)}`
    const byKey = Object.fromEntries(rows.map((r) => [r.key, r.value?.value]))

    const enabled = byKey.first_session_discount_enabled ?? true
    const actual = byKey.first_session_discount_actual_price_idr ?? 2000
    const gimmick = byKey.first_session_discount_gimmick_price_idr ?? 12000
    const duration = byKey.first_session_discount_duration_minutes ?? 12
    const modes = Array.isArray(byKey.first_session_discount_modes)
      ? byKey.first_session_discount_modes
      : ['chat']

    await sql`
      INSERT INTO pricing_promotions (
        enabled, eligibility, actual_price_idr, gimmick_price_idr, duration_minutes, modes
      )
      VALUES (
        ${enabled},
        'first_session',
        ${actual},
        ${gimmick},
        ${duration},
        ${modes}
      )
      ON CONFLICT (eligibility) DO NOTHING
    `
  }

  // --- Pricing relational migration — Stage 5 cleanup ---
  //
  // The seven legacy `app_config` rows below were the JSON-on-app_config
  // source of truth for pricing tiers and the first-session discount, copied
  // into `pricing_tiers` / `pricing_promotions` by the Stage 1 backfill above.
  // Stage 3 cut every read path over to the relational tables; this delete
  // removes the now-orphaned rows so operator edits in CC can't get out of
  // sync with the live source.
  //
  // Idempotent: a fresh dev DB just deletes zero rows. A previously-migrated
  // DB on this revision is a no-op. The seed of these keys in the Phase 4
  // app_config INSERT block above (~line 627) uses ON CONFLICT (key) DO
  // NOTHING — so even if the seed runs *after* this delete during a future
  // refactor, we don't accidentally resurrect them on the next pass.
  await sql`
    DELETE FROM app_config
    WHERE key IN (
      'first_session_discount_enabled',
      'first_session_discount_actual_price_idr',
      'first_session_discount_gimmick_price_idr',
      'first_session_discount_duration_minutes',
      'first_session_discount_modes',
      'pricing_chat_tiers_json',
      'pricing_call_tiers_json'
    )
  `

  // --- Phase 5 — Payment service rename + Xendit prep ---
  //
  // The `payment_sessions` table is renamed to `payment_requests` so future
  // products (courses, merch, subscriptions) can reuse the same payment layer
  // without the chat-coupled name. See requirement/phase5-xendit-plan.md
  // Architecture (revised 2026-05-23) for the full rationale.
  //
  // The migration is idempotent in both directions: on fresh DBs the
  // earlier CREATE TABLE block builds `payment_sessions`, this block then
  // renames it. On already-migrated DBs the rename is a no-op.

  // 1. Rename the table if it still has the old name.
  //    Schema-anchored via current_schema() so the rename works correctly when
  //    migrate runs against a non-public schema (test DBs use search_path).
  //    Without the schema qualifier, to_regclass('payment_requests') would
  //    fall through to public.payment_requests (after the dev schema migrated)
  //    and the test schema's rename would be skipped.
  await sql`
    DO $$
    BEGIN
      IF to_regclass(current_schema() || '.payment_sessions') IS NOT NULL
         AND to_regclass(current_schema() || '.payment_requests') IS NULL THEN
        EXECUTE 'ALTER TABLE ' || quote_ident(current_schema()) || '.payment_sessions RENAME TO payment_requests';
      END IF;
    END
    $$
  `

  // 2. Rename indexes Postgres auto-named after the old table
  await sql`ALTER INDEX IF EXISTS idx_payment_sessions_customer       RENAME TO idx_payment_requests_customer`
  await sql`ALTER INDEX IF EXISTS idx_payment_sessions_status_expires RENAME TO idx_payment_requests_status_expires`
  // Primary-key index doesn't get auto-renamed by ALTER TABLE ... RENAME.
  // Fix it on existing dev DBs so a future reader doesn't see payment_sessions_pkey on the payment_requests table.
  await sql`ALTER INDEX IF EXISTS payment_sessions_pkey              RENAME TO payment_requests_pkey`

  // 3. Rename FK columns on dependent tables (schema-anchored for the same reason as above)
  await sql`
    DO $$
    DECLARE
      schema_name TEXT := current_schema();
    BEGIN
      IF EXISTS (SELECT 1 FROM pg_attribute
                 WHERE attrelid = to_regclass(schema_name || '.chat_sessions')
                   AND attname  = 'payment_session_id'
                   AND NOT attisdropped) THEN
        EXECUTE 'ALTER TABLE ' || quote_ident(schema_name) || '.chat_sessions RENAME COLUMN payment_session_id TO payment_request_id';
      END IF;
      IF EXISTS (SELECT 1 FROM pg_attribute
                 WHERE attrelid = to_regclass(schema_name || '.session_extensions')
                   AND attname  = 'payment_session_id'
                   AND NOT attisdropped) THEN
        EXECUTE 'ALTER TABLE ' || quote_ident(schema_name) || '.session_extensions RENAME COLUMN payment_session_id TO payment_request_id';
      END IF;
      IF EXISTS (SELECT 1 FROM pg_attribute
                 WHERE attrelid = to_regclass(schema_name || '.pairing_failures')
                   AND attname  = 'payment_session_id'
                   AND NOT attisdropped) THEN
        EXECUTE 'ALTER TABLE ' || quote_ident(schema_name) || '.pairing_failures RENAME COLUMN payment_session_id TO payment_request_id';
      END IF;
    END
    $$
  `
  await sql`ALTER INDEX IF EXISTS idx_chat_sessions_payment RENAME TO idx_chat_sessions_payment_request`

  // 4. Rename the app_config key. Skip if both rows exist (we'd violate the PK).
  await sql`
    UPDATE app_config
       SET key = 'payment_request_timeout_minutes'
     WHERE key = 'payment_session_timeout_minutes'
       AND NOT EXISTS (SELECT 1 FROM app_config WHERE key = 'payment_request_timeout_minutes')
  `

  // 5. Status enum rewrite: rename failed_pairing → failed_delivery (product-agnostic),
  //    add 'failed' (createInvoice errored before customer paid — distinct from expired).
  //    Drop both the legacy and current constraint names so re-runs are idempotent.
  await sql`ALTER TABLE payment_requests DROP CONSTRAINT IF EXISTS payment_sessions_status_check`
  await sql`ALTER TABLE payment_requests DROP CONSTRAINT IF EXISTS payment_requests_status_check`
  await sql`UPDATE payment_requests SET status = 'failed_delivery' WHERE status = 'failed_pairing'`
  await sql`
    ALTER TABLE payment_requests
    ADD CONSTRAINT payment_requests_status_check
      CHECK (status IN ('pending','confirmed','consumed','expired','abandoned','failed','failed_delivery'))
  `

  // 6. cause_tag rewrite on pairing_failures — payment_session_expired → payment_request_expired
  await sql`ALTER TABLE pairing_failures DROP CONSTRAINT IF EXISTS pairing_failures_cause_tag_check`
  await sql`UPDATE pairing_failures SET cause_tag = 'payment_request_expired' WHERE cause_tag = 'payment_session_expired'`
  await sql`
    ALTER TABLE pairing_failures
    ADD CONSTRAINT pairing_failures_cause_tag_check
      CHECK (cause_tag IN (
        'no_mitra_available',
        'all_mitras_rejected',
        'targeted_mitra_offline',
        'targeted_mitra_rejected',
        'targeted_mitra_timeout',
        'payment_request_expired',
        'customer_cancelled',
        'extension_rejected',
        'extension_safeguard_tripped'
      ))
  `

  // 7. Add product-agnostic columns (microservice-prep) + xendit_* columns.
  //    product_type defaults to 'chat_session' so every existing row is
  //    self-describing without a manual backfill.
  await sql`
    ALTER TABLE payment_requests
      ADD COLUMN IF NOT EXISTS product_type           TEXT NOT NULL DEFAULT 'chat_session',
      ADD COLUMN IF NOT EXISTS product_metadata       JSONB NOT NULL DEFAULT '{}'::jsonb,
      ADD COLUMN IF NOT EXISTS xendit_invoice_id      TEXT,
      ADD COLUMN IF NOT EXISTS xendit_invoice_url     TEXT,
      ADD COLUMN IF NOT EXISTS xendit_payment_method  TEXT,
      ADD COLUMN IF NOT EXISTS xendit_paid_amount     INTEGER
  `

  // 8. Partial unique index on xendit_invoice_id — webhook retries land on the same
  //    invoice id, this turns "already processed" into a constraint violation our
  //    handler can detect.
  await sql`
    CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_requests_xendit_invoice
    ON payment_requests (xendit_invoice_id)
    WHERE xendit_invoice_id IS NOT NULL
  `

  // 9. Backfill product_metadata for pre-Phase-5 rows so subscribers can read
  //    chat-session details without falling back to the legacy top-level columns.
  await sql`
    UPDATE payment_requests
       SET product_metadata = jsonb_build_object(
         'duration_minutes',    duration_minutes,
         'mode',                mode,
         'is_extension',        is_extension,
         'targeted_mitra_id',   targeted_mitra_id
       )
     WHERE product_metadata = '{}'::jsonb
       AND product_type = 'chat_session'
  `

  // 10. webhook_logs — survival/audit table for every inbound payment-provider
  //     webhook. The route handler inserts a row BEFORE auth checks or business
  //     logic so a forensic record exists even when the request is rejected,
  //     the body is malformed, or processing throws.
  //
  //     Primary fields are extracted as columns (queryable in CC, indexed where
  //     useful); the full body is kept in `raw_body` JSONB so we can replay or
  //     diff later. `provider` is a string column (not enum) so adding a new
  //     payment provider doesn't require a migration.
  //
  //     No FK to payment_requests — logs must survive even if the matching
  //     payment row was wiped, never existed, or arrives for a product/event
  //     type we don't yet model.
  await sql`
    CREATE TABLE IF NOT EXISTS webhook_logs (
      id                    UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      provider              TEXT NOT NULL DEFAULT 'xendit',
      received_at           TIMESTAMPTZ NOT NULL DEFAULT NOW(),

      -- Parsed primary fields (Xendit Invoice callback shape — NULL for other event types)
      xendit_event_id       TEXT,
      external_id           TEXT,
      payment_request_id    UUID,
      status                TEXT,
      amount                BIGINT,
      currency              TEXT,
      payment_method        TEXT,
      paid_at               TIMESTAMPTZ,
      metadata_app          TEXT,

      -- Integrity + verbatim record
      callback_token_valid  BOOLEAN NOT NULL,
      headers               JSONB NOT NULL,
      raw_body              JSONB NOT NULL,

      -- Outcome (filled by the handler after processing). Leaving these NULL
      -- is itself a useful signal — it means the handler crashed before the
      -- finally block could stamp the result.
      http_status           SMALLINT,
      processing_result     TEXT,
      processing_error      TEXT,
      processed_at          TIMESTAMPTZ
    )
  `
  await sql`
    CREATE INDEX IF NOT EXISTS idx_webhook_logs_received_at
    ON webhook_logs (received_at DESC)
  `
  await sql`
    CREATE INDEX IF NOT EXISTS idx_webhook_logs_external_id
    ON webhook_logs (external_id)
    WHERE external_id IS NOT NULL
  `
  await sql`
    CREATE INDEX IF NOT EXISTS idx_webhook_logs_payment_request
    ON webhook_logs (payment_request_id)
    WHERE payment_request_id IS NOT NULL
  `

  console.log('Migration complete.')
  await sql.end()
}

migrate().catch((err) => {
  console.error('Migration failed:', err)
  process.exit(1)
})
