/**
 * Vist360 panorama viewer.
 *
 * Compact Three.js viewer supporting sphere, cube, flat, little-planet, and
 * cylinder projections with DOM-overlay hotspots. Supports an `editable`
 * mode used by the admin live preview: click-to-place picking and hotspot
 * edit callbacks instead of navigation.
 */
import {
	WebGLRenderer,
	Scene,
	PerspectiveCamera,
	TextureLoader,
	SphereGeometry,
	BoxGeometry,
	CylinderGeometry,
	PlaneGeometry,
	MeshBasicMaterial,
	Mesh,
	Vector3,
	MathUtils,
	SRGBColorSpace,
} from 'three';

const RADIUS = 500;
const CLICK_TOLERANCE = 6;

/* Scene-type presets: geometry factory + camera behaviour. */
const PROJECTIONS = {
	sphere: {
		geometry: () => new SphereGeometry( RADIUS, 60, 40 ),
		fov: 75,
	},
	little_planet: {
		geometry: () => new SphereGeometry( RADIUS, 60, 40 ),
		fov: 130,
		pitch: -85,
	},
	cube: {
		geometry: () => new BoxGeometry( RADIUS, RADIUS, RADIUS ),
		fov: 75,
	},
	cylinder: {
		geometry: () =>
			new CylinderGeometry( RADIUS, RADIUS, RADIUS, 60, 1, true ),
		fov: 75,
		lockPitch: 30,
	},
	flat: {
		geometry: ( texture ) => {
			const aspect = texture.image
				? texture.image.width / texture.image.height
				: 2;
			return new PlaneGeometry( RADIUS * aspect, RADIUS );
		},
		fov: 55,
		flat: true,
	},
};

export default class PanoramaViewer {
	constructor( container, config ) {
		this.container = container;
		this.config = config;
		this.editable = !! config.editable;
		this.sceneIndex = Math.min(
			config.viewer.start_scene || 0,
			Math.max( 0, config.scenes.length - 1 )
		);
		this.yaw = 0;
		this.pitch = 0;
		this.gyro = { active: false, yaw: 0, pitch: 0 };
		this.pointer = { down: false, x: 0, y: 0, startX: 0, startY: 0 };
		this.autorotate = ! this.editable && !! config.viewer.autorotate;
		this.destroyed = false;

		this.three = new Scene();
		this.camera = new PerspectiveCamera( 75, 1, 0.1, 1100 );
		this.renderer = new WebGLRenderer( { antialias: true } );
		this.renderer.setPixelRatio( Math.min( window.devicePixelRatio, 2 ) );
		this.loader = new TextureLoader();
		this.loader.setCrossOrigin( 'anonymous' );

		this.buildDom();
		this.bindEvents();
		this.loadScene( this.sceneIndex );
		this.resize();
		this.renderer.setAnimationLoop( () => this.tick() );
		this.track( 'view' );
	}

	/* ---------------------------------------------------------------- DOM */

	buildDom() {
		const { i18n, viewer, title } = this.config;

		this.container.classList.add( 'is-ready' );
		this.canvasWrap = this.el( 'div', 'vist360-viewer__canvas' );
		this.canvasWrap.appendChild( this.renderer.domElement );

		this.hotspotLayer = this.el( 'div', 'vist360-viewer__hotspots' );
		this.loadingEl = this.el(
			'div',
			'vist360-viewer__loading',
			`<span class="vist360-viewer__spinner"></span><p>${ this.escape(
				i18n.loading
			) }</p>`
		);

		this.container.append(
			this.canvasWrap,
			this.hotspotLayer,
			this.loadingEl
		);

		if ( ! this.editable && title ) {
			this.container.appendChild(
				this.el( 'div', 'vist360-viewer__title', this.escape( title ) )
			);
		}

		if ( ! this.editable && viewer.show_controls ) {
			this.buildControls();
		}
	}

