'use strict';
const OgvJsSupport = require( 'ext.tmh.OgvJsSupport' );

/**
 * All JS for loading an actual video.js player for MediaWiki
 *
 * No matter how the player is loaded, it should eventually go through this class
 * to make sure our customizations are properly applied.
 * This should not have any loaders/hooks itself.
 */
class InlinePlayer {

	/**
	 * @param {HTMLMediaElement} element
	 * @param {videojs.Options} options for the video.js player
	 */
	constructor( element, options ) {
		/**
		 * The eventual videojs configuration used when loading the videojs player
		 *
		 * @type {videojs.Options}
		 * @private
		 */
		this.playerConfig = Object.assign( {}, options );
		/**
		 * The original audio/video element
		 *
		 * @type {HTMLMediaElement}
		 * @private
		 */
		this.videoplayer = element;
		/**
		 * The original audio/video element as a jquery collection
		 *
		 * @type {jQuery}
		 * @private
		 */
		this.$videoplayer = $( element );
		this.$videoplayer.attr( 'playsinline', '' );
		/**
		 * Whether his player is audio only
		 *
		 * @type {boolean}
		 * @private
		 */
		this.isAudio = element.tagName.toLowerCase() === 'audio';
		/**
		 * The video.js instance associated with this inline player.
		 * NOTE: Only available after infusion
		 *
		 * @type {videojs.Player}
		 * @private
		 */
		this.videojsPlayer = null;
	}

	/**
	 * Lazy load resources once for this class
	 *
	 * Should do this as early as you know for sure you will load a player
	 * External users may call this early to prefetch resources
	 *
	 * @private
	 */
	static lazyInit() {
		if ( InlinePlayer.initialized ) {
			return;
		}
		require( './mw-info-button/mw-info-button-plugin.js' );
		require( './videojs-resolution-switcher/videojs-resolution-switcher.js' );
		require( './mw-subtitles-button/mw-subtitles-create.js' );
		require( './mw-subtitles-button/mw-subtitles-button.js' );

		// Add translations for the plugins
		// video.js translations don't have region postfixes (yet)
		videojs.addLanguage( mw.config.get( 'wgUserLanguage' ).split( '-' )[ 0 ], {
			'More information': mw.msg( 'videojs-more-information' ),
			Quality: mw.msg( 'videojs-quality' ),
			'Create captions': mw.msg( 'videojs-captions-create' ),
			'Create subtitles': mw.msg( 'videojs-subtitles-create' )
		} );

		InlinePlayer.initialized = true;
	}

