/**
 * VoiceRecorder — per-participant audio capture for a LiveKit voice
 * call. Spawns one `MediaRecorder` per active audio track (local mic
 * + each remote participant) with a 500ms timeslice. Every chunk is
 * forwarded over IPC to the Electron main process, which append-
 * writes it to a per-participant WebM file inside a session folder.
 *
 * Crash-safe by construction: the WebM/Opus container is built from
 * independently-decodable clusters, so a truncated tail on power
 * loss just means the decoder stops at the last complete cluster.
 * Every 500ms of audio is on disk before the next chunk arrives,
 * and the main process fsyncs every ~10 seconds.
 *
 * Only active when `platform.features.hasRecording === true`
 * (Electron). Other platforms should never instantiate this — the
 * VoiceContext gates `startRecording()` behind that flag.
 */
import { RoomEvent } from 'livekit-client';

interface Platform {
	features?: { hasRecording?: boolean };
	recording?: {
		startSession: (payload: any) => Promise<any>;
		openTrack: (payload: any) => Promise<any>;
		append: (payload: any) => Promise<any>;
		closeTrack: (payload: any) => Promise<any>;
		finalize: (payload: any) => Promise<any>;
	} | null;
}

interface RecordingOpts {
	platform: Platform;
	room: any; // LiveKit Room
	channelId: string | null;
	channelName: string | null;
	rootDir: string | null;
	onError?: (err: Error) => void;
}

interface TrackRecorder {
	participantId: string;
	displayName: string;
	trackKey: string;
	stream: MediaStream;
	recorder: MediaRecorder;
	joinedOffsetMs: number;
	// `pendingIo` serialises the append IPCs for this track so
	// chunks arrive in order even if the main-process write is
	// slower than 500ms. Without this, two parallel `append` calls
	// can interleave their bytes and break the WebM cluster stream.
	pendingIo: Promise<any>;
}

function sanitizeParticipantId(id: any): string {
	return String(id ?? 'unknown');
}

function generateSessionId(): string {
	// Filesystem-safe ISO timestamp — no `:` so Windows is happy,
	// plus a short random suffix so two recordings started in the
	// same second don't collide.
	const iso = new Date().toISOString().replace(/[:.]/g, '-');
	const rand = Math.random().toString(36).slice(2, 6);
	return `${iso}-${rand}`;
}

/** Resolve the LiveKit microphone publication (handles both the
 *  newer `trackPublications` Map and older `tracks` map names). */
function findLocalMicPublication(localParticipant: any): any | null {
	const map =
		localParticipant?.trackPublications || localParticipant?.tracks;
	if (!map) return null;
	for (const pub of map.values()) {
		if (!pub) continue;
		const kind = pub.kind === 'audio' || pub.track?.kind === 'audio';
		const src = (pub.source?.toString?.() ?? '').toLowerCase();
		if (kind && (src === 'microphone' || src === '' || src === 'mic')) {
			return pub;
		}
	}
	return null;
}

/** Yield every audio publication on a participant whose `source`
 *  isn't screen_share_audio (which is mixed with the screen share
 *  track and would be redundant / confusing in a per-voice file). */
function* iterAudioPublications(participant: any): Generator<any> {
	const map = participant?.trackPublications || participant?.tracks;
	if (!map) return;
	for (const pub of map.values()) {
		if (!pub) continue;
		const kind = pub.kind === 'audio' || pub.track?.kind === 'audio';
		if (!kind) continue;
		const src = (pub.source?.toString?.() ?? '').toLowerCase();
		if (src === 'screen_share_audio') continue;
		yield pub;
	}
}

/** Best `mimeType` the current Chromium build supports for the
 *  MediaRecorder. Electron ships Chromium so Opus-in-WebM is
 *  always available; falling through is defensive. */
function pickMimeType(): string {
	const candidates = [
		'audio/webm;codecs=opus',
		'audio/webm',
		'audio/ogg;codecs=opus',
	];
	for (const c of candidates) {
		if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(c)) {
			return c;
		}
	}
	return 'audio/webm';
}

export class VoiceRecorder {
	readonly sessionId: string;
	readonly startedAt: number;
	private readonly opts: RecordingOpts;
	private readonly mimeType: string;
	private readonly recorders = new Map<string, TrackRecorder>();
	private stopped = false;
	// We capture refs to the RoomEvent handlers so we can detach
	// them in stop() — React's strict-mode double mount + the
	// context's own event listeners would otherwise leave dangling
	// subscriptions after the recorder is destroyed.
	private readonly handlers: Array<[string, (...args: any[]) => void]> = [];

	constructor(opts: RecordingOpts) {
		this.opts = opts;
		this.sessionId = generateSessionId();
		this.startedAt = Date.now();
		this.mimeType = pickMimeType();
	}

