/**
 * Client upload-queue under a realistic burst — the scenario an event actually
 * produces (a guest multi-selects 10-20 photos at once) and the one the
 * server-side load test could NOT cover, because that test hit POST /upload
 * directly and bypassed the browser queue entirely.
 *
 * Drives the REAL client path (UploadSheet → /upload → addToQueue →
 * processQueue → XHR) and asserts the four properties that matter for not
 * losing a guest's photos:
 *
 *   1. SERIAL drain      — the queue uploads ONE file at a time per device
 *                          (processQueue's find-next-pending loop), never a
 *                          parallel fan-out. Proven by counting how many upload
 *                          requests sit in the intercept handler at once.
 *   2. PERSISTENCE       — every staged file is written to IndexedDB with its
 *                          blob before it's sent, and the blob is dropped only
 *                          after the upload succeeds. Read straight out of IDB
 *                          mid-burst.
 *   3. ALL LAND          — every file in the burst is created server-side.
 *   4. RESUME ON RELOAD  — a hard reload mid-burst (tab closed / PWA killed)
 *                          resumes the remaining uploads from IndexedDB via
 *                          loadQueue(), losing nothing.
 *
 * A tiny per-upload latency is injected via route interception so the serial
 * drain is observable and there's a window to reload mid-burst — no need for
 * large fixture files.
 */
