/**
 * Regression guard — a guest-authored caption cannot brick the offline keepsake.
 *
 * The viewer's data is inlined as `<script>window.__EXPORT_DATA__={…};</script>` (it must be:
 * guests open index.html over file://, where a cross-origin fetch of a sibling data.json is
 * blocked). Captions and comments are guest text and land in that payload.
 *
 * The escape used to be `</` → `<\/`. Against XSS that holds — `</script><img src=x onerror=…>`
 * round-trips inert. It does NOT stop the caption steering the HTML TOKENIZER: `<!--<script` with
 * no later `-->` drives the parser into script-data-double-escaped state, where the template's own
 * `</script>` steps back to script-data-escaped instead of closing the element. Everything after —
 * including the viewer bundle — is swallowed as script data. Nothing executes and nothing leaks;
 * `__EXPORT_DATA__` is never assigned and the keepsake renders blank.
 *
 * What makes it worth a browser-level test rather than a unit test alone: the failure is SILENT and
 * POST-DISTRIBUTION. The export succeeds, the ZIP is well-formed, the job writes `done`,
 * /export/status is green, and the host hands out a file that only fails when a guest
 * double-clicks it — in every copy, unfixably. It is not visible by reading the escape. It is only
 * visible by running a real parser over the real artifact, which is what this does: release, pull
 * the actual Memories.zip, extract index.html, open it over file:// in Chromium, and assert the
 * viewer actually booted.
 *
 * The near-miss worth recording: `<!--<script>alert(1)</script>-->` comes back CLEAN, because the
 * trailing `-->` returns the parser to script-data state. A probe using the terminated form
 * quietly repairs the very thing it is testing for. Only the unterminated variant exposes it.
 */
import { test, expect } from '../../fixtures/test';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { seedUpload } from '../../helpers/seed';
import { BASE } from '../../helpers/env';

/** Unterminated on purpose — see the header. The terminated form self-repairs. */
const TOKENIZER_PAYLOAD = '<!--<script';
/** The classic break-out. Already handled, kept so the fix can never regress on it. */
const BREAKOUT_PAYLOAD = '</script><img src=x onerror=window.__XSS__=1>';

test.describe('Export — a caption cannot brick the keepsake viewer', () => {
  test('the exported viewer boots with a tokenizer-hostile caption in it', async ({
    page,
    host,
    guest,
    db,
  }) => {
    test.setTimeout(120_000);
    const bearer = { Authorization: `Bearer ${host.jwt}` };

    const g = await guest('Trickster');
    const a = await seedUpload(g.jwt, { caption: TOKENIZER_PAYLOAD });
    const b = await seedUpload(g.jwt, { caption: BREAKOUT_PAYLOAD });
    for (const id of [a, b]) {
      await expect.poll(() => db.compressionStatus(id), { timeout: 30_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: 90_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-viewer-'));
    try {
      const zipPath = join(dir, 'Memories.zip');
      writeFileSync(zipPath, Buffer.from(await dl.arrayBuffer()));
      // Extract the WHOLE archive: index.html pulls in the viewer's own JS/CSS, and the point of
      // this test is that those later resources are still reachable by the parser.
      execFileSync('unzip', ['-qo', zipPath, '-d', dir]);

      // file://, not http://. That is how a guest actually opens the keepsake, and it is the
      // whole reason the data is inlined rather than fetched from a sibling data.json.
      let xss = false;
      page.on('dialog', (d) => {
        xss = true;
        void d.dismiss();
      });
      await page.goto('file://' + join(dir, 'index.html'));

      // 1. The payload was assigned at all. This is the assertion that fails on the old escape —
      //    the second script block is never reached, so the global stays undefined.
      const captions = await page.evaluate(() => {
        const d = (window as unknown as { __EXPORT_DATA__?: { posts?: { caption?: string }[] } })
          .__EXPORT_DATA__;
        return d?.posts?.map((p) => p.caption ?? '') ?? null;
      });
      expect(captions, '__EXPORT_DATA__ was never assigned — the viewer is bricked').not.toBeNull();

      // 2. The captions survived verbatim. The escape is a transport encoding, not a sanitiser:
      //    a guest's text has to come back exactly, or we have silently rewritten their words.
      expect(captions).toContain(TOKENIZER_PAYLOAD);
      expect(captions).toContain(BREAKOUT_PAYLOAD);

      // 3. And nothing executed.
      expect(
        await page.evaluate(() => (window as unknown as { __XSS__?: number }).__XSS__ === 1),
        'the caption must be inert, not merely non-fatal'
      ).toBe(false);
      expect(xss).toBe(false);

      // 4. The viewer actually rendered — the whole document parsed, not just the head. If the
      //    tokenizer had swallowed the bundle, the body would be empty of viewer output.
      await expect(page.locator('body')).not.toBeEmpty();
    } finally {
      rmSync(dir, { recursive: true, force: true });
    }
  });
});
