import {ActorsManager} from './utils/ActorsManager';
import {SceneCamerasController} from './utils/SceneCamerasController';
import {Cooker} from './utils/Cooker';
import {SceneCookController} from './utils/CookController';
import {CoreGraph} from '../../core/graph/CoreGraph';
import {CorePerformance} from '../../core/performance/CorePerformance';
import {DispatchController} from './utils/DispatchController';
import {SceneExpressionsController} from './utils/SceneExpressionsController';
import {SceneLifeCycleController} from './utils/LifeCycleController';
import {LoadingController} from './utils/LoadingController';
import {MissingReferencesController} from './utils/missingReferences/MissingReferencesController';
import {GraphNodesController} from './utils/GraphNodesController';
import {NodesController} from './utils/NodesController';
// import {PolySceneSerializer} from './utils/Serializer';
import {SceneEventsDispatcher} from './utils/events/EventsDispatcher';
import {ObjectsController} from './utils/ObjectsController';
import {ScenePerformanceMonitor} from './utils/ScenePerformanceMonitor';
import {ReferencesController} from './utils/ReferencesController';
import {
	onTimeTickHook,
	TimeController,
	TimeControllerUpdateTimeOptions,
	TIME_CONTROLLER_UPDATE_TIME_OPTIONS_DEFAULT,
} from './utils/TimeController';
import {UniformsController} from './utils/UniformsController';
import {ViewersRegister} from './utils/ViewersRegister';
import {SceneWebGLController} from './utils/WebGLController';
import {WindowController} from './utils/WindowController';
import {SceneAssetsController} from './utils/AssetsController';
import {SceneTraverserController} from './utils/SceneTraverser';
import {BaseNodeType} from '../nodes/_Base';
import {ObjNodeChildrenMap} from '../poly/registers/nodes/Obj';
import {Constructor, valueof} from '../../types/GlobalTypes';
import {Raycaster, Scene, WebGLRenderer} from 'three';
import {CoreString} from '../../core/String';
import {SceneRenderersRegister, RegisterRendererOptions} from './utils/SceneRenderersRegister';
import {Poly} from '../Poly';
import {NodeCreateOptions} from '../nodes/utils/hierarchy/ChildrenController';
import {SceneWebXRController} from './utils/WebXREventsController';
import {CoreObjectType, ObjectContent} from '../../core/geometry/ObjectContent';
import {CoreParamSerializer, BaseParamSerializer} from '../params/utils/CoreParamSerializer';
import type {ParamType} from '../poly/ParamType';

interface PolySceneCreateOptions {
	root: NodeCreateOptions;
	paramsSerializerClass?: typeof CoreParamSerializer<ParamType>;
}

type SceneBatchUpdateCallback = () => void | Promise<void>;
interface UpdateState {
	scene: Scene;
}

/**
 *
 *
 * PolyScene contains all nodes within a scene.
 *
 */
export class PolyScene {
	/**
	 * Returns the THREE.Scene.
	 *
	 * @remarks
	 *
	 * Read more about how to use a THREE.Scene on [THREE's documentation](https://threejs.org/docs/?q=scene#api/en/scenes/Scene)
	 *
	 */
	threejsScene(): Scene {
		return this.root().object;
	}
	private _uuid!: string;
	setUuid(uuid: string) {
		return (this._uuid = uuid);
	}
	get uuid() {
		return this._uuid;
	}
	private _name: string | undefined;
	setName(newName: string) {
		return (this._name = PolyScene.sanitizeName(newName));
	}
	static sanitizeName(newName: string) {
		newName = CoreString.sanitizeName(newName);
		// force lower case for now, as the editor export uses this
		// and it may be safer to use lowercase to make it work across OSes
		newName = newName.toLowerCase();
		return newName;
	}
	name() {
		return this._name;
	}

	protected _camerasController: SceneCamerasController | undefined;
	get camerasController() {
		return (this._camerasController = this._camerasController || new SceneCamerasController(this));
	}
	/**
	 * Returns the camera object that has been set as main
	 *
	 */
	mainCamera() {
		return this.camerasController.mainCamera();
	}

	private _cooker = new Cooker(this);
	get cooker() {
		return this._cooker;
	}

	// private _cube_cameras_controller: CubeCamerasController;
	// get cube_cameras_controller() {
	// 	return (this._cube_cameras_controller = this._cube_cameras_controller || new CubeCamerasController(this));
	// }
	private _actorsManager: ActorsManager | undefined;
	get actorsManager() {
		return (this._actorsManager = this._actorsManager || new ActorsManager(this));
	}

	private _assetsController: SceneAssetsController | undefined;
	get assets() {
		return (this._assetsController = this._assetsController || new SceneAssetsController());
	}

	public readonly cookController = new SceneCookController();
	/**
	 * Returns a promise to wait for all nodes to have cooked when loading a scene.
	 *
	 */
	async waitForCooksCompleted(): Promise<void> {
		return await this.cookController.waitForCooksCompleted();
	}

