import { existsSync } from "node:fs";
import { mkdir, readdir, rm, unlink } from "node:fs/promises";
import { join } from "node:path";
import { findBy } from "@nontara/language-codes";
import type { Subprocess } from "bun";
import type { ConsolaInstance } from "consola";
import HLS from "hls-parser";
import type { SubtitleTrack } from "@/models/trpc/media";
import type {
	AudioCodec,
	PlaybackStatus,
	Resolution,
	VideoCodec,
} from "@/models/trpc/playback";
import {
	getFFmpegAudioCodec,
	getFFmpegVideoCodec,
	getScaleFilter,
	shouldScaleVideo,
} from "./codecs";

const { MasterPlaylist, Rendition, Variant } = HLS.types;

export interface PlaybackSessionOptions {
	sessionId: string;
	sourcePath: string;
	sourceVideoCodec: string | null;
	sourceAudioCodec: string | null;
	sourceWidth: number | null;
	sourceHeight: number | null;
	targetVideoCodec: VideoCodec;
	targetAudioCodec: AudioCodec;
	videoStreamIndex: number;
	audioStreamIndex: number;
	subtitleTracks: SubtitleTrack[];
	requestedSubtitleStreamIndex?: number;
	burnInSubtitle?: boolean;
	maxResolution: Resolution;
	needsVideoTranscode: boolean;
	needsAudioTranscode: boolean;
	logger: ConsolaInstance;
}

export class PlaybackSession {
	private ffmpegProcesses: Set<Subprocess> = new Set();
	public readonly sessionId: string;
	private readonly outputDir: string;
	private readonly masterPlaylistPath: string;
	private readonly avPlaylistPath: string;
	private readonly options: PlaybackSessionOptions;
	private readonly logger: ConsolaInstance;

	public status: PlaybackStatus = "PREPARING";
	public startedAt: Date;
	public lastAccessedAt: Date;
	public currentOffset = 0;
	public isRestarting = false;
	private lastSeekTime: Date | null = null;
	private sequenceNumber = 0;

	constructor(options: PlaybackSessionOptions, startOffset = 0) {
		this.options = options;
		this.sessionId = options.sessionId;
		this.logger = options.logger;
		this.startedAt = new Date();
		this.lastAccessedAt = new Date();
		this.currentOffset = startOffset;

		// Output directory in ./cache/playback/{sessionId}
		this.outputDir = join(process.cwd(), "cache", "playback", this.sessionId);
		this.masterPlaylistPath = join(this.outputDir, "master.m3u8");
		this.avPlaylistPath = join(this.outputDir, "stream.m3u8");
	}

	public async start(): Promise<void> {
		try {
			this.logger.info(`Starting playback session ${this.sessionId}`);

			// Create output directory
			if (!existsSync(this.outputDir)) {
				await mkdir(this.outputDir, { recursive: true });
			}

			// Start AV stream
			const avStream = await this._createAVStream();
			this.ffmpegProcesses.add(avStream.process);

			// Start subtitle streams only if burn-in is disabled
			let subtitleRenditions: (InstanceType<typeof Rendition> | undefined)[] =
				[];
			if (!this.options.burnInSubtitle) {
				subtitleRenditions = await Promise.all(
					this.options.subtitleTracks.map(async (track) => {
						try {
							const langInfo = findBy("3", track.language || "und");
							if (!langInfo) {
								this.logger.warn(
									`Subtitle track has unknown language code: ${track.language}`,
								);
								return undefined;
							}

							const subtitleStream = await this._createSubtitleStream(
								track.streamIndex,
								langInfo["1"],
							);
							this.ffmpegProcesses.add(subtitleStream.process);

							// Determine if this subtitle should be default
							// If user requested a specific subtitle, use it; otherwise use track's default
							const isDefaultSubtitle =
								this.options.requestedSubtitleStreamIndex !== undefined
									? track.streamIndex ===
										this.options.requestedSubtitleStreamIndex
									: track.isDefault;

							if (isDefaultSubtitle) {
								this.logger.debug(
									`[Subtitle ${langInfo["1"]}] Setting as DEFAULT subtitle (stream index: ${track.streamIndex})`,
								);
							}

							return new Rendition({
								type: "SUBTITLES",
								groupId: "subs",
								language: langInfo["1"],
								name: track.title || langInfo.name,
								uri: subtitleStream.uri,
								isDefault: isDefaultSubtitle,
								autoselect: true,
								forced: track.isForced,
							});
						} catch (error) {
							this.logger.error(
								`Failed to create subtitle stream for track ${track.streamIndex}: ${error}`,
							);
							return undefined;
						}
					}),
				);
			} else {
				this.logger.info(
					"[Subtitle] Skipping subtitle stream creation (burn-in enabled)",
				);
			}

			// Build master playlist
			const masterPlaylist = new MasterPlaylist({
				variants: [
					new Variant({
						uri: "stream.m3u8",
						bandwidth: 6000000,
						subtitles: subtitleRenditions.filter(
							(r): r is InstanceType<typeof Rendition> => r !== undefined,
						),
					}),
				],
			});

			await Bun.write(this.masterPlaylistPath, HLS.stringify(masterPlaylist));

			this.status = "STREAMING";
			this.logger.info(
				`Playback session ${this.sessionId} started with ${this.ffmpegProcesses.size} FFmpeg processes`,
			);

			// Wait for AV playlist file to be created (max 30 seconds)
			const maxWaitTime = 30000;
			const startTime = Date.now();
			while (!existsSync(this.avPlaylistPath)) {
				if (Date.now() - startTime > maxWaitTime) {
					throw new Error("Timeout waiting for AV playlist file to be created");
				}
				await Bun.sleep(500);
			}

			this.logger.info(
				`AV playlist created for session ${this.sessionId} after ${Date.now() - startTime}ms`,
			);
		} catch (error) {
			this.logger.error(`Failed to start playback session: ${error}`);
			this.status = "ERROR";
			throw error;
		}
	}

