/**
 * Regression guard — the keepsake never renders a broken image tile.
 *
 * The HTML export wrote `thumb: "media/<id>_thumb.jpg"` into `data.json` unconditionally. When
 * ffmpeg produced no poster frame — which it did, silently and with exit 0, for any clip of a
 * second or less — the ZIP writer skipped the unopenable file but `data.json` still advertised it.
 * The viewer then requested an entry the archive did not contain and drew a broken `<img>`.
 *
 * The viewer was never the problem: `+page.svelte` already guards `{#if post.media.thumb}` and
 * falls back to a proper dark video tile with a play glyph. The guard simply never fired, because
 * the backend always handed it a non-empty string. The fix is the backend telling the truth —
 * `thumb: ""` when there is no poster — so no viewer change was needed.
 *
 * This asserts the property that actually matters to a guest and that no server-side signal can
 * report: **every image in the opened keepsake resolves**. `naturalWidth > 0` is false for exactly
 * the broken-tile case, whatever produced it — a missing video poster, a failed image thumbnail, or
 * some future path nobody has thought of yet. It is deliberately not an assertion about ffmpeg.
 *
 * Runs over `file://`, the way a guest opens it.
 */
import { test, expect } from '../../fixtures/test';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { uploadRaw } from '../../helpers/upload-client';
import { seedUpload } from '../../helpers/seed';
import { BASE } from '../../helpers/env';

test.describe('Export — the keepsake has no broken tiles', () => {
  test('every image in the opened viewer resolves', async ({ page, host, guest, db }) => {
    test.setTimeout(150_000);
    const bearer = { Authorization: `Bearer ${host.jwt}` };

    const g = await guest('TileChecker');
    const ids: string[] = [await seedUpload(g.jwt, { caption: 'ein Foto' })];

    // Both clips: the 1.000 s one is the case that produced the broken tile, the 5 s one is the
    // ordinary path that had no coverage at all.
    for (const file of ['sample.mp4', 'sample-5s.mp4']) {
      const bytes = readFileSync(join(process.cwd(), 'fixtures', 'media', file));
      const res = await uploadRaw(g.jwt, bytes, { filename: file, contentType: 'video/mp4' });
      expect(res.status).toBe(201);
      ids.push(((await res.json()) as { id: string }).id);
    }
    for (const id of ids) {
      await expect.poll(() => db.compressionStatus(id), { timeout: 45_000 }).toBe('done');
    }

    expect(
      (await fetch(`${BASE}/api/v1/host/gallery/release`, { method: 'POST', headers: bearer }))
        .status
    ).toBe(204);
    await expect
      .poll(
        async () => {
          const res = await fetch(`${BASE}/api/v1/export/status`, { headers: bearer });
          return (await res.json()).html?.status;
        },
        { timeout: 120_000, intervals: [500] }
      )
      .toBe('done');

    const ticketRes = await fetch(`${BASE}/api/v1/export/ticket?kind=html`, {
      method: 'POST',
      headers: bearer,
    });
    const { ticket } = (await ticketRes.json()) as { ticket: string };
    const dl = await fetch(`${BASE}/api/v1/export/html?ticket=${encodeURIComponent(ticket)}`);
    expect(dl.status).toBe(200);

    const dir = mkdtempSync(join(tmpdir(), 'eventsnap-tiles-'));
    try {
      const zipPath = join(dir, 'Memories.zip');
      writeFileSync(zipPath, Buffer.from(await dl.arrayBuffer()));
      execFileSync('unzip', ['-qo', zipPath, '-d', dir]);

      await page.goto('file://' + join(dir, 'index.html'));

      // Every post is present, whether or not it has a poster.
      const posts = await page.evaluate(() => {
        const d = (
          window as unknown as {
            __EXPORT_DATA__?: { posts?: { media?: { thumb?: string; type?: string } }[] };
          }
        ).__EXPORT_DATA__;
        return d?.posts?.map((p) => ({ thumb: p.media?.thumb ?? '', type: p.media?.type })) ?? null;
      });
      expect(posts, '__EXPORT_DATA__ was never assigned').not.toBeNull();
      expect(posts!.length).toBe(3);

      // Any thumb data.json DOES advertise must be a file the archive actually contains.
      const entries = execFileSync('unzip', ['-Z1', zipPath], { encoding: 'utf8' }).split('\n');
      for (const p of posts!.filter((p) => p.thumb)) {
        expect(
          entries.includes(p.thumb),
          `data.json advertises ${p.thumb} but the archive does not contain it`
        ).toBe(true);
      }

      await page.waitForLoadState('networkidle');

      // POSITIVE anchor FIRST, and it is load-bearing. The "nothing is broken" assertion below
      // filters `img` elements, so a viewer that rendered NOTHING AT ALL yields `[]` and passes —
      // this file, whose whole subject is that the images resolve, was the one spec that would
      // have stayed green through a total viewer regression. Everything else it checks
      // (`__EXPORT_DATA__`, the archive entries) comes from the backend and the classic head
      // script, neither of which needs the viewer bundle to have run at all.
      const rendered = await page.locator('img').count();
      expect(rendered, 'the keepsake viewer rendered no images at all').toBeGreaterThan(0);

      // THE assertion: nothing rendered broken.
      const broken = await page.evaluate(() =>
        Array.from(document.querySelectorAll('img'))
          .filter((i) => i.complete && i.naturalWidth === 0)
          .map((i) => i.getAttribute('src') ?? '(no src)')
      );
      expect(broken, `broken image tiles in the keepsake: ${broken.join(', ')}`).toEqual([]);
    } finally {
      rmSync(dir, { recursive: true, force: true });
    }
  });
});
