/**
 * Stream engine: HTML5 Audio (+ HLS/DASH on demand) with auto-reconnect using exponential
 * back-off, failover across mounts, adaptive quality, Media Session integration, a cross-tab
 * "single player" lock, and an extension surface for add-ons (EQ, crossfade, fallbacks, other
 * kinds of sources through a SourcePolicy).
 */
import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
import type { Stream, Engine, EngineState } from '@shared/types';
import { attachSource, orderStreams, type Quality } from './media';

export const STATES = {
	IDLE: 'idle',
	LOADING: 'loading',
	PLAYING: 'playing',
	RECONNECTING: 'reconnecting',
	ERROR: 'error',
} as const;

const MAX_RETRIES = 8;
const VOLUME_KEY = 'streamsera:volume';
const QUALITY_KEY = 'streamsera:quality';

/** Plugins get the raw element and lifecycle events (EQ, visualizers, analytics). */
export interface EnginePlugin {
	/** Ask for CORS on the audio element (needed for Web Audio processing) for this player config. */
	wantsCors?: ( config: any ) => boolean;
	/** Crossfade (ms) to use when the listener switches station; undefined = no opinion. */
	crossfadeMs?: ( config: any ) => number | undefined;
	onCreate?: ( audio: HTMLAudioElement, api: EngineApi ) => void;
	onPlay?: ( audio: HTMLAudioElement, api: EngineApi ) => void;
	onPause?: ( audio: HTMLAudioElement, api: EngineApi ) => void;
	onState?: ( state: EngineState, api: EngineApi ) => void;
	onFailed?: ( api: EngineApi ) => void;
	onDestroy?: ( audio: HTMLAudioElement ) => void;
}

export interface EngineApi {
	getAudio: () => HTMLAudioElement | null;
	play: () => void;
	pause: () => void;
	/** Fade the element volume over ms (used by crossfade). */
	fade: ( to: number, ms: number ) => Promise< void >;
	instanceId: string;
	/** Whether the element is loaded with CORS (needed for Web Audio processing). */
	corsEnabled: () => boolean;
}

/**
 * How the engine treats the current source. The defaults suit live radio (cache-busted URLs,
 * buffer dropped on pause, reconnect with back-off on any drop); add-ons that play other kinds of
 * audio through the player pass their own policy with the source.
 */
export interface SourcePolicy {
	/** Cache-bust the URL on every (re)connect. Default true. */
	bust?: boolean;
	/** `preload` attribute of the element. Default 'none'. */
	preload?: '' | 'none' | 'metadata' | 'auto';
	/** Pause keeps the buffer and position; the next play resumes the same URL. Default false. */
	keepOnPause?: boolean;
	/** Attempts before giving up (then onFailed fires). Default 8 with back-off and failover. */
	maxRetries?: number;
	/** Don't treat `stalled` as a drop. Default false. */
	ignoreStall?: boolean;
	/** `ended` means done: stop and fire onEnded instead of reconnecting. Default false. */
	endedIsFinal?: boolean;
	/** Called on the element right before each play attempt (e.g. to set playbackRate). */
	onLoad?: ( audio: HTMLAudioElement ) => void;
}

export interface EngineOptions {
	streams?: Stream[];
	/** Behaviour for the current source (defaults: live radio). */
	policy?: SourcePolicy;
	/** Called when a source with `endedIsFinal` finishes. */
	onEnded?: () => void;
	instanceId: string;
	autoplay?: boolean;
	/**
	 * When autoplay is blocked by the browser, start muted instead of waiting (browsers allow
	 * muted autoplay). The UI then shows an "Unmute" prompt.
	 */
	autoplayMuted?: boolean;
	mediaSession?: {
		title?: string;
		artist?: string;
		album?: string;
		artwork?: string;
	};
	/** Ask for CORS so Web Audio can read samples; falls back automatically when the server lacks CORS. */
	wantCors?: boolean;
	plugins?: EnginePlugin[];
	onFailed?: () => void;
	/** Starting volume for this player, 0–100 (negative or missing: the site default). */
	defaultVolume?: number;
}

export interface EngineExtended extends Engine {
	quality: Quality;
	setQuality: ( q: Quality ) => void;
	api: EngineApi;
	/** Stream currently attached. */
	currentStream: Stream | null;
	corsEnabled: boolean;
	/**
	 * Autoplay status: 'blocked' = the browser refused to start with sound and the player is
	 * waiting for a tap; 'muted' = playing silently (autoplayMuted) until the listener unmutes.
	 */
	autoplayState: '' | 'blocked' | 'muted';
	/** Clear the autoplay prompt (after the listener tapped play / unmute). */
	clearAutoplayState: () => void;
	/** Reconnect attempt in progress (while reconnecting). */
	attempt: number;
	/** Most reconnect attempts before giving up. */
	maxAttempts: number;
}