	private async _createAVStream(): Promise<{
		process: Subprocess;
		uri: string;
	}> {
		this.logger.debug("[AV] Starting audio/video stream creation");
		this.logger.debug(`[AV] Source path: ${this.options.sourcePath}`);
		this.logger.debug(`[AV] Current offset: ${this.currentOffset}s`);
		this.logger.debug(
			`[AV] Video stream index: ${this.options.videoStreamIndex}`,
		);
		this.logger.debug(
			`[AV] Audio stream index: ${this.options.audioStreamIndex}`,
		);

		const args: string[] = ["-y"];

		// Add seek BEFORE input for fast seeking
		if (this.currentOffset > 0) {
			this.logger.debug(
				`[AV] Adding seek offset: ${this.currentOffset}s (fast seek before input)`,
			);
			args.push("-ss", this.currentOffset.toString());
		}

		// Enable genpts for timestamp generation
		args.push("-fflags", "+genpts");

		// Input file
		args.push("-i", this.options.sourcePath);

		// Video stream mapping
		args.push("-map", `0:${this.options.videoStreamIndex}`);
		this.logger.debug(
			`[AV] Mapped video stream: 0:${this.options.videoStreamIndex}`,
		);

		// Video filters
		const videoFilters: string[] = [];

		// Add subtitle burn-in if requested
		if (
			this.options.burnInSubtitle &&
			this.options.requestedSubtitleStreamIndex !== undefined
		) {
			// Escape the source path for FFmpeg filter syntax
			const escapedPath = this.options.sourcePath.replace(
				/([\\':,[\]])/g,
				"\\$1",
			);
			const subtitleFilter = `subtitles=${escapedPath}:si=${this.options.requestedSubtitleStreamIndex}`;
			videoFilters.push(subtitleFilter);
			this.logger.info(
				`[AV] Burn-in subtitle enabled for stream index ${this.options.requestedSubtitleStreamIndex}`,
			);
			this.logger.debug(`[AV] Subtitle filter: ${subtitleFilter}`);
		}

		// Add scaling if needed
		if (
			shouldScaleVideo(this.options.sourceHeight, this.options.maxResolution)
		) {
			const scaleFilter = getScaleFilter(this.options.maxResolution);
			videoFilters.push(scaleFilter);
			this.logger.debug(
				`[AV] Source resolution: ${this.options.sourceWidth}x${this.options.sourceHeight}`,
			);
			this.logger.debug(
				`[AV] Target resolution: ${this.options.maxResolution}, applying scale filter: ${scaleFilter}`,
			);
		} else {
			this.logger.debug(
				`[AV] No scaling needed, source: ${this.options.sourceWidth}x${this.options.sourceHeight}, max: ${this.options.maxResolution}`,
			);
		}

		// Apply video filters if any
		if (videoFilters.length > 0) {
			args.push("-vf", videoFilters.join(","));
			this.logger.debug(
				`[AV] Applied video filters: ${videoFilters.join(",")}`,
			);
		}

		// Video codec - must transcode if filters are applied
		const mustTranscode =
			this.options.needsVideoTranscode || videoFilters.length > 0;
		if (mustTranscode) {
			const codecName = getFFmpegVideoCodec(this.options.targetVideoCodec);
			args.push("-c:v", codecName);
			this.logger.info(
				`[AV] Video transcoding enabled: ${this.options.sourceVideoCodec} -> ${codecName}`,
			);

			// Encoding settings for live streaming
			if (codecName === "libx264" || codecName === "libx265") {
				args.push(
					"-preset",
					"veryfast",
					"-crf",
					"23",
					"-g",
					"60",
					"-sc_threshold",
					"0",
				);
				this.logger.debug(
					`[AV] Applied ${codecName} encoding settings: preset=veryfast, crf=23, g=60`,
				);
			} else if (codecName === "libvpx-vp9") {
				args.push(
					"-speed",
					"6",
					"-tile-columns",
					"4",
					"-frame-parallel",
					"1",
					"-row-mt",
					"1",
					"-deadline",
					"realtime",
					"-cpu-used",
					"8",
				);
				this.logger.debug(
					"[AV] Applied VP9 encoding settings: speed=6, realtime deadline",
				);
			}
		} else {
			args.push("-c:v", "copy");
			this.logger.info(
				`[AV] Video stream copying enabled (no transcoding): ${this.options.sourceVideoCodec}`,
			);
		}

		// Audio stream mapping
		args.push("-map", `0:${this.options.audioStreamIndex}`);
		this.logger.debug(
			`[AV] Mapped audio stream: 0:${this.options.audioStreamIndex}`,
		);

		// Audio codec
		if (this.options.needsAudioTranscode) {
			const codecName = getFFmpegAudioCodec(this.options.targetAudioCodec);
			args.push("-c:a", codecName, "-ac", "2", "-b:a", "128k");
			this.logger.info(
				`[AV] Audio transcoding enabled: ${this.options.sourceAudioCodec} -> ${codecName} (stereo, 128k)`,
			);
		} else {
			args.push("-c:a", "copy");
			this.logger.info(
				`[AV] Audio stream copying enabled (no transcoding): ${this.options.sourceAudioCodec}`,
			);
		}

		// HLS specific settings
		args.push(
			"-f",
			"hls",
			"-hls_time",
			"6",
			"-hls_playlist_type",
			"event",
			"-hls_flags",
			"independent_segments+omit_endlist",
			"-hls_segment_type",
			"fmp4",
			"-start_number",
			this.sequenceNumber.toString(),
			"-hls_segment_filename",
			join(this.outputDir, "stream_%03d.ts"),
			"stream.m3u8",
		);

		this.logger.debug(
			`[AV] HLS settings: segment time=6s, type=event, format=fmp4, flags=independent_segments+omit_endlist, start_number=${this.sequenceNumber}`,
		);
		this.logger.debug(`[AV] Output directory: ${this.outputDir}`);
		this.logger.debug("[AV] Output manifest: stream.m3u8");
		this.logger.info(`[AV] FFmpeg command: ffmpeg ${args.join(" ")}`);

		this.logger.debug("[AV] Spawning FFmpeg process...");
		const process = Bun.spawn(["ffmpeg", ...args], {
			cwd: this.outputDir,
			stdout: "ignore",
			stderr: "ignore",
		});

		this.logger.info(
			`[AV] FFmpeg process spawned successfully (PID: ${process.pid})`,
		);
		this.logger.debug("[AV] Audio/video stream creation completed");

		return {
			process,
			uri: "stream.m3u8",
		};
	}

	private async _createSubtitleStream(
		subtitleTrackIndex: number,
		language: string,
	): Promise<{
		process: Subprocess;
		uri: string;
	}> {
		const subtitleDir = join(this.outputDir, "subtitle", language);
		const subtitleManifestPath = join(subtitleDir, "subs.m3u8");

		// Create subtitle directory
		await mkdir(subtitleDir, { recursive: true });

		const args: string[] = [
			"-y",
			"-fflags",
			"+genpts",
			"-i",
			this.options.sourcePath,
			"-vn",
			"-an",
			"-map",
			`0:${subtitleTrackIndex}`,
			"-c:s",
			"webvtt",
			"-f",
			"segment",
			"-segment_time",
			"6",
			"-segment_list",
			"subs.m3u8",
			"-segment_list_type",
			"m3u8",
			"-segment_format",
			"webvtt",
			"-segment_start_number",
			"0",
			"subs_%03d.vtt",
		];

		this.logger.info(
			`[Subtitle ${language}] FFmpeg command: ffmpeg ${args.join(" ")}`,
		);

		const process = Bun.spawn(["ffmpeg", ...args], {
			cwd: subtitleDir,
			stdout: "ignore",
			stderr: "ignore",
		});

		// Wait for subtitle manifest to be created
		let attempts = 0;
		const maxAttempts = 20;
		while (attempts < maxAttempts) {
			const exists = await Bun.file(subtitleManifestPath).exists();
			if (exists) break;
			await Bun.sleep(1000);
			attempts++;
		}

		if (attempts >= maxAttempts) {
			throw new Error(
				`Subtitle manifest not ready for language ${language}; skipping rendition`,
			);
		}

		return {
			process,
			uri: `subtitle/${language}/subs.m3u8`,
		};
	}

	public async stop(clearSegments = true): Promise<void> {
		try {
			this.logger.info(`Stopping playback session ${this.sessionId}`);

			// Kill all FFmpeg processes
			this.logger.info(`Killing ${this.ffmpegProcesses.size} FFmpeg processes`);
			for (const process of this.ffmpegProcesses) {
				process.kill();
			}
			this.ffmpegProcesses.clear();

			// Wait a bit for processes to terminate
			await Bun.sleep(500);

			// Cleanup output directory
			if (clearSegments && existsSync(this.outputDir)) {
				await rm(this.outputDir, { recursive: true, force: true });
				this.logger.debug(
					`Cleaned up output directory for session ${this.sessionId}`,
				);
			}

			this.status = "STOPPED";
		} catch (error) {
			this.logger.error(`Error stopping playback session: ${error}`);
			throw error;
		}
	}

	public async restart(seekTime: number): Promise<void> {
		if (this.isRestarting) {
			throw new Error("Session is already restarting");
		}

		try {
			this.isRestarting = true;
			this.logger.info(
				`Restarting playback session ${this.sessionId} at offset ${seekTime}s`,
			);

			// Stop all current FFmpeg processes without clearing directory
			for (const process of this.ffmpegProcesses) {
				process.kill();
			}
			this.ffmpegProcesses.clear();

			// Wait for processes to terminate
			await Bun.sleep(500);

			// Clear old segments
			await this.clearSegments();

			// Update offset and sequence number
			this.currentOffset = seekTime;
			this.sequenceNumber += 1;
			this.lastSeekTime = new Date();

			// Restart with new offset
			this.status = "PREPARING";
			await this.start();

			this.logger.info(
				`Successfully restarted session ${this.sessionId} at offset ${seekTime}s`,
			);
		} catch (error) {
			this.logger.error(`Error restarting playback session: ${error}`);
			this.status = "ERROR";
			throw error;
		} finally {
			this.isRestarting = false;
		}
	}

	private async clearSegments(): Promise<void> {
		try {
			if (!existsSync(this.outputDir)) {
				return;
			}

			const files = await readdir(this.outputDir);

			// Delete all .ts segment files and subtitle directory
			const deletePromises = files
				.map((file) => {
					const filePath = join(this.outputDir, file);
					if (file.endsWith(".ts") || file.endsWith(".m3u8")) {
						return unlink(filePath);
					}
					if (file === "subtitle") {
						return rm(filePath, { recursive: true, force: true });
					}
					return Promise.resolve();
				})
				.filter((p) => p !== Promise.resolve());

			await Promise.all(deletePromises);

			this.logger.debug(`Cleared segments for session ${this.sessionId}`);
		} catch (error) {
			this.logger.error(`Error clearing segments: ${error}`);
			throw error;
		}
	}

	public canSeek(): boolean {
		// Can't seek while restarting
		if (this.isRestarting) {
			return false;
		}

		// Can't seek if session is not streaming
		if (this.status !== "STREAMING" && this.status !== "READY") {
			return false;
		}

		// Rate limiting: 2 second cooldown between seeks
		if (this.lastSeekTime) {
			const timeSinceLastSeek = Date.now() - this.lastSeekTime.getTime();
			if (timeSinceLastSeek < 2000) {
				return false;
			}
		}

		return true;
	}

	public updateLastAccessed(): void {
		this.lastAccessedAt = new Date();
	}

	public getPlaylistUrl(baseUrl: string): string {
		return `${baseUrl}/playback/${this.sessionId}/master.m3u8`;
	}

	public isTranscoding(): boolean {
		const hasScaling = shouldScaleVideo(
			this.options.sourceHeight,
			this.options.maxResolution,
		);
		return (
			this.options.needsVideoTranscode ||
			this.options.needsAudioTranscode ||
			hasScaling
		);
	}

	public getOutputDir(): string {
		return this.outputDir;
	}
}
