import {ApplicableControlsNode} from './../../core/camera/CoreCameraControlsController';
import {createRaycaster} from './../../core/RaycastHelper';
import {ViewerCamerasController} from './utils/ViewerCamerasController';
import {ViewerControlsController} from './utils/ViewerControlsController';
import {ViewerEventsController} from './utils/ViewerEventsController';
import {ViewerWebGLController} from './utils/ViewerWebglController';
import {ViewerAudioController} from './utils/ViewerAudioController';
import {Camera, Object3D, Vector2} from 'three';
import {PolyScene} from '../scene/PolyScene';
import {Poly, PolyEngine} from '../Poly';
import {AbstractRenderer} from './Common';
import {ViewerRaycastersController} from './utils/ViewerRaycastersController';
import {ViewerPerformanceMonitor} from './utils/ViewerPerformanceMonitor';
import {AvailableRenderConfig} from '../../core/camera/CoreCameraRendererController';
import {MouseHelper} from '../../core/event/MouseHelper';

const HOVERED_CLASS_NAME = 'hovered';

type ViewerTickCallback = (delta: number) => void;
type ViewerRenderCallback = (delta: number, renderer: AbstractRenderer) => void;
type ViewerBaseCallback = ViewerTickCallback | ViewerRenderCallback;
type OnResizeCallback = (width: number, height: number) => void;
interface BaseViewerCallbackOptions {
	persistent?: boolean;
}
interface ViewerCallbackContainer<T extends ViewerBaseCallback> {
	callback: T;
	options: BaseViewerCallbackOptions;
}
type ViewerCallbacksMap<T extends ViewerBaseCallback> = Map<string, ViewerCallbackContainer<T>>;
export interface HTMLElementWithViewer<C extends Camera> extends HTMLElement {
	scene: PolyScene;
	viewer: TypedViewer<C>;
	Poly: PolyEngine;
}
type UpdateCameraAspectCallback = (aspect: number, resolution: Vector2) => void;

export interface CreateViewerOptions {
	canvas?: HTMLCanvasElement;
	autoRender?: boolean;
	renderer?: AbstractRenderer;
	cameraMaskOverride?: string;
}
export interface TypedViewerOptions<C extends Camera> extends CreateViewerOptions {
	camera: C;
	scene: PolyScene;
	updateCameraAspect: UpdateCameraAspectCallback;
}
export interface TypedViewerInterectionObserverChangeOptions {
	root?: HTMLElement;
	rootMargin?: string;
	threshold?: number;
	playPauseScene?: boolean;
	onChange?: (isIntersecting: boolean) => void;
}
export interface BaseViewerMountOptions {
	updateAutoRenderOnIntersectionChange?: boolean;
}
type RenderFuncWithDelta = (delta: number) => void;
type RenderFunc = () => void;

/**
 *
 *
 * Base class to create a viewer. It is used for the [Threejs viewer](/docs/api/ThreejsViewer) as well as the [Mapbox Viewer](https://github.com/polygonjs/plugin-mapbox)
 *
 */
export abstract class TypedViewer<C extends Camera> {
	// protected _display_scene: Scene;
	// protected _canvas: HTMLCanvasElement | undefined;
	protected _domElement: HTMLElementWithViewer<C> | undefined;
	protected _active: boolean = false;
	private static _nextViewerId = 0;
	private _id: Readonly<string>;
	protected _renderObjectOverride: Object3D | undefined;
	protected _canvas: HTMLCanvasElement | undefined;
	protected _camera: C;
	protected _scene: PolyScene;
	public readonly updateCameraAspect: UpdateCameraAspectCallback;
	protected _doRender: boolean = true;
	protected _controlsNode: ApplicableControlsNode | undefined;
	protected _renderFunc: RenderFuncWithDelta | undefined;
	protected _renderCSSFunc: RenderFunc | undefined;
	protected _renderer: AbstractRenderer | undefined;

	constructor(options: TypedViewerOptions<C>) {
		this._id = TypedViewer._nextId();
		this._camera = options.camera;
		this._scene = options.scene;
		this._canvas = options.canvas;
		if (options.autoRender != null) {
			this._doRender = options.autoRender;
		}
		this.updateCameraAspect = options.updateCameraAspect;
		this.scene().viewersRegister.registerViewer(this);

		// init in constructor to ensure scene is present in viewer
		this.raycastersController = new ViewerRaycastersController(this);
	}
	private static _nextId() {
		return `${TypedViewer._nextViewerId++}`;
	}
	createRaycaster() {
		return createRaycaster();
	}
	abstract renderer(): AbstractRenderer | undefined;
	abstract rendererConfig(): AvailableRenderConfig | undefined;

