import { HORIZONTAL_SLIDES_SELECTOR } from '../utils/constants.js'
import { queryAll } from '../utils/util.js'

const HIDE_SCROLLBAR_TIMEOUT = 500;
const PROGRESS_SPACING = 4;
const MIN_PROGRESS_SEGMENT_HEIGHT = 6;
const MIN_PLAYHEAD_HEIGHT = 18;

/**
 * The reader mode lets you read a reveal.js presentation
 * as a linear scrollable page.
 */
export default class Reader {

	constructor( Reveal ) {

		this.Reveal = Reveal;

		this.active = false;
		this.activatedCallbacks = [];

		this.onScroll = this.onScroll.bind( this );

	}

	/**
	 * Activates the reader mode. This rearranges the presentation DOM
	 * by—among other things—wrapping each slide in a page element.
	 */
	activate() {

		if( this.active ) return;

		const state = this.Reveal.getState();

		this.active = true;

		this.slideHTMLBeforeActivation = this.Reveal.getSlidesElement().innerHTML;

		const horizontalSlides = queryAll( this.Reveal.getRevealElement(), HORIZONTAL_SLIDES_SELECTOR );

		this.viewportElement.classList.add( 'loading-scroll-mode', 'reveal-reader' );
		this.viewportElement.addEventListener( 'scroll', this.onScroll, { passive: true } );

		let presentationBackground;

		const viewportStyles = window.getComputedStyle( this.viewportElement );
		if( viewportStyles && viewportStyles.background ) {
			presentationBackground = viewportStyles.background;
		}

		const pageElements = [];
		const pageContainer = horizontalSlides[0].parentNode;

		function createPage( slide, h, v ) {

			// Wrap the slide in a page element and hide its overflow
			// so that no page ever flows onto another
			const page = document.createElement( 'div' );
			page.className = 'reader-page';
			pageElements.push( page );

			// Copy the presentation-wide background to each page
			if( presentationBackground ) {
				page.style.background = presentationBackground;
			}

			const stickyContainer = document.createElement( 'div' );
			stickyContainer.className = 'reader-page-sticky';
			page.appendChild( stickyContainer );

			const contentContainer = document.createElement( 'div' );
			contentContainer.className = 'reader-page-content';
			stickyContainer.appendChild( contentContainer );

			contentContainer.appendChild( slide );

			slide.classList.remove( 'past', 'future' );

			if( typeof h === 'number' ) slide.setAttribute( 'data-index-h', h );
			if( typeof v === 'number' ) slide.setAttribute( 'data-index-v', v );

			if( slide.slideBackgroundElement ) {
				slide.slideBackgroundElement.remove( 'past', 'future' );
				contentContainer.insertBefore( slide.slideBackgroundElement, slide );
			}

		}

		// Slide and slide background layout
		horizontalSlides.forEach( ( horizontalSlide, h ) => {

			if( this.Reveal.isVerticalStack( horizontalSlide ) ) {
				horizontalSlide.querySelectorAll( 'section' ).forEach( ( verticalSlide, v ) => {
					createPage( verticalSlide, h, v );
				});
			}
			else {
				createPage( horizontalSlide, h, 0 );
			}

		}, this );

		this.createProgressBar();

		// Remove leftover stacks
		queryAll( this.Reveal.getRevealElement(), '.stack' ).forEach( stack => stack.remove() );

		pageElements.forEach( page => pageContainer.appendChild( page ) );

		// Re-run JS-based content layout after the slide is added to page DOM
		this.Reveal.slideContent.layout( this.Reveal.getSlidesElement() );

		this.Reveal.layout();
		this.Reveal.setState( state );

		this.viewportElement.classList.remove( 'loading-scroll-mode' );

		this.activatedCallbacks.forEach( callback => callback() );
		this.activatedCallbacks = [];

	}