	private _dispatchController: DispatchController | undefined;
	get dispatchController() {
		return (this._dispatchController = this._dispatchController || new DispatchController(this));
	}
	private _eventsDispatcher: SceneEventsDispatcher | undefined;
	get eventsDispatcher() {
		return (this._eventsDispatcher = this._eventsDispatcher || new SceneEventsDispatcher(this));
	}
	private _webXRController: SceneWebXRController | undefined;
	get webXR() {
		return (this._webXRController = this._webXRController || new SceneWebXRController(this));
	}
	/**
	 * When using Polygonjs viewers, a raycaster is created to use mouse events and detect if there are any object under the cursor.
	 * But if no viewer is created, such as when [importing a scene in react three fiber](/docs/integrations/react_three_fiber),
	 * It is then useful to give a raycaster.
	 *
	 */
	setRaycaster(raycaster: Raycaster) {
		this.eventsDispatcher.setRaycaster(raycaster);
	}

	private _graph = new CoreGraph();
	get graph() {
		return this._graph;
	}

	public readonly lifecycleController: SceneLifeCycleController = new SceneLifeCycleController(this);
	public readonly loadingController: LoadingController = new LoadingController(this);
	public readonly missingExpressionReferencesController: MissingReferencesController =
		new MissingReferencesController(this);
	public readonly expressionsController: SceneExpressionsController = new SceneExpressionsController();
	public readonly nodesController = new NodesController(this);
	public readonly graphNodesController = new GraphNodesController(this);
	public readonly renderersRegister = new SceneRenderersRegister(this);
	/**
	 * Creates a new node.
	 *
	 * nodeClass can be either a string or a node class. Both examples below work:
	 *
	 * - polyScene.createNode('box'): returns a BoxSopNode
	 * - polyScene.createNode(boxSopNode): returns a BoxSopNode
	 *
	 */
	createNode<S extends keyof ObjNodeChildrenMap>(nodeClass: S, options?: NodeCreateOptions): ObjNodeChildrenMap[S];
	createNode<K extends valueof<ObjNodeChildrenMap>>(nodeClass: Constructor<K>, options?: NodeCreateOptions): K;
	createNode<K extends valueof<ObjNodeChildrenMap>>(nodeClass: Constructor<K>, options?: NodeCreateOptions): K {
		return this.root().createNode(nodeClass, options) as K;
	}
	/**
	 * returns all nodes with a given type
	 *
	 * - polyScene.nodesByType('box'): returns all BoxSopNodes
	 */
	nodesByType(type: string): BaseNodeType[] {
		return this.nodesController.nodesByType(type);
	}
	protected _objectsController = new ObjectsController(this);
	get objectsController() {
		return this._objectsController;
	}
	/**
	 * returns a THREE.Object3D whose name matches the mask
	 *
	 */
	findObjectByMask<T extends CoreObjectType>(mask: string): ObjectContent<T> | undefined {
		return this._objectsController.findObjectByMask(mask);
	}
	/**
	 * returns a list THREE.Object3Ds whose names matche the mask
	 *
	 */
	objectsByMask<T extends CoreObjectType>(mask: string, parent?: ObjectContent<T>): ObjectContent<T>[] {
		return this._objectsController.objectsByMask(mask, parent);
	}

	protected _referencesController = new ReferencesController(this);
	get referencesController() {
		return this._referencesController;
	}

	protected _performance: CorePerformance | undefined;
	get performance() {
		return (this._performance = this._performance || new CorePerformance());
	}
	public readonly perfMonitor = new ScenePerformanceMonitor(this);

	protected _viewersRegister: ViewersRegister | undefined;
	get viewersRegister() {
		return (this._viewersRegister = this._viewersRegister || new ViewersRegister(this));
	}
	public readonly sceneTraverser = new SceneTraverserController(this);
	/**
	 * updates Polygonjs scene internals. This is called automatically when using Polygonjs viewers,
	 * but you would need to call it yourself in the render loop when adding your scene to threejs or react-three-fiber.
	 * See [https://polygonjs.com/docs/integrations](https://polygonjs.com/docs/integrations)
	 *
	 */
	update(delta: number, state?: UpdateState) {
		// setDelta is necessary here, as this function is most likely called from an integration with threejs, using a custom render loop
		this.timeController.setDelta(delta);
		this.timeController.incrementTimeIfPlaying(TIME_CONTROLLER_UPDATE_TIME_OPTIONS_DEFAULT);
		this.sceneTraverser.traverseScene(state?.scene);
	}

	//
	//
	// time
	//
	//
	readonly timeController = new TimeController(this);
	/**
	 * sets the current frame
	 *
	 */
	setFrame(frame: number) {
		this.timeController.setFrame(frame);
	}
	setFrameToStart() {
		this.timeController.setFrameToStart();
	}