	protected _mounted = false;
	/**
	 * mounts the viewer onto an element
	 *
	 *
	 */
	mount(element: HTMLElement, options?: BaseViewerMountOptions) {
		let updateAutoRenderOnIntersectionChange = true;
		if (options && options.updateAutoRenderOnIntersectionChange != null) {
			updateAutoRenderOnIntersectionChange = options.updateAutoRenderOnIntersectionChange;
		}

		this._domElement = element as HTMLElementWithViewer<C>;
		this._domElement.viewer = this;
		this._domElement.scene = this._scene;
		this._domElement.Poly = Poly;
		if (updateAutoRenderOnIntersectionChange) {
			this.updateAutoRenderOnIntersectionChange({playPauseScene: true});
		}
		this.controlsController().mount();
		this._mounted = true;
	}
	/**
	 * unmounts the viewer
	 *
	 *
	 */
	unmount() {
		if (!this._domElement) {
			return;
		}
		// let childElement: Element | undefined;
		// while ((childElement = this._domElement.children[0])) {
		// 	this._domElement.removeChild(childElement);
		// }
		// when unmounting, it is very unsafe to remove all HTMLElements
		// that have been created inside _domElement
		// as those could have been created by an app specific code
		// OR... those could have been created when an element is shared by multiple scenes
		// at different times
		this._audioController?.unmount();
		const canvas = this.canvas();
		canvas.parentElement?.removeChild(canvas);
		this.eventsController().unmount();
		this.controlsController().unmount();

		this._mounted = false;
	}
	static _canvasIdPrefix() {
		return 'TypedViewer';
	}
	static createCanvas(id?: string) {
		id = id || TypedViewer._nextId();
		const canvas = Poly.canvasRegister.findOrCreateCanvas();
		canvas.id = `${this._canvasIdPrefix()}_${id}`;
		canvas.style.display = 'block';
		canvas.style.outline = 'none';
		// we add 100% to the width and height
		// to make it easy for the canvas to not grow larger
		// than its container, without requiring css to enforce this
		canvas.style.width = '100%';
		canvas.style.height = '100%';

		// This gets rid of an annoying popup showing an option to copy the image.
		// when doing a long press on touch device
		canvas.addEventListener('contextmenu', (e) => {
			e.preventDefault();
			e.stopPropagation();
		});

		return canvas;
	}

	controlsNode() {
		return this._controlsNode;
	}
	/**
	 * return the canvas and create one if none yet
	 *
	 *
	 */
	canvas() {
		return (this._canvas = this._canvas || TypedViewer.createCanvas(this._id));
	}

	protected _tickAndRender(delta: number) {
		this._tick(delta);
		this.render(delta);
		this._postRender(delta);
	}
	protected _tick(delta: number) {
		// updating the camera controls
		// must be done before the actor nodes are run.
		// otherwise, we are bound to fetch an out of date camera matrix.
		this.controlsController().update(delta);
		//
		this.raycastersController.updateRaycasters();
		//
		this._runOnBeforeTickCallbacks(delta);
		this.scene().update(delta);
		this._runOnAfterTickCallbacks(delta);
	}
	render(delta: number) {
		if (!this._doRender) {
			return;
		}
		// if (this._canvas) {
		// super.render(delta);
		const renderer = this._renderer;
		if (!renderer) {
			console.error('render: no renderer');
			return;
		}

		this._runOnBeforeRenderCallbacks(delta, renderer);
		if (this._renderFunc) {
			this._renderFunc(delta);
		}
		if (this._renderCSSFunc) {
			this._renderCSSFunc();
		}
		// this.controlsController().update(delta);
		this._runOnAfterRenderCallbacks(delta, renderer);
		// } else {
		// 	console.warn('no canvas to render onto');
		// }
	}
	protected _postRender(delta: number) {
		this._scene.viewersRegister.markViewerAsRendered(this);
		if (this.scene().timeController.playing()) {
			this.performanceMonitor.measurePerformance(delta);
		}
	}

