/**
 * Tests for concurrent ID mapping operations.
 *
 * Validates that saveIdMapping's lockfile-based mutual exclusion and
 * read-merge-write prevents data loss when multiple processes write
 * to ids.yml concurrently.
 *
 * See: Bug report "tbd missing short ID mapping after concurrent create"
 */

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, rm, mkdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import {
  loadIdMapping,
  saveIdMapping,
  addIdMapping,
  generateUniqueShortId,
  type IdMapping,
} from '../src/file/id-mapping.js';
import { TEST_ULIDS } from './test-helpers.js';

function emptyMapping(): IdMapping {
  return {
    shortToUlid: new Map(),
    ulidToShort: new Map(),
  };
}

describe('saveIdMapping concurrent safety', () => {
  let tempDir: string;

  beforeEach(async () => {
    tempDir = await mkdtemp(join(tmpdir(), 'tbd-concurrent-'));
  });

  afterEach(async () => {
    await rm(tempDir, { recursive: true, force: true });
  });

  it('preserves entries from concurrent writers', async () => {
    // Simulate the race: two "processes" load the same empty mapping,
    // each adds its own entry, then both save.
    // Without the lock + merge, the second save would overwrite the first.

    // Process A loads mapping (empty)
    const mappingA = await loadIdMapping(tempDir);
    // Process B loads mapping (same empty state)
    const mappingB = await loadIdMapping(tempDir);

    // Process A adds its entry and saves
    addIdMapping(mappingA, TEST_ULIDS.CONCURRENT_1, 'aa01');
    await saveIdMapping(tempDir, mappingA);

    // Process B adds its entry and saves (would have overwritten A without merge)
    addIdMapping(mappingB, TEST_ULIDS.CONCURRENT_2, 'bb02');
    await saveIdMapping(tempDir, mappingB);

    // Verify both entries survived
    const result = await loadIdMapping(tempDir);
    expect(result.shortToUlid.get('aa01')).toBe(TEST_ULIDS.CONCURRENT_1);
    expect(result.shortToUlid.get('bb02')).toBe(TEST_ULIDS.CONCURRENT_2);
    expect(result.ulidToShort.get(TEST_ULIDS.CONCURRENT_1)).toBe('aa01');
    expect(result.ulidToShort.get(TEST_ULIDS.CONCURRENT_2)).toBe('bb02');
  });

  it('preserves entries when multiple stale snapshots save sequentially', async () => {
    // Simulate 5 concurrent creates (the reproduction scenario):
    // All load the same snapshot, each adds one entry, then all save.

    const entries: { ulid: string; shortId: string }[] = [
      { ulid: TEST_ULIDS.CONCURRENT_1, shortId: 'cc01' },
      { ulid: TEST_ULIDS.CONCURRENT_2, shortId: 'cc02' },
      { ulid: TEST_ULIDS.CONCURRENT_3, shortId: 'cc03' },
      { ulid: TEST_ULIDS.CONCURRENT_4, shortId: 'cc04' },
      { ulid: TEST_ULIDS.ULID_5, shortId: 'cc05' },
    ];

    // All 5 "processes" load the same empty mapping
    const snapshots = await Promise.all(entries.map(() => loadIdMapping(tempDir)));

    // Each adds its own entry to its stale snapshot
    for (let i = 0; i < entries.length; i++) {
      addIdMapping(snapshots[i]!, entries[i]!.ulid, entries[i]!.shortId);
    }

    // All save sequentially (worst case — each has a stale snapshot)
    for (const snapshot of snapshots) {
      await saveIdMapping(tempDir, snapshot);
    }

    // Verify ALL entries survived
    const result = await loadIdMapping(tempDir);
    for (const entry of entries) {
      expect(result.shortToUlid.get(entry.shortId)).toBe(entry.ulid);
      expect(result.ulidToShort.get(entry.ulid)).toBe(entry.shortId);
    }
    expect(result.shortToUlid.size).toBe(5);
  });

  it('preserves all entries when saves happen concurrently via Promise.all', async () => {
    // With lockfile-based mutual exclusion, even truly concurrent saves
    // are serialized and all entries must survive.
    const entries: { ulid: string; shortId: string }[] = [
      { ulid: TEST_ULIDS.CONCURRENT_1, shortId: 'dd01' },
      { ulid: TEST_ULIDS.CONCURRENT_2, shortId: 'dd02' },
      { ulid: TEST_ULIDS.CONCURRENT_3, shortId: 'dd03' },
    ];

    const snapshots = await Promise.all(entries.map(() => loadIdMapping(tempDir)));

    for (let i = 0; i < entries.length; i++) {
      addIdMapping(snapshots[i]!, entries[i]!.ulid, entries[i]!.shortId);
    }

    // Save all concurrently — the lockfile serializes these
    await Promise.all(snapshots.map((s) => saveIdMapping(tempDir, s)));

    // All entries must survive
    const result = await loadIdMapping(tempDir);
    for (const entry of entries) {
      expect(result.shortToUlid.get(entry.shortId)).toBe(entry.ulid);
      expect(result.ulidToShort.get(entry.ulid)).toBe(entry.shortId);
    }
    expect(result.shortToUlid.size).toBe(3);
  });

  it('does not lose pre-existing entries when saving new ones', async () => {
    // Write initial entries
    const initial = emptyMapping();
    addIdMapping(initial, TEST_ULIDS.CONCURRENT_1, 'ee01');
    addIdMapping(initial, TEST_ULIDS.CONCURRENT_2, 'ee02');
    await saveIdMapping(tempDir, initial);

    // New process loads a stale (empty!) mapping and adds its own entry
    const stale = emptyMapping();
    addIdMapping(stale, TEST_ULIDS.CONCURRENT_3, 'ee03');
    await saveIdMapping(tempDir, stale);

    // Pre-existing entries must still be present
    const result = await loadIdMapping(tempDir);
    expect(result.shortToUlid.get('ee01')).toBe(TEST_ULIDS.CONCURRENT_1);
    expect(result.shortToUlid.get('ee02')).toBe(TEST_ULIDS.CONCURRENT_2);
    expect(result.shortToUlid.get('ee03')).toBe(TEST_ULIDS.CONCURRENT_3);
    expect(result.shortToUlid.size).toBe(3);
  });

  it('handles generateUniqueShortId with stale snapshot', async () => {
    // Process A creates a mapping entry and saves
    const mappingA = await loadIdMapping(tempDir);
    const shortA = generateUniqueShortId(mappingA);
    addIdMapping(mappingA, TEST_ULIDS.CONCURRENT_1, shortA);
    await saveIdMapping(tempDir, mappingA);

    // Process B has a stale snapshot (loaded before A saved)
    const staleB = emptyMapping();
    const shortB = generateUniqueShortId(staleB);
    addIdMapping(staleB, TEST_ULIDS.CONCURRENT_2, shortB);
    await saveIdMapping(tempDir, staleB);

    // Both entries should survive
    const result = await loadIdMapping(tempDir);
    expect(result.ulidToShort.has(TEST_ULIDS.CONCURRENT_1)).toBe(true);
    expect(result.ulidToShort.has(TEST_ULIDS.CONCURRENT_2)).toBe(true);
  });

  it('cleans up lockfile after save', async () => {
    const mapping = emptyMapping();
    addIdMapping(mapping, TEST_ULIDS.CONCURRENT_1, 'ff01');
    await saveIdMapping(tempDir, mapping);

    // Lockfile directory should not remain after successful save
    const { readdir } = await import('node:fs/promises');
    const mappingsDir = join(tempDir, 'mappings');
    const files = await readdir(mappingsDir);
    expect(files.filter((f) => f.includes('.lock'))).toEqual([]);
  });

  it('refuses to save when result would lose entries (append-only guard)', async () => {
    // Write initial mapping with 3 entries
    const initial = emptyMapping();
    addIdMapping(initial, TEST_ULIDS.CONCURRENT_1, 'gg01');
    addIdMapping(initial, TEST_ULIDS.CONCURRENT_2, 'gg02');
    addIdMapping(initial, TEST_ULIDS.CONCURRENT_3, 'gg03');
    await saveIdMapping(tempDir, initial);

    // Attempt to save a mapping with only 1 entry (would lose 2 entries).
    // The merge will recover on-disk entries, so this actually succeeds
    // because the merge produces 3 entries (>= 3 on disk).
    const partial = emptyMapping();
    addIdMapping(partial, TEST_ULIDS.CONCURRENT_4, 'gg04');
    await saveIdMapping(tempDir, partial);

    // All 4 entries should exist (3 on-disk + 1 new, merged)
    const result = await loadIdMapping(tempDir);
    expect(result.shortToUlid.size).toBe(4);
    expect(result.shortToUlid.get('gg01')).toBe(TEST_ULIDS.CONCURRENT_1);
    expect(result.shortToUlid.get('gg04')).toBe(TEST_ULIDS.CONCURRENT_4);
  });
});

