import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { sql } from 'drizzle-orm';
import { readMigrationFiles } from 'drizzle-orm/migrator';
import postgres from 'postgres';
import { DEFAULT_DATABASE_URL } from './defaults.js';
import type { DbHandle } from './client.js';

interface PgliteExecutor {
  exec(query: string): Promise<unknown>;
}

interface ExecuteRows<T> {
  rows: T[];
}

function migrationsFolder(): string {
  const here = dirname(fileURLToPath(import.meta.url));
  return resolve(here, '../drizzle');
}

/* ------------------------------------------------------------------ */
/*  Shared hash-ledger migration core (#1392 / #1402)                  */
/* ------------------------------------------------------------------ */
//
// Both tiers migrate through this single core, which applies migrations in
// JOURNAL ORDER, one statement at a time, and skips by HASH — never by
// folderMillis timestamp. The previous postgres path delegated to drizzle's
// postgres-js migrator, which:
//
//   * applies only migrations with folderMillis > last-applied, silently
//     skipping journal entries whose `when` is older than the ledger's newest
//     stamp — 0009/0010 carry April-2025 timestamps below 0008's June-2026
//     one, so any database last migrated in the 0008 era silently loses
//     0009/0010 forever (#1402 D1); and
//   * wraps each migration in ONE transaction, which breaks migrations that
//     do `ALTER TYPE ADD VALUE` and then reference the new value in the same
//     migration (0009) — Postgres' check_safe_enum_use rejects it (#1402 D2).
//
// Per-statement execution (each statement autocommits) and skip-by-hash fix
// both. The PGlite path has run this way since it was written; this is the
// TODO it left behind, now shared instead of duplicated.

/** One migration as loaded from the shipped drizzle/ folder. */
export interface MigrationPlanEntry {
  hash: string;
  folderMillis: number;
  statements: string[];
}

/** The persistence operations the hash-ledger core needs, per tier. */
export interface HashLedgerDeps {
  /** Create the drizzle schema + ledger table if absent (idempotent). */
  ensureLedger(): Promise<void>;
  /** Hashes already recorded in the ledger. */
  appliedHashes(): Promise<string[]>;
  /** Record one fully-applied migration in the ledger. */
  recordApplied(hash: string, folderMillis: number): Promise<void>;
  /** Execute one SQL statement, autocommitting (never inside a wider tx). */
  runStatement(statement: string): Promise<void>;
}

function loadPlan(): MigrationPlanEntry[] {
  return readMigrationFiles({ migrationsFolder: migrationsFolder() }).map((m) => ({
    hash: m.hash,
    folderMillis: m.folderMillis,
    statements: m.sql.map((s) => s.trim()).filter((s) => s.length > 0),
  }));
}

/**
 * Apply every unapplied migration in journal order, skipping by hash.
 *
 * Failure model: each statement autocommits, and the ledger row is written
 * only after all statements of a migration succeed. A crash mid-migration
 * leaves the prefix applied with no ledger entry, so the next boot replays
 * those statements and fails loudly on "already exists". Recovery: drop the
 * partially-applied objects, or insert the migration's hash into
 * `drizzle.__drizzle_migrations` manually. The thrown error identifies the
 * statement and migration that failed.
 */
export async function applyMigrationsByHash(
  deps: HashLedgerDeps,
  plan: MigrationPlanEntry[] = loadPlan(),
): Promise<{ applied: number; skipped: number }> {
  await deps.ensureLedger();
  const alreadyApplied = new Set(await deps.appliedHashes());

  let applied = 0;
  let skipped = 0;
  for (const migration of plan) {
    if (alreadyApplied.has(migration.hash)) {
      skipped += 1;
      continue;
    }
    for (const [stmtIdx, stmt] of migration.statements.entries()) {
      try {
        await deps.runStatement(stmt);
      } catch (err) {
        const cause = err instanceof Error ? err.message : String(err);
        throw new Error(
          `migration hash=${migration.hash} statement #${stmtIdx} failed: ${cause}\n` +
            `Statement: ${stmt.slice(0, 200)}${stmt.length > 200 ? '…' : ''}`,
          { cause: err },
        );
      }
    }
    await deps.recordApplied(migration.hash, migration.folderMillis);
    applied += 1;
  }
  return { applied, skipped };
}

const LEDGER_DDL = [
  'CREATE SCHEMA IF NOT EXISTS drizzle',
  `CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
      id SERIAL PRIMARY KEY,
      hash text NOT NULL,
      created_at bigint
    )`,
];

function connectionString(url?: string): string {
  return url ?? process.env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL;
}

/**
 * Apply Drizzle migrations against a postgres database, hash-ledger style.
 * Idempotent: re-running against a fully-migrated database applies nothing.
 */