	setRenderObjectOverride(object?: Object3D | null) {
		if (object) {
			this._renderObjectOverride = object;
		} else {
			this._renderObjectOverride = undefined;
		}
	}

	active() {
		return this._active;
	}
	activate() {
		this._active = true;
	}
	deactivate() {
		this._active = false;
	}

	protected _camerasController: ViewerCamerasController | undefined;
	camerasController(): ViewerCamerasController {
		return (this._camerasController = this._camerasController || new ViewerCamerasController(this));
	}
	protected _controlsController: ViewerControlsController<C> = new ViewerControlsController(this);
	controlsController() {
		return this._controlsController;
	}
	protected _eventsController: ViewerEventsController | undefined;
	eventsController(): ViewerEventsController {
		return (this._eventsController = this._eventsController || new ViewerEventsController(this));
	}
	protected _webGLController: ViewerWebGLController | undefined;
	webglController(): ViewerWebGLController {
		return (this._webGLController = this._webGLController || new ViewerWebGLController(this));
	}
	private _audioController: ViewerAudioController | undefined;
	audioController(): ViewerAudioController {
		return (this._audioController = this._audioController || new ViewerAudioController(this));
	}
	public readonly raycastersController: ViewerRaycastersController;
	public readonly performanceMonitor: ViewerPerformanceMonitor = new ViewerPerformanceMonitor(this);

	domElement() {
		return this._domElement;
	}
	scene() {
		return this._scene;
	}

	/**
	 * return the camera the viewer was created with
	 *
	 *
	 */
	camera() {
		return this._camera;
	}
	// cameraControlsController(): ThreejsCameraControlsController | undefined {
	// 	return undefined;
	// }
	id() {
		return this._id;
	}
	protected _size = new Vector2();
	size(target: Vector2) {
		target.copy(this._size);
	}
	updateSize() {}

	/**
	 * disposes the viewer
	 *
	 *
	 */
	dispose() {
		this._scene.viewersRegister.unregisterViewer(this);
		this.controlsController().unmount();
		if (!this._domElement) {
			return;
		}
		let child: Element;
		while ((child = this._domElement.children[0])) {
			this._domElement.removeChild(child);
		}
	}

	// html container class
	resetContainerClass() {
		this.domElement()?.classList.remove(HOVERED_CLASS_NAME);
	}
	setContainerClassHovered() {
		this.domElement()?.classList.add(HOVERED_CLASS_NAME);
	}
	markAsReady() {}

	/**
	 * sets auto render state. If falls, the viewer will not render.
	 *
	 *
	 */
	setAutoRender(state = true) {
		this._doRender = state;
	}
	autoRenderState(): boolean {
		return this._doRender;
	}

	//
	//
	// Visibility detection
	//
	//
	private _observer: IntersectionObserver | undefined;
	/**
	 * This sets the viewer to detect if it is visible, and to pause/unpause itself when its visibility changes.
	 * This can be very useful to improve performance.
	 *
	 */
	updateAutoRenderOnIntersectionChange(_options: TypedViewerInterectionObserverChangeOptions) {
		if (!this._domElement) {
			console.warn('cannot apply');
			return;
		}
		let {threshold, root, rootMargin, playPauseScene, onChange} = _options;
		if (threshold == null) {
			threshold = 0.01;
		}
		const observerOptions: IntersectionObserverInit = {
			root,
			rootMargin,
			threshold,
		};
		const onObserverChange: IntersectionObserverCallback = (entries, observer) => {
			if (!this._scene.loadingController.loaded()) {
				// we must check that the scene is loaded before
				// calling .play(),
				// otherwise, the scene will start playing as soon
				// as the viewer is created,
				// even if we have the option .autoPlay=false in the loading options
				return;
			}
			entries.forEach((entry) => {
				const isVisible: boolean = entry.isIntersecting;
				this.setAutoRender(isVisible);
				if (playPauseScene) {
					if (isVisible) {
						this._scene.play();
					} else {
						this._scene.pause();
					}
				}
				if (onChange) {
					onChange(isVisible);
				}
			});
		};
		this.disableUpdateAutoRenderOnIntersectionChange();
		this._observer = new IntersectionObserver(onObserverChange, observerOptions);
		this._observer.observe(this._domElement);
	}
	/**
	 * Cancels the listening of the viewer's visibility.
	 *
	 */
	disableUpdateAutoRenderOnIntersectionChange() {
		if (!this._domElement) {
			return;
		}
		if (!this._observer) {
			return;
		}
		this._observer.unobserve(this._domElement);
	}