	/**
	 * Deactivates the reader mode and restores the standard slide-based
	 * presentation.
	 */
	deactivate() {

		if( !this.active ) return;

		const state = this.Reveal.getState();

		this.active = false;

		this.viewportElement.removeEventListener( 'scroll', this.onScroll );
		this.viewportElement.classList.remove( 'reveal-reader' );

		this.removeProgressBar();

		this.Reveal.getSlidesElement().innerHTML = this.slideHTMLBeforeActivation;
		this.Reveal.sync();
		this.Reveal.setState( state );

	}

	toggle( override ) {

		if( typeof override === 'boolean' ) {
			override ? this.activate() : this.deactivate();
		}
		else {
			this.isActive() ? this.deactivate() : this.activate();
		}

	}

	/**
	 * Checks if the reader mode is currently active.
	 */
	isActive() {

		return this.active;

	}

	/**
	 * Retrieve a slide by its original h/v index (i.e. the indices the
	 * slide had before being linearized).
	 *
	 * @param {number} h
	 * @param {number} v
	 * @returns {HTMLElement}
	 */
	getSlideByIndices( h, v ) {

		const page = this.pages.find( page => page.indexh === h && page.indexv === v );

		return page ? page.slideElement : null;

	}

	/**
	 * Renders the progress bar component.
	 */
	createProgressBar() {

		this.progressBar = document.createElement( 'div' );
		this.progressBar.className = 'reader-progress';

		this.progressBarInner = document.createElement( 'div' );
		this.progressBarInner.className = 'reader-progress-inner';
		this.progressBar.appendChild( this.progressBarInner );

		this.progressBarPlayhead = document.createElement( 'div' );
		this.progressBarPlayhead.className = 'reader-progress-playhead';
		this.progressBarInner.appendChild( this.progressBarPlayhead );

		this.viewportElement.insertBefore( this.progressBar, this.viewportElement.firstChild );

		const handleDocumentMouseMove	= ( event ) => {

			let progress = ( event.clientY - this.progressBarInner.getBoundingClientRect().top ) / this.progressBarHeight;

			progress = Math.max( Math.min( progress, 1 ), 0 );

			this.viewportElement.scrollTop = progress * ( this.viewportElement.scrollHeight - this.viewportElement.offsetHeight );

		};

		const handleDocumentMouseUp = ( event ) => {

			this.draggingProgressBar = false;
			this.showProgressBar();

			document.removeEventListener( 'mousemove', handleDocumentMouseMove );
			document.removeEventListener( 'mouseup', handleDocumentMouseUp );

		};

		const handleMouseDown = ( event ) => {

			event.preventDefault();

			this.draggingProgressBar = true;

			document.addEventListener( 'mousemove', handleDocumentMouseMove );
			document.addEventListener( 'mouseup', handleDocumentMouseUp );

			handleDocumentMouseMove( event );

		};

		this.progressBarInner.addEventListener( 'mousedown', handleMouseDown );

	}

	removeProgressBar() {

		if( this.progressBar ) {
			this.progressBar.remove();
			this.progressBar = null;
		}

	}

	layout() {

		if( this.isActive() ) {
			this.syncPages();
			this.onScroll();
		}

	}

