// tests/background/webm-remux.test.ts
//
// Plan 01-08 Task 2: RED unit-level contract tests for the new
// `remuxSegments()` helper that will live in
// `src/background/webm-remux.ts` (created in Task 3).
//
// Pins the five invariants that the remux pipeline must honor in order
// for SPEC §10 #7 (`last_30sec.webm plays back in a browser`) to hold:
//
//   1. Single-EBML-header output. The bytes returned by remuxSegments
//      contain EXACTLY ONE `1A 45 DF A3` (EBML) and EXACTLY ONE
//      `18 53 80 67` (Segment). That's the defining structural
//      property the D-14-remux amendment commits to.
//
//   2. Size sanity. The output blob's byte count lives within
//      [0.7×, 1.3×] of the summed input sizes — guards against
//      silently dropping content OR ballooning it.
//
//   3. ffprobe-reported `format=duration` >= 25 s. Mirrors the gate the
//      operator-facing test in tests/offscreen/webm-playback.test.ts
//      enforces but at the unit-helper level.
//
//   4. ffprobe `-count_frames` reports `[905, 912]` frames inclusive
//      (the per-segment sum is 301+300+311=912 frames per the d13
//      byte-level probe; the muxer may legitimately drop at most one
//      partial frame per segment boundary → 3 boundaries × 1 frame =
//      ±3 absolute frames tolerance). I-01 fix during plan checker
//      pass: tightened from the prior ±20% band to ±1% / ±3 frames.
//
//   5. Empty input ⇒ empty Blob. `remuxSegments([])` returns a
//      `Promise<Blob>` whose `.size === 0`. Defense-in-depth — the
//      saveArchive EmptyVideoBufferError throw guards this path
//      upstream, but the helper must be safe on its own.
//
// The fixture used here is `tests/fixtures/last_30sec.webm` sliced at
// the byte offsets the d13 debug session documented (0 / 509038 /
// 970967 — verified empirically against the committed fixture at the
// top of this test file's authoring session).
//
// Skip discipline: ffprobe-dependent tests gate behind `ffprobeAvailable()`
// matching the pattern in tests/offscreen/webm-playback.test.ts. The
// byte-magic tests (1, 2, 5) do NOT skip — they run anywhere.
//
// This file is RED at land time (the `src/background/webm-remux.ts`
// module does not yet exist). Task 3 implements remuxSegments and
// flips all 5 to GREEN.

import { describe, it, expect } from 'vitest';
import { readFileSync, existsSync, statSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, resolve, join } from 'node:path';
import { tmpdir } from 'node:os';

import type { VideoSegment } from '../../src/shared/types';

const here = dirname(fileURLToPath(import.meta.url));
// Plan 01-08 Task 5 closeout note (2026-05-17): `tests/fixtures/last_30sec.webm`
// is now the post-remux OUTPUT (single-EBML, validated by webm-playback.test.ts).
// These unit tests need the pre-remux INPUT shape (3-EBML concat), so they
// read from the preserved raw fixture instead.
const FIXTURE_PATH = resolve(here, '..', '..', 'tests', 'fixtures', 'raw-3ebml-concat.webm');
const FFPROBE_BIN = '/usr/bin/ffprobe';

// Byte offsets of each EBML header in `tests/fixtures/raw-3ebml-concat.webm`,
// per the d13 debug session probe (Evidence/H4 byte-level EBML probe).
// Verified at Plan 01-08 Task 2 land time against the original committed
// fixture (now preserved as raw-3ebml-concat.webm): offsets [0, 509038, 970967];
// total 1_633_459 bytes.
const SEG1_START = 0;
const SEG2_START = 509038;
const SEG3_START = 970967;

// Single-EBML invariant constants.
const EBML_MAGIC = new Uint8Array([0x1a, 0x45, 0xdf, 0xa3]);
const SEGMENT_MAGIC = new Uint8Array([0x18, 0x53, 0x80, 0x67]);