	//
	//
	// RESIZE CALLBACKS
	//
	//
	protected _onResizeCallbacks: OnResizeCallback[] | undefined;
	registerOnResizeCallback(callback: OnResizeCallback) {
		this._onResizeCallbacks = this._onResizeCallbacks || [];
		this._onResizeCallbacks.push(callback);
	}
	unregisterOnResizeCallback(callback: OnResizeCallback) {
		if (!this._onResizeCallbacks) {
			return;
		}
		const index = this._onResizeCallbacks.indexOf(callback);
		if (index >= 0) {
			this._onResizeCallbacks.splice(index, 1);
		}
		if (this._onResizeCallbacks.length == 0) {
			this._onResizeCallbacks = undefined;
		}
	}
	protected _runOnResizeCallbacks() {
		const canvas = this.canvas();
		if (canvas) {
			MouseHelper.resetCacheForCanvas(canvas);
		}
		if (!this._onResizeCallbacks) {
			return;
		}
		for (const callback of this._onResizeCallbacks) {
			callback(this._size.x, this._size.y);
		}
	}

	//
	//
	// CALLBACKS
	//
	//
	// tick callbacks
	private _onBeforeTickCallbacksMap: ViewerCallbacksMap<ViewerTickCallback> | undefined;
	private _onAfterTickCallbacksMap: ViewerCallbacksMap<ViewerTickCallback> | undefined;
	protected _onBeforeTickCallbacks: Array<ViewerTickCallback> = [];
	protected _onAfterTickCallbacks: Array<ViewerTickCallback> = [];
	// render callbacks
	private _onBeforeRenderCallbacksMap: ViewerCallbacksMap<ViewerRenderCallback> | undefined;
	private _onAfterRenderCallbacksMap: ViewerCallbacksMap<ViewerRenderCallback> | undefined;
	protected _onBeforeRenderCallbacks: Array<ViewerRenderCallback> = [];
	protected _onAfterRenderCallbacks: Array<ViewerRenderCallback> = [];