	buildControls() {
		const { i18n, scenes, viewer } = this.config;
		const bar = this.el( 'div', 'vist360-viewer__controls' );

		if ( scenes.length > 1 ) {
			const strip = this.el( 'div', 'vist360-viewer__scenes' );
			strip.setAttribute( 'role', 'tablist' );
			strip.setAttribute( 'aria-label', i18n.scenes );

			scenes.forEach( ( scene, index ) => {
				const thumb = this.el( 'button', 'vist360-viewer__scene-thumb' );
				thumb.type = 'button';
				thumb.title = scene.title || `${ index + 1 }`;
				if ( scene.thumb_url ) {
					thumb.style.backgroundImage = `url(${ scene.thumb_url })`;
				}
				thumb.addEventListener( 'click', () => this.goTo( index ) );
				strip.appendChild( thumb );
			} );
			bar.appendChild( strip );
			this.sceneStrip = strip;
		} else {
			bar.appendChild( this.el( 'div', 'vist360-viewer__scenes' ) );
		}

		const buttons = this.el( 'div', 'vist360-viewer__buttons' );

		this.autorotateBtn = this.controlButton(
			'&#8635;',
			i18n.autorotate,
			() => {
				this.autorotate = ! this.autorotate;
				this.autorotateBtn.classList.toggle(
					'is-active',
					this.autorotate
				);
			}
		);
		if ( this.autorotate ) {
			this.autorotateBtn.classList.add( 'is-active' );
		}
		buttons.appendChild( this.autorotateBtn );

		if ( viewer.show_share ) {
			buttons.appendChild(
				this.controlButton( '&#8618;', i18n.share, () => this.share() )
			);
		}

		buttons.appendChild(
			this.controlButton( '&#x26F6;', i18n.fullscreen, () => {
				if ( document.fullscreenElement ) {
					document.exitFullscreen();
				} else if ( this.container.requestFullscreen ) {
					this.container.requestFullscreen();
				}
			} )
		);

		bar.appendChild( buttons );
		this.container.appendChild( bar );
	}

	controlButton( glyph, label, onClick ) {
		const button = this.el( 'button', 'vist360-viewer__btn' );
		button.type = 'button';
		button.innerHTML = glyph;
		button.title = label;
		button.setAttribute( 'aria-label', label );
		button.addEventListener( 'click', onClick );
		return button;
	}

	el( tag, className, html = '' ) {
		const node = document.createElement( tag );
		node.className = className;
		if ( html ) {
			node.innerHTML = html;
		}
		return node;
	}

	/* ------------------------------------------------------------- events */

	bindEvents() {
		const canvas = this.renderer.domElement;

		canvas.addEventListener( 'pointerdown', ( event ) => {
			this.pointer = {
				down: true,
				x: event.clientX,
				y: event.clientY,
				startX: event.clientX,
				startY: event.clientY,
			};
			this.autorotate = false;
			canvas.setPointerCapture( event.pointerId );
		} );

		canvas.addEventListener( 'pointermove', ( event ) => {
			if ( ! this.pointer.down ) {
				return;
			}
			const scale = 0.1 * ( this.camera.fov / 75 );
			this.yaw -= ( event.clientX - this.pointer.x ) * scale;
			this.pitch += ( event.clientY - this.pointer.y ) * scale;
			this.clampPitch();
			this.pointer.x = event.clientX;
			this.pointer.y = event.clientY;
		} );

		canvas.addEventListener( 'pointerup', ( event ) => {
			const moved =
				Math.abs( event.clientX - this.pointer.startX ) +
				Math.abs( event.clientY - this.pointer.startY );
			this.pointer.down = false;

			if (
				this.editable &&
				moved < CLICK_TOLERANCE &&
				this.config.onPick
			) {
				const picked = this.pickAt( event );
				if ( picked ) {
					this.config.onPick( picked.yaw, picked.pitch );
				}
			}
		} );

		canvas.addEventListener(
			'wheel',
			( event ) => {
				event.preventDefault();
				this.camera.fov = MathUtils.clamp(
					this.camera.fov + event.deltaY * 0.05,
					30,
					this.projection().fov + 15
				);
				this.camera.updateProjectionMatrix();
			},
			{ passive: false }
		);

		this.resizeObserver = new window.ResizeObserver( () => this.resize() );
		this.resizeObserver.observe( this.container );

		if (
			! this.editable &&
			this.config.viewer.gyroscope &&
			window.DeviceOrientationEvent
		) {
			this.setupGyro();
		}
	}