let channel: BroadcastChannel | null = null;
function getChannel() {
	if (
		channel ||
		typeof window === 'undefined' ||
		! ( 'BroadcastChannel' in window )
	) {
		return channel;
	}
	channel = new window.BroadcastChannel( 'streamsera' );
	return channel;
}

/**
 * Cache-bust a live stream URL so a reconnect never resumes a stale buffer.
 * @param url
 */
export function bust( url: string ): string {
	try {
		const u = new URL( url, window.location.href );
		u.searchParams.set( '_ss', Date.now().toString( 36 ) );
		return u.toString();
	} catch ( e ) {
		return url;
	}
}

/**
 * The listener's last volume (remembered in their browser), else the player's starting volume,
 * else the site-wide default.
 *
 * @param playerDefault Starting volume of this player, 0–100 (negative or missing: site default).
 */
export function readVolume( playerDefault?: number ): number {
	const site = Number.isFinite( window.StreamSeraConfig?.defaultVolume )
		? window.StreamSeraConfig.defaultVolume / 100
		: 0.8;
	const fallback =
		typeof playerDefault === 'number' &&
		Number.isFinite( playerDefault ) &&
		playerDefault >= 0
			? Math.min( 100, playerDefault ) / 100
			: site;
	try {
		const v = parseFloat( window.localStorage.getItem( VOLUME_KEY ) );
		return Number.isFinite( v )
			? Math.min( 1, Math.max( 0, v ) )
			: fallback;
	} catch ( e ) {
		return fallback;
	}
}

function readQuality(): Quality {
	try {
		const q = window.localStorage.getItem( QUALITY_KEY ) as Quality | null;
		return q && [ 'auto', 'low', 'normal', 'high' ].includes( q )
			? q
			: 'auto';
	} catch ( e ) {
		return 'auto';
	}
}

