import { randomUUID } from "node:crypto";
import { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import { database } from "@/db";
import {
	episodes,
	mediaItems,
	mediaStreams,
	movies,
	titles,
} from "@/db/schema";
import type {
	AudioCodec,
	CreatePlaybackSessionInput,
	PlaybackStatus,
	VideoCodec,
} from "@/models/trpc/playback";
import { BaseService } from "@/services/BaseService";
import {
	isAudioCodecSupported,
	isVideoCodecSupported,
	normalizeAudioCodec,
	normalizeVideoCodec,
	selectBestAudioCodec,
	selectBestVideoCodec,
} from "./codecs";
import {
	PlaybackSession,
	type PlaybackSessionOptions,
} from "./PlaybackSession";

const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes

export class PlaybackService extends BaseService {
	private sessions = new Map<string, PlaybackSession>();
	private cleanupTimer: Timer | null = null;

	public isRunning = false;

	constructor() {
		super("PlaybackService");
	}

	public async mount(): Promise<void> {
		this.logger.info("Mounting PlaybackService");

		// Start cleanup timer
		this.cleanupTimer = setInterval(() => {
			this.cleanupInactiveSessions();
		}, CLEANUP_INTERVAL_MS);

		this.isRunning = true;
		this.logger.info("PlaybackService mounted successfully");
	}

	public async dispose(): Promise<void> {
		this.logger.info("Disposing PlaybackService");

		// Clear cleanup timer
		if (this.cleanupTimer) {
			clearInterval(this.cleanupTimer);
			this.cleanupTimer = null;
		}

		// Stop all active sessions
		const stopPromises = Array.from(this.sessions.values()).map((session) =>
			session.stop().catch((error) => {
				this.logger.error(`Error stopping session: ${error}`);
			}),
		);

		await Promise.all(stopPromises);
		this.sessions.clear();

		this.isRunning = false;
		this.logger.info("PlaybackService disposed");
	}

	public async createSession(input: CreatePlaybackSessionInput): Promise<{
		sessionId: string;
		playlistUrl: string;
		status: PlaybackStatus;
	}> {
		try {
			// Get media item and streams based on input
			const mediaData = await this.getMediaData(input);

			if (!mediaData) {
				throw new TRPCError({
					code: "NOT_FOUND",
					message: "Media not found",
				});
			}

			// Find video and audio streams
			const videoStream = mediaData.streams.find(
				(s) => s.codecType === "VIDEO",
			);
			const audioStreams = mediaData.streams.filter(
				(s) => s.codecType === "AUDIO",
			);

			if (!videoStream || audioStreams.length === 0) {
				throw new TRPCError({
					code: "BAD_REQUEST",
					message: "Media must have at least one video and audio stream",
				});
			}

			// Select audio stream
			// input.audioStreamIndex is the ABSOLUTE stream index from the file
			let selectedAudioStream = audioStreams[0];

			if (input.audioStreamIndex !== undefined) {
				const foundStream = audioStreams.find(
					(s) => s.streamIndex === input.audioStreamIndex,
				);
				if (foundStream) {
					selectedAudioStream = foundStream;
				} else {
					this.logger.warn(
						`Audio stream index ${input.audioStreamIndex} not found, using default`,
					);
				}
			}

			// Get all subtitle streams
			const subtitleStreams = mediaData.streams.filter(
				(s) => s.codecType === "SUBTITLE",
			);

			// Map subtitle streams to SubtitleTrack format
			const subtitleTracks = subtitleStreams.map((stream, index) => ({
				streamIndex: stream.streamIndex,
				codecName: stream.codecName,
				codecLongName: stream.codecLongName || null,
				language: stream.language || null,
				title: stream.title || null,
				isDefault: index === 0, // First subtitle is default
				isForced: false,
			}));

			// Check codec compatibility
			const sourceVideoCodec = normalizeVideoCodec(videoStream.codecName);
			const sourceAudioCodec = normalizeAudioCodec(
				selectedAudioStream.codecName,
			);

			// Video transcoding no longer forced by subtitles (they are separate streams now)
			const needsVideoTranscode = !isVideoCodecSupported(
				videoStream.codecName,
				input.supportedVideoCodecs,
			);
			const needsAudioTranscode = !isAudioCodecSupported(
				selectedAudioStream.codecName,
				input.supportedAudioCodecs,
			);

			// Select target codecs
			const targetVideoCodec: VideoCodec = needsVideoTranscode
				? selectBestVideoCodec(input.supportedVideoCodecs)
				: (sourceVideoCodec as VideoCodec);

			const targetAudioCodec: AudioCodec = needsAudioTranscode
				? selectBestAudioCodec(input.supportedAudioCodecs)
				: (sourceAudioCodec as AudioCodec);

			// Create session
			const sessionId = randomUUID();

			const sessionOptions: PlaybackSessionOptions = {
				sessionId,
				sourcePath: mediaData.path,
				sourceVideoCodec: videoStream.codecName,
				sourceAudioCodec: selectedAudioStream.codecName,
				sourceWidth: videoStream.width,
				sourceHeight: videoStream.height,
				targetVideoCodec,
				targetAudioCodec,
				videoStreamIndex: videoStream.streamIndex,
				audioStreamIndex: selectedAudioStream.streamIndex,
				subtitleTracks,
				requestedSubtitleStreamIndex: input.subtitleStreamIndex,
				burnInSubtitle: input.burnInSubtitle,
				maxResolution: input.maxResolution,
				needsVideoTranscode,
				needsAudioTranscode,
				logger: this.logger,
			};

			const session = new PlaybackSession(
				sessionOptions,
				input.startOffset || 0,
			);
			this.sessions.set(sessionId, session);

			// Start the session
			await session.start();

			// Get base URL from environment or default
			const baseUrl =
				process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
			const playlistUrl = session.getPlaylistUrl(baseUrl);

			this.logger.info(
				`Created playback session ${sessionId} (transcode: video=${needsVideoTranscode}, audio=${needsAudioTranscode})`,
			);

			return {
				sessionId,
				playlistUrl,
				status: session.status,
			};
		} catch (error) {
			this.logger.error(`Error creating playback session: ${error}`);
			throw error;
		}
	}

	private async getMediaData(input: CreatePlaybackSessionInput): Promise<{
		path: string;
		streams: Array<{
			codecType: string;
			codecName: string | null;
			codecLongName: string | null;
			streamIndex: number;
			width: number | null;
			height: number | null;
			language: string | null;
			title: string | null;
		}>;
	} | null> {
		let mediaData: Array<{
			path: string;
			codecType: string;
			codecName: string | null;
			codecLongName: string | null;
			streamIndex: number;
			width: number | null;
			height: number | null;
			language: string | null;
			title: string | null;
		}> = [];

		if (input.titleId) {
			// Check if title exists and is a movie
			const titleData = await database
				.select({
					id: titles.id,
					type: titles.type,
					movieId: movies.id,
				})
				.from(titles)
				.leftJoin(movies, eq(movies.titleId, titles.id))
				.where(eq(titles.id, input.titleId))
				.limit(1);

			if (titleData.length === 0) {
				return null;
			}

			const title = titleData[0];

			if (title.type !== "MOVIE") {
				throw new TRPCError({
					code: "BAD_REQUEST",
					message: "Use episodeId for series content",
				});
			}

			if (!title.movieId) {
				throw new TRPCError({
					code: "INTERNAL_SERVER_ERROR",
					message: "Movie data not found",
				});
			}

			// Query media items and streams for movie
			mediaData = await database
				.select({
					path: mediaItems.path,
					codecType: mediaStreams.codecType,
					codecName: mediaStreams.codecName,
					codecLongName: mediaStreams.codecLongName,
					streamIndex: mediaStreams.streamIndex,
					width: mediaStreams.width,
					height: mediaStreams.height,
					language: mediaStreams.language,
					title: mediaStreams.title,
				})
				.from(mediaItems)
				.innerJoin(mediaStreams, eq(mediaStreams.mediaItemId, mediaItems.id))
				.where(
					and(
						eq(mediaItems.parentType, "MOVIE"),
						eq(mediaItems.movieId, title.movieId),
					),
				)
				.orderBy(mediaStreams.streamIndex);
		} else if (input.episodeId) {
			// Validate episode exists
			const episodeData = await database
				.select({
					id: episodes.id,
				})
				.from(episodes)
				.where(eq(episodes.id, input.episodeId))
				.limit(1);

			if (episodeData.length === 0) {
				return null;
			}

			// Query media items and streams for episode
			mediaData = await database
				.select({
					path: mediaItems.path,
					codecType: mediaStreams.codecType,
					codecName: mediaStreams.codecName,
					codecLongName: mediaStreams.codecLongName,
					streamIndex: mediaStreams.streamIndex,
					width: mediaStreams.width,
					height: mediaStreams.height,
					language: mediaStreams.language,
					title: mediaStreams.title,
				})
				.from(mediaItems)
				.innerJoin(mediaStreams, eq(mediaStreams.mediaItemId, mediaItems.id))
				.where(
					and(
						eq(mediaItems.parentType, "EPISODE"),
						eq(mediaItems.episodeId, input.episodeId),
					),
				)
				.orderBy(mediaStreams.streamIndex);
		}

		if (mediaData.length === 0) {
			return null;
		}

		return {
			path: mediaData[0].path,
			streams: mediaData.map((item) => ({
				codecType: item.codecType,
				codecName: item.codecName,
				codecLongName: item.codecLongName,
				streamIndex: item.streamIndex,
				width: item.width,
				height: item.height,
				language: item.language,
				title: item.title,
			})),
		};
	}

	public getSession(sessionId: string): PlaybackSession | undefined {
		const session = this.sessions.get(sessionId);
		if (session) {
			session.updateLastAccessed();
		}
		return session;
	}

	public async stopSession(sessionId: string): Promise<boolean> {
		const session = this.sessions.get(sessionId);
		if (!session) {
			return false;
		}

		await session.stop();
		this.sessions.delete(sessionId);

		this.logger.info(`Stopped playback session ${sessionId}`);
		return true;
	}

	public async seekSession(
		sessionId: string,
		seekTime: number,
	): Promise<{ success: boolean; newOffset: number; message?: string }> {
		const session = this.sessions.get(sessionId);

		if (!session) {
			throw new TRPCError({
				code: "NOT_FOUND",
				message: "Playback session not found",
			});
		}

		// Validate seek time is non-negative
		if (seekTime < 0) {
			throw new TRPCError({
				code: "BAD_REQUEST",
				message: "Seek time must be non-negative",
			});
		}

		// Check if session can seek (not restarting, rate limiting, etc)
		if (!session.canSeek()) {
			const message = session.isRestarting
				? "Session is currently restarting, please wait"
				: "Cannot seek at this time (cooldown period or invalid state)";

			throw new TRPCError({
				code: "PRECONDITION_FAILED",
				message,
			});
		}

		// Check if seeking to current position (no-op)
		const currentOffset = session.currentOffset;
		if (Math.abs(seekTime - currentOffset) < 1) {
			this.logger.debug(
				`Seek to ${seekTime}s is too close to current offset ${currentOffset}s, skipping`,
			);
			return {
				success: true,
				newOffset: currentOffset,
				message: "Already at requested position",
			};
		}

		try {
			await session.restart(seekTime);

			this.logger.info(`Seeked session ${sessionId} to ${seekTime}s`);

			return {
				success: true,
				newOffset: seekTime,
			};
		} catch (error) {
			this.logger.error(
				`Error seeking session ${sessionId} to ${seekTime}s: ${error}`,
			);
			throw new TRPCError({
				code: "INTERNAL_SERVER_ERROR",
				message: "Failed to seek playback session",
				cause: error,
			});
		}
	}

	public listSessions(): PlaybackSession[] {
		return Array.from(this.sessions.values());
	}

	private cleanupInactiveSessions(): void {
		const now = Date.now();
		const sessionsToCleanup: string[] = [];

		for (const [sessionId, session] of this.sessions.entries()) {
			const inactiveTime = now - session.lastAccessedAt.getTime();
			if (inactiveTime > SESSION_TIMEOUT_MS) {
				sessionsToCleanup.push(sessionId);
			}
		}

		for (const sessionId of sessionsToCleanup) {
			this.logger.info(
				`Cleaning up inactive session ${sessionId} (inactive for ${Math.round(SESSION_TIMEOUT_MS / 60000)} minutes)`,
			);
			this.stopSession(sessionId).catch((error) => {
				this.logger.error(`Error cleaning up session ${sessionId}: ${error}`);
			});
		}

		if (sessionsToCleanup.length > 0) {
			this.logger.debug(
				`Cleaned up ${sessionsToCleanup.length} inactive sessions`,
			);
		}
	}
}