	/**
	 * Updates our reader pages to match the latest configuration and
	 * presentation size.
	 */
	syncPages() {

		const config = this.Reveal.getConfig();

		const slideSize = this.Reveal.getComputedSlideSize( window.innerWidth, window.innerHeight );
		const scale = this.Reveal.getScale();
		const readerLayout = config.readerLayout;

		const viewportHeight = this.viewportElement.offsetHeight;
		const compactHeight = slideSize.height * scale;
		const pageHeight = readerLayout === 'full' ? viewportHeight : compactHeight;

		// The height that needs to be scrolled between scroll triggers
		const scrollTriggerHeight = viewportHeight / 2;

		this.viewportElement.style.setProperty( '--page-height', pageHeight + 'px' );
		this.viewportElement.style.scrollSnapType = typeof config.readerScrollSnap === 'string' ?
												`y ${config.readerScrollSnap}` : '';

		const pageElements = Array.from( this.Reveal.getRevealElement().querySelectorAll( '.reader-page' ) );

		this.pages = pageElements.map( pageElement => {
			const page = {
				pageElement: pageElement,
				stickyElement: pageElement.querySelector( '.reader-page-sticky' ),
				slideElement: pageElement.querySelector( 'section' ),
				backgroundElement: pageElement.querySelector( '.slide-background' ),
				top: pageElement.offsetTop,
				scrollTriggers: [],
				scrollTriggerHeight
			};

			page.indexh = parseInt( page.slideElement.getAttribute( 'data-index-h' ), 10 );
			page.indexv = parseInt( page.slideElement.getAttribute( 'data-index-v' ), 10 );

			page.slideElement.style.width = slideSize.width + 'px';
			page.slideElement.style.height = config.center === true ? '' : slideSize.height + 'px';

			// Each fragment 'group' is an array containing one or more
			// fragments. Multiple fragments that appear at the same time
			// are part of the same group.
			page.fragments = this.Reveal.fragments.sort( pageElement.querySelectorAll( '.fragment:not(.disabled)' ) );
			page.fragmentGroups = this.Reveal.fragments.sort( pageElement.querySelectorAll( '.fragment' ), true );

			// Create scroll triggers that show/hide fragments
			if( page.fragmentGroups.length ) {
				const segmentSize = 1 / page.fragmentGroups.length;
				let segmentY = segmentSize / 4;
				page.scrollTriggers.push(
					// Trigger for the initial state with no fragments visible
					{ range: [ 0, segmentY ], fragmentIndex: -1 },

					// Triggers for each fragment group
					...page.fragmentGroups.map( ( fragments, i ) => {
						segmentY += segmentSize;
							return {
								range: [ segmentY - segmentSize, segmentY ],
								fragmentIndex: i
							};
						}
					)
				);
			}

			// Add scroll padding based on how many scroll triggers we have
			page.scrollPadding = scrollTriggerHeight * Math.max( page.scrollTriggers.length - 1, 0 );

			// In the compact layout, only slides with scroll triggers cover the
			// full viewport height. This helps avoid empty gaps before or after
			// a sticky slide.
			if( readerLayout === 'compact' && page.scrollTriggers.length > 0 ) {
				page.pageHeight = viewportHeight;
				page.pageElement.style.setProperty( '--page-height', viewportHeight + 'px' );
			}
			else {
				page.pageHeight = pageHeight;
				page.pageElement.style.removeProperty( '--page-height' );
			}

			page.pageElement.style.scrollSnapAlign = page.pageHeight < viewportHeight ? 'center' : 'start';

			// This variable is used to pad the height of our page in CSS
			page.pageElement.style.setProperty( '--page-scroll-padding', page.scrollPadding + 'px' );

			// The total height including scrollable space
			page.totalHeight = page.pageHeight + page.scrollPadding;

			page.bottom = page.top + page.totalHeight;

			// If this is a sticky page, stick it to the vertical center
			if( page.scrollTriggers.length > 0 ) {
				page.stickyElement.style.position = 'sticky';
				page.stickyElement.style.top = Math.max( ( viewportHeight - page.pageHeight ) / 2, 0 ) + 'px';

				// Make this page freeze at the vertical center of the viewport
				page.top -= ( viewportHeight - page.pageHeight ) / 2;
			}
			else {
				page.stickyElement.style.position = 'relative';
			}

			return page;
		} );

		this.viewportElement.setAttribute( 'data-reader-scroll-bar', config.readerScrollbar )

		if( config.readerScrollbar ) {
			// Create the progress bar if it doesn't already exist
			if( !this.progressBar ) this.createProgressBar();

			this.syncProgressBar();
		}
		else {
			this.removeProgressBar();
		}

	}