	/**
	 * returns the current frame
	 *
	 */
	frame(): number {
		return this.timeController.frame();
	}
	/**
	 * returns the current time
	 *
	 */
	time(): number {
		return this.timeController.time();
	}
	maxFrame() {
		return this.timeController.maxFrame();
	}
	/**
	 * starts playing the scene
	 *
	 */
	play() {
		this.timeController.play();
	}
	/**
	 * pauses the scene
	 *
	 */
	pause() {
		this.timeController.pause();
	}
	/**
	 * increments the time
	 *
	 */
	incrementTime(options?: TimeControllerUpdateTimeOptions) {
		this.timeController.incrementTime(options);
	}
	/**
	 * increments the time if the scene is playing()
	 *
	 */
	incrementTimeIfPlaying(options?: TimeControllerUpdateTimeOptions) {
		this.timeController.incrementTimeIfPlaying(options);
	}
	/**
	 * registers a renderer
	 *
	 */
	registerRenderer(renderer: WebGLRenderer, options?: RegisterRendererOptions) {
		return this.renderersRegister.registerRenderer(renderer, options);
	}

	//
	//
	// uniforms
	//
	//
	private _uniformsController: UniformsController | undefined;
	get uniformsController() {
		return (this._uniformsController = this._uniformsController || new UniformsController(this));
	}

	//
	//
	// webgl
	//
	//
	private _webglController: SceneWebGLController | undefined;
	get webglController() {
		return (this._webglController = this._webglController || new SceneWebGLController());
	}

	//
	//
	// window
	//
	//
	private _windowController: WindowController | undefined;
	get windowController() {
		return (this._windowController = this._windowController || new WindowController(this));
	}

	//
	//
	// constructor
	//
	//
	constructor(options?: PolySceneCreateOptions) {
		this._graph.setScene(this);
		this._paramSerializerClass = options?.paramsSerializerClass;
		this.nodesController.createRoot(options?.root);
		Poly.scenesRegister.registerScene(this);
	}

	private _disposed = false;
	dispose() {
		if (this._disposed == true) {
			return;
		}
		this._disposed = true;
		this.batchUpdates(() => {
			this.nodesController.traverseNodes((node) => {
				node.parent()?.removeNode(node);
			});
		});
		if (this._windowController) {
			this._windowController.dispose();
			this._windowController = undefined;
		}
		this.timeController.dispose();
		this.renderersRegister.dispose();
		this.camerasController.dispose();
		this.root().dispose();
		Poly.scenesRegister.deregisterScene(this);
	}
	disposed() {
		return this._disposed;
	}

	private _paramSerializerClass?: BaseParamSerializer;
	paramSerializerClass() {
		return this._paramSerializerClass;
	}

	//
	//
	// cooker
	//
	//
	/**
	 * batchUpdates can be useful to set multiple parameter values without triggering a recook for each update.
	 *
	 */
	async batchUpdates(callback: SceneBatchUpdateCallback) {
		this._cooker.block();

		await callback();

		this._cooker.unblock();
	}

	//
	//
	// nodes
	//
	//
	/**
	 * returns a node based on its path
	 *
	 * - polyScene.node('/geo1')
	 *
	 */
	node(path: string) {
		return this.nodesController.node(path);
	}
	/**
	 * returns the root node
	 *
	 */
	root() {
		return this.nodesController.root();
	}
	/**
	 * traverse all nodes and runs a callback for each
	 *
	 */
	traverseNodes(callback: (node: BaseNodeType) => void) {
		this.nodesController.traverseNodes(callback);
	}

	//
	//
	// CALLBACKS
	//
	//
	/**
	 * 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: onTimeTickHook): void {
		this.timeController.registerOnBeforeTick(callbackName, callback);
	}
	/**
	 * unregisters BeforeTick callback
	 *
	 */
	unRegisterOnBeforeTick(callbackName: string): void {
		this.timeController.unRegisterOnBeforeTick(callbackName);
	}
	/**
	 * Returns the list registered BeforeTick callback names
	 *
	 */
	registeredBeforeTickCallbacks(): Map<string, onTimeTickHook> {
		return this.timeController.registeredBeforeTickCallbacks();
	}
	/**
	 * return true if a callback is registered with that name
	 *
	 */
	hasBeforeTickCallback(callbackName: string): boolean {
		return this.timeController.hasBeforeTickCallback(callbackName);
	}
	/**
	 * registers AfterTick callback. AfterTick callbacks are run after updating the frame (and therefore after any time dependent node has changed)
	 *
	 */
	registerOnAfterTick(callbackName: string, callback: onTimeTickHook): void {
		this.timeController.registerOnAfterTick(callbackName, callback);
	}
	/**
	 * unregisters AfterTick callback
	 *
	 */
	unRegisterOnAfterTick(callbackName: string): void {
		this.timeController.unRegisterOnAfterTick(callbackName);
	}
	/**
	 * Returns the list registered AfterTick callback names
	 *
	 */
	registeredAfterTickCallbacks(): Map<string, onTimeTickHook> {
		return this.timeController.registeredAfterTickCallbacks();
	}
	/**
	 * return true if a callback is registered with that name
	 *
	 */
	hasAfterTickCallback(callbackName: string): boolean {
		return this.timeController.hasAfterTickCallback(callbackName);
	}
}