describe('saveIdMapping rejects on lock contention (prevents degraded-mode data loss)', () => {
  let tempDir: string;

  beforeEach(async () => {
    tempDir = await mkdtemp(join(tmpdir(), 'tbd-lockrace-'));
  });

  afterEach(async () => {
    await rm(tempDir, { recursive: true, force: true });
  });

  it('recovers from stale lock left by crashed process', async () => {
    // If a previous tbd process crashed while holding the lock,
    // the lock directory remains. The stale detection should break it
    // and allow the next save to succeed.

    // Write initial data
    const initial = emptyMapping();
    addIdMapping(initial, TEST_ULIDS.CONCURRENT_1, 'sl01');
    await saveIdMapping(tempDir, initial);

    // Simulate a stale lock from a crashed process
    const mappingsDir = join(tempDir, 'mappings');
    const lockPath = join(mappingsDir, 'ids.yml.lock');
    await mkdir(lockPath);

    // Save should succeed after detecting and breaking the stale lock
    // (default staleMs=5000, timeout=10000, so stale is detected first)
    const mapping = emptyMapping();
    addIdMapping(mapping, TEST_ULIDS.CONCURRENT_1, 'sl01');
    addIdMapping(mapping, TEST_ULIDS.CONCURRENT_2, 'sl02');
    await saveIdMapping(tempDir, mapping);

    // Both entries must be present
    const result = await loadIdMapping(tempDir);
    expect(result.shortToUlid.get('sl01')).toBe(TEST_ULIDS.CONCURRENT_1);
    expect(result.shortToUlid.get('sl02')).toBe(TEST_ULIDS.CONCURRENT_2);
  }, 15_000);

  it('saves successfully after contending lock is released', async () => {
    // Write initial mapping
    const initial = emptyMapping();
    addIdMapping(initial, TEST_ULIDS.CONCURRENT_1, 'ok01');
    await saveIdMapping(tempDir, initial);

    // Simulate a briefly held lock that releases before timeout
    const mappingsDir = join(tempDir, 'mappings');
    const lockPath = join(mappingsDir, 'ids.yml.lock');
    await mkdir(lockPath);

    // Release the lock after 100ms
    setTimeout(() => {
      void import('node:fs/promises').then(({ rmdir }) =>
        rmdir(lockPath).catch((_e: unknown) => {
          /* lock already released */
        }),
      );
    }, 100);

    // Save should succeed after waiting for lock release
    const mapping = emptyMapping();
    addIdMapping(mapping, TEST_ULIDS.CONCURRENT_1, 'ok01');
    addIdMapping(mapping, TEST_ULIDS.CONCURRENT_2, 'ok02');
    await saveIdMapping(tempDir, mapping);

    // Both entries must be present
    const result = await loadIdMapping(tempDir);
    expect(result.shortToUlid.get('ok01')).toBe(TEST_ULIDS.CONCURRENT_1);
    expect(result.shortToUlid.get('ok02')).toBe(TEST_ULIDS.CONCURRENT_2);
  });

  it('high-concurrency Promise.all saves all survive with lock serialization', async () => {
    // Regression test: 10 concurrent saves from stale snapshots.
    // With degraded mode, some entries would be lost.
    // With proper locking, all are serialized and merged.
    const count = 10;
    const entries: { ulid: string; shortId: string }[] = [];
    for (let i = 0; i < count; i++) {
      // Generate distinct ULIDs using a simple pattern
      const ulid = `01highconcur00000000000${String(i).padStart(2, '0')}a`;
      entries.push({ ulid, shortId: `hc${String(i).padStart(2, '0')}` });
    }

    // All "processes" load the same empty mapping
    const snapshots = await Promise.all(entries.map(() => loadIdMapping(tempDir)));

    // Each adds its own entry to its stale snapshot
    for (let i = 0; i < entries.length; i++) {
      addIdMapping(snapshots[i]!, entries[i]!.ulid, entries[i]!.shortId);
    }

    // Save all concurrently — lockfile serializes, read-merge-write preserves
    await Promise.all(snapshots.map((s) => saveIdMapping(tempDir, s)));

    // ALL entries must survive
    const result = await loadIdMapping(tempDir);
    for (const entry of entries) {
      expect(
        result.shortToUlid.get(entry.shortId),
        `Missing mapping for ${entry.shortId} (ulid: ${entry.ulid})`,
      ).toBe(entry.ulid);
    }
    expect(result.shortToUlid.size).toBe(count);
  });
});