	setupGyro() {
		const listen = () => {
			window.addEventListener( 'deviceorientation', ( event ) => {
				if ( null === event.alpha ) {
					return;
				}
				this.gyro.active = true;
				this.gyro.yaw = -event.alpha;
				this.gyro.pitch = event.beta - 90;
			} );
		};

		// iOS requires a user-gesture permission request.
		if (
			typeof window.DeviceOrientationEvent.requestPermission ===
			'function'
		) {
			this.container.addEventListener(
				'pointerdown',
				() => {
					window.DeviceOrientationEvent.requestPermission()
						.then( ( state ) => 'granted' === state && listen() )
						.catch( () => {} );
				},
				{ once: true }
			);
		} else {
			listen();
		}
	}

	clampPitch() {
		const lock = this.projection().lockPitch || 90;
		this.pitch = MathUtils.clamp( this.pitch, -lock, lock );
	}

	/**
	 * Convert a click on the canvas into yaw/pitch degrees.
	 *
	 * @param {PointerEvent} event Pointer event.
	 * @return {{yaw: number, pitch: number}|null} Picked angles.
	 */
	pickAt( event ) {
		const rect = this.renderer.domElement.getBoundingClientRect();
		if ( ! rect.width || ! rect.height ) {
			return null;
		}

		const ndc = new Vector3(
			( ( event.clientX - rect.left ) / rect.width ) * 2 - 1,
			-( ( event.clientY - rect.top ) / rect.height ) * 2 + 1,
			0.5
		);
		const direction = ndc
			.unproject( this.camera )
			.sub( this.camera.position )
			.normalize();

		return {
			yaw:
				Math.round(
					MathUtils.radToDeg(
						Math.atan2( -direction.x, -direction.z )
					) * 10
				) / 10,
			pitch:
				Math.round(
					MathUtils.radToDeg( Math.asin( direction.y ) ) * 10
				) / 10,
		};
	}

	/* -------------------------------------------------------------- scene */

	projection() {
		const type = this.config.scenes[ this.sceneIndex ]?.type || 'sphere';
		return PROJECTIONS[ type ] || PROJECTIONS.sphere;
	}

	loadScene( index ) {
		const scene = this.config.scenes[ index ];
		if ( ! scene || ! scene.image_url ) {
			return;
		}

		this.sceneIndex = index;
		this.loadingEl.style.display = '';
		this.hotspotLayer.innerHTML = '';

		this.loader.load( scene.image_url, ( texture ) => {
			if ( this.destroyed ) {
				return;
			}
			texture.colorSpace = SRGBColorSpace;

			if ( this.mesh ) {
				this.three.remove( this.mesh );
				this.mesh.geometry.dispose();
				this.mesh.material.map?.dispose();
				this.mesh.material.dispose();
			}

			const projection = this.projection();
			const geometry = projection.geometry( texture );
			if ( ! projection.flat ) {
				geometry.scale( -1, 1, 1 );
			}

			this.mesh = new Mesh(
				geometry,
				new MeshBasicMaterial( { map: texture } )
			);
			if ( projection.flat ) {
				this.mesh.position.set( 0, 0, -RADIUS );
			}
			this.three.add( this.mesh );

			this.yaw = 0;
			this.pitch = projection.pitch || 0;
			this.camera.fov = projection.fov;
			this.camera.updateProjectionMatrix();

			this.loadingEl.style.display = 'none';
			this.renderHotspots( scene );
			this.updateSceneStrip();
		} );
	}

	goTo( index ) {
		if ( index !== this.sceneIndex && this.config.scenes[ index ] ) {
			this.loadScene( index );
			this.track( 'scene_change', index );
		}
	}

