/**
 * Production migration runner.
 *
 * Why this exists (and why `drizzle-kit migrate` isn't enough):
 *
 *   - Drizzle's bundled `migrate()` wraps every migration in a single
 *     transaction. Postgres forbids `CREATE INDEX CONCURRENTLY` inside
 *     a transaction (raises 25001) — so any migration containing
 *     CONCURRENTLY silently aborts or, worse, leaves the migration
 *     marked applied with the index missing. `0052_audit_critical_fixes.sql`
 *     ships six CONCURRENTLY composite indexes today and they never
 *     landed in prod.
 *
 *   - `drizzle-kit push` skips DDL the kit can't infer from the schema —
 *     e.g. CHECK constraints, partial unique indexes, the berth-pdf
 *     circular FK. push-only deployments diverge from migration-tracked
 *     truth.
 *
 * This script:
 *   1. Reads migrations in journal order from `src/lib/db/migrations`.
 *   2. Tracks applied state in `drizzle.__drizzle_migrations` (matching
 *      Drizzle's schema so other tooling sees the same source of truth).
 *   3. For each pending migration: splits on `--> statement-breakpoint`,
 *      classifies each statement as concurrency-safe (CREATE INDEX
 *      CONCURRENTLY / REINDEX CONCURRENTLY → outside tx) or
 *      transactional (everything else → batched in one tx per migration).
 *   4. Records hash + when-applied so re-runs are no-ops.
 *
 * Modes:
 *   `pnpm db:migrate`           — apply pending migrations
 *   `pnpm db:migrate:status`    — show pending vs applied without applying
 *   `pnpm db:migrate:baseline`  — mark every migration as applied without
 *                                 running it. Use ONCE per environment when
 *                                 the schema was bootstrapped via `db:push`
 *                                 (dev + the original prod cutover). After
 *                                 baseline, all future migrations go through
 *                                 `db:migrate` and are tracked in
 *                                 `__drizzle_migrations`.
 */

import 'dotenv/config';
import { createHash } from 'node:crypto';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import postgres from 'postgres';

const STATEMENT_BREAKPOINT = '--> statement-breakpoint';
const MIGRATIONS_DIR = join(process.cwd(), 'src/lib/db/migrations');
const SCHEMA_NAME = 'drizzle';
const TABLE_NAME = '__drizzle_migrations';

interface JournalEntry {
  idx: number;
  version: string;
  when: number;
  tag: string;
  breakpoints: boolean;
}

interface Journal {
  version: string;
  dialect: string;
  entries: JournalEntry[];
}

interface MigrationFile {
  tag: string;
  /** Folder millis from journal `when` — Drizzle uses this as the
   *  primary key in `__drizzle_migrations`. */
  folderMillis: number;
  /** Full file contents. */
  sql: string;
  /** SHA-256 hex of the raw file for re-application detection. */
  hash: string;
}

interface Statement {
  /** Raw SQL text (trimmed). */
  sql: string;
  /** True when the statement must execute outside a transaction. */
  needsAutocommit: boolean;
}

function isConcurrencyDDL(sql: string): boolean {
  const head = sql
    .replace(/^\s*--.*$/gm, '')
    .trim()
    .toUpperCase();
  return (
    /\bCREATE\s+INDEX\s+CONCURRENTLY\b/.test(head) ||
    /\bREINDEX\s+\w*\s*CONCURRENTLY\b/.test(head) ||
    /\bDROP\s+INDEX\s+CONCURRENTLY\b/.test(head)
  );
}

function readMigrations(): MigrationFile[] {
  const journal = JSON.parse(
    readFileSync(join(MIGRATIONS_DIR, 'meta', '_journal.json'), 'utf8'),
  ) as Journal;

  const files = readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith('.sql'));
  const byTag = new Map(files.map((f) => [f.replace(/\.sql$/, ''), f]));

  return journal.entries.map((entry) => {
    const filename = byTag.get(entry.tag);
    if (!filename) {
      throw new Error(`Migration ${entry.tag} in journal but ${entry.tag}.sql not on disk`);
    }
    const sql = readFileSync(join(MIGRATIONS_DIR, filename), 'utf8');
    const hash = createHash('sha256').update(sql).digest('hex');
    return { tag: entry.tag, folderMillis: entry.when, sql, hash };
  });
}

function splitStatements(sql: string): Statement[] {
  // Drizzle inserts `--> statement-breakpoint` between every statement
  // when `breakpoints: true` in drizzle.config. We split on those AND
  // strip trailing semicolons. Anything before the first breakpoint
  // counts too.
  const parts = sql.split(STATEMENT_BREAKPOINT);
  const out: Statement[] = [];
  for (const part of parts) {
    const trimmed = part.trim();
    if (!trimmed || trimmed.startsWith('--')) {
      // Comment-only chunks (pre-breakpoint header etc.) — skip if
      // they have no executable SQL.
      const nonComment = trimmed
        .split('\n')
        .filter((line) => !line.trim().startsWith('--') && line.trim().length > 0);
      if (nonComment.length === 0) continue;
    }
    out.push({ sql: trimmed, needsAutocommit: isConcurrencyDDL(trimmed) });
  }
  return out;
}

