/**
 * Regression guard — videos must actually play.
 *
 * Two independent defects made every video unplayable, and nothing in the suite covered
 * either one (no test anywhere played media or asserted a `<video>` src):
 *
 *  1. The lightbox fed `<video>` the URL from `pickMediaUrl`, which is mime-agnostic.
 *     Compression only ever produces a *thumbnail* for a video — one ffmpeg frame — so in
 *     the DEFAULT saver mode the element's src was `/api/v1/upload/{id}/thumbnail`: a JPEG,
 *     served as image/jpeg with nosniff so the browser can't even sniff its way out.
 *     Chromium reported DEMUXER_ERROR_COULD_NOT_OPEN.
 *
 *  2. `stream_media_file` ignored `Range` entirely — always 200 with the whole body, never
 *     an Accept-Ranges or Content-Range. iOS Safari opens every `<video>` with a
 *     `Range: bytes=0-1` probe and abandons the load without a 206, so video failed on the
 *     app's primary platform even in `original` data mode.
 *
 * Fixing either alone still leaves video broken, so both are asserted here.
 */
import { test, expect } from '../../fixtures/test';
import { uploadRaw } from '../../helpers/upload-client';
import { BASE } from '../../helpers/env';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';

const SAMPLE_MP4 = join(process.cwd(), 'fixtures', 'media', 'sample.mp4');

async function seedVideo(jwt: string): Promise<string> {
  const res = await uploadRaw(jwt, readFileSync(SAMPLE_MP4), {
    filename: 'clip.mp4',
    contentType: 'video/mp4',
    caption: 'ein Video',
  });
  if (res.status !== 201) throw new Error(`video seed failed: ${res.status}`);
  return ((await res.json()) as { id: string }).id;
}

test.describe('Video — the lightbox plays it', () => {
  test('the <video> src is the original, not the thumbnail JPEG', async ({
    page,
    guest,
    signIn,
    db,
  }) => {
    const g = await guest('VideoWatcher');
    const id = await seedVideo(g.jwt);

    // The poster assertion below needs the ffmpeg thumbnail to EXIST — the lightbox binds
    // `poster={upload.thumbnail_url ?? undefined}`, so the attribute is simply absent until
    // compression finishes. Without this wait the test races the worker and fails against a
    // cold stack (first run after `stack:down -v`, cold ffmpeg), which is exactly when a suite
    // is least likely to be believed. The `src` assertion is unconditional; only the poster
    // needs the wait.
    await expect.poll(() => db.compressionStatus(id), { timeout: 60_000 }).toBe('done');

    await signIn(page, g);
    await page.goto('/feed');

    const card = page.locator('article').first();
    await expect(card).toBeVisible({ timeout: 15_000 });
    await card.getByRole('button', { name: 'Bild vergrößern' }).click();

    const video = page.locator('video');
    await expect(video).toBeVisible({ timeout: 10_000 });

    // The bug in one assertion: this was `/thumbnail` in the default data mode.
    await expect(video).toHaveAttribute('src', `/api/v1/upload/${id}/original`);

    // The poster SHOULD still be the thumbnail — that's what it's for.
    await expect(video).toHaveAttribute('poster', `/api/v1/upload/${id}/thumbnail`);

    // …and it must actually RESOLVE. Asserting only the attribute is what let a phantom thumbnail
    // survive nine rounds of green: `thumbnail_path` was written for a file ffmpeg never created,
    // so this URL 404'd for every clip of a second or less while the attribute looked perfect.
    // One extra fetch is the whole difference.
    const poster = await fetch(`${BASE}/api/v1/upload/${id}/thumbnail`, {
      headers: { Authorization: `Bearer ${g.jwt}` },
    });
    expect(poster.status, 'the poster URL must serve real bytes, not just exist').toBe(200);
    expect(poster.headers.get('content-type')).toContain('image/');
    expect((await poster.arrayBuffer()).byteLength).toBeGreaterThan(0);

    // And the browser must accept the bytes as media. preload="none" means nothing is
    // fetched until we ask, so drive a load explicitly and wait for metadata.
    const readyState = await video.evaluate(async (el: HTMLVideoElement) => {
      el.preload = 'metadata';
      el.load();
      await new Promise<void>((resolve) => {
        if (el.readyState > 0) return resolve();
        el.addEventListener('loadedmetadata', () => resolve(), { once: true });
        el.addEventListener('error', () => resolve(), { once: true });
        setTimeout(resolve, 15_000);
      });
      return { ready: el.readyState, err: el.error?.message ?? null };
    });
    expect(readyState.err, `the browser rejected the media: ${readyState.err}`).toBeNull();
    expect(readyState.ready, 'video metadata must load').toBeGreaterThan(0);
  });

  test('the video body is not downloaded before the user presses play', async ({
    page,
    guest,
    signIn,
  }) => {
    // saver mode exists to protect a guest's mobile data, and there is no smaller video
    // derivative to fall back to — so opening the lightbox must not pull the file down.
    //
    // Asserting "no request at all" would be wrong: WebKit opens a connection for a
    // preload="none" <video> and immediately ABORTS it (observed: GET with no Range,
    // response status 0, nothing transferred), whereas Chromium issues nothing. The
    // portable guarantee — and the one that actually protects the data plan — is that no
    // response carrying the body ever completes. Dropping preload="none" fails this.
    const g = await guest('VideoThrifty');
    const id = await seedVideo(g.jwt);

    await signIn(page, g);
    const delivered: string[] = [];
    page.on('response', (r) => {
      if (!r.url().includes(`/upload/${id}/original`)) return;
      // status 0 = aborted before any bytes landed.
      if (r.status() === 200 || r.status() === 206) {
        delivered.push(`${r.status()} len=${r.headers()['content-length'] ?? '?'}`);
      }
    });

    await page.goto('/feed');
    const card = page.locator('article').first();
    await expect(card).toBeVisible({ timeout: 15_000 });
    await card.getByRole('button', { name: 'Bild vergrößern' }).click();
    await expect(page.locator('video')).toBeVisible({ timeout: 10_000 });
    await page.waitForTimeout(2000);

    expect(delivered, 'no video bytes may be delivered before play').toEqual([]);
  });
});

