/**
 * AudioStreamReader sync contract tests.
 *
 * The visualizer's bars must track the player's position in real time even
 * though the reader is an independent ffmpeg process. These tests pin the
 * two mechanisms that make that true:
 *
 * 1. `read(out, target)` serves the FFT window *at* the requested playback
 *    position — not at the decode head, which drifts from the player
 *    (startup skew, stalls).
 * 2. Decode is paced at the player's clock rate (`-readrate <speed>`), so
 *    the decode head keeps up with the position at any playback speed —
 *    native-rate pacing falls behind by (speed-1)s per second.
 *
 * Uses a self-generated WAV (440Hz sine, mono, 44.1kHz s16le) so the
 * expected samples can be computed analytically and compared exactly.
 */
import { test, expect } from "bun:test";
import { tmpdir } from "os";
import { join } from "path";
import { AudioStreamReader } from "../src/utils/audio-stream-reader";

const SAMPLE_RATE = 44100;
const FREQ = 440;
const AMP = 30000;

/** Write a WAV file containing `seconds` of a 440Hz sine at AMP amplitude. */
function writeSineWav(path: string, seconds: number): void {
	const total = Math.round(seconds * SAMPLE_RATE);
	const dataSize = total * 2;
	const buf = new Uint8Array(44 + dataSize);
	const dv = new DataView(buf.buffer);
	const ascii = (off: number, s: string) => {
		for (let i = 0; i < s.length; i++) buf[off + i] = s.charCodeAt(i);
	};
	ascii(0, "RIFF");
	dv.setUint32(4, 36 + dataSize, true);
	ascii(8, "WAVE");
	ascii(12, "fmt ");
	dv.setUint32(16, 16, true);
	dv.setUint16(20, 1, true); // PCM
	dv.setUint16(22, 1, true); // mono
	dv.setUint32(24, SAMPLE_RATE, true);
	dv.setUint32(28, SAMPLE_RATE * 2, true);
	dv.setUint16(32, 2, true);
	dv.setUint16(34, 16, true);
	ascii(36, "data");
	dv.setUint32(40, dataSize, true);
	for (let i = 0; i < total; i++) {
		const v = Math.round(AMP * Math.sin((2 * Math.PI * FREQ * i) / SAMPLE_RATE));
		dv.setInt16(44 + i * 2, v, true);
	}
	Bun.write(path, buf);
}

/** Analytic sample value at a file index, matching the writer's formula. */
function expectedAt(fileIndex: number): number {
	return Math.round(AMP * Math.sin((2 * Math.PI * FREQ * fileIndex) / SAMPLE_RATE));
}

/**
 * Block until the reader's decode head has advanced past `samples` samples.
 * The head advances at readrate × real time, so this bounds how long we wait.
 */
async function waitForHead(
	reader: AudioStreamReader,
	samples: number,
	timeoutMs = 8000,
): Promise<void> {
	const start = Date.now();
	while (reader.samplesWritten < samples) {
		if (Date.now() - start > timeoutMs) {
			throw new Error("reader decode head did not advance in time");
		}
		await Bun.sleep(25);
	}
}

const hasFfmpeg = !!Bun.which("ffmpeg");

test.skipIf(!hasFfmpeg)(
	"read() serves the exact window at the requested position",
	async () => {
		const wav = join(tmpdir(), `podtui-reader-${process.pid}-${Date.now()}.wav`);
		writeSineWav(wav, 20);
		const reader = new AudioStreamReader({ url: wav });
		try {
			reader.start(5, 1);
			// Cover targets up to ~5.6s (head must pass the read target).
			await waitForHead(reader, Math.round(0.6 * SAMPLE_RATE));

			const out = new Float64Array(512);

			// Window at 5.1s: the window ENDS at the target, so out[i] is at
			// file index 5*SR + round((5.1-5)*SR) - (len-1) + i.
			expect(reader.read(out, 5.1)).toBe(512);
			for (let i = 0; i < 512; i++) {
				const idx =
					Math.round(5 * SAMPLE_RATE) +
					Math.round((5.1 - 5) * SAMPLE_RATE) -
					(out.length - 1) +
					i;
				expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
			}

			// Window at 5.105s is the same stream shifted by exactly
			// round(0.005*SR)=221 samples — pins that the target maps to a
			// precise offset, not "whatever the decode head is at".
			const later = new Float64Array(512);
			expect(reader.read(later, 5.105)).toBe(512);
			for (let i = 0; i <= 512 - 222; i++) {
				expect(later[i]).toBe(out[i + 221]);
			}
		} finally {
			reader.stop();
			await Bun.$`rm -f ${wav}`.quiet();
		}
	},
);