	/**
	 * Takes the HTMLMediaElement of the InlinePlayer
	 * and infuses it with JS (videoJS) to enrich the element.
	 *
	 * @return {jQuery.Promise<videojs.Player>|undefined}
	 */
	infuse() {
		if ( this.$videoplayer.closest( '.video-js' ).length ) {
			// This player has already been transformed.
			return;
		}

		InlinePlayer.lazyInit();
		this.playerConfig = $.extend(
			true, // deep
			{},
			InlinePlayer.globalConfig,
			this.isAudio ? InlinePlayer.audioConfig : InlinePlayer.videoConfig,
			this.playerConfig
		);

		if ( !OgvJsSupport.isMediaNativelySupported( this.videoplayer ) ) {
			this.playerConfig.ogvjs = {
				base: OgvJsSupport.basePath(),
				audioContext: OgvJsSupport.initAudioContext()
			};
			this.playerConfig.techOrder.push( 'ogvjs' );
			// ogvjs tech does not support picture in picture
			this.playerConfig.controlBar.pictureInPictureToggle = false;
		}

		// Future interactions go faster if we've preloaded a little
		this.$videoplayer.attr( {
			preload: 'metadata'
		} );

		/** @type {HTMLSourceElement[]} */
		const nonNativeSources = [];
		let resolutions = [];
		let defaultRes;
		if ( this.isAudio ) {
			// Audio: manipulate source elements to preferred order.
			// This means preferring native-playback over ogv.js-playback
			// so we don't go loading it when we don't need it.
			this.$videoplayer.find( 'source' ).each( ( _index, source ) => {
				if ( !this.videoplayer.canPlayType( source.type ) ) {
					nonNativeSources.push( source );
				}
			} );

			nonNativeSources.forEach( ( source ) => {
				$( source ).detach().appendTo( this.$videoplayer );
			} );
		} else {
			resolutions = this.extractResolutions();

			// Do not autoselect above 1080p due to bandwidth requirements.
			// Also, note a fake res of 99999 is used to mark original files.
			// Never auto-select an original unless it is the only file.
			const maxRes = 1080;

			// Pick the first resolution at least the size of the player,
			// unless they're all too small.
			let playerHeight = Math.min(
				maxRes,
				// Account for screen density
				this.$videoplayer.height() * window.devicePixelRatio
			);
			if ( !OgvJsSupport.canPlayNatively() ) {
				// Don't pick high-res versions on ogv.js which may be slow.
				playerHeight = Math.min( playerHeight, 480 );
			}
			resolutions.sort( ( a, b ) => a - b );
			for ( let i = 0, l = resolutions.length; i < l; i++ ) {
				if ( resolutions[ i ] <= maxRes ) {
					defaultRes = resolutions[ i ];
					if ( defaultRes >= playerHeight ) {
						break;
					}
				}
			}
			if ( !this.isAudio && defaultRes ) {
				this.playerConfig.plugins.videoJsResolutionSwitcher.default = defaultRes;
			}
			if ( playerHeight >= 120 ) { // 5em === 65px
				// We place the progressbar on top of the other controls
				this.$videoplayer.addClass( 'vjs-high-controls' );
				this.playerConfig.controlBar.volumePanel.inline = true;
				this.playerConfig.controlBar.volumePanel.vertical = false;
			}
		}
		// We remove SRT subtitles tracks as we can't handle them
		this.$videoplayer.find( 'track[type="text/x-srt"]' ).remove();
		// Make sure the menu's can overflow thumbnail frames
		this.$videoplayer.closest( '.thumbinner' ).addClass( 'mw-overflow' );
		this.$videoplayer.addClass( 'video-js' );

		// eslint-disable-next-line es-x/no-array-prototype-fill
		if ( this.playerConfig.fill ) {
			// In fill mode, remove any inline width/height
			// from the inline player
			this.videoplayer.style.removeProperty( 'width' );
			this.videoplayer.style.removeProperty( 'height' );
		}

		const NS_FILE = mw.config.get( 'wgNamespaceIds' ).file;
		const resource = this.videoplayer.getAttribute( 'resource' );
		const resourceTitle = resource ?
			decodeURIComponent( resource.slice( resource.lastIndexOf( '/' ) + 1 ) ) :
			this.$videoplayer.data( 'mwtitle' );
		if ( resourceTitle ) {
			this.playerConfig.mwTitle = mw.Title.newFromText(
				resourceTitle, NS_FILE
			);
		} else {
			mw.log.warn( 'Unable to detect resource Title for this media element' );
		}

		// Launch the player
		return OgvJsSupport.loadIfNeeded( 'ext.tmh.videojs-ogvjs', this.videoplayer )
			.then( () => {
				const d = $.Deferred();
				this.videojsPlayer = videojs( this.videoplayer, this.playerConfig );
				// Do not use the ready callback of the videojs function
				// The texttracks are not done initializing in that ready callback (T309414)
				this.videojsPlayer.ready( () => {
					InlinePlayer.activePlayers.push( this.videojsPlayer );
					this.selectDefaultTrack();
					/* More custom stuff goes here */
					d.resolve( this.videojsPlayer );
				} );
				return d.promise();
			} );
	}

