import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
  applyMigrationsByHash,
  readJournalTags,
  type HashLedgerDeps,
  type MigrationPlanEntry,
} from './migrate.js';

/* ------------------------------------------------------------------ */
/*  In-memory hash-ledger harness                                      */
/* ------------------------------------------------------------------ */

interface LedgerHarness extends HashLedgerDeps {
  ledger: Map<string, number>;
  /** Recorded statement executions in order: `${hashPrefix}:${stmtIndex}`. */
  executed: string[];
  /** Optional: statements that should throw when executed. */
  failOn?: (migrationHash: string, stmtIdx: number) => boolean;
}

function makeHarness(plan: MigrationPlanEntry[]): LedgerHarness {
  const hashToEntry = new Map(plan.map((p) => [p.hash, p]));
  const h: LedgerHarness = {
    ledger: new Map(),
    executed: [],
    ensureLedger: async () => {},
    appliedHashes: async () => [...h.ledger.keys()],
    recordApplied: async (hash, folderMillis) => {
      h.ledger.set(hash, folderMillis);
    },
    runStatement: async (statement) => {
      void statement;
      // runStatement does not know which migration it belongs to; the
      // executed log is filled by the wrapper below.
    },
  };
  // Wrap runStatement so the executed log records migration context. We
  // reconstruct context by tracking a cursor the core advances per migration.
  let cursor = 0;
  const flat: Array<{ hash: string; idx: number }> = [];
  for (const m of plan)
    for (const [i] of m.statements.entries()) flat.push({ hash: m.hash, idx: i });
  h.runStatement = async () => {
    const at = flat[cursor] ?? { hash: '??', idx: -1 };
    cursor += 1;
    if (h.failOn && at.hash !== '??' && h.failOn(at.hash, at.idx)) {
      throw new Error(`simulated failure in ${at.hash} #${at.idx.toString()}`);
    }
    h.executed.push(`${at.hash.slice(0, 6)}:${at.idx.toString()}`);
  };
  void hashToEntry;
  return h;
}

/* ------------------------------------------------------------------ */
/*  Fixtures                                                           */
/* ------------------------------------------------------------------ */

// Reproduces the REAL journal defect shape (#1402 D1): 0009/0010 carry
// `when` timestamps BELOW 0008's. Under the old drizzle postgres-js
// migrator these were silently skipped on any upgrade whose ledger was
// last stamped in the 0008 era.
const JOURNAL_FIXTURE: MigrationPlanEntry[] = [
  { hash: 'aaaa0000', folderMillis: 1773368153122, statements: ['CREATE TABLE a (id int)'] },
  { hash: 'bbbb0008', folderMillis: 1776822435828, statements: ['CREATE TABLE b (id int)'] },
  // Backdated entries, exactly as shipped:
  {
    hash: 'cccc0009',
    folderMillis: 1745280000000,
    statements: ['ALTER TYPE t ADD VALUE', "CREATE TABLE c (s t DEFAULT 'pending')"],
  },
  { hash: 'dddd0010', folderMillis: 1745366400000, statements: ['CREATE TABLE d (id int)'] },
];

/** A ledger last stamped at the 0008 era: only pre-0009 hashes recorded. */
const LEDGER_AT_0008_ERA = new Map<string, number>([
  ['aaaa0000', 1773368153122],
  ['bbbb0008', 1776822435828],
]);

/* ------------------------------------------------------------------ */
/*  The core: apply-by-hash in journal order                           */
/* ------------------------------------------------------------------ */

