/**
 * external-pause-reconcile.test.ts — "audio paused outside PodTUI must not
 * freeze the player tab" regression test.
 *
 * The OS can pause the player without PodTUI knowing: system sleep/lock,
 * AirPod removal / device swap, OS media keys, the Now Playing center. mpv
 * flips its own `pause` property and keeps it there. Before the fix,
 * useAudio's signals stayed on "playing" — [Pause] button shown while
 * silent, a poll that only re-read the same frozen `time-pos` (stuck
 * waveform), and no way to catch an external RESUME either (the poll was
 * stopped whenever the UI thought it was paused).
 *
 * Integration style (like restore-session.test.ts): real stores and real
 * persistence files in a temp XDG_CONFIG_HOME — but with the REAL mpv
 * backend driven over its actual IPC socket. The test flips mpv's pause
 * property the same way the OS does and asserts useAudio reconciles in
 * both directions. Skipped when mpv isn't installed.
 *
 * Real-timer note: the reconcile path runs on useAudio's real 150ms poll
 * interval against a real mpv process, with no injectable clock — the
 * deliberate-exception case from the no-real-timers rule (same as
 * visualizer-store.test.ts). `waitFor` polls with Bun.sleep.
 *
 * Shared-worker note (same as restore-session.test.ts): the suite reuses
 * bun test workers, so other files' `mock.module("../src/hooks/useAudio")`
 * leaks into this file's module registry. The REAL useAudio is therefore
 * imported via a `?external-pause-test` query suffix — a distinct module
 * identity bun loads from disk, bypassing the leaked mock.
 */