	// onBeforeTick
	/**
	 * registers a BeforeTick callback. BeforeTick callbacks are run before updating the frame (and therefore before any time dependent node has changed)
	 *
	 */
	registerOnBeforeTick(callbackName: string, callback: ViewerTickCallback, options: BaseViewerCallbackOptions = {}) {
		this._registerCallback(callbackName, callback, this.registeredBeforeTickCallbacks(), options);
	}
	/**
	 * unregisters BeforeTick callback
	 *
	 */
	unRegisterOnBeforeTick(callbackName: string) {
		this._unregisterCallback(callbackName, this._onBeforeTickCallbacksMap);
	}
	/**
	 * Returns the list registered BeforeTick callback names
	 *
	 */
	registeredBeforeTickCallbacks() {
		return (this._onBeforeTickCallbacksMap = this._onBeforeTickCallbacksMap || new Map());
	}
	// onAfterTick
	/**
	 * registers AfterTick callback. AfterTick callbacks are run after updating the frame (and therefore after any time dependent node has changed)
	 *
	 */
	registerOnAfterTick(callbackName: string, callback: ViewerTickCallback, options: BaseViewerCallbackOptions = {}) {
		this._registerCallback(callbackName, callback, this.registeredAfterTickCallbacks(), options);
	}
	/**
	 * unregisters AfterTick callback
	 *
	 */
	unRegisterOnAfterTick(callbackName: string) {
		this._unregisterCallback(callbackName, this._onAfterTickCallbacksMap);
	}
	/**
	 * Returns the list registered AfterTick callback names
	 *
	 */
	registeredAfterTickCallbacks() {
		return (this._onAfterTickCallbacksMap = this._onAfterTickCallbacksMap || new Map());
	}
	// onBeforeRender
	/**
	 * registers a BeforeRender callback. BeforeRender callbacks are run before the frame is rendered
	 *
	 */
	registerOnBeforeRender(
		callbackName: string,
		callback: ViewerRenderCallback,
		options: BaseViewerCallbackOptions = {}
	) {
		this._registerCallback(callbackName, callback, this.registeredBeforeRenderCallbacks(), options);
	}
	/**
	 * unregisters BeforeRender callback
	 *
	 */
	unRegisterOnBeforeRender(callbackName: string) {
		this._unregisterCallback(callbackName, this._onBeforeRenderCallbacksMap);
	}
	/**
	 * Returns the list registered BeforeRender callback names
	 *
	 */
	registeredBeforeRenderCallbacks() {
		return (this._onBeforeRenderCallbacksMap = this._onBeforeRenderCallbacksMap || new Map());
	}
	// onAfterRender
	/**
	 * registers a AfterRender callback. AfterRender callbacks are run after the frame is rendered
	 *
	 */ registerOnAfterRender(
		callbackName: string,
		callback: ViewerRenderCallback,
		options: BaseViewerCallbackOptions = {}
	) {
		this._registerCallback(callbackName, callback, this.registeredAfterRenderCallbacks(), options);
	}
	/**
	 * unregisters AfterRender callback
	 *
	 */
	unRegisterOnAfterRender(callbackName: string) {
		this._unregisterCallback(callbackName, this._onAfterRenderCallbacksMap);
	}
	/**
	 * Returns the list AfterRender BeforeRender callback names
	 *
	 */
	registeredAfterRenderCallbacks() {
		return (this._onAfterRenderCallbacksMap = this._onAfterRenderCallbacksMap || new Map());
	}
	private _registerCallback<C extends ViewerBaseCallback>(
		callbackName: string,
		callback: C,
		map: ViewerCallbacksMap<C>,
		options: BaseViewerCallbackOptions = {}
	) {
		if (map.has(callbackName)) {
			console.warn(`callback ${callbackName} already registered`);
			return;
		}
		map.set(callbackName, {callback, options});
		this._updateCallbacks();
	}
	private _unregisterCallback<C extends ViewerBaseCallback>(callbackName: string, map?: ViewerCallbacksMap<C>) {
		if (!map) {
			return;
		}
		const callbackContainer = map.get(callbackName);
		if (!callbackContainer) {
			return;
		}
		const options = callbackContainer.options;
		if (options.persistent == true) {
			return;
		}
		map.delete(callbackName);
		this._updateCallbacks();
	}
	private _updateCallbacks() {
		this._onBeforeTickCallbacks = [];
		this._onBeforeTickCallbacksMap?.forEach((callbackContainer) => {
			this._onBeforeTickCallbacks.push(callbackContainer.callback);
		});
		this._onAfterTickCallbacks = [];
		this._onAfterTickCallbacksMap?.forEach((callbackContainer) => {
			this._onAfterTickCallbacks.push(callbackContainer.callback);
		});
		this._onBeforeRenderCallbacks = [];
		this._onBeforeRenderCallbacksMap?.forEach((callbackContainer) => {
			this._onBeforeRenderCallbacks.push(callbackContainer.callback);
		});
		this._onAfterRenderCallbacks = [];
		this._onAfterRenderCallbacksMap?.forEach((callbackContainer) => {
			this._onAfterRenderCallbacks.push(callbackContainer.callback);
		});
	}
	private _runTickCallbacks(callbacks: ViewerTickCallback[], delta: number) {
		for (const callback of callbacks) {
			callback(delta);
		}
	}
	private _runRenderCallbacks(callbacks: ViewerRenderCallback[], delta: number, renderer: AbstractRenderer) {
		for (const callback of callbacks) {
			callback(delta, renderer);
		}
	}
	protected _runOnBeforeTickCallbacks(delta: number) {
		this._runTickCallbacks(this._onBeforeTickCallbacks, delta);
	}
	protected _runOnAfterTickCallbacks(delta: number) {
		this._runTickCallbacks(this._onAfterTickCallbacks, delta);
	}
	protected _runOnBeforeRenderCallbacks(delta: number, renderer: AbstractRenderer) {
		this._runRenderCallbacks(this._onBeforeRenderCallbacks, delta, renderer);
	}
	protected _runOnAfterRenderCallbacks(delta: number, renderer: AbstractRenderer) {
		this._runRenderCallbacks(this._onAfterRenderCallbacks, delta, renderer);
	}
}

export type BaseViewerType = TypedViewer<Camera>;