// Per-segment frame counts from the d13 byte-level probe (segments
// individually counted via ffprobe -count_frames):
//   seg1 = 301, seg2 = 300, seg3 = 311 → sum = 912.
// I-01 tolerance: muxer may drop at most one partial frame per segment
// boundary (3 boundaries → ±3 absolute frames).
const EXPECTED_FRAME_COUNT_LOWER = 905;
const EXPECTED_FRAME_COUNT_UPPER = 912;

// Playable-duration floor — mirrors MIN_PLAYABLE_DURATION_MS in
// tests/offscreen/webm-playback.test.ts. The recorder lifecycle holds
// 3 × 10 s segments minus boundary slack; gate at 25 s.
const MIN_DURATION_MS = 25_000;

// Size sanity band (input-sum × [lower, upper]). Catches silent content
// drop AND container-overhead explosion.
const SIZE_LOWER_RATIO = 0.7;
const SIZE_UPPER_RATIO = 1.3;

/**
 * Predicate gating tests that shell out to ffprobe. Same shape as the
 * helper in tests/offscreen/webm-playback.test.ts (kept inline rather
 * than imported because the two test files live in distinct
 * directories and Vitest discourages cross-tree relative test
 * imports). Mirror updates between the two by hand.
 *
 * @returns true if /usr/bin/ffprobe exists and is a regular file.
 */
function ffprobeAvailable(): boolean {
  try {
    return existsSync(FFPROBE_BIN) && statSync(FFPROBE_BIN).isFile();
  } catch {
    return false;
  }
}

/**
 * Count non-overlapping byte-window matches of `magic` inside `bytes`.
 * Pure 4-byte pattern scan; no false-positive risk at the fixture
 * scale (~1.6 MB) — EBML element IDs are structurally rare enough that
 * a naive scan is correct, but more importantly the assertion uses
 * EXACT-1 counts so any spurious match is a real bug surface.
 *
 * @param bytes - Byte array to scan.
 * @param magic - 4-byte EBML element identifier.
 * @returns Count of occurrences.
 */
function countMagic(bytes: Uint8Array, magic: Uint8Array): number {
  if (magic.length !== 4) {
    throw new Error(`countMagic expects 4-byte magic; got ${magic.length}`);
  }
  let count = 0;
  for (let i = 0; i + 4 <= bytes.length; i++) {
    if (
      bytes[i] === magic[0] &&
      bytes[i + 1] === magic[1] &&
      bytes[i + 2] === magic[2] &&
      bytes[i + 3] === magic[3]
    ) {
      count++;
    }
  }
  return count;
}

/**
 * Slice the canonical 3-segment fixture into 3 standalone WebM Blobs
 * at the byte offsets the d13 byte-level probe identified. Each
 * returned Blob is a complete, individually playable ~10 s WebM with
 * its own EBML header + Segment + Cluster tree.
 *
 * The `timestamp` field on each VideoSegment uses the synthetic
 * monotonic sequence `1_000_000 + i*10_000` (microsecond-style
 * placeholders matching the recorder-side rotation cadence). The
 * remux helper sorts by timestamp ascending so the relative ordering
 * is what matters, not the absolute value.
 *
 * @returns Three VideoSegment entries, one per fixture segment.
 */
function splitFixtureIntoSegments(): VideoSegment[] {
  const buf = readFileSync(FIXTURE_PATH);
  const total = buf.length;
  const seg1 = buf.subarray(SEG1_START, SEG2_START);
  const seg2 = buf.subarray(SEG2_START, SEG3_START);
  const seg3 = buf.subarray(SEG3_START, total);
  return [
    { data: new Blob([new Uint8Array(seg1)], { type: 'video/webm' }), timestamp: 1_000_000 },
    { data: new Blob([new Uint8Array(seg2)], { type: 'video/webm' }), timestamp: 1_010_000 },
    { data: new Blob([new Uint8Array(seg3)], { type: 'video/webm' }), timestamp: 1_020_000 },
  ];
}

/**
 * Read container-level format duration via ffprobe. Mirrors the
 * helper in webm-playback.test.ts and returns NaN on parse failure.
 *
 * @param fixturePath - Absolute path to the WebM file.
 * @returns Duration in milliseconds, or NaN on parse failure.
 */