	/**
	 * Select a default text track to enable
	 * based on user language with fallback to content language
	 */
	selectDefaultTrack() {
		let userLanguageTrack;
		let contentLanguageTrack;
		const tracks = this.videojsPlayer.textTracks();

		// video.js's TrackList is array-like, but not iterable
		for ( const track of Array.from( tracks ) ) {
			// For now we only support subtitles
			// Also does not deal with language fallbacks
			if ( track.kind === 'subtitles' ) {
				const tl = track.language.toLowerCase();
				if ( tl === mw.config.get( 'wgUserLanguage' ).toLowerCase() ) {
					userLanguageTrack = track;
					break;
				}
				if ( tl === mw.config.get( 'wgUserLanguage' ).toLowerCase().split( '-' )[ 0 ] ) {
					userLanguageTrack = track;
				}
				if ( tl === mw.config.get( 'wgContentLanguage' ).toLowerCase() ) {
					contentLanguageTrack = track;
				}
			}
		}
		if ( userLanguageTrack ) {
			userLanguageTrack.mode = 'showing';
		} else if ( contentLanguageTrack ) {
			contentLanguageTrack.mode = 'showing';
		}
	}

	/**
	 * Extract the list of resolutions from the HTMLSourceElements
	 * contained within the HTMLMediaElement.
	 *
	 * Also sets translated labels and the extracted res
	 * as attributes 'label' and 'res' on the HTMLSourceElements,
	 * for use by the resolution switcher plugin
	 *
	 * @return {number[]}
	 */
	extractResolutions() {
		// Video: extract the relevant resolutions from source elements
		// and pass them into the videoJsResolutionSwitcher plugin in
		// our preferred order and labeling.
		const resolutions = [];
		this.$videoplayer.find( 'source' ).each( ( _index, source ) => {
			// FIXME would be better if we can configure the plugin
			// to make use of our preferred attributes
			const $source = $( source );
			const transcodeKey = $source.data( 'transcodekey' );
			let res = parseInt( $source.data( 'height' ), 10 );
			let label = mw.msg( 'timedmedia-source-original' );

			if ( transcodeKey ) {
				const matches = transcodeKey.match( /^(\d+)p\./ );
				if ( matches ) {
					// Video derivative of fixed size.
					res = parseInt( matches[ 1 ], 10 );
					// Messages that can be used here:
					// * timedmedia-resolution-120
					// * timedmedia-resolution-160
					// * timedmedia-resolution-180
					// * timedmedia-resolution-240
					// * timedmedia-resolution-360
					// * timedmedia-resolution-480
					// * timedmedia-resolution-720
					// * timedmedia-resolution-1080
					// * timedmedia-resolution-1440
					// * timedmedia-resolution-2160
					label = mw.msg( 'timedmedia-resolution-' + res );
				}
			} else {
				// Original source; sort to top and never auto-select.
				res = 99999;
			}
			$source.attr( 'res', res );
			$source.attr( 'label', label );
			resolutions.push( res );
		} );
		return resolutions;
	}
}

/**
 * videoJS options for the videojs Html5 Tech plugin.
 * These options are merged into the final config for the player during initialize()
 *
 * Text tracks are not preloaded because we may have a large number of subtitles
 * which never get used, and it's expensive to load them all.
 *
 * More consistent subtitle rendering is available due to disabling native text tracks.
 *
 * Disabling native audio tracks avoids accidentally exposing the Opus audio tracks
 * in HTTP Live Streaming playlists on Safari, which can't actually play them and
 * breaks things if you switch to them
 *
 * @static
 * @type {Object}
 */
InlinePlayer.html5techOpt = {
	preloadTextTracks: false,
	nativeTextTracks: false,
	nativeAudioTracks: false
};

/**
 * videoJS player options shared by all uses
 * These options are merged into the final config for the player during initialize()
 *
 * @static
 * @type {videojs.Options}
 */