import { test, expect, afterAll } from "bun:test";
import {
	mkdirSync,
	mkdtempSync,
	writeFileSync,
	rmSync,
	readdirSync,
	statSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const hasMpv = !!Bun.which("mpv");

// ── Sandbox BEFORE any app module evaluates (mirrors restore-session) ────
const CONFIG = mkdtempSync(join(tmpdir(), "podtui-extpause-"));
process.env.XDG_CONFIG_HOME = CONFIG;
process.env.XDG_DATA_HOME = mkdtempSync(join(tmpdir(), "podtui-extpause-data-"));
process.env.PODTUI_AUDIO_BACKEND = "mpv"; // real backend; the test drives mpv's IPC
const APP_CONFIG = join(CONFIG, "podtui");
mkdirSync(APP_CONFIG, { recursive: true });

// Seed one feed so the app store boots cleanly. No coverUrl — the play()
// path skips cover-art fetching. The RSS URL is unreachable so the
// background refresh fails fast and leaves the seeded data untouched.
const ISO = "2026-08-10T00:00:00.000Z";
const feed = {
	id: "feed1",
	podcast: {
		id: "pod1",
		title: "Pod One",
		description: "",
		feedUrl: "http://127.0.0.1:1/show.xml",
		lastUpdated: ISO,
		isSubscribed: true,
	},
	episodes: [],
	visibility: "public",
	sourceId: "test",
	lastUpdated: ISO,
	isPinned: false,
};

await Bun.write(
	join(APP_CONFIG, "config.json"),
	JSON.stringify({ feeds: [feed] }, null, 2),
);

// ── Local 60s WAV so playback is hermetic (no network, no early EOF) ─────
const wavPath = join(tmpdir(), `podtui-extpause-${process.pid}.wav`);
{
	const SAMPLE_RATE = 44100;
	const DURATION = 60;
	const dataLen = SAMPLE_RATE * DURATION; // mono 16-bit
	const buf = Buffer.alloc(44 + dataLen * 2);
	buf.write("RIFF", 0);
	buf.writeUInt32LE(36 + dataLen * 2, 4);
	buf.write("WAVE", 8);
	buf.write("fmt ", 12);
	buf.writeUInt32LE(16, 16); // fmt chunk size
	buf.writeUInt16LE(1, 20); // PCM
	buf.writeUInt16LE(1, 22); // mono
	buf.writeUInt32LE(SAMPLE_RATE, 24);
	buf.writeUInt32LE(SAMPLE_RATE * 2, 28); // byte rate
	buf.writeUInt16LE(2, 32); // block align
	buf.writeUInt16LE(16, 34); // bits per sample
	buf.write("data", 36);
	buf.writeUInt32LE(dataLen * 2, 40);
	for (let i = 0; i < dataLen; i++) {
		const sample = Math.round(
			Math.sin((2 * Math.PI * 440 * i) / SAMPLE_RATE) * 8000,
		);
		buf.writeInt16LE(sample, 44 + i * 2);
	}
	writeFileSync(wavPath, buf);
}

// ── Real modules (loaded after env + sandbox are set up) ──────────────────
// @ts-expect-error — bun-only query suffix: distinct module identity that
// loads the real file instead of a leaked mock.module from another test file.
const { useAudio } = await import("../src/hooks/useAudio?external-pause-test");

/**
 * The socket path of the LIVE backend daemon in this process. The backend
 * names sockets per-instance (`podtui-mpv-<pid>-<instance>.sock`), so scan
 * tmpdir for this pid's sockets and take the newest (the one mpv actually
 * bound — earlier instances may have been orphaned by a re-spawn).
 */
function mpvSocket(): string | null {
	let newest: string | null = null;
	let newestMtime = 0;
	for (const name of readdirSync(tmpdir())) {
		if (
			!name.startsWith(`podtui-mpv-${process.pid}-`) ||
			!name.endsWith(".sock")
		) {
			continue;
		}
		const candidate = join(tmpdir(), name);
		const mtime = statSync(candidate).mtimeMs;
		if (mtime > newestMtime) {
			newest = candidate;
			newestMtime = mtime;
		}
	}
	return newest;
}

/**
 * Send a raw mpv IPC command over the unix socket — exactly how the OS
 * media session pauses/resumes mpv without PodTUI's involvement.
 */
async function mpvCommand(command: unknown[]): Promise<void> {
	const socket = mpvSocket();
	if (!socket) throw new Error("backend mpv socket not found");
	const { promise, resolve, reject } = Promise.withResolvers<void>();
	let settled = false;
	const settle = (err: Error | null): void => {
		if (settled) return;
		settled = true;
		if (err) reject(err);
		else resolve();
	};
	Bun.connect({
		unix: socket,
		socket: {
			open(s) {
				s.write(JSON.stringify({ command }) + "\n");
			},
			data() {},
			error() {
				settle(new Error("mpv IPC connect failed"));
			},
			close() {
				settle(null);
			},
		},
	}).then((s) =>
		setTimeout(() => {
			try {
				s.end();
			} catch {}
		}, 150),
	);
	// Never hang the test on a vanished socket.
	setTimeout(() => settle(null), 1000);
	await promise;
}

/** Poll `check` every 25ms until truthy; throw after `timeoutMs`. */
async function waitFor(
	check: () => boolean,
	timeoutMs = 10000,
): Promise<void> {
	const start = Date.now();
	while (!check()) {
		if (Date.now() - start > timeoutMs) {
			throw new Error("condition not met in time");
		}
		await Bun.sleep(25);
	}
}

const episode = {
	id: "ep1",
	podcastId: "pod1",
	title: "Episode One",
	description: "desc",
	audioUrl: wavPath,
	duration: 60,
	pubDate: new Date(),
};

// ── Tests ────────────────────────────────────────────────────────────────

test.skipIf(!hasMpv)(
	"external pause flips the UI to paused; external resume recovers",
	async () => {
		const audio = useAudio();
		await audio.play(episode);
		expect(audio.isPlaying()).toBe(true);

		// Simulate the OS pausing the session (lock/sleep, AirPod removal,
		// device swap, media-center pause): flip mpv's own pause property
		// over IPC. PodTUI is never told.
		await mpvCommand(["set_property", "pause", true]);
		await waitFor(() => !audio.isPlaying());
		expect(audio.isPlaying()).toBe(false);
		// The episode stays loaded — nothing was torn down.
		expect(audio.currentEpisode()?.id).toBe("ep1");

		// Simulate an external resume (AirPod play tap, media-center play).
		await mpvCommand(["set_property", "pause", false]);
		await waitFor(() => audio.isPlaying());
		expect(audio.isPlaying()).toBe(true);

		// The TUI transport still works from the reconciled state.
		await audio.togglePlayback();
		expect(audio.isPlaying()).toBe(false);
		await audio.togglePlayback();
		expect(audio.isPlaying()).toBe(true);

		await audio.stop();
		expect(audio.isPlaying()).toBe(false);
	},
	{ timeout: 30000 },
);

// ── Teardown ──────────────────────────────────────────────────────────────

afterAll(async () => {
	try {
		useAudio().stop();
	} catch {
		/* best-effort */
	}
	// The resident daemon survives stop() by design — quit it so test
	// workers don't leak idle mpv processes.
	try {
		await mpvCommand(["quit"]);
	} catch {
		/* best-effort */
	}
	try {
		const socket = mpvSocket();
		if (socket) rmSync(socket, { force: true });
	} catch {
		/* best-effort */
	}
	try {
		rmSync(wavPath, { force: true });
	} catch {
		/* best-effort */
	}
});