function probeContainerDurationMs(fixturePath: string): number {
  const proc = spawnSync(
    FFPROBE_BIN,
    [
      '-v', 'error',
      '-show_entries', 'format=duration',
      '-of', 'default=noprint_wrappers=1:nokey=1',
      '-i', fixturePath,
    ],
    {
      stdio: ['ignore', 'pipe', 'pipe'],
      encoding: 'utf-8',
      timeout: 30_000,
      maxBuffer: 1 * 1024 * 1024,
    },
  );
  if (proc.signal !== null) {
    throw new Error(`ffprobe killed by signal ${proc.signal}`);
  }
  const stdout = (proc.stdout ?? '').trim();
  const seconds = parseFloat(stdout);
  return Number.isFinite(seconds) ? Math.round(seconds * 1000) : Number.NaN;
}

/**
 * Read decoded frame count via `ffprobe -count_frames`. Counts the
 * video stream's nb_read_frames property (the actual decode-side
 * count, not the container-claimed count which can lie under the
 * D-13 multi-EBML pathology).
 *
 * @param fixturePath - Absolute path to the WebM file.
 * @returns Frame count, or NaN on parse failure.
 */
function probeDecodedFrameCount(fixturePath: string): number {
  const proc = spawnSync(
    FFPROBE_BIN,
    [
      '-v', 'error',
      '-count_frames',
      '-select_streams', 'v:0',
      '-show_entries', 'stream=nb_read_frames',
      '-of', 'default=noprint_wrappers=1:nokey=1',
      '-i', fixturePath,
    ],
    {
      stdio: ['ignore', 'pipe', 'pipe'],
      encoding: 'utf-8',
      timeout: 30_000,
      maxBuffer: 1 * 1024 * 1024,
    },
  );
  if (proc.signal !== null) {
    throw new Error(`ffprobe killed by signal ${proc.signal}`);
  }
  const stdout = (proc.stdout ?? '').trim();
  const n = parseInt(stdout, 10);
  return Number.isFinite(n) ? n : Number.NaN;
}

/**
 * Write a Blob to a fresh tmpfile and return the path. Caller is
 * responsible for cleanup via the returned dir handle.
 *
 * @param blob - Blob to materialize on disk.
 * @returns { path, dir } — `dir` to remove recursively after use.
 */
async function blobToTmpFile(blob: Blob): Promise<{ path: string; dir: string }> {
  const dir = mkdtempSync(join(tmpdir(), 'webm-remux-test-'));
  const path = join(dir, 'remuxed.webm');
  const ab = await blob.arrayBuffer();
  writeFileSync(path, Buffer.from(ab));
  return { path, dir };
}