test.describe('Media — HTTP Range', () => {
  test('a range request is answered 206 with the right slice', async ({ guest }) => {
    const g = await guest('RangeReader');
    const id = await seedVideo(g.jwt);
    const url = `${BASE}/api/v1/upload/${id}/original`;

    const full = await fetch(url);
    expect(full.status).toBe(200);
    expect(full.headers.get('accept-ranges'), 'clients must be told seeking works').toBe('bytes');
    const total = Number(full.headers.get('content-length'));
    expect(total).toBeGreaterThan(0);

    // The exact probe iOS Safari opens a <video> with. Two bytes, inclusive.
    const probe = await fetch(url, { headers: { Range: 'bytes=0-1' } });
    expect(probe.status, 'iOS abandons the load without a 206').toBe(206);
    expect(probe.headers.get('content-range')).toBe(`bytes 0-1/${total}`);
    expect(Number(probe.headers.get('content-length'))).toBe(2);
    expect((await probe.arrayBuffer()).byteLength).toBe(2);

    // A mid-file seek must return the matching slice, not the whole body.
    const mid = await fetch(url, { headers: { Range: 'bytes=10-19' } });
    expect(mid.status).toBe(206);
    expect(mid.headers.get('content-range')).toBe(`bytes 10-19/${total}`);
    const midBytes = Buffer.from(await mid.arrayBuffer());
    expect(midBytes.byteLength).toBe(10);
    expect(midBytes).toEqual(Buffer.from(await full.arrayBuffer()).subarray(10, 20));

    // Open-ended range runs to EOF.
    const tail = await fetch(url, { headers: { Range: `bytes=${total - 5}-` } });
    expect(tail.status).toBe(206);
    expect(Number(tail.headers.get('content-length'))).toBe(5);

    // Past EOF must be 416 — answering 200 makes a player re-request forever.
    const bad = await fetch(url, { headers: { Range: `bytes=${total + 10}-` } });
    expect(bad.status).toBe(416);
    expect(bad.headers.get('content-range')).toBe(`bytes */${total}`);
  });

  test('image derivatives are range-capable too', async ({ guest, db }) => {
    // Same helper serves all four media routes, so the guarantee is uniform.
    const g = await guest('RangeImages');
    const res = await uploadRaw(
      g.jwt,
      readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg')),
      { filename: 'r.jpg', contentType: 'image/jpeg' }
    );
    const { id } = (await res.json()) as { id: string };
    await expect.poll(() => db.compressionStatus(id), { timeout: 30_000 }).toBe('done');

    const preview = await fetch(`${BASE}/api/v1/upload/${id}/preview`, {
      headers: { Range: 'bytes=0-9' },
    });
    expect(preview.status).toBe(206);
    expect(Number(preview.headers.get('content-length'))).toBe(10);
  });
});