	/** Spin up the session folder + attach recorders for every
	 *  audio track that's already published plus handlers for
	 *  tracks that arrive later (participants joining mid-call). */
	async start(): Promise<void> {
		const { platform, room } = this.opts;
		if (!platform.features?.hasRecording || !platform.recording) {
			throw new Error('Recording is not available on this platform.');
		}
		const started = await platform.recording.startSession({
			sessionId: this.sessionId,
			rootDir: this.opts.rootDir,
			channelId: this.opts.channelId,
			channelName: this.opts.channelName,
			startedAt: this.startedAt,
		});
		if (!started?.ok) {
			throw new Error(started?.error ?? 'Failed to start recording session.');
		}

		// Local mic — iterate any existing publication and hook
		// future re-publications (mute/unmute cycles republish the
		// track, which would otherwise slip past TrackSubscribed).
		const localId = room.localParticipant?.identity ?? 'local';
		const localName =
			room.localParticipant?.metadata ||
			room.localParticipant?.name ||
			'You';
		const localPub = findLocalMicPublication(room.localParticipant);
		if (localPub?.track?.mediaStreamTrack) {
			await this.attachTrack(localId, localName, localPub.track.mediaStreamTrack);
		}

		// Remote participants — both the currently-connected set
		// and any future joiners.
		const remotes = room.remoteParticipants?.values
			? Array.from(room.remoteParticipants.values())
			: [];
		for (const participant of remotes as any[]) {
			for (const pub of iterAudioPublications(participant)) {
				if (pub.track?.mediaStreamTrack) {
					await this.attachTrack(
						sanitizeParticipantId(participant.identity),
						participant.metadata || participant.name || participant.identity || 'Participant',
						pub.track.mediaStreamTrack,
					);
				}
			}
		}

		// Hook future subscribes — this fires for every newly
		// subscribed audio track, including ones that come in after
		// a participant joins mid-recording.
		const onSubscribed = (track: any, _publication: any, participant: any) => {
			if (this.stopped) return;
			if (track?.kind !== 'audio') return;
			const src = (track?.source?.toString?.() ?? '').toLowerCase();
			if (src === 'screen_share_audio') return;
			const mediaTrack = track?.mediaStreamTrack;
			if (!mediaTrack) return;
			void this.attachTrack(
				sanitizeParticipantId(participant?.identity),
				participant?.metadata || participant?.name || participant?.identity || 'Participant',
				mediaTrack,
			);
		};
		const onUnsubscribed = (_track: any, _publication: any, participant: any) => {
			if (this.stopped) return;
			this.detachParticipantTracks(sanitizeParticipantId(participant?.identity));
		};
		const onParticipantDisconnected = (participant: any) => {
			if (this.stopped) return;
			this.detachParticipantTracks(sanitizeParticipantId(participant?.identity));
		};
		const onLocalTrackPublished = (publication: any) => {
			if (this.stopped) return;
			const track = publication?.track;
			if (track?.kind !== 'audio') return;
			if (!track?.mediaStreamTrack) return;
			void this.attachTrack(localId, localName, track.mediaStreamTrack);
		};
		const onLocalTrackUnpublished = (publication: any) => {
			if (this.stopped) return;
			const track = publication?.track;
			if (track?.kind !== 'audio') return;
			this.detachParticipantTracks(localId);
		};

		room.on(RoomEvent.TrackSubscribed, onSubscribed);
		room.on(RoomEvent.TrackUnsubscribed, onUnsubscribed);
		room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected);
		room.on(RoomEvent.LocalTrackPublished, onLocalTrackPublished);
		room.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished);
		this.handlers.push([RoomEvent.TrackSubscribed, onSubscribed]);
		this.handlers.push([RoomEvent.TrackUnsubscribed, onUnsubscribed]);
		this.handlers.push([RoomEvent.ParticipantDisconnected, onParticipantDisconnected]);
		this.handlers.push([RoomEvent.LocalTrackPublished, onLocalTrackPublished]);
		this.handlers.push([RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished]);
	}

	/** Flush every open recorder, close every track, finalize the
	 *  session manifest. Idempotent — safe to call from React
	 *  cleanup paths. */
	async stop(): Promise<void> {
		if (this.stopped) return;
		this.stopped = true;
		const { platform, room } = this.opts;
		// Detach room listeners first so late-arriving events don't
		// try to attach new tracks mid-teardown.
		for (const [event, handler] of this.handlers) {
			try { room.off(event, handler); } catch {}
		}
		this.handlers.length = 0;

		const endedAt = Date.now();
		const stops: Array<Promise<void>> = [];
		for (const entry of this.recorders.values()) {
			stops.push(this.stopRecorder(entry, endedAt));
		}
		await Promise.allSettled(stops);
		this.recorders.clear();

		if (platform.recording) {
			try {
				await platform.recording.finalize({
					sessionId: this.sessionId,
					endedAt,
				});
			} catch (err) {
				this.opts.onError?.(err instanceof Error ? err : new Error(String(err)));
			}
		}
	}

	// ── Internals ─────────────────────────────────────────────

	private async attachTrack(
		participantId: string,
		displayName: string,
		mediaTrack: MediaStreamTrack,
	): Promise<void> {
		if (this.stopped) return;
		// Skip duplicates if the same mediaTrack already has a
		// running recorder (track republications during mute cycles
		// can fire TrackSubscribed twice).
		for (const entry of this.recorders.values()) {
			if (entry.participantId === participantId && entry.recorder.state === 'recording') {
				return;
			}
		}

		const joinedOffsetMs = Math.max(0, Date.now() - this.startedAt);
		const opened = await this.opts.platform.recording!.openTrack({
			sessionId: this.sessionId,
			participantId,
			displayName,
			joinedOffsetMs,
		});
		if (!opened?.ok || !opened.trackKey) {
			this.opts.onError?.(
				new Error(opened?.error ?? 'Failed to open recording track.'),
			);
			return;
		}
		const trackKey = opened.trackKey;
		const stream = new MediaStream([mediaTrack]);
		let recorder: MediaRecorder;
		try {
			recorder = new MediaRecorder(stream, { mimeType: this.mimeType });
		} catch (err) {
			this.opts.onError?.(err instanceof Error ? err : new Error(String(err)));
			return;
		}

		const entry: TrackRecorder = {
			participantId,
			displayName,
			trackKey,
			stream,
			recorder,
			joinedOffsetMs,
			pendingIo: Promise.resolve(),
		};

		recorder.ondataavailable = (ev: BlobEvent) => {
			if (!ev.data || ev.data.size === 0) return;
			// Queue each append after the previous one resolves so
			// bytes arrive in order on the main-process side.
			entry.pendingIo = entry.pendingIo
				.catch(() => undefined)
				.then(async () => {
					try {
						const buf = await ev.data.arrayBuffer();
						await this.opts.platform.recording!.append({
							sessionId: this.sessionId,
							trackKey,
							chunk: buf,
						});
					} catch (err) {
						this.opts.onError?.(
							err instanceof Error ? err : new Error(String(err)),
						);
					}
				});
		};
		recorder.onerror = (ev: any) => {
			this.opts.onError?.(
				ev?.error instanceof Error
					? ev.error
					: new Error(String(ev?.error ?? 'MediaRecorder error')),
			);
		};

		recorder.start(500);
		this.recorders.set(trackKey, entry);
	}

	private detachParticipantTracks(participantId: string): void {
		const keys: string[] = [];
		for (const [key, entry] of this.recorders) {
			if (entry.participantId === participantId) keys.push(key);
		}
		for (const key of keys) {
			const entry = this.recorders.get(key);
			if (!entry) continue;
			this.recorders.delete(key);
			void this.stopRecorder(entry, Date.now());
		}
	}

	private async stopRecorder(
		entry: TrackRecorder,
		endedAt: number,
	): Promise<void> {
		try {
			if (entry.recorder.state !== 'inactive') {
				// requestData() makes MediaRecorder emit one final
				// dataavailable for whatever's in its internal buffer,
				// then stop() flushes the tail.
				try { entry.recorder.requestData(); } catch {}
				await new Promise<void>((resolve) => {
					const onStop = () => {
						entry.recorder.removeEventListener('stop', onStop);
						resolve();
					};
					entry.recorder.addEventListener('stop', onStop);
					try { entry.recorder.stop(); } catch { resolve(); }
				});
			}
		} catch (err) {
			this.opts.onError?.(err instanceof Error ? err : new Error(String(err)));
		}

		// Wait for any pending append IPCs to finish so closeTrack
		// doesn't race ahead of the last in-flight chunk.
		try { await entry.pendingIo; } catch {}

		if (this.opts.platform.recording) {
			try {
				await this.opts.platform.recording.closeTrack({
					sessionId: this.sessionId,
					trackKey: entry.trackKey,
					leftOffsetMs: Math.max(0, endedAt - this.startedAt),
				});
			} catch (err) {
				this.opts.onError?.(
					err instanceof Error ? err : new Error(String(err)),
				);
			}
		}

		// Stop every MediaStreamTrack we constructed for this
		// recorder — LiveKit still owns the underlying device track,
		// but the wrapper MediaStream we created needs to be
		// released to free browser resources.
		try {
			for (const t of entry.stream.getTracks()) {
				if (t === entry.stream.getTracks()[0]) continue; // device track belongs to LiveKit
			}
		} catch {}
	}
}