describe('remuxSegments (Plan 01-08 Task 2 — RED until Task 3)', () => {
  it('emits exactly one EBML header and one Segment element', async () => {
    // Dynamic import wrapped in try/catch so RED before Task 3 lands
    // produces a precise message rather than a module-resolution
    // crash that hides the contract.
    let remux: typeof import('../../src/background/webm-remux');
    try {
      remux = await import('../../src/background/webm-remux');
    } catch (e) {
      expect.fail(
        `src/background/webm-remux.ts does not exist yet — this is the Task 3 GREEN gate. Error: ${String(e)}`,
      );
    }
    const segments = splitFixtureIntoSegments();
    const out = await remux.remuxSegments(segments);
    const bytes = new Uint8Array(await out.arrayBuffer());

    const ebmlCount = countMagic(bytes, EBML_MAGIC);
    const segmentCount = countMagic(bytes, SEGMENT_MAGIC);
    expect(
      ebmlCount,
      `Expected exactly 1 EBML header magic (0x1A 0x45 0xDF 0xA3) in remux output, found ${ebmlCount}. ` +
        `The whole point of D-14-remux is to collapse 3 source EBML headers into 1.`,
    ).toBe(1);
    expect(
      segmentCount,
      `Expected exactly 1 Segment element magic (0x18 0x53 0x80 0x67) in remux output, found ${segmentCount}. ` +
        `Multiple Segment elements mean the merge collapsed into a multi-segment Matroska (broken under SPEC §10 #7).`,
    ).toBe(1);
  });

  it('emits a blob whose size is within [0.7x, 1.3x] of the input total', async () => {
    let remux: typeof import('../../src/background/webm-remux');
    try {
      remux = await import('../../src/background/webm-remux');
    } catch (e) {
      expect.fail(`webm-remux module missing: ${String(e)}`);
    }
    const segments = splitFixtureIntoSegments();
    const inputTotal = segments.reduce((sum, seg) => sum + seg.data.size, 0);
    const out = await remux.remuxSegments(segments);
    const lower = inputTotal * SIZE_LOWER_RATIO;
    const upper = inputTotal * SIZE_UPPER_RATIO;
    expect(
      out.size,
      `Output size ${out.size} bytes; input sum ${inputTotal}; expected band ` +
        `[${lower.toFixed(0)}, ${upper.toFixed(0)}]. Below band = silent content drop; ` +
        `above band = container overhead explosion (re-encode regression?).`,
    ).toBeGreaterThanOrEqual(lower);
    expect(out.size).toBeLessThanOrEqual(upper);
  });

  it.skipIf(!ffprobeAvailable())(
    'ffprobe format=duration on the remuxed output is at least 25 s',
    async () => {
      let remux: typeof import('../../src/background/webm-remux');
      try {
        remux = await import('../../src/background/webm-remux');
      } catch (e) {
        expect.fail(`webm-remux module missing: ${String(e)}`);
      }
      const segments = splitFixtureIntoSegments();
      const out = await remux.remuxSegments(segments);
      const handle = await blobToTmpFile(out);
      try {
        const durationMs = probeContainerDurationMs(handle.path);
        expect(
          durationMs,
          `ffprobe reported container duration=${durationMs} ms for remux output. ` +
            `SPEC §10 #7 floor is ${MIN_DURATION_MS} ms. ` +
            `If <10 s, the multi-EBML pathology returned (Plan 01-08 regression). ` +
            `If 0/NaN, the muxer produced no Info.Duration EBML (check Muxer config).`,
        ).toBeGreaterThanOrEqual(MIN_DURATION_MS);
      } finally {
        rmSync(handle.dir, { recursive: true, force: true });
      }
    },
  );

  it.skipIf(!ffprobeAvailable())(
    'ffprobe -count_frames reports between 905 and 912 frames inclusive',
    async () => {
      let remux: typeof import('../../src/background/webm-remux');
      try {
        remux = await import('../../src/background/webm-remux');
      } catch (e) {
        expect.fail(`webm-remux module missing: ${String(e)}`);
      }
      const segments = splitFixtureIntoSegments();
      const out = await remux.remuxSegments(segments);
      const handle = await blobToTmpFile(out);
      try {
        const frames = probeDecodedFrameCount(handle.path);
        expect(
          frames,
          `ffprobe -count_frames reported ${frames} frames in remux output. ` +
            `Expected band [${EXPECTED_FRAME_COUNT_LOWER}, ${EXPECTED_FRAME_COUNT_UPPER}] ` +
            `(per-segment sum 301+300+311=912 ± 3 boundary partial-frame drops). ` +
            `Below band = frame loss (silent SimpleBlock-skip bug); above band = ` +
            `duplicate-frame bug (re-emitting the same SimpleBlock from multiple sources).`,
        ).toBeGreaterThanOrEqual(EXPECTED_FRAME_COUNT_LOWER);
        expect(frames).toBeLessThanOrEqual(EXPECTED_FRAME_COUNT_UPPER);
      } finally {
        rmSync(handle.dir, { recursive: true, force: true });
      }
    },
  );

  it('empty input returns an empty Blob', async () => {
    let remux: typeof import('../../src/background/webm-remux');
    try {
      remux = await import('../../src/background/webm-remux');
    } catch (e) {
      expect.fail(`webm-remux module missing: ${String(e)}`);
    }
    const out = await remux.remuxSegments([]);
    expect(out).toBeInstanceOf(Blob);
    expect(
      out.size,
      'Empty input must produce an empty blob — saveArchive guards this path upstream but ' +
        'remuxSegments must be safe on its own (defense-in-depth, T-1-08-01 + T-1-08-02).',
    ).toBe(0);
  });
});