	/**
	 * Rerenders progress bar segments so that they match the current
	 * reveal.js config and size.
	 */
	syncProgressBar() {

		this.progressBarInner.querySelectorAll( '.reader-progress-slide' ).forEach( slide => slide.remove() );

		const viewportHeight = this.viewportElement.offsetHeight;
		const scrollHeight = this.viewportElement.scrollHeight;

		this.progressBarHeight = this.progressBarInner.offsetHeight;
		this.playheadHeight = Math.max( this.pages[0].pageHeight / scrollHeight * this.progressBarHeight, MIN_PLAYHEAD_HEIGHT );
		this.progressBarScrollableHeight = this.progressBarHeight - this.playheadHeight;

		this.progressBarPlayhead.style.height = this.playheadHeight - PROGRESS_SPACING + 'px';

		const progressSegmentHeight = viewportHeight / scrollHeight * this.progressBarHeight;

		// Don't show individual segments if they're too small
		if( progressSegmentHeight > MIN_PROGRESS_SEGMENT_HEIGHT ) {

			this.pages.forEach( page => {

				page.progressBarSlide = document.createElement( 'div' );
				page.progressBarSlide.className = 'reader-progress-slide';
				page.progressBarSlide.classList.toggle( 'has-triggers', page.scrollTriggers.length > 0 );
				page.progressBarSlide.style.top = page.top / scrollHeight * this.progressBarHeight + 'px';
				page.progressBarSlide.style.height = page.totalHeight / scrollHeight * this.progressBarHeight - PROGRESS_SPACING + 'px';
				this.progressBarInner.appendChild( page.progressBarSlide );

				const scrollTriggerTopOffset = page.scrollTriggerHeight / scrollHeight * this.progressBarHeight;
				const scrollPaddingProgressHeight = page.scrollPadding / scrollHeight * this.progressBarHeight;

				// Create visual representations for each scroll trigger
				page.scrollTriggerElements = page.scrollTriggers.map( ( trigger, i ) => {

					const triggerElement = document.createElement( 'div' );
					triggerElement.className = 'reader-progress-trigger';
					triggerElement.style.top = scrollTriggerTopOffset + trigger.range[0] * scrollPaddingProgressHeight + 'px';
					triggerElement.style.height = ( trigger.range[1] - trigger.range[0] ) * scrollPaddingProgressHeight - PROGRESS_SPACING + 'px';
					page.progressBarSlide.appendChild( triggerElement );

					if( i === 0 ) triggerElement.style.display = 'none';

					return triggerElement;

				} );

			} );

		}
		else {

			this.pages.forEach( page => page.progressBarSlide = null );

		}

	}

	/**
	 * Moves the progress bar playhead to the specified position.
	 *
	 * @param {number} progress 0-1
	 */
	setProgressBarValue( progress ) {

		if( this.progressBar ) {

			this.progressBarPlayhead.style.transform = `translateY(${progress * this.progressBarScrollableHeight}px)`;

			this.pages
				.filter( page => page.progressBarSlide )
				.forEach( ( page ) => {
					page.progressBarSlide.classList.toggle( 'active', !!page.active );

					page.scrollTriggers.forEach( ( trigger, i ) => {
						page.scrollTriggerElements[i].classList.toggle( 'active', page.active && trigger.active );
					} );
				} );

			this.showProgressBar();

		}

	}

	/**
	 * Show the progress bar and, if configured, automatically hide
	 * it after a delay.
	 */
	showProgressBar() {

		this.progressBar.classList.add( 'visible' );

		clearTimeout( this.hideProgressBarTimeout );

		if( this.Reveal.getConfig().readerScrollbar === 'auto' && !this.draggingProgressBar ) {

			this.hideProgressBarTimeout = setTimeout( () => {
				this.progressBar.classList.remove( 'visible' );
			}, HIDE_SCROLLBAR_TIMEOUT );

		}

	}