export default function useStreamEngine( {
	streams = [],
	policy,
	onEnded,
	instanceId,
	autoplay = false,
	autoplayMuted = false,
	mediaSession,
	wantCors = false,
	plugins = [],
	onFailed,
	defaultVolume,
}: EngineOptions ): EngineExtended {
	const [ autoplayState, setAutoplayState ] = useState<
		'' | 'blocked' | 'muted'
	>( '' );
	const autoplayMutedRef = useRef( autoplayMuted );
	autoplayMutedRef.current = autoplayMuted;
	// True only while the autoplay attempt (no user gesture yet) is in flight.
	const autoAttemptRef = useRef( false );
	const audioRef = useRef< HTMLAudioElement | null >( null );
	const detachRef = useRef< ( () => void ) | null >( null );
	const retriesRef = useRef( 0 );
	const timerRef = useRef< ReturnType< typeof setTimeout > | null >( null );
	const wantPlayRef = useRef( false );
	const orderedRef = useRef< Stream[] >( [] );
	const indexRef = useRef( 0 );
	const corsRef = useRef( wantCors );
	const pluginsRef = useRef( plugins );
	pluginsRef.current = plugins;
	const policyRef = useRef< SourcePolicy >( policy || {} );
	policyRef.current = policy || {};
	const onEndedRef = useRef( onEnded );
	onEndedRef.current = onEnded;

	const [ state, setStateRaw ] = useState< EngineState >( STATES.IDLE );
	const [ volume, setVolumeState ] = useState( () =>
		readVolume( defaultVolume )
	);
	const [ muted, setMuted ] = useState( false );
	const [ error, setError ] = useState( '' );
	const [ quality, setQualityState ] = useState< Quality >( readQuality );
	const [ currentStream, setCurrentStream ] = useState< Stream | null >(
		null
	);
	const currentStreamRef = useRef< Stream | null >( null );
	currentStreamRef.current = currentStream;

	const fade = useCallback( ( to: number, ms: number ) => {
		const audio = audioRef.current;
		return new Promise< void >( ( resolve ) => {
			if ( ! audio || ms <= 0 ) {
				if ( audio ) {
					audio.volume = to;
				}
				resolve();
				return;
			}
			const from = audio.volume;
			const start = performance.now();
			const step = () => {
				const t = Math.min( 1, ( performance.now() - start ) / ms );
				audio.volume = from + ( to - from ) * t;
				if ( t < 1 ) {
					requestAnimationFrame( step );
				} else {
					resolve();
				}
			};
			requestAnimationFrame( step );
		} );
	}, [] );

	const apiRef = useRef< EngineApi >( {
		getAudio: () => audioRef.current,
		play: () => {},
		pause: () => {},
		fade,
		instanceId,
		corsEnabled: () => corsRef.current,
	} );

	const setState = useCallback( ( s: EngineState ) => {
		setStateRaw( s );
		pluginsRef.current.forEach( ( p ) => p.onState?.( s, apiRef.current ) );
	}, [] );

	// Compare streams by value: the station object can be rebuilt on every now-playing poll, and
	// a new array with the same URLs must not restart playback (or reset the reconnect count).
	const streamsKey = ( streams || [] )
		.map( ( x ) => `${ x.url }|${ x.format || '' }|${ x.bitrate || '' }` )
		.join( ',' );
	// Recompute order when streams or quality change; restart from the primary.
	useEffect( () => {
		orderedRef.current = orderStreams( streams, quality );
		indexRef.current = 0;
		// A new source while playing (station switched, an add-on changed source) starts right away.
		if ( wantPlayRef.current && audioRef.current ) {
			retriesRef.current = 0;
			setState( STATES.LOADING );
			loadAndPlayRef.current();
		}
		// eslint-disable-next-line react-hooks/exhaustive-deps
	}, [ streamsKey, quality ] );

	// Refs so loadAndPlay/scheduleRetry can call each other without stale closures.
	const loadAndPlayRef = useRef< () => Promise< void > >( async () => {} );
	const scheduleRetryRef = useRef< ( reason: string ) => void >( () => {} );
	const retryPendingRef = useRef( false );

	loadAndPlayRef.current = async () => {
		const audio = audioRef.current;
		const stream =
			orderedRef.current[ indexRef.current ] || orderedRef.current[ 0 ];
		if ( ! audio || ! stream ) {
			setState( STATES.ERROR );
			setError( 'No stream URL' );
			return;
		}
		detachRef.current?.();
		detachRef.current = null;
		setCurrentStream( stream );
		audio.crossOrigin = corsRef.current ? 'anonymous' : null;
		const pol = policyRef.current;
		audio.preload = pol.preload ?? 'none';
		try {
			detachRef.current = await attachSource(
				audio,
				stream,
				pol.bust === false ? stream.url : bust( stream.url )
			);
			audio.load();
			audio.playbackRate = 1;
			pol.onLoad?.( audio );
			await audio.play();
		} catch ( err: any ) {
			if ( err?.name === 'NotAllowedError' ) {
				// Autoplay policy. Browsers allow muted autoplay, so when the player is set up
				// for it start silently and ask the listener to unmute; otherwise wait for a tap.
				if (
					autoAttemptRef.current &&
					autoplayMutedRef.current &&
					! audio.muted
				) {
					try {
						audio.muted = true;
						setMuted( true );
						await audio.play();
						setAutoplayState( 'muted' );
						return;
					} catch ( e ) {
						// Even muted autoplay was refused: undo the mute, so the listener's
						// "Tap to listen" / Play starts with sound. Fall through to the blocked state.
						audio.muted = false;
						setMuted( false );
					}
				}
				wantPlayRef.current = false;
				setState( STATES.IDLE );
				if ( autoAttemptRef.current ) {
					setAutoplayState( 'blocked' );
				}
				return;
			}
			scheduleRetryRef.current( 'Playback blocked' );
		} finally {
			autoAttemptRef.current = false;
		}
	};

	scheduleRetryRef.current = ( reason: string ) => {
		if ( ! wantPlayRef.current ) {
			return;
		}
		// One failure can be reported twice (the element's error event and the rejected
		// play() promise): count it once while a retry is already waiting.
		if ( retryPendingRef.current ) {
			return;
		}
		// A source with its own retry budget gives up early (its owner moves on via onFailed).
		const limit = policyRef.current.maxRetries;
		if ( typeof limit === 'number' && retriesRef.current >= limit ) {
			setState( STATES.ERROR );
			setError( reason || 'Source unavailable' );
			wantPlayRef.current = false;
			onFailed?.();
			return;
		}
		// If we asked for CORS and the server doesn't support it, retry once without.
		if ( corsRef.current && retriesRef.current === 0 ) {
			corsRef.current = false;
			retriesRef.current += 1;
			setState( STATES.RECONNECTING );
			loadAndPlayRef.current();
			return;
		}
		if ( retriesRef.current >= MAX_RETRIES ) {
			setState( STATES.ERROR );
			setError( reason || 'Stream unavailable' );
			wantPlayRef.current = false;
			pluginsRef.current.forEach(
				( p ) => p.onFailed?.( apiRef.current )
			);
			onFailed?.();
			return;
		}
		const delay = Math.min( 30000, 1000 * 2 ** retriesRef.current );
		retriesRef.current += 1;
		setState( STATES.RECONNECTING );
		if ( timerRef.current ) {
			clearTimeout( timerRef.current );
			retryPendingRef.current = false;
		}
		retryPendingRef.current = true;
		timerRef.current = setTimeout( () => {
			retryPendingRef.current = false;
			// Fail over to the next mount after two failed attempts on the current one.
			if (
				retriesRef.current % 2 === 0 &&
				orderedRef.current.length > 1
			) {
				indexRef.current =
					( indexRef.current + 1 ) % orderedRef.current.length;
			}
			loadAndPlayRef.current();
		}, delay );
	};

	// Create the audio element once.
	useEffect( () => {
		const audio = new Audio();
		audio.preload = 'none';
		audio.volume = volume;
		audioRef.current = audio;

		const onPlaying = () => {
			retriesRef.current = 0;
			setState( STATES.PLAYING );
			setError( '' );
			pluginsRef.current.forEach(
				( p ) => p.onPlay?.( audio, apiRef.current )
			);
		};
		const onWaiting = () => {
			if ( wantPlayRef.current ) {
				setState( STATES.LOADING );
			}
		};
		const onPause = () => {
			if ( ! wantPlayRef.current ) {
				setState( STATES.IDLE );
				pluginsRef.current.forEach(
					( p ) => p.onPause?.( audio, apiRef.current )
				);
			}
		};
		const onError = () => scheduleRetryRef.current( 'Connection lost' );
		const onStalled = () => {
			if ( policyRef.current.ignoreStall ) {
				return;
			}
			if ( wantPlayRef.current && audio.readyState < 3 ) {
				scheduleRetryRef.current( 'Stream stalled' );
			}
		};
		const handleEnded = () => {
			if ( policyRef.current.endedIsFinal ) {
				wantPlayRef.current = false;
				setState( STATES.IDLE );
				onEndedRef.current?.();
				return;
			}
			scheduleRetryRef.current( 'Stream ended' );
		};

		audio.addEventListener( 'playing', onPlaying );
		audio.addEventListener( 'waiting', onWaiting );
		audio.addEventListener( 'pause', onPause );
		audio.addEventListener( 'error', onError );
		audio.addEventListener( 'stalled', onStalled );
		audio.addEventListener( 'ended', handleEnded );

		const ch = getChannel();
		const onMessage = ( ev: { data?: { type?: string; id?: string } } ) => {
			if (
				ev?.data?.type === 'play' &&
				ev.data.id !== instanceId &&
				wantPlayRef.current
			) {
				wantPlayRef.current = false;
				audio.pause();
				detachRef.current?.();
				detachRef.current = null;
				audio.removeAttribute( 'src' );
				audio.load();
				setState( STATES.IDLE );
			}
		};
		const onLocal = ( e: Event ) =>
			onMessage( {
				data: { type: 'play', id: ( e as CustomEvent ).detail },
			} );
		ch?.addEventListener( 'message', onMessage );
		window.addEventListener( 'streamsera:play', onLocal );

		pluginsRef.current.forEach(
			( p ) => p.onCreate?.( audio, apiRef.current )
		);

		return () => {
			if ( timerRef.current ) {
				clearTimeout( timerRef.current );
				retryPendingRef.current = false;
			}
			audio.pause();
			detachRef.current?.();
			audio.removeAttribute( 'src' );
			audio.load();
			ch?.removeEventListener( 'message', onMessage );
			window.removeEventListener( 'streamsera:play', onLocal );
			pluginsRef.current.forEach( ( p ) => p.onDestroy?.( audio ) );
		};
		// eslint-disable-next-line react-hooks/exhaustive-deps
	}, [ instanceId ] );

	// A source that keeps its buffer on pause resumes where it stopped instead of reloading.
	const resumeOrLoad = useCallback( () => {
		const audio = audioRef.current;
		const stream = orderedRef.current[ 0 ];
		if (
			policyRef.current.keepOnPause &&
			audio &&
			stream &&
			currentStreamRef.current?.url === stream.url &&
			audio.currentSrc
		) {
			audio.play().catch( () => loadAndPlayRef.current() );
			return;
		}
		loadAndPlayRef.current();
	}, [] );

	const play = useCallback( () => {
		if ( ! orderedRef.current.length ) {
			setState( STATES.ERROR );
			setError( 'No stream URL' );
			return;
		}
		wantPlayRef.current = true;
		retriesRef.current = 0;
		if ( ! autoAttemptRef.current ) {
			// A real tap: any "tap to listen" prompt is done with.
			setAutoplayState( '' );
		}
		setState( STATES.LOADING );
		getChannel()?.postMessage( { type: 'play', id: instanceId } );
		window.dispatchEvent(
			new CustomEvent( 'streamsera:play', { detail: instanceId } )
		);
		resumeOrLoad();
	}, [ instanceId, setState, resumeOrLoad ] );

	const pause = useCallback( () => {
		const audio = audioRef.current;
		wantPlayRef.current = false;
		if ( timerRef.current ) {
			clearTimeout( timerRef.current );
			retryPendingRef.current = false;
		}
		if ( audio ) {
			audio.pause();
			if ( ! policyRef.current.keepOnPause ) {
				// Drop the buffer so the next play is live, not delayed.
				detachRef.current?.();
				detachRef.current = null;
				audio.removeAttribute( 'src' );
				audio.load();
			}
		}
		setState( STATES.IDLE );
	}, [ setState ] );

	apiRef.current.play = play;
	apiRef.current.pause = pause;

	const toggle = useCallback( () => {
		if ( wantPlayRef.current ) {
			pause();
		} else {
			play();
		}
	}, [ play, pause ] );

	const setVolume = useCallback( ( v: number ) => {
		const value = Math.min( 1, Math.max( 0, v ) );
		setVolumeState( value );
		if ( audioRef.current ) {
			audioRef.current.volume = value;
			audioRef.current.muted = value === 0;
		}
		setMuted( value === 0 );
		if ( value > 0 ) {
			setAutoplayState( ( a ) => ( a === 'muted' ? '' : a ) );
		}
		try {
			window.localStorage.setItem( VOLUME_KEY, String( value ) );
		} catch ( e ) {
			// ignore
		}
	}, [] );

	const toggleMute = useCallback( () => {
		const next = ! muted;
		setMuted( next );
		if ( audioRef.current ) {
			audioRef.current.muted = next;
		}
		if ( ! next ) {
			setAutoplayState( ( a ) => ( a === 'muted' ? '' : a ) );
		}
	}, [ muted ] );

	const setQuality = useCallback(
		( q: Quality ) => {
			setQualityState( q );
			try {
				window.localStorage.setItem( QUALITY_KEY, q );
			} catch ( e ) {
				// ignore
			}
			// Re-attach with the new pick if we're playing.
			if ( wantPlayRef.current ) {
				orderedRef.current = orderStreams( streams, q );
				indexRef.current = 0;
				retriesRef.current = 0;
				loadAndPlayRef.current();
			}
		},
		[ streams ]
	);

	useEffect( () => {
		if ( autoplay ) {
			autoAttemptRef.current = true;
			play();
		}
		// eslint-disable-next-line react-hooks/exhaustive-deps
	}, [ autoplay ] );

	const clearAutoplayState = useCallback( () => setAutoplayState( '' ), [] );

	// Media Session.
	useEffect( () => {
		if (
			typeof navigator === 'undefined' ||
			! ( 'mediaSession' in navigator )
		) {
			return;
		}
		try {
			navigator.mediaSession.metadata = new window.MediaMetadata( {
				title: mediaSession?.title || '',
				artist: mediaSession?.artist || '',
				album: mediaSession?.album || '',
				artwork: mediaSession?.artwork
					? [ { src: mediaSession.artwork, sizes: '512x512' } ]
					: [],
			} );
			navigator.mediaSession.setActionHandler( 'play', play );
			navigator.mediaSession.setActionHandler( 'pause', pause );
			navigator.mediaSession.setActionHandler( 'stop', pause );
			navigator.mediaSession.playbackState =
				state === STATES.PLAYING ? 'playing' : 'paused';
		} catch ( e ) {
			// ignore
		}
	}, [
		mediaSession?.title,
		mediaSession?.artist,
		mediaSession?.album,
		mediaSession?.artwork,
		state,
		play,
		pause,
	] );

	return {
		state,
		error,
		isPlaying: state === STATES.PLAYING,
		isBusy: state === STATES.LOADING || state === STATES.RECONNECTING,
		play,
		pause,
		toggle,
		volume,
		setVolume,
		muted,
		toggleMute,
		quality,
		setQuality,
		api: apiRef.current,
		currentStream,
		corsEnabled: corsRef.current,
		autoplayState,
		clearAutoplayState,
		/** Reconnect attempt in progress (1-based while reconnecting) and the most there will be. */
		attempt: retriesRef.current,
		maxAttempts:
			typeof policyRef.current.maxRetries === 'number'
				? policyRef.current.maxRetries
				: MAX_RETRIES,
	};
}