	/**
	 * Replace scene data and re-render (used by the admin live preview).
	 *
	 * @param {Array}  scenes     New scenes array.
	 * @param {number} sceneIndex Scene to display.
	 */
	setScenes( scenes, sceneIndex = this.sceneIndex ) {
		const previous = this.config.scenes[ this.sceneIndex ];
		this.config.scenes = scenes;
		const target = Math.min( sceneIndex, scenes.length - 1 );

		if ( target < 0 ) {
			this.hotspotLayer.innerHTML = '';
			this.hotspots = [];
			return;
		}

		if (
			target !== this.sceneIndex ||
			scenes[ target ]?.image_url !== previous?.image_url
		) {
			this.loadScene( target );
		} else {
			this.renderHotspots( scenes[ target ] );
		}
	}

	updateSceneStrip() {
		if ( ! this.sceneStrip ) {
			return;
		}
		Array.from( this.sceneStrip.children ).forEach( ( thumb, index ) => {
			thumb.classList.toggle( 'is-active', index === this.sceneIndex );
		} );
	}

	/* ----------------------------------------------------------- hotspots */

	renderHotspots( scene ) {
		this.hotspotLayer.innerHTML = '';
		this.hotspots = ( scene.hotspots || [] ).map( ( hotspot, index ) => {
			const node = this.el(
				'button',
				`vist360-hotspot is-${ hotspot.type }`
			);
			node.type = 'button';
			node.innerHTML = `<span class="vist360-hotspot__dot"></span>${
				hotspot.title
					? `<span class="vist360-hotspot__label">${ this.escape(
							hotspot.title
					  ) }</span>`
					: ''
			}`;
			node.addEventListener( 'click', () => {
				if ( this.editable ) {
					if ( this.config.onHotspotEdit ) {
						this.config.onHotspotEdit( index );
					}
					return;
				}
				this.activateHotspot( hotspot );
			} );
			this.hotspotLayer.appendChild( node );

			const yaw = MathUtils.degToRad( hotspot.yaw );
			const pitch = MathUtils.degToRad( hotspot.pitch );

			return {
				node,
				position: new Vector3(
					-Math.cos( pitch ) * Math.sin( yaw ),
					Math.sin( pitch ),
					-Math.cos( pitch ) * Math.cos( yaw )
				).multiplyScalar( RADIUS * 0.9 ),
			};
		} );
	}

	activateHotspot( hotspot ) {
		this.track( 'hotspot_click' );

		switch ( hotspot.type ) {
			case 'navigation':
				this.goTo( hotspot.target_scene );
				break;
			case 'link':
				if ( hotspot.url ) {
					window.open( hotspot.url, '_blank', 'noopener' );
				}
				break;
			case 'media':
				if ( hotspot.media_url ) {
					this.openPopup(
						hotspot.title,
						`<img src="${ this.escape(
							hotspot.media_url
						) }" alt="">`
					);
				}
				break;
			default:
				this.openPopup(
					hotspot.title,
					this.escape( hotspot.content || '' )
				);
		}
	}

	openPopup( title, html ) {
		this.closePopup();
		this.popup = this.el(
			'div',
			'vist360-viewer__popup',
			`<div class="vist360-viewer__popup-inner">
				<button type="button" class="vist360-viewer__popup-close" aria-label="${ this.escape(
					this.config.i18n.close
				) }">&times;</button>
				${ title ? `<h4>${ this.escape( title ) }</h4>` : '' }
				<div>${ html }</div>
			</div>`
		);
		this.popup
			.querySelector( '.vist360-viewer__popup-close' )
			.addEventListener( 'click', () => this.closePopup() );
		this.popup.addEventListener( 'click', ( event ) => {
			if ( event.target === this.popup ) {
				this.closePopup();
			}
		} );
		this.container.appendChild( this.popup );
	}

	closePopup() {
		this.popup?.remove();
		this.popup = null;
	}