import { test, expect } from '../../fixtures/test';
import { skipIfNoIdbBlobs } from '../../helpers/webkit';
import { FeedPage, UploadSheet } from '../../page-objects';
import { mkdtempSync, copyFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import type { Page } from '@playwright/test';

const SAMPLE_JPG = join(process.cwd(), 'fixtures', 'media', 'sample.jpg');
const BURST = 16; // in the 10-20 "multi-select" range

// Make BURST distinct files. The queue dedupes on name+size+lastModified, so
// distinct NAMES are enough to keep them from collapsing into one item — content
// can be identical (we only assert rows are created, not what they decode to).
function makeBurstFiles(): { dir: string; paths: string[] } {
  const dir = mkdtempSync(join(tmpdir(), 'eventsnap-burst-'));
  const paths = Array.from({ length: BURST }, (_, i) => {
    const p = join(dir, `burst_${String(i).padStart(2, '0')}.jpg`);
    copyFileSync(SAMPLE_JPG, p);
    return p;
  });
  return { dir, paths };
}

// White-box: read the queue rows straight from IndexedDB, including whether the
// blob is still attached — so we can prove blobs are kept until upload and
// dropped after. Mirrors the reader in offline-resume.spec.
async function queueRows(page: Page): Promise<Array<{ status: string; hasBlob: boolean }>> {
  return page.evaluate(async () => {
    return new Promise<Array<{ status: string; hasBlob: boolean }>>((resolve, reject) => {
      const req = indexedDB.open('eventsnap-uploads', 3);
      req.onerror = () => reject(req.error);
      req.onsuccess = () => {
        const dbh = req.result;
        const tx = dbh.transaction('queue', 'readonly');
        const all = tx.objectStore('queue').getAll();
        all.onsuccess = () =>
          resolve(all.result.map((r: any) => ({ status: r.status, hasBlob: r.blob != null })));
        all.onerror = () => reject(all.error);
      };
    });
  });
}

/**
 * Intercept POST /upload to (a) inject latency so the serial drain is observable
 * and (b) count how many uploads are in the handler simultaneously. A serial
 * client never has more than one in flight; a parallel fan-out would spike.
 * Returns a `peak()` accessor for the max concurrency seen.
 */
async function instrumentUploads(page: Page, delayMs: number) {
  let active = 0;
  let peak = 0;
  await page.route('**/api/v1/upload', async (route) => {
    active++;
    peak = Math.max(peak, active);
    await new Promise((r) => setTimeout(r, delayMs));
    active--;
    await route.continue();
  });
  return { peak: () => peak };
}

async function warmUploadChunk(page: Page) {
  // Load the code-split /upload route while online so a later reload/navigation
  // doesn't fetch the chunk at a bad moment (mirrors offline-resume.spec).
  await page.goto('/upload');
  await page.goto('/feed');
}

test.describe('Upload — client queue under a burst', () => {
  test('a 16-file burst drains serially, persists to IndexedDB, and all land', async ({
    page,
    guest,
    signIn,
    db,
    browserName,
  }) => {
    skipIfNoIdbBlobs(browserName);
    const g = await guest('BurstSerial');
    await signIn(page, g);
    await warmUploadChunk(page);
    const uploads = await instrumentUploads(page, 200);

    // Stage the whole burst through the real UI.
    const { dir, paths } = makeBurstFiles();
    try {
      const feed = new FeedPage(page);
      const sheet = new UploadSheet(page);
      await feed.openUploadSheet();
      await sheet.stageFiles(paths);
      await sheet.captionInput.waitFor({ state: 'visible', timeout: 10_000 });
      await sheet.fillCaption('burst of 16 #hochzeit');
      await sheet.submit();
      await page.waitForURL('**/feed', { timeout: 15_000 });

      // (2) PERSISTENCE: all 16 are written to IndexedDB with blobs essentially
      // immediately — before most have been sent. Catch the burst while the
      // queue still holds most of them.
      await expect
        .poll(async () => (await queueRows(page)).length, { timeout: 10_000 })
        .toBe(BURST);

      // While draining, pending rows keep their blob; done rows have dropped it.
      // Poll for a mid-burst moment that shows both invariants at once.
      await expect
        .poll(
          async () => {
            const rows = await queueRows(page);
            const pendingKeepBlob = rows
              .filter((r) => r.status === 'pending' || r.status === 'uploading')
              .every((r) => r.hasBlob);
            const doneDropBlob = rows.filter((r) => r.status === 'done').every((r) => !r.hasBlob);
            return pendingKeepBlob && doneDropBlob;
          },
          { timeout: 10_000 }
        )
        .toBe(true);

      // (3) ALL LAND: every file is created server-side.
      await expect.poll(() => db.countUploadsForUser(g.userId), { timeout: 30_000 }).toBe(BURST);

      // (1) SERIAL: never more than one upload in flight at a time.
      expect(uploads.peak()).toBe(1);

      // Clean end — the queue settles all items to done (no stuck error/blocked).
      await expect
        .poll(async () => (await queueRows(page)).every((r) => r.status === 'done'), {
          timeout: 10_000,
        })
        .toBe(true);
    } finally {
      rmSync(dir, { recursive: true, force: true });
    }
  });

  test('a hard reload mid-burst resumes the remaining uploads — nothing lost', async ({
    page,
    guest,
    signIn,
    db,
    browserName,
  }) => {
    skipIfNoIdbBlobs(browserName);
    const g = await guest('BurstResume');
    await signIn(page, g);
    await warmUploadChunk(page);
    // Bigger delay → a wide, reliable mid-burst window to reload inside. (We don't
    // assert seriality here — test 1 already proves it, and a reload aborts the
    // in-flight request mid-intercept, which would overcount concurrency.)
    await instrumentUploads(page, 300);

    const { dir, paths } = makeBurstFiles();
    try {
      const feed = new FeedPage(page);
      const sheet = new UploadSheet(page);
      await feed.openUploadSheet();
      await sheet.stageFiles(paths);
      await sheet.captionInput.waitFor({ state: 'visible', timeout: 10_000 });
      await sheet.fillCaption('burst then reload');
      await sheet.submit();
      await page.waitForURL('**/feed', { timeout: 15_000 });

      // Wait until we're genuinely mid-burst: a few landed, but far from all.
      await expect
        .poll(() => db.countUploadsForUser(g.userId), { timeout: 20_000 })
        .toBeGreaterThanOrEqual(3);
      const landedBeforeReload = await db.countUploadsForUser(g.userId);
      expect(landedBeforeReload).toBeLessThan(BURST);

      // Hard reload — wipes the JS module (and its in-flight drain), exactly like
      // a closed tab / killed PWA. The remaining pending items live only in
      // IndexedDB now.
      await page.reload();
      // Deliberately NOT navigating to /upload. Rehydration is now module-level and
      // auth-gated (upload-queue.ts `hydrateQueue`), so the queue resumes wherever the
      // reload lands. This assertion is the regression guard for the defect it replaced:
      // `loadQueue()` used to have a single call site in the whole app — the /upload
      // route's onMount — so a guest who reloaded anywhere else saw a 0 badge and their
      // staged photos never left the phone, having already been shown a success.

      // (4) RESUME: every file ends up server-side without re-staging anything.
      // `>=` not `===`: the only imperfection possible is a DUPLICATE (an upload
      // that succeeded server-side in the ~1ms between the XHR load event and the
      // IndexedDB 'done' write, caught by the reload, then re-sent on resume).
      // That's at worst one extra row, never a lost photo — the property we care
      // about. A shortfall (< BURST) would be a real data-loss regression.
      await expect
        .poll(() => db.countUploadsForUser(g.userId), { timeout: 30_000 })
        .toBeGreaterThanOrEqual(BURST);
    } finally {
      rmSync(dir, { recursive: true, force: true });
    }
  });
});