	/**
	 * Scrolls the given slide element into view.
	 *
	 * @param {HTMLElement} slideElement
	 */
	scrollToSlide( slideElement ) {

		if( !this.active ) {
			this.activatedCallbacks.push( () => this.scrollToSlide( slideElement ) );
		}
		else {
			slideElement.parentNode.scrollIntoView();
		}

	}

	onScroll() {

		const viewportHeight = this.viewportElement.offsetHeight;
		const scrollTop = this.viewportElement.scrollTop;

		// Find the page closest to the center of the viewport, this
		// is the page we want to focus and activate
		const activePage = this.pages.reduce( ( closestPage, page ) => {

			// For tall pages with multiple scroll triggers we need to
			// check the distnace from both the top of the page and the
			// bottom
			const distance = Math.min(
				Math.abs( ( page.top + page.pageHeight / 2 ) - scrollTop - viewportHeight / 2 ),
				Math.abs( ( page.top + ( page.totalHeight - page.pageHeight / 2 ) ) - scrollTop - viewportHeight / 2 )
			);

			return distance < closestPage.distance ? { page, distance } : closestPage;

		}, { page: this.pages[0], distance: Infinity } ).page;

		this.pages.forEach( ( page, pageIndex ) => {
			const isWithinPreloadRange = scrollTop + viewportHeight >= page.top - viewportHeight && scrollTop < page.top + page.bottom + viewportHeight;
			const isPartiallyVisible = scrollTop + viewportHeight >= page.top && scrollTop < page.top + page.bottom;

			// Preload content when it appears within range
			if( isWithinPreloadRange ) {
				if( !page.preloaded ) {
					page.preloaded = true;
					this.Reveal.slideContent.load( page.slideElement );
				}
			}
			else if( page.preloaded ) {
				page.preloaded = false;
				this.Reveal.slideContent.unload( page.slideElement );
			}

			// Activate the current page
			if( page === activePage ) {
				// Ignore if the page is already active
				if( !page.active ) {
					page.active = true;
					page.pageElement.classList.add( 'present' );
					page.slideElement.classList.add( 'present' );

					this.Reveal.setCurrentReaderPage( page.pageElement, page.indexh, page.indexv );
					this.Reveal.slideContent.startEmbeddedContent( page.slideElement );
					this.Reveal.backgrounds.bubbleSlideContrastClassToElement( page.slideElement, this.viewportElement );

					if( page.backgroundElement ) {
						this.Reveal.slideContent.startEmbeddedContent( page.backgroundElement );
					}
				}
			}
			// Deactivate previously active pages
			else if( page.active ) {
				page.active = false;
				page.pageElement.classList.remove( 'present' );
				page.slideElement.classList.remove( 'present' );
				this.Reveal.slideContent.stopEmbeddedContent( page.slideElement );

				if( page.backgroundElement ) {
					this.Reveal.slideContent.stopEmbeddedContent( page.backgroundElement );
				}
			}

			// Handle scroll triggers for slides in view
			if( isPartiallyVisible && page.totalHeight > page.pageHeight ) {
				let scrollProgress = ( scrollTop - page.top ) / page.scrollPadding;
				scrollProgress = Math.max( Math.min( scrollProgress, 1 ), 0 );

				page.scrollTriggers.forEach( trigger => {
					if( scrollProgress >= trigger.range[0] && scrollProgress < trigger.range[1] ) {
						if( !trigger.active ) {
							trigger.active = true;
							this.Reveal.fragments.update( trigger.fragmentIndex, page.fragments, page.slideElement );
						}
					}
					else {
						trigger.active = false;
					}
				} );
			}
		} );

		this.setProgressBarValue( scrollTop / ( this.viewportElement.scrollHeight - viewportHeight ) );

	}

	get viewportElement() {

		return this.Reveal.getViewportElement();

	}

}