	share() {
		this.track( 'share' );
		const { shareUrl, title, i18n } = this.config;

		if ( navigator.share ) {
			navigator.share( { title, url: shareUrl } ).catch( () => {} );
			return;
		}

		const encoded = encodeURIComponent( shareUrl );
		this.openPopup(
			i18n.share,
			`<div class="vist360-viewer__share">
				<a href="https://www.facebook.com/sharer/sharer.php?u=${ encoded }" target="_blank" rel="noopener noreferrer">Facebook</a>
				<a href="https://twitter.com/intent/tweet?url=${ encoded }" target="_blank" rel="noopener noreferrer">X</a>
				<a href="https://www.linkedin.com/sharing/share-offsite/?url=${ encoded }" target="_blank" rel="noopener noreferrer">LinkedIn</a>
				<a href="https://pinterest.com/pin/create/button/?url=${ encoded }" target="_blank" rel="noopener noreferrer">Pinterest</a>
				<a href="mailto:?body=${ encoded }">Email</a>
				<button type="button" class="vist360-viewer__copy">${ this.escape(
					i18n.copyLink
				) }</button>
			</div>`
		);
		this.popup
			.querySelector( '.vist360-viewer__copy' )
			?.addEventListener( 'click', () => {
				window.navigator.clipboard
					?.writeText( shareUrl )
					.catch( () => {} );
				this.closePopup();
			} );
	}

	/* ------------------------------------------------------------- render */

	tick() {
		if ( this.autorotate && ! this.pointer.down ) {
			this.yaw += 0.03;
		}

		const yaw = MathUtils.degToRad(
			this.yaw + ( this.gyro.active ? this.gyro.yaw : 0 )
		);
		const pitch = MathUtils.degToRad(
			MathUtils.clamp(
				this.pitch + ( this.gyro.active ? this.gyro.pitch : 0 ),
				-89,
				89
			)
		);

		if ( this.projection().flat ) {
			this.camera.position.set( yaw * 100, pitch * 100, 0 );
			this.camera.lookAt( yaw * 100, pitch * 100, -RADIUS );
		} else {
			this.camera.position.set( 0, 0, 0 );
			this.camera.lookAt(
				Math.cos( pitch ) * Math.sin( yaw ),
				Math.sin( pitch ),
				-Math.cos( pitch ) * Math.cos( yaw )
			);
		}

		this.renderer.render( this.three, this.camera );
		this.positionHotspots();
	}

	positionHotspots() {
		if ( ! this.hotspots?.length ) {
			return;
		}

		const width = this.container.clientWidth;
		const height = this.canvasWrap.clientHeight;
		const cameraDirection = new Vector3();
		this.camera.getWorldDirection( cameraDirection );

		this.hotspots.forEach( ( { node, position } ) => {
			const behind =
				position.clone().normalize().dot( cameraDirection ) <= 0;
			const projected = position.clone().project( this.camera );

			if (
				behind ||
				Math.abs( projected.x ) > 1.1 ||
				Math.abs( projected.y ) > 1.1
			) {
				node.style.display = 'none';
				return;
			}

			node.style.display = '';
			node.style.left = `${ ( ( projected.x + 1 ) / 2 ) * width }px`;
			node.style.top = `${ ( ( 1 - projected.y ) / 2 ) * height }px`;
		} );
	}

	resize() {
		const width = this.container.clientWidth;
		const height =
			this.canvasWrap.clientHeight || this.container.clientHeight;
		if ( ! width || ! height ) {
			return;
		}
		this.camera.aspect = width / height;
		this.camera.updateProjectionMatrix();
		this.renderer.setSize( width, height );
	}

	destroy() {
		this.destroyed = true;
		this.renderer.setAnimationLoop( null );
		this.resizeObserver?.disconnect();
		this.mesh?.geometry.dispose();
		this.mesh?.material.map?.dispose();
		this.mesh?.material.dispose();
		this.renderer.dispose();
		this.container.classList.remove( 'is-ready' );
		this.container.innerHTML = '';
	}

	/* ---------------------------------------------------------- analytics */

	track( event, sceneIndex = this.sceneIndex ) {
		if ( this.editable || ! this.config.analytics ) {
			return;
		}
		window
			.fetch( this.config.restUrl, {
				method: 'POST',
				headers: { 'Content-Type': 'application/json' },
				keepalive: true,
				body: JSON.stringify( {
					tour_id: this.config.tourId,
					event,
					scene_index: sceneIndex,
				} ),
			} )
			.catch( () => {} );
	}

	escape( text ) {
		const div = document.createElement( 'div' );
		div.textContent = text || '';
		return div.innerHTML;
	}
}