async function ensureMigrationsTable(sql: postgres.Sql): Promise<void> {
  await sql.unsafe(`CREATE SCHEMA IF NOT EXISTS "${SCHEMA_NAME}"`);
  await sql.unsafe(`
    CREATE TABLE IF NOT EXISTS "${SCHEMA_NAME}"."${TABLE_NAME}" (
      id SERIAL PRIMARY KEY,
      hash text NOT NULL,
      created_at bigint
    )
  `);
}

async function getAppliedHashes(sql: postgres.Sql): Promise<Set<string>> {
  const rows = await sql.unsafe<{ hash: string }[]>(
    `SELECT hash FROM "${SCHEMA_NAME}"."${TABLE_NAME}"`,
  );
  return new Set(rows.map((r) => r.hash));
}

async function applyMigration(sql: postgres.Sql, migration: MigrationFile): Promise<void> {
  const statements = splitStatements(migration.sql);
  if (statements.length === 0) {
    console.log(`  [${migration.tag}] no executable statements, skipping`);
    return;
  }

  const autocommit = statements.filter((s) => s.needsAutocommit);
  const transactional = statements.filter((s) => !s.needsAutocommit);

  // Transactional batch first — schema changes that CONCURRENTLY ops
  // depend on (e.g. column adds before CREATE INDEX) need to exist
  // before the index build runs. Drizzle migrations are written in
  // this order; we preserve it within each phase.
  if (transactional.length > 0) {
    await sql.begin(async (tx) => {
      for (const stmt of transactional) {
        await tx.unsafe(stmt.sql);
      }
    });
  }

  // CONCURRENTLY ops run one at a time, each as its own implicit tx.
  // No `BEGIN`/`COMMIT` wrapping — postgres-js's `sql.unsafe` runs
  // each call as an independent transaction.
  for (const stmt of autocommit) {
    await sql.unsafe(stmt.sql);
  }

  // Record the migration as applied. created_at mirrors Drizzle's own
  // schema so `drizzle-kit migrate` (if ever invoked) sees the same
  // state we wrote.
  await sql.unsafe(
    `INSERT INTO "${SCHEMA_NAME}"."${TABLE_NAME}" (hash, created_at) VALUES ($1, $2)`,
    [migration.hash, migration.folderMillis],
  );
}

async function main(): Promise<void> {
  const url = process.env.DATABASE_URL;
  if (!url) {
    console.error('DATABASE_URL must be set');
    process.exit(1);
  }

  const mode = process.argv[2] ?? 'apply';
  if (!['apply', 'status', 'baseline'].includes(mode)) {
    console.error(`Unknown mode: ${mode}. Use 'apply' (default), 'status', or 'baseline'.`);
    process.exit(1);
  }

  const sql = postgres(url, { max: 1, prepare: false });

  try {
    await ensureMigrationsTable(sql);
    const applied = await getAppliedHashes(sql);
    const migrations = readMigrations();
    const pending = migrations.filter((m) => !applied.has(m.hash));

    if (mode === 'status') {
      console.log(`Applied: ${applied.size}`);
      console.log(`Pending: ${pending.length}`);
      if (pending.length > 0) {
        console.log('');
        console.log('Pending migrations:');
        for (const m of pending) {
          const statements = splitStatements(m.sql);
          const conc = statements.filter((s) => s.needsAutocommit).length;
          const tx = statements.length - conc;
          console.log(`  ${m.tag} — ${tx} transactional + ${conc} concurrency-safe`);
        }
      }
      return;
    }

    if (mode === 'baseline') {
      if (pending.length === 0) {
        console.log('All migrations already tracked. Nothing to baseline.');
        return;
      }
      console.log(
        `Baselining ${pending.length} migration${
          pending.length === 1 ? '' : 's'
        } as applied without running them.`,
      );
      console.log(
        'This is correct ONLY when the schema is already in place (e.g. created via db:push).',
      );
      for (const m of pending) {
        await sql.unsafe(
          `INSERT INTO "${SCHEMA_NAME}"."${TABLE_NAME}" (hash, created_at) VALUES ($1, $2)`,
          [m.hash, m.folderMillis],
        );
        console.log(`  → ${m.tag} marked as applied`);
      }
      console.log(`Done. ${pending.length} baselined.`);
      return;
    }

    if (pending.length === 0) {
      console.log('No pending migrations.');
      return;
    }

    console.log(`Applying ${pending.length} migration${pending.length === 1 ? '' : 's'}...`);
    for (const m of pending) {
      const statements = splitStatements(m.sql);
      const conc = statements.filter((s) => s.needsAutocommit).length;
      console.log(`  → ${m.tag} (${statements.length} statements, ${conc} CONCURRENTLY)`);
      await applyMigration(sql, m);
    }
    console.log(`Done. ${pending.length} applied.`);
  } finally {
    await sql.end({ timeout: 5 });
  }
}

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