test.skipIf(!hasFfmpeg)(
	"decode keeps up with the player clock at 2x speed",
	async () => {
		const wav = join(tmpdir(), `podtui-reader-${process.pid}-${Date.now()}.wav`);
		writeSineWav(wav, 20);
		const reader = new AudioStreamReader({ url: wav });
		try {
			reader.start(0, 2);
			// At 2x pacing the head reaches 2.5s after ~1.25s of wall time.
			// With native-rate pacing it would only be at ~1.25s, and the
			// window at 2.5s would clamp to the head — content mismatch.
			await waitForHead(reader, Math.round(2.5 * SAMPLE_RATE));

			const out = new Float64Array(512);
			expect(reader.read(out, 2.5)).toBe(512);
			for (let i = 0; i < 512; i++) {
				const idx =
					Math.round(2.5 * SAMPLE_RATE) - (out.length - 1) + i;
				expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
			}
		} finally {
			reader.stop();
			await Bun.$`rm -f ${wav}`.quiet();
		}
	},
);

test.skipIf(!hasFfmpeg)(
	"read() clamps to the nearest samples when the target is beyond the head",
	async () => {
		const wav = join(tmpdir(), `podtui-reader-${process.pid}-${Date.now()}.wav`);
		writeSineWav(wav, 20);
		const reader = new AudioStreamReader({ url: wav });
		try {
			reader.start(0, 1);
			await waitForHead(reader, Math.round(0.3 * SAMPLE_RATE));

			// Target far beyond the decode head: serve the newest available
			// window (real sine samples, never zeros or garbage).
			const out = new Float64Array(512);
			expect(reader.read(out, 999)).toBe(512);
			const maxAbs = Math.max(...Array.from(out, Math.abs));
			expect(maxAbs).toBeGreaterThan(10000);
			for (const v of out) {
				expect(Math.abs(v)).toBeLessThanOrEqual(AMP + 1);
			}
		} finally {
			reader.stop();
			await Bun.$`rm -f ${wav}`.quiet();
		}
	},
);

test.skipIf(!hasFfmpeg)(
	"sustained render loop: ffmpeg stays alive and decode head maintains a lead over the player",
	async () => {
		// Real wall-clock time is required here: this test validates ffmpeg's
		// actual decode pacing (-readrate + -readrate_initial_burst) against
		// the platform clock. Deterministic time control cannot reproduce the
		// race where ffmpeg exits early and the bars freeze — that only
		// surfaces when a real process writes to a real pipe.
		//
		// Simulates the actual render loop: for ~5s of wall time, advance a
		// simulated player position at 1× realtime and call read() each frame.
		// The decode head must stay ahead of the player position so read()
		// always returns 512 samples, and ffmpeg must not exit early (which
		// would freeze the bars). This test would have caught the
		// backpressure-pacing failure where ffmpeg decoded all data into the
		// pipe buffer instantly, exited, and the readLoop stopped.
		const wav = join(
			tmpdir(),
			`podtui-reader-${process.pid}-${Date.now()}.wav`,
		);
		writeSineWav(wav, 30);
		const reader = new AudioStreamReader({ url: wav });
		try {
			reader.start(0, 1);

			const FRAME_MS = 33;
			const DURATION_MS = 5000;
			const out = new Float64Array(512);
			let successes = 0;
			let failures = 0;
			let minLead = Infinity;

			const start = Date.now();
			for (let frame = 0; Date.now() - start < DURATION_MS; frame++) {
				const playerPos = (Date.now() - start) / 1000;
				const count = reader.read(out, playerPos);
				if (count === 512) successes++;
				else failures++;

				// The decode head should stay ahead of the player position.
				const headPos = reader.samplesWritten / SAMPLE_RATE;
				const lead = headPos - playerPos;
				if (frame > 3) minLead = Math.min(minLead, lead);

				await Bun.sleep(FRAME_MS);
			}

			// ffmpeg must still be running — it must not have exited early.
			expect(reader.running).toBe(true);

			// The vast majority of frames should return a full window.
			// A few early failures during ffmpeg startup are acceptable.
			expect(failures).toBeLessThan(5);
			expect(successes).toBeGreaterThan(100);

			// The decode head must maintain a positive lead over the player.
			// Without -readrate_initial_burst, the head would lag behind by
			// the ffmpeg startup latency and never catch up.
			expect(minLead).toBeGreaterThan(0);
		} finally {
			reader.stop();
			await Bun.$`rm -f ${wav}`.quiet();
		}
	},
	{ timeout: 15000 },
);