InlinePlayer.globalConfig = {
	// controls are initially hidden inside the dialog
	// to avoid a flash of the native controls
	controls: true,
	responsive: true,
	fill: false,
	iframe: false,
	language: mw.config.get( 'wgUserLanguage' ),
	controlBar: {
		// iOS does not allow using the fullscreen, so no point in adding that control
		// eslint-disable-next-line compat/compat
		fullscreenToggle: !!document.fullscreenEnabled || !!document.webkitFullscreenEnabled,
		volumePanel: {
			vertical: true,
			inline: false
		}
	},
	enableDocumentPictureInPicture: true,
	userActions: {
		// https://docs.videojs.com/tutorial-options.html#useractions.hotkeys
		hotkeys: true
	},
	techOrder: [ 'html5' ],
	plugins: {
		infoButton: {}
	},
	html5: InlinePlayer.html5techOpt
};

/**
 * videoJS player options specific to audio playback
 * These options are merged into the final config for the player during initialize()
 *
 * @static
 * @type {videojs.Options}
 */
InlinePlayer.audioConfig = {
	bigPlayButton: false,
	controlBar: {
		fullscreenToggle: false,
		pictureInPictureToggle: false
	},
	// Audio interface breakpoints
	// play, volume, info and CC are most important here
	breakpoints: {
		// from 40: play only
		tiny: 79,
		// from 80: play and volume
		xsmall: 119,
		// from 120: play, volume [,info]
		small: 199,
		// from 200: play, volume, position [,CC] [,info]
		medium: 259,
		// from 260: play, volume, position, remaining [,CC] [,info]
		large: 339,
		// from 340: play, volume, position, remaining [,CC] [,info]
		xlarge: 1000,
		huge: 2000
	}
};

/**
 * videoJS player options specific to videos
 * These options are merged into the final config for the player during initialize()
 *
 * @static
 * @type {videojs.Options}
 */
InlinePlayer.videoConfig = {
	// Video interace breakpoints
	// Encourage play/pause, fullscreen (to reach all controls) and info
	// Subtitles are too small to read upto 400px or so anyway
	// Resolution is already matched to current size
	breakpoints: {
		// most controls are 40px wide
		// play and fullscreen
		tiny: 159,
		// from 160: play, volume, space, fullscreen [,info]
		xsmall: 199,
		// from 200: play, volume, position, fullscreen [,info]
		small: 239,
		// from 240: play, volume, position, resolution, fullscreen [,info]
		medium: 299,
		// from 300: play, volume, position, time remaining, resolution, fullscreen [,info]
		large: 339,
		// from 340: play, volume, position, time remaining,
		// [CC,] resolution, fullscreen [,info]
		xlarge: 1000,
		huge: 2000
	},
	controlBar: {
		currentTimeDisplay: true,
		timeDivider: true,
		durationDisplay: true,
		remainingTimeDisplay: false
	},
	plugins: {
		videoJsResolutionSwitcher: {
			sourceOrder: true
		}
	}
};

/**
 * Set to true if the class has been initialized
 *
 * @static
 * @type {boolean}
 * @private
 */
InlinePlayer.initialized = false;

/**
 * Array of all videojs players that have been created
 *
 * This is filled automatically, and cleaned by
 * the jQuery plugin $.fn.disposeDetachedPlayers
 *
 * @static
 * @type {Array<videojs.Player>}
 * @private
 */
InlinePlayer.activePlayers = [];

/**
 * Remove any detached players from previous live previews etc
 *
 * @private
 * @return {jQuery}
 * @chainable
 */
function disposeDetachedPlayers() {
	InlinePlayer.activePlayers = InlinePlayer.activePlayers.filter( ( player ) => {
		if ( !player.el().ownerDocument.body.contains( player.el() ) ) {
			player.dispose();
			return false;
		}
		return true;
	} );
	return this;
}

/**
 * jQuery plugin to cleanup all resources of
 * a player which is no longer in the document
 *
 * @return {jQuery}
 * @chainable
 */
$.disposeDetachedPlayers = disposeDetachedPlayers;

module.exports = InlinePlayer;