describe('applyMigrationsByHash', () => {
  it('applies backdated journal entries that a timestamp-based migrator would skip (#1402 D1)', async () => {
    const h = makeHarness(JOURNAL_FIXTURE);
    h.ledger = new Map(LEDGER_AT_0008_ERA);

    const result = await applyMigrationsByHash(h, JOURNAL_FIXTURE);

    // D1 in one sentence: 0009 and 0010 applied despite folderMillis < 0008.
    expect(result).toEqual({ applied: 2, skipped: 2 });
    expect(h.ledger.has('cccc0009')).toBe(true);
    expect(h.ledger.has('dddd0010')).toBe(true);
  });

  it('executes statements individually (ALTER TYPE visibility, #1402 D2 shape)', async () => {
    const h = makeHarness(JOURNAL_FIXTURE);
    await applyMigrationsByHash(h, JOURNAL_FIXTURE);
    // 0009's two statements recorded as separate executions, in order.
    expect(h.executed).toContain('cccc00:0');
    expect(h.executed).toContain('cccc00:1');
    expect(h.executed.indexOf('cccc00:0')).toBeLessThan(h.executed.indexOf('cccc00:1'));
  });

  it('is idempotent: a fully-applied ledger applies nothing', async () => {
    const h = makeHarness(JOURNAL_FIXTURE);
    const first = await applyMigrationsByHash(h, JOURNAL_FIXTURE);
    const second = await applyMigrationsByHash(h, JOURNAL_FIXTURE);
    expect(first.applied).toBe(4);
    expect(second).toEqual({ applied: 0, skipped: 4 });
    expect(h.executed).toHaveLength(5); // 5 statements; second run executed NONE (not 10)
  });

  it('records no ledger row when a statement fails (crash prefix replays loudly)', async () => {
    const h = makeHarness(JOURNAL_FIXTURE);
    h.failOn = (hash, idx) => hash === 'cccc0009' && idx === 1;

    await expect(applyMigrationsByHash(h, JOURNAL_FIXTURE)).rejects.toThrow(
      /cccc0009 statement #1 failed: simulated failure/,
    );
    // Statement 0 of 0009 executed, but NO ledger row for 0009: the next run
    // replays it and fails loudly on "already exists" instead of silently
    // believing 0009 applied.
    expect(h.ledger.has('cccc0009')).toBe(false);
    expect(h.executed).toContain('cccc00:0');
  });

  it('applies in JOURNAL order, not timestamp order', async () => {
    const h = makeHarness(JOURNAL_FIXTURE);
    await applyMigrationsByHash(h, JOURNAL_FIXTURE);
    // 5 statements total (0009 has two); prefix per migration: journal order,
    // so 0009's pair sits between 0008 and 0010.
    const order = h.executed.map((e) => e.slice(0, 4));
    expect(order).toEqual(['aaaa', 'bbbb', 'cccc', 'cccc', 'dddd']);
  });
});

/* ------------------------------------------------------------------ */
/*  Journal integrity against the shipped folder                       */
/* ------------------------------------------------------------------ */

describe('readJournalTags', () => {
  it('reads the shipped journal in order and sees the known backdated pair', () => {
    const folder = resolve(__dirname, '../drizzle');
    const tags = readJournalTags(folder);
    expect(tags.length).toBeGreaterThan(0);
    // The shipped defect (#1402 D1): these two entries carry April-2025
    // timestamps below 0008's June-2026 one. If this assertion ever fails
    // because the journal was FIXED (timestamps corrected or drizzle-kit
    // regenerated), update #1402 — the hash-ledger core stays correct either
    // way; this test pins the shipped reality the core was built for.
    const t9 = tags.find((t) => t.startsWith('0009_'));
    const t10 = tags.find((t) => t.startsWith('0010_'));
    const t8 = tags.find((t) => t.startsWith('0008_'));
    expect([t8, t9, t10]).toBeDefined();
    const journal = JSON.parse(readFileSync(resolve(folder, 'meta', '_journal.json'), 'utf8')) as {
      entries: Array<{ tag: string; when: number }>;
    };
    const when = new Map(journal.entries.map((e) => [e.tag, e.when]));
    if (t8 && t9 && t10) {
      expect(when.get(t9)!).toBeLessThan(when.get(t8)!); // backdated below 0008
      expect(when.get(t10)!).toBeLessThan(when.get(t8)!); // backdated below 0008
    }
  });
});