export async function runMigrations(url?: string): Promise<void> {
  const sqlClient = postgres(connectionString(url), { max: 1 });
  try {
    await applyMigrationsByHash({
      ensureLedger: async () => {
        for (const ddl of LEDGER_DDL) await sqlClient.unsafe(ddl);
      },
      appliedHashes: async () => {
        const rows = (await sqlClient.unsafe(
          'SELECT hash FROM drizzle.__drizzle_migrations',
        )) as Array<{ hash: string }>;
        return rows.map((r) => String(r.hash));
      },
      recordApplied: async (hash, folderMillis) => {
        await sqlClient.unsafe(
          'INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ($1, $2)',
          [hash, folderMillis],
        );
      },
      runStatement: async (stmt) => {
        await sqlClient.unsafe(stmt);
      },
    });
  } finally {
    await sqlClient.end();
  }
}

/**
 * Apply Drizzle migrations against an embedded PGlite database.
 *
 * We don't reuse drizzle's pglite migrator for the same reasons as the
 * postgres path (single-transaction wrap; folderMillis skip). PGlite's
 * `exec()` runs each statement under the Simple Query protocol,
 * autocommitting between them — exactly the semantics the shared core needs.
 *
 * The ledger rows this writes are interoperable with the postgres path (same
 * schema, same hashes), because both consume the same shipped migrations.
 */
export async function runPgliteMigrations(handle: DbHandle): Promise<void> {
  const client = (handle.db as unknown as { $client?: PgliteExecutor }).$client;
  if (!client || typeof client.exec !== 'function') {
    throw new Error('runPgliteMigrations: handle.db is not backed by a PGlite client');
  }

  await applyMigrationsByHash({
    ensureLedger: async () => {
      for (const ddl of LEDGER_DDL) await client.exec(ddl);
    },
    appliedHashes: async () => {
      const rows = (await handle.db.execute(
        sql`SELECT hash FROM drizzle.__drizzle_migrations`,
      )) as unknown as ExecuteRows<{ hash: string }>;
      return rows.rows.map((r) => String(r.hash));
    },
    recordApplied: async (hash, folderMillis) => {
      await handle.db.execute(
        sql`INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES (${hash}, ${folderMillis})`,
      );
    },
    runStatement: async (stmt) => {
      await client.exec(stmt);
    },
  });
}

/* ------------------------------------------------------------------ */
/*  Migration status (#1392: the installer must VERIFY, not assume)     */
/* ------------------------------------------------------------------ */

/** Read the journal tags (migration folder names) in journal order. */
export function readJournalTags(folder: string = migrationsFolder()): string[] {
  const journal = JSON.parse(readFileSync(resolve(folder, 'meta', '_journal.json'), 'utf8')) as {
    entries?: Array<{ tag?: string }>;
  };
  return (journal.entries ?? []).map((e) => e.tag ?? '').filter((t) => t.length > 0);
}

export interface MigrationStatus {
  /** Hashes recorded in the database's ledger (0 if no ledger exists). */
  appliedCount: number;
  /** Migrations shipped in this package's drizzle/ folder. */
  expectedCount: number;
  /** The tag (folder name) of the last journal entry, for error messages. */
  expectedLastTag: string;
  /** True iff every shipped migration's hash is in the ledger. */
  complete: boolean;
}

/**
 * Report whether a postgres database carries the full shipped schema.
 *
 * Read-only apart from `ensureLedger` semantics: it never creates the ledger
 * (unlike the migrators), so a database with NO ledger reports
 * appliedCount=0 / complete=false — the exact #1392/#1389 signature (an
 * install whose dependency set shipped no migrations at all).
 */
export async function getMigrationStatus(url?: string): Promise<MigrationStatus> {
  const sqlClient = postgres(connectionString(url), { max: 1 });
  try {
    const plan = loadPlan();
    const tags = readJournalTags();
    const expectedHashes = new Set(plan.map((m) => m.hash));

    let appliedHashes: string[] = [];
    const regRows = (await sqlClient.unsafe(
      "SELECT to_regclass('drizzle.__drizzle_migrations') AS reg",
    )) as Array<{ reg: string | null }>;
    if (regRows[0]?.reg) {
      const rows = (await sqlClient.unsafe(
        'SELECT hash FROM drizzle.__drizzle_migrations',
      )) as Array<{ hash: string }>;
      appliedHashes = rows.map((r) => String(r.hash));
    }

    const appliedSet = new Set(appliedHashes);
    return {
      appliedCount: appliedHashes.length,
      expectedCount: plan.length,
      expectedLastTag: tags[tags.length - 1] ?? '',
      complete:
        plan.length > 0 &&
        plan.every((m) => appliedSet.has(m.hash)) &&
        // A ledger with entries OUTSIDE the shipped plan means the database
        // came from a different (e.g. newer) build — not "complete" either.
        appliedHashes.every((h) => expectedHashes.has(h)),
    };
  } finally {
    await sqlClient.end();
  }
}
