import { EffectComposer, RenderPass } from "postprocessing";
import {
	BufferGeometry, Camera, Color, DepthTexture, Group,
	Material, NearestFilter, NoToneMapping, Object3D, PCFSoftShadowMap,
	PerspectiveCamera, RGBAFormat, Scene, SRGBColorSpace,
	Texture, WebGLRenderer, type WebGLRendererParameters, WebGLRenderTarget, type WebXRArrayCamera
} from 'three';
import * as Stats from 'three/examples/jsm/libs/stats.module.js';

import { isDevEnvironment, LogType, showBalloonError, showBalloonMessage } from './debug/index.js';
import { Addressables } from './engine_addressables.js';
import { AnimationsRegistry } from './engine_animation.js';
import { Application } from './engine_application.js';
import { AssetDatabase } from './engine_assetdatabase.js';
import { VERSION } from './engine_constants.js';
import { ContextEvent, ContextRegistry } from './engine_context_registry.js';
import { WaitForPromise } from './engine_coroutine.js';
import { ObjectUtils } from "./engine_create_objects.js";
import { destroy, foreachComponent } from './engine_gameobject.js';
import { getLoader } from './engine_gltf.js';
import { Input } from './engine_input.js';
import { invokeLifecycleFunctions } from './engine_lifecycle_functions_internal.js';
import { type ILightDataRegistry, LightDataRegistry } from './engine_lightdata.js';
import { LODsManager } from "./engine_lods.js";
import * as looputils from './engine_mainloop_utils.js';
import { NetworkConnection } from './engine_networking.js';
import { Physics } from './engine_physics.js';
import { PlayerViewManager } from './engine_playerview.js';
import { RendererData as SceneLighting } from './engine_scenelighting.js';
import { logHierarchy } from './engine_three_utils.js';
import { Time } from './engine_time.js';
import { patchTonemapping } from './engine_tonemapping.js';
import type { CoroutineData, ICamera, IComponent, IContext, ILight, LoadedModel, Model, Vec2 } from "./engine_types.js";
import { deepClone, delay, DeviceUtilities, getParam } from './engine_utils.js';
import type { INeedleXRSessionEventReceiver, NeedleXRSession } from './engine_xr.js';
import { NeedleMenu } from './webcomponents/needle menu/needle-menu.js';


const debug = getParam("debugcontext");
const stats = getParam("stats");
const debugActive = getParam("debugactive");
const debugframerate = getParam("debugframerate");
const debugCoroutine = getParam("debugcoroutine");

// this is where functions that setup unity scenes will be pushed into
// those will be accessed from our custom html element to load them into their context
export const build_scene_functions: { [name: string]: (context: Context) => Promise<void> } = {};


export declare class LoadingProgressArgs {
	/** the name or URL of the loaded file */
	name: string;
	/** the loading progress event from the loader */
	progress: ProgressEvent;
	/** the index of the loaded file */
	index: number;
	/** the total number of files to load */
	count: number;
}
export declare class ContextCreateArgs {
	/** list of glTF or GLB files to load */
	files: Array<string>;
	abortSignal?: AbortSignal;
	/** called when loading a provided glTF file started */
	onLoadingStart?: (index: number, file: string) => void;
	/** called on update for each loaded glTF file */
	onLoadingProgress?: (args: LoadingProgressArgs) => void;
	/** Called after a gLTF file has finished loading */
	onLoadingFinished?: (index: number, file: string, glTF: Model | null) => void;
}

export class ContextArgs {
	name?: string;
	/** for debugging only */
	alias?: string;
	/** the hash is used as a seed when initially loading the scene files */
	hash?: string;

	/** when true the context will not check if it's visible in the viewport and always update and render */
	runInBackground?: boolean;
	/** the DOM element the context belongs to or is inside of (this does not have to be the canvas. use renderer.domElement if you want to access the dom canvas) */
	domElement?: HTMLElement | null;
	/** externally owned renderer */
	renderer?: WebGLRenderer;
	/** externally owned camera */
	camera?: Camera;
	/** externally owned scene */
	scene?: Scene;
}

export enum FrameEvent {
	Start = -1,
	EarlyUpdate = 0,
	Update = 1,
	LateUpdate = 2,
	OnBeforeRender = 3,
	OnAfterRender = 4,
	PrePhysicsStep = 9,
	PostPhysicsStep = 10,
	Undefined = -1,
}

/** threejs callback event signature */
export declare type OnRenderCallback = (renderer: WebGLRenderer, scene: Scene, camera: Camera, geometry: BufferGeometry, material: Material, group: Group) => void


export function registerComponent(script: IComponent, context?: Context) {
	if (!script) return;
	if (!script.isComponent) {
		if (isDevEnvironment() || debug)
			console.error("Registered script is not a Needle Engine component. \nThe script will be ignored. Please make sure your component extends \"Behaviour\" imported from \"@needle-tools/engine\"\n", script);
		return;
	}
	if (!context) {
		context = Context.Current;
		if (debug) console.warn("> Registering component without context");
	}
	const new_scripts = context?.new_scripts;
	if (!new_scripts.includes(script)) {
		new_scripts.push(script);
	}
}

/**
 * The context is the main object that holds all the data and state of the Needle Engine.  
 * It can be used to access the scene, renderer, camera, input, physics, networking, and more.
 * @example
 * ```typescript
 * import { Behaviour } from "@needle-tools/engine";
 * import { Mesh, BoxGeometry, MeshBasicMaterial } from "three";
 * export class MyScript extends Behaviour {
 *   start() {
 *     console.log("Hello from MyScript");
 *     this.context.scene.add(new Mesh(new BoxGeometry(), new MeshBasicMaterial()));
 *   }
 * }
 * ```
 */
export class Context implements IContext {

	private static _defaultTargetFramerate: { value?: number, toString?() } = { value: 90, toString() { return this.value; } }
	/** When a new context is created this is the framerate that will be used by default */
	static get DefaultTargetFrameRate(): number | undefined {
		return Context._defaultTargetFramerate.value;
	}
	/** When a new context is created this is the framerate that will be used by default */
	static set DefaultTargetFrameRate(val: number | undefined) {
		Context._defaultTargetFramerate.value = val;
	}

	private static _defaultWebglRendererParameters: WebGLRendererParameters = {
		antialias: true,
		alpha: false,
		// Note: this is due to a bug on OSX devices. See NE-5370
		powerPreference: (DeviceUtilities.isiOS() || DeviceUtilities.isMacOS()) ? "default" : "high-performance",
	};
	/** The default parameters that will be used when creating a new WebGLRenderer.  
	 * Modify in global context to change the default parameters for all new contexts.
	 * @example
	 * ```typescript
	 * import { Context } from "@needle-tools/engine";
	 * Context.DefaultWebGLRendererParameters.antialias = false;
	 * ```
	 */
	static get DefaultWebGLRendererParameters(): WebGLRendererParameters {
		return Context._defaultWebglRendererParameters;
	}

	/** The needle engine version */
	get version() {
		return VERSION;
	}

	/** The currently active context. Only set during the update loops */
	static get Current(): Context {
		return ContextRegistry.Current as Context;
	}

	/** @internal this property should not be set by user code */
	static set Current(context: Context) {
		ContextRegistry.Current = context;
	}

	/** The name of the context */
	name: string;
	/** An alias for the context */
	alias: string | undefined | null;
	/** When the renderer or camera are managed by an external process (e.g. when running in r3f context). 
	 * When this is false you are responsible to call update(timestamp, xframe.  
	 * It is also currently assumed that rendering is handled performed by an external process
	 * */
	isManagedExternally: boolean = false;
	/** set to true to pause the update loop. You can receive an event for it in your components. 
	 * Note that script updates will not be called when paused */
	isPaused: boolean = false;
	/** When enabled the application will run while not visible on the page */
	runInBackground: boolean = false;
	/** 
	 * Set to the target framerate you want your application to run in (you can use ?stats to check the fps)
	 * Set to undefined if you want to run at the maximum framerate
	 */
	targetFrameRate?: number | { value?: number };

	/** Use a higher number for more accurate physics simulation.   
	 * When undefined physics steps will be 1 for mobile devices and 5 for desktop devices  
	 * Set to 0 to disable physics updates
	 * TODO: changing physics steps is currently not supported because then forces that we get from the character controller and rigidbody et al are not correct anymore - this needs to be properly tested before making this configureable
	*/
	private physicsSteps?: number = 1;

	/** used to append to loaded assets */
	hash?: string;

	/** The `<needle-engine>` web component */
	domElement: HTMLElement;

	appendHTMLElement(element: HTMLElement) {
		if (this.domElement.shadowRoot)
			return this.domElement.shadowRoot.appendChild(element);
		else return this.domElement.appendChild(element);
	}

	get resolutionScaleFactor() { return this._resolutionScaleFactor; }
	/** use to scale the resolution up or down of the renderer. default is 1 */
	set resolutionScaleFactor(val: number) {
		if (val === this._resolutionScaleFactor) return;
		if (typeof val !== "number") return;
		if (val <= 0) {
			console.error("Invalid resolution scale factor", val);
			return;
		}
		this._resolutionScaleFactor = val;
		this.updateSize();
	}
	private _resolutionScaleFactor: number = 1;

	// domElement.clientLeft etc doesnt return absolute position
	private _boundingClientRectFrame: number = -1;
	private _boundingClientRect: DOMRect | null = null;
	private _domX; private _domY;
	/** update bounding rects + domX, domY */
	private calculateBoundingClientRect() {
		// workaround for mozilla webXR viewer
		if (this.xr) {
			this._domX = 0;
			this._domY = 0;
			return;
		}
		// TODO: cache this
		if (this._boundingClientRectFrame === this.time.frame) return;
		this._boundingClientRectFrame = this.time.frame;
		this._boundingClientRect = this.domElement.getBoundingClientRect();
		this._domX = this._boundingClientRect.x;
		this._domY = this._boundingClientRect.y;
	}

	/** The width of the `<needle-engine>` element on the website */
	get domWidth(): number {
		// for mozilla XR
		if (this.isInAR) return window.innerWidth;
		return this.domElement.clientWidth;
	}
	/** The height of the `<needle-engine>` element on the website */
	get domHeight(): number {
		// for mozilla XR
		if (this.isInAR) return window.innerHeight;
		return this.domElement.clientHeight;
	}
	/** the X position of the Needle Engine element on the website */
	get domX(): number {
		this.calculateBoundingClientRect();
		return this._domX;
	}
	/** the Y position of the Needlee Engine element on the website */
	get domY(): number {
		this.calculateBoundingClientRect();
		return this._domY;
	}
	get isInXR() { return this.renderer?.xr?.isPresenting || false; }
	/** shorthand for `NeedleXRSession.active`  
	 * Automatically set by NeedleXRSession when a XR session is active 
	 * @returns the active XR session or null if no session is active
	 * */
	xr: NeedleXRSession | null = null;
	get xrSessionMode() { return this.xr?.mode; }
	get isInVR() { return this.xrSessionMode === "immersive-vr"; }
	get isInAR() { return this.xrSessionMode === "immersive-ar"; }
	/** If a XR session is active and in pass through mode (immersive-ar on e.g. Quest) */
	get isInPassThrough() { return this.xr ? this.xr.isPassThrough : false; }
	/** access the raw `XRSession` object (shorthand for `context.renderer.xr.getSession()`). For more control use `NeedleXRSession.active` */
	get xrSession() { return this.renderer?.xr?.getSession(); }
	/** @returns the latest XRFrame (if a XRSession is currently active)
	 * @link https://developer.mozilla.org/en-US/docs/Web/API/XRFrame
	 */
	get xrFrame() { return this._xrFrame }
	/** @returns the current WebXR camera while the WebXRManager is active (shorthand for `context.renderer.xr.getCamera()`) */
	get xrCamera(): WebXRArrayCamera | undefined { return this.renderer.xr.isPresenting ? this.renderer?.xr?.getCamera() : undefined }
	private _xrFrame: XRFrame | null = null;
	get arOverlayElement(): HTMLElement {
		const el = this.domElement as any;
		if (typeof el.getAROverlayContainer === "function")
			return el.getAROverlayContainer();
		return this.domElement;
	}
	/** Current event of the update cycle */
	get currentFrameEvent(): FrameEvent {
		return this._currentFrameEvent;
	}
	private _currentFrameEvent: FrameEvent = FrameEvent.Undefined;

	scene: Scene;
	renderer!: WebGLRenderer;
	composer: EffectComposer | null = null;

	// all scripts
	readonly scripts: IComponent[] = [];
	readonly scripts_pausedChanged: IComponent[] = [];
	// scripts with update event
	readonly scripts_earlyUpdate: IComponent[] = [];
	readonly scripts_update: IComponent[] = [];
	readonly scripts_lateUpdate: IComponent[] = [];
	readonly scripts_onBeforeRender: IComponent[] = [];
	readonly scripts_onAfterRender: IComponent[] = [];
	readonly scripts_WithCorroutines: IComponent[] = [];
	readonly scripts_immersive_vr: INeedleXRSessionEventReceiver[] = [];
	readonly scripts_immersive_ar: INeedleXRSessionEventReceiver[] = [];
	readonly coroutines: { [FrameEvent: number]: Array<CoroutineData> } = {}

	/** callbacks called once after the context has been created */
	readonly post_setup_callbacks: Function[] = [];
	/** called every frame at the beginning of the frame (after component start events and before earlyUpdate) */
	readonly pre_update_callbacks: Function[] = [];
	/** called every frame before rendering (after all component events) */
	readonly pre_render_callbacks: Array<(frame: XRFrame | null) => void> = [];
	/** called every frame after rendering (after all component events) */
	readonly post_render_callbacks: Function[] = [];

	/** called every frame befroe update (this list is emptied every frame) */
	readonly pre_update_oneshot_callbacks: Function[] = [];

	readonly new_scripts: IComponent[] = [];
	readonly new_script_start: IComponent[] = [];
	readonly new_scripts_pre_setup_callbacks: Function[] = [];
	readonly new_scripts_post_setup_callbacks: Function[] = [];
	readonly new_scripts_xr: INeedleXRSessionEventReceiver[] = [];

	/** The main camera component of the scene - this camera is used for rendering */
	mainCameraComponent: ICamera | undefined = undefined;

	/** The main camera of the scene - this camera is used for rendering */
	get mainCamera(): Camera {
		if (this._mainCamera) {
			return this._mainCamera;
		}
		if (this.mainCameraComponent) {
			const cam = this.mainCameraComponent as ICamera;
			if (!cam.threeCamera)
				cam.buildCamera();
			return cam.threeCamera;
		}
		if (!this._fallbackCamera) {
			this._fallbackCamera = new PerspectiveCamera(75, this.domWidth / this.domHeight, 0.1, 1000);
		}
		return this._fallbackCamera;
	}
	/** Set the main camera of the scene. If set to null the camera of the {@link mainCameraComponent} will be used - this camera is used for rendering */
	set mainCamera(cam: Camera | null) {
		this._mainCamera = cam;
	}
	private _mainCamera: Camera | null = null;
	private _fallbackCamera: PerspectiveCamera | null = null;

	application: Application;
	/** access animation mixer used by components in the scene */
	animations: AnimationsRegistry;
	/** access timings (current frame number, deltaTime, timeScale, ...) */
	time: Time;
	input: Input;
	/** access physics related methods (e.g. raycasting). To access the phyiscs engine use `context.physics.engine` */
	physics: Physics;
	/** access networking methods (use it to send or listen to messages or join a networking backend) */
	connection: NetworkConnection;
	/** 
	 * @deprecated AssetDataBase is deprecated
	 */
	assets: AssetDatabase;
	mainLight: ILight | null = null;
	/** @deprecated Use sceneLighting */
	get rendererData() { return this.sceneLighting }
	sceneLighting: SceneLighting;
	addressables: Addressables;
	lightmaps: ILightDataRegistry;
	players: PlayerViewManager;
	readonly lodsManager: LODsManager;
	readonly menu: NeedleMenu;

	get isCreated() { return this._isCreated; }

	private _sizeChanged: boolean = false;
	private _isCreated: boolean = false;
	private _isCreating: boolean = false;
	private _isVisible: boolean = false;

	private _stats = stats ? new Stats.default() : null;

	constructor(args?: ContextArgs) {
		this.name = args?.name || "";
		this.alias = args?.alias;
		this.domElement = args?.domElement || document.body;
		this.hash = args?.hash;

		if (args?.renderer) {
			this.renderer = args.renderer;
			this.isManagedExternally = true;
		}
		if (args?.runInBackground !== undefined) this.runInBackground = args.runInBackground;
		if (args?.scene) this.scene = args.scene;
		else this.scene = new Scene();
		if (args?.camera) this._mainCamera = args.camera;

		this.application = new Application(this);
		this.time = new Time();
		this.input = new Input(this);
		this.physics = new Physics(this);
		this.connection = new NetworkConnection(this);
		// eslint-disable-next-line deprecation/deprecation
		this.assets = new AssetDatabase();
		this.sceneLighting = new SceneLighting(this);
		this.addressables = new Addressables(this);
		this.lightmaps = new LightDataRegistry(this);
		this.players = new PlayerViewManager(this);
		this.menu = new NeedleMenu(this);
		this.lodsManager = new LODsManager(this);
		this.animations = new AnimationsRegistry(this);


		const resizeCallback = () => this._sizeChanged = true;
		window.addEventListener('resize', resizeCallback);
		this._disposeCallbacks.push(() => window.removeEventListener('resize', resizeCallback));

		const resizeObserver = new ResizeObserver(_ => this._sizeChanged = true);
		resizeObserver.observe(this.domElement);
		this._disposeCallbacks.push(() => resizeObserver.disconnect());

		this._intersectionObserver = new IntersectionObserver(entries => {
			this._isVisible = entries[0].isIntersecting;
		});
		this._disposeCallbacks.push(() => this._intersectionObserver?.disconnect());

		ContextRegistry.register(this);
	}

	/** calling this function will dispose the current renderer and create a new one */
	createNewRenderer(params?: WebGLRendererParameters) {
		this.renderer?.dispose();

		params = { ...Context.DefaultWebGLRendererParameters, ...params };
		if (!params.canvas) {
			// get canvas already configured in the Needle Engine Web Component
			const canvas = this.domElement?.shadowRoot?.querySelector("canvas");
			if (canvas) {
				params.canvas = canvas;
				if (debug) {
					console.log("Using canvas from shadow root", canvas);
				}
			}
		}
		if (debug) console.log("Using Renderer Parameters:", params, this.domElement)

		this.renderer = new WebGLRenderer(params);

		this.renderer.debug.checkShaderErrors = isDevEnvironment() || getParam("checkshadererrors") === true;

		// some tonemapping other than "NONE" is required for adjusting exposure with EXR environments
		this.renderer.toneMappingExposure = 1; // range [0...inf] instead of the usual -15..15
		this.renderer.toneMapping = NoToneMapping; // could also set to LinearToneMapping, ACESFilmicToneMapping

		this.renderer.setClearColor(new Color('lightgrey'), 0);
		// // @ts-ignore
		// this.renderer.alpha = false;
		this.renderer.shadowMap.enabled = true;
		this.renderer.shadowMap.type = PCFSoftShadowMap;
		this.renderer.setSize(this.domWidth, this.domHeight);
		this.renderer.outputColorSpace = SRGBColorSpace;
		// this.renderer.toneMapping = AgXToneMapping;
		this.lodsManager.setRenderer(this.renderer);

		this.input.bindEvents();
	}


	private _intersectionObserver: IntersectionObserver | null = null;
	private internalOnUpdateVisible() {
		this._intersectionObserver?.disconnect();
		this._intersectionObserver?.observe(this.domElement);
	}

	private _disposeCallbacks: Function[] = [];


	/** will request a renderer size update the next render call (will call updateSize the next update) */
	requestSizeUpdate() { this._sizeChanged = true; }

	/** Clamps the renderer max resolution. If undefined the max resolution is not clamped. Default is undefined */
	maxRenderResolution?: Vec2;

	/** update the renderer and canvas size */
	updateSize(force: boolean = false) {
		if (force || (!this.isManagedExternally && this.renderer.xr?.isPresenting === false)) {
			this._sizeChanged = false;
			const scaleFactor = this.resolutionScaleFactor;
			let width = this.domWidth * scaleFactor;
			let height = this.domHeight * scaleFactor;
			if (this.maxRenderResolution) {
				this.maxRenderResolution.x = Math.max(1, this.maxRenderResolution.x);
				width = Math.min(this.maxRenderResolution.x, width);
				this.maxRenderResolution.y = Math.max(1, this.maxRenderResolution.y);
				height = Math.min(this.maxRenderResolution.y, height);
			}
			const camera = this.mainCamera as PerspectiveCamera;
			this.updateAspect(camera);
			this.renderer.setSize(width, height, true);
			this.renderer.setPixelRatio(window.devicePixelRatio);
			// avoid setting pixel values here since this can cause pingpong updates
			// e.g. when system scale is set to 125%
			// https://github.com/needle-tools/needle-engine-support/issues/69
			this.renderer.domElement.style.width = "100%";
			this.renderer.domElement.style.height = "100%";
			if (this.composer) {
				this.composer.setSize?.call(this.composer, width, height);
				if ("setPixelRatio" in this.composer && typeof this.composer.setPixelRatio === "function")
					this.composer.setPixelRatio?.call(this.composer, window.devicePixelRatio);
			}
		}
	}

	updateAspect(camera: PerspectiveCamera, width?: number, height?: number) {
		if (!camera) return;
		if (width === undefined)
			width = this.domWidth;
		if (height === undefined)
			height = this.domHeight;
		const pa = camera.aspect;
		camera.aspect = width / height;
		if (pa !== camera.aspect)
			camera.updateProjectionMatrix();
	}

	/** This will recreate the whole needle engine context and dispose the whole scene content  
	 * All content will be reloaded (loading times might be faster due to browser caches)   
	 * All scripts will be recreated */
	recreate() {
		this.clear();
		this.create(this._originalCreationArgs);
	}

	private _originalCreationArgs?: ContextCreateArgs;

	/** @deprecated use create. This method will be removed in a future version */
	async onCreate(opts?: ContextCreateArgs) {
		return this.create(opts);
	}
	async create(opts?: ContextCreateArgs) {
		try {
			this._isCreating = true;
			if (opts !== this._originalCreationArgs)
				this._originalCreationArgs = deepClone(opts);
			window.addEventListener("unhandledrejection", this.onUnhandledRejection)
			const res = await this.internalOnCreate(opts);
			this._isCreated = res;
			return res;
		}
		finally {
			window.removeEventListener("unhandledrejection", this.onUnhandledRejection)
			this._isCreating = false;
		}
	}

	private onUnhandledRejection = (event: PromiseRejectionEvent) => {
		this.onError(event.reason);
	};

	/** Dispatches an error */
	private onError(error: string) {
		this.domElement.dispatchEvent(new CustomEvent("error", { detail: error }));
	}

	/** Will destroy all scenes and objects in the scene
	 */
	clear() {
		ContextRegistry.dispatchCallback(ContextEvent.ContextClearing, this);
		invokeLifecycleFunctions(this, ContextEvent.ContextClearing);
		// NOTE: this does dispose the environment/background image too
		// which is probably not desired if it is set via the skybox-image attribute
		destroy(this.scene, true, true);
		this.scene = new Scene();
		this.addressables?.dispose();
		this.lightmaps?.clear();
		this.physics?.engine?.clearCaches();
		this.lodsManager.disable();

		if (!this.isManagedExternally) {
			if (this.renderer) {
				this.renderer.renderLists.dispose();
				this.renderer.state.reset();
				this.renderer.resetState();
			}
		}
		// We do not want to clear the renderer here because when switching src we want to keep the last rendered frame in case the loading screen is not visible
		// if a user wants to see the background they can still call setClearAlpha(0) and clear manually
		ContextRegistry.dispatchCallback(ContextEvent.ContextCleared, this);
	}

	dispose() {
		this.internalOnDestroy();
	}

	/**@deprecated use dispose()  */
	onDestroy() { this.internalOnDestroy(); }
	private internalOnDestroy() {
		Context.Current = this;
		ContextRegistry.dispatchCallback(ContextEvent.ContextDestroying, this);
		invokeLifecycleFunctions(this, ContextEvent.ContextDestroying);
		this.clear();
		this.renderer?.setAnimationLoop(null);
		if (this.renderer) {
			this.renderer.setClearAlpha(0);
			this.renderer.clear();
			if (!this.isManagedExternally) {
				if (debug) console.log("Disposing renderer");
				this.renderer.dispose();
			}
		}
		this.scene = null!;
		this.renderer = null!;
		this.input.dispose();
		this.menu.onDestroy();
		this.animations.onDestroy();
		for (const cb of this._disposeCallbacks) {
			try {
				cb();
			}
			catch (e) {
				console.error("Error in on dispose callback:", e, cb);
			}
		}
		if (this.domElement?.parentElement) {
			this.domElement.parentElement.removeChild(this.domElement);
		}
		this._isCreated = false;
		ContextRegistry.dispatchCallback(ContextEvent.ContextDestroyed, this);
		invokeLifecycleFunctions(this, ContextEvent.ContextDestroyed);
		ContextRegistry.unregister(this);
		if (Context.Current === this) {
			//@ts-ignore
			Context.Current = null;
		}
	}

	registerCoroutineUpdate(script: IComponent, coroutine: Generator, evt: FrameEvent): Generator {
		if (typeof coroutine?.next !== "function") {
			console.error("Registered invalid coroutine function from " + script.name + "\nCoroutine functions must be generators: \"*myCoroutine() {...}\"\nStart a coroutine from a component by calling \"this.startCoroutine(myCoroutine())\"")
			return coroutine;
		}
		if (!this.coroutines[evt]) this.coroutines[evt] = [];
		this.coroutines[evt].push({ comp: script, main: coroutine });
		return coroutine;
	}

	unregisterCoroutineUpdate(coroutine: Generator, evt: FrameEvent): void {
		if (!this.coroutines[evt]) return;
		const idx = this.coroutines[evt].findIndex(c => c.main === coroutine);
		if (idx >= 0) this.coroutines[evt].splice(idx, 1);
	}

	stopAllCoroutinesFrom(script: IComponent) {
		for (const evt in this.coroutines) {
			const rout: CoroutineData[] = this.coroutines[evt];
			for (let i = rout.length - 1; i >= 0; i--) {
				const r = rout[i];
				if (r.comp === script) {
					rout.splice(i, 1);
				}
			}
		}
	}

	private _cameraStack: ICamera[] = [];

	setCurrentCamera(cam: ICamera) {
		if (!cam) return;
		if (!cam.threeCamera) cam.buildCamera(); // < to build camera
		if (!cam.threeCamera) {
			console.warn("Camera component is missing camera", cam)
			return;
		}
		const index = this._cameraStack.indexOf(cam);
		if (index >= 0) this._cameraStack.splice(index, 1);
		this._cameraStack.push(cam);
		this.mainCameraComponent = cam;
		const camera = cam.threeCamera as PerspectiveCamera;
		if (camera.isPerspectiveCamera)
			this.updateAspect(camera);
		(this.mainCameraComponent as ICamera)?.applyClearFlagsIfIsActiveCamera();
	}

	removeCamera(cam?: ICamera | null) {
		if (!cam) return;
		const index = this._cameraStack.indexOf(cam);
		if (index >= 0) this._cameraStack.splice(index, 1);

		if (this.mainCameraComponent === cam) {
			this.mainCameraComponent = undefined;

			if (this._cameraStack.length > 0) {
				const last = this._cameraStack[this._cameraStack.length - 1];
				this.setCurrentCamera(last);
			}
		}
	}



	private _onBeforeRenderListeners = new Map<string, OnRenderCallback[]>();
	private _onAfterRenderListeners = new Map<string, OnRenderCallback[]>();

	/** use this to subscribe to onBeforeRender events on threejs objects */
	addBeforeRenderListener(target: Object3D, callback: OnRenderCallback) {
		if (!this._onBeforeRenderListeners.has(target.uuid)) {
			this._onBeforeRenderListeners.set(target.uuid, []);
			target.onBeforeRender = this._createRenderCallbackWrapper(target, this._onBeforeRenderListeners);
		}
		this._onBeforeRenderListeners.get(target.uuid)!.push(callback);
	}
	removeBeforeRenderListener(target: Object3D, callback: OnRenderCallback) {
		if (this._onBeforeRenderListeners.has(target.uuid)) {
			const arr = this._onBeforeRenderListeners.get(target.uuid)!;
			const idx = arr.indexOf(callback);
			if (idx >= 0) arr.splice(idx, 1);
		}
	}

	/** use this to subscribe to onAfterRender events on threejs objects */
	addAfterRenderListener(target: Object3D, callback: OnRenderCallback) {
		if (!this._onAfterRenderListeners.has(target.uuid)) {
			this._onAfterRenderListeners.set(target.uuid, []);
			target.onAfterRender = this._createRenderCallbackWrapper(target, this._onAfterRenderListeners);
		}
		this._onAfterRenderListeners.get(target.uuid)?.push(callback);
	}
	removeAfterRenderListener(target: Object3D, callback: OnRenderCallback) {
		if (this._onAfterRenderListeners.has(target.uuid)) {
			const arr = this._onAfterRenderListeners.get(target.uuid)!;
			const idx = arr.indexOf(callback);
			if (idx >= 0) arr.splice(idx, 1);
		}
	}


	private _createRenderCallbackWrapper(target: Object3D, array: Map<string, OnRenderCallback[]>): OnRenderCallback {
		return (renderer, scene, camera, geometry, material, group) => {
			const arr = array.get(target.uuid);
			if (!arr) return;
			for (let i = 0; i < arr.length; i++) {
				const fn = arr[i];
				fn(renderer, scene, camera, geometry, material, group);
			}
		}
	}



	private _requireDepthTexture: boolean = false;
	private _requireColorTexture: boolean = false;
	private _renderTarget?: WebGLRenderTarget;
	private _isRendering: boolean = false;

	get isRendering() { return this._isRendering; }

	setRequireDepth(val: boolean) {
		this._requireDepthTexture = val;
	}

	setRequireColor(val: boolean) {
		this._requireColorTexture = val;
	}

	get depthTexture(): DepthTexture | null {
		return this._renderTarget?.depthTexture || null;
	}

	get opaqueColorTexture(): Texture | null {
		return this._renderTarget?.texture || null;
	}

	/** returns true if the dom element is visible on screen */
	get isVisibleToUser() {
		if (this.isInXR) return true;
		if (!this._isVisible) return false;
		const style = getComputedStyle(this.domElement);
		return style.visibility !== "hidden" && style.display !== "none" && style.opacity !== "0";
	}


	private _createId: number = 0;
	private async internalOnCreate(opts?: ContextCreateArgs): Promise<boolean> {
		const createId = ++this._createId;

		if (debug) console.log("Creating context", this.name, opts);

		// wait for async imported dependencies to be loaded
		// see https://linear.app/needle/issue/NE-4445
		const dependenciesReady = globalThis["needle:dependencies:ready"];
		if (dependenciesReady instanceof Promise) {
			if (debug) console.log("Waiting for dependencies to be ready");
			await dependenciesReady
				.catch(err => {
					if (debug || isDevEnvironment()) {
						showBalloonError("Needle Engine dependencies failed to load. Please check the console for more details");
						const printedError = false;
						if (err instanceof ReferenceError) {
							let offendingComponentName = "YourComponentName";
							const offendingComponentStartIndex = err.message.indexOf("'");
							if (offendingComponentStartIndex > 0) {
								const offendingComponentEndIndex = err.message.indexOf("'", offendingComponentStartIndex + 1);
								if (offendingComponentEndIndex > 0) {
									const name = err.message.substring(offendingComponentStartIndex + 1, offendingComponentEndIndex);
									if (name.length > 3) offendingComponentName = name;
								}
							}
							console.error(`Needle Engine dependencies failed to load:\n\n# Make sure you don't have circular imports in your scripts!\n→ Possible solution: Replace @serializable(${offendingComponentName}) in your script with @serializable(Behaviour)\n\n---`, err)
							return;
						}
						if (!printedError) {
							console.error("Needle Engine dependencies failed to load", err);
						}
					}
				})
				.then(() => {
					if (debug) console.log("Needle Engine dependencies are ready");
				});
		}

		this.clear();
		// stop the animation loop if its running during creation
		// since we do not want to start enabling scripts etc before they are deserialized
		if (this.isManagedExternally === false) {
			this.createNewRenderer();
			this.renderer?.setAnimationLoop(null);
		}

		await delay(1);

		Context.Current = this;
		await ContextRegistry.dispatchCallback(ContextEvent.ContextCreationStart, this);

		// load and create scene
		let prepare_succeeded = true;
		let loadedFiles!: Array<LoadedModel | null>;
		try {
			Context.Current = this;
			if (opts) {
				loadedFiles = await this.internalLoadInitialContent(createId, opts);
			}
			else loadedFiles = [];
		}
		catch (err) {
			console.error(err);
			prepare_succeeded = false;
		}
		if (!prepare_succeeded) {
			this.onError("Failed to load initial content");
			return false;
		}
		if (createId !== this._createId || opts?.abortSignal?.aborted) {
			return false;
		}

		this.internalOnUpdateVisible();

		if (!this.renderer) {
			if (debug) console.warn("Context has no renderer (perhaps it was disconnected?", this.domElement.isConnected);
			return false;
		}

		if (!this.isManagedExternally && !this.domElement.shadowRoot) {
			this.domElement.prepend(this.renderer.domElement);
		}

		Context.Current = this;

		// TODO: we could configure if we need physics
		// await this.physics.engine?.initialize();

		// Setup
		Context.Current = this;
		for (let i = 0; i < this.new_scripts.length; i++) {
			const script = this.new_scripts[i];
			if (script.gameObject !== undefined && script.gameObject !== null) {
				if (script.gameObject.userData === undefined) script.gameObject.userData = {};
				if (script.gameObject.userData.components === undefined) script.gameObject.userData.components = [];
				const arr = script.gameObject.userData.components;
				if (!arr.includes(script)) arr.push(script);
			}
			// if (script.gameObject && !this.raycastTargets.includes(script.gameObject)) {
			// 	this.raycastTargets.push(script.gameObject);
			// }
		}

		// const context = new SerializationContext(this.scene);
		// for (let i = 0; i < this.new_scripts.length; i++) {
		// 	const script = this.new_scripts[i];
		// 	const ser = script as unknown as ISerializable;
		// 	if (ser.$serializedTypes === undefined) continue;
		// 	context.context = this;
		// 	context.object = script.gameObject;
		// 	deserializeObject(ser, script, context);
		// }

		// resolve post setup callbacks (things that rely on threejs objects having references to components)
		if (this.post_setup_callbacks) {
			for (let i = 0; i < this.post_setup_callbacks.length; i++) {
				Context.Current = this;
				await this.post_setup_callbacks[i](this);
			}
		}

		if (!this._mainCamera) {
			Context.Current = this;
			let camera: ICamera | null = null;
			foreachComponent(this.scene, comp => {
				const cam = comp as ICamera;
				if (cam?.isCamera) {
					looputils.updateActiveInHierarchyWithoutEventCall(cam.gameObject);
					if (!cam.activeAndEnabled) return undefined;
					if (cam.tag === "MainCamera") {
						camera = cam;
						return true;
					}
					else camera = cam;
				}
				return undefined;
			});
			if (camera) {
				this.setCurrentCamera(camera);
			}
			else {
				const res = ContextRegistry.dispatchCallback(ContextEvent.MissingCamera, this, { files: loadedFiles });
				if (!res && !this.mainCamera && !this.isManagedExternally)
					console.warn("Missing camera in main scene", this);
			}
		}

		this.input.bindEvents();

		Context.Current = this;
		looputils.processNewScripts(this);

		// We have to step once so that colliders that have been created in onEnable can be raycasted in start
		if (this.physics.engine) {
			this.physics.engine?.step(0);
			this.physics.engine?.postStep();
		}

		// const mainCam = this.mainCameraComponent as Camera;
		// if (mainCam) {
		// 	mainCam.applyClearFlagsIfIsActiveCamera();
		// }

		if (!this.isManagedExternally && this.composer && this.mainCamera) {
			const renderPass = new RenderPass(this.scene, this.mainCamera);
			this.renderer.setSize(this.domWidth, this.domHeight);
			this.composer.addPass(renderPass);
			this.composer.setSize(this.domWidth, this.domHeight);
		}

		this._sizeChanged = true;

		if (this._stats) {
			this._stats.showPanel(0);
			this._stats.dom.style.position = "absolute"; // (default is fixed)
			this.domElement.shadowRoot?.appendChild(this._stats.dom);
		}

		if (debug)
			logHierarchy(this.scene, true);

		// If no target framerate was set we use the default
		if (this.targetFrameRate === undefined) {
			if (debug) console.warn("No target framerate set, using default", Context.DefaultTargetFrameRate);
			// the _defaultTargetFramerate is intentionally an object so it can be changed at any time if not explictly set by the user
			this.targetFrameRate = Context._defaultTargetFramerate;
		}
		else if (debug) console.log("Target framerate set to", this.targetFrameRate);

		this._dispatchReadyAfterFrame = true;
		const res = ContextRegistry.dispatchCallback(ContextEvent.ContextCreated, this, { files: loadedFiles });
		if (res) {
			if ("internalSetLoadingMessage" in this.domElement && typeof this.domElement.internalSetLoadingMessage === "function")
				this.domElement?.internalSetLoadingMessage("finish loading");
			await res;
		}
		if (opts?.abortSignal?.aborted) {
			return false;
		}
		invokeLifecycleFunctions(this, ContextEvent.ContextCreated);
		if (debug) console.log("Context Created...", this.renderer, this.renderer.domElement)

		this._isCreating = false;
		if (!this.isManagedExternally && !opts?.abortSignal?.aborted)
			this.restartRenderLoop();
		return true;
	}

	private async internalLoadInitialContent(createId: number, args: ContextCreateArgs): Promise<Array<LoadedModel>> {
		const results = new Array<LoadedModel>();
		// early out if we dont have any files to load
		if (args.files.length === 0) return results;


		const files = [...args.files];
		const progressArg: LoadingProgressArgs = {
			name: "",
			progress: null!,
			index: 0,
			count: files.length
		}

		const loader = getLoader();
		// this hash should be constant since it is used to initialize the UIDProvider per initially loaded scene
		const loadingHash = 0;
		for (let i = 0; i < files.length; i++) {
			if (args.abortSignal?.aborted) {
				if (debug) console.log("Aborting loading because of abort signal");
				break;
			}
			// abort loading if the create id has changed
			if (createId !== this._createId) {
				if (debug) console.log("Aborting loading because create id changed", createId, this._createId);
				break;
			}
			const file = files[i];
			args?.onLoadingStart?.call(this, i, file);
			if (debug) console.log("Context Load " + file);
			const res = await loader.loadSync(this, file, file, loadingHash, prog => {
				if (args.abortSignal?.aborted) return;
				progressArg.name = file;
				progressArg.progress = prog;
				progressArg.index = i;
				progressArg.count = files.length;
				args.onLoadingProgress?.call(this, progressArg);
			});
			args?.onLoadingFinished?.call(this, i, file, res ?? null);
			if (res) {
				results.push({
					src: file,
					file: res
				});
			}
			else {
				// a file could not be loaded
				console.warn("Could not load file: " + file);
			}
		}

		// if the id was changed while still loading
		// then we want to cleanup/destroy previously loaded files
		if (createId !== this._createId || args.abortSignal?.aborted) {
			for (const res of results) {
				if (res && res.file) {
					for (const scene of res.file.scenes)
						destroy(scene, true, true);
				}
			}
		}
		// otherwise we want to add the loaded files to the current scene
		else {
			let anyModelFound = false;
			for (const res of results) {
				if (res && res.file) {
					// TODO: should we load all scenes in a glTF here?
					if (res.file.scene) {
						anyModelFound = true;
						this.scene.add(res.file.scene);
					}
					else {
						console.warn("No scene found in loaded file");
					}
				}
			}
			// If the loaded files do not contain ANY model
			// We then attempt to create a mesh from each material in the loaded files to visualize it
			// It's ok to do this at this point because we know the context has been cleared because the whole `src` attribute has been set
			if (!anyModelFound) {
				for (const res of results) {
					if (res && res.file && "parser" in res.file) {
						let y = 0;
						if (!Array.isArray(res.file.parser.json.materials)) continue;
						for (let i = 0; i < res.file.parser.json.materials.length; i++) {
							const mat = await res.file.parser.getDependency("material", i);
							const parent = new Object3D();
							parent.position.x = i * 1.1;
							parent.position.y = y;
							this.scene.add(parent);
							ObjectUtils.createPrimitive("ShaderBall", {
								parent,
								material: mat
							});
						}
						y += 1;
					}
				}
			}
		}

		return results;
	}


	/** Sets the animation loop.   
	 * Can not be done while creating the context or when disposed 
	 **/
	public restartRenderLoop(): boolean {
		if (!this.renderer) {
			console.error("Can not start render loop without renderer");
			return false;
		}
		if (this._isCreating) {
			console.warn("Can not start render loop while creating context");
			return false;
		}
		this.renderer.setAnimationLoop((timestamp, frame: XRFrame | null) => {
			if (this.isManagedExternally) return;
			this.update(timestamp, frame)
		});
		return true;
	}

	private _renderlooperrors = 0;

	/** Performs a full update step including script callbacks, rendering (unless isManagedExternally is set to false) and post render callbacks */
	public update(timestamp: DOMHighResTimeStamp, frame?: XRFrame | null) {
		if (frame === undefined) frame = null;
		if (isDevEnvironment() || debug || looputils.hasNewScripts()) {
			try {
				performance.mark('update.start');
				this.internalStep(timestamp, frame);
				this._renderlooperrors = 0;
				performance.mark('update.end');
				performance.measure('NE Frame', 'update.start', 'update.end');
			}
			catch (err) {
				this._renderlooperrors += 1;
				if ((isDevEnvironment() || debug) && (err instanceof Error || err instanceof TypeError))
					showBalloonMessage(`Caught unhandled exception during render-loop - see console for details.`, LogType.Error);
				console.error("Frame #" + this.time.frame + "\n", err);
				if (this._renderlooperrors >= 3) {
					console.warn("Stopping render loop due to error")
					this.renderer.setAnimationLoop(null);
				}
				this.domElement.dispatchEvent(new CustomEvent("error", { detail: err }));
			}
		}
		else {
			this.internalStep(timestamp, frame);
		}
	}

	/** Call to **manually** perform physics steps.   
	 * By default the context uses the `physicsSteps` property to perform steps during the update loop   
	 * If you just want to increase the accuracy of physics you can instead set the `physicsSteps` property to a higher value
	 * */
	public updatePhysics(steps: number) {
		this.internalUpdatePhysics(steps);
	}

	private _lastTimestamp = 0;
	private _accumulatedTime = 0;
	private _dispatchReadyAfterFrame = false;

	// TODO: we need to skip after render callbacks if the render loop is managed externally. When changing this we also need to to update the r3f sample
	private internalStep(timestamp: DOMHighResTimeStamp, frame: XRFrame | null) {
		if (this.internalOnBeforeRender(timestamp, frame) === false) return;
		this.internalOnRender();
		this.internalOnAfterRender();
	}

	private internalOnBeforeRender(timestamp: DOMHighResTimeStamp, frame: XRFrame | null) {

		// If we don't auto reset we get wrong stats in WebXR. AutoReset was turned off to support custom blits and count them too
		this.renderer.info.autoReset = true;


		const sessionStarted = frame !== null && this._xrFrame === null;
		this._xrFrame = frame;
		if (sessionStarted) {
			this.domElement.dispatchEvent(new CustomEvent("xr-session-started", { detail: { context: this, session: this.xrSession, frame: frame } }));
		}

		this._currentFrameEvent = FrameEvent.Undefined;

		if (this.isManagedExternally === false && this.isInXR === false && this.targetFrameRate !== undefined) {
			if (this._lastTimestamp === 0) this._lastTimestamp = timestamp;
			this._accumulatedTime += (timestamp - this._lastTimestamp) / 1000;
			this._lastTimestamp = timestamp;
			let targetFrameRate = this.targetFrameRate;
			if (typeof targetFrameRate === "object") targetFrameRate = targetFrameRate.value!;
			// if(debug) console.log(this._accumulatedTime, (1 / (targetFrameRate)))
			if (this._accumulatedTime < (1 / (targetFrameRate + 1))) {
				return false;
			}
			this._accumulatedTime = 0;
		}

		this._stats?.begin();

		Context.Current = this;
		if (this.onHandlePaused()) return false;

		Context.Current = this;
		this.time.update();
		if (debugframerate)
			console.log("FPS", (this.time.smoothedFps).toFixed(0));


		looputils.processNewScripts(this);
		looputils.updateIsActive(this.scene);
		looputils.processStart(this);
		invokeLifecycleFunctions(this, FrameEvent.Start);

		while (this._cameraStack.length > 0 && (!this.mainCameraComponent || this.mainCameraComponent.destroyed)) {
			this._cameraStack.splice(this._cameraStack.length - 1, 1);
			const last = this._cameraStack[this._cameraStack.length - 1];
			this.setCurrentCamera(last);
		}

		if (this.pre_update_oneshot_callbacks) {
			for (const i in this.pre_update_oneshot_callbacks) {
				this.pre_update_oneshot_callbacks[i]();
			}
			this.pre_update_oneshot_callbacks.length = 0;
		}

		if (this.pre_update_callbacks) {
			for (const i in this.pre_update_callbacks) {
				this.pre_update_callbacks[i]();
			}
		}

		this._currentFrameEvent = FrameEvent.EarlyUpdate;

		for (let i = 0; i < this.scripts_earlyUpdate.length; i++) {
			const script = this.scripts_earlyUpdate[i];
			if (!script.activeAndEnabled) continue;
			if (script.earlyUpdate !== undefined) {
				Context.Current = this;
				script.earlyUpdate();
			}
		}
		this.executeCoroutines(FrameEvent.EarlyUpdate);
		invokeLifecycleFunctions(this, FrameEvent.EarlyUpdate);
		if (this.onHandlePaused()) return false;

		this._currentFrameEvent = FrameEvent.Update;

		for (let i = 0; i < this.scripts_update.length; i++) {
			const script = this.scripts_update[i];
			if (!script.activeAndEnabled) continue;
			if (script.update !== undefined) {
				Context.Current = this;
				script.update();
			}
		}
		this.executeCoroutines(FrameEvent.Update);
		invokeLifecycleFunctions(this, FrameEvent.Update);
		if (this.onHandlePaused()) return false;

		this._currentFrameEvent = FrameEvent.LateUpdate;

		for (let i = 0; i < this.scripts_lateUpdate.length; i++) {
			const script = this.scripts_lateUpdate[i];
			if (!script.activeAndEnabled) continue;
			if (script.lateUpdate !== undefined) {
				Context.Current = this;
				script.lateUpdate();
			}
		}

		// this.mainLight = null;
		this.executeCoroutines(FrameEvent.LateUpdate);
		invokeLifecycleFunctions(this, FrameEvent.LateUpdate);
		if (this.onHandlePaused()) return false;

		if (this.physicsSteps === undefined) {
			this.physicsSteps = 1;
		}
		if (this.physics.engine && this.physicsSteps > 0) {
			this.internalUpdatePhysics(this.physicsSteps);
		}

		if (this.onHandlePaused()) return false;

		if (this.isVisibleToUser || this.runInBackground) {

			this._currentFrameEvent = FrameEvent.OnBeforeRender;

			// should we move these callbacks in the regular three onBeforeRender events?
			for (let i = 0; i < this.scripts_onBeforeRender.length; i++) {
				const script = this.scripts_onBeforeRender[i];
				if (!script.activeAndEnabled) continue;
				// if(script.isActiveAndEnabled === false) continue;
				if (script.onBeforeRender !== undefined) {
					Context.Current = this;
					script.onBeforeRender(frame);
				}
			}

			this.executeCoroutines(FrameEvent.OnBeforeRender);
			invokeLifecycleFunctions(this, FrameEvent.OnBeforeRender);

			if (this._sizeChanged)
				this.updateSize();

			if (this.pre_render_callbacks) {
				for (const i in this.pre_render_callbacks) {
					this.pre_render_callbacks[i](frame);
				}
			}

		}

		return true;
	}

	private internalUpdatePhysics(steps: number) {
		if (!this.physics.engine) return false;
		const physicsSteps = steps;
		const dt = this.time.deltaTime / physicsSteps;
		for (let i = 0; i < physicsSteps; i++) {
			this._currentFrameEvent = FrameEvent.PrePhysicsStep;
			this.executeCoroutines(FrameEvent.PrePhysicsStep);
			this.physics.engine.step(dt);
			this._currentFrameEvent = FrameEvent.PostPhysicsStep;
			this.executeCoroutines(FrameEvent.PostPhysicsStep);
		}
		this.physics.engine.postStep();
		return true;
	}

	private internalOnRender() {
		if (!this.isManagedExternally) {
			// when loading assets we compile them async after GLTFLoader is done
			// but as a fallback we still register them (if e.g. there's no camera for compile async)
			looputils.runPrewarm(this);
			this._currentFrameEvent = FrameEvent.Undefined;
			this.renderNow();
			this._currentFrameEvent = FrameEvent.OnAfterRender;
		}
	}

	private internalOnAfterRender() {
		if (this.isVisibleToUser || this.runInBackground) {
			for (let i = 0; i < this.scripts_onAfterRender.length; i++) {
				const script = this.scripts_onAfterRender[i];
				if (!script.activeAndEnabled) continue;
				if (script.onAfterRender !== undefined) {
					Context.Current = this;
					script.onAfterRender();
				}
			}

			this.executeCoroutines(FrameEvent.OnAfterRender);
			invokeLifecycleFunctions(this, FrameEvent.OnAfterRender);

			if (this.post_render_callbacks) {
				for (const i in this.post_render_callbacks) {
					this.post_render_callbacks[i]();
				}
			}
		}

		this._currentFrameEvent = -1;

		this.connection.sendBufferedMessagesNow();

		if (this._stats) {
			this._stats.end();
			if (this.time.frameCount % 150 === 0)
				console.log(this.renderer.info.render.calls + " DrawCalls", "\nRender:", { ...this.renderer.info.render }, "\nMemory:", { ...this.renderer.info.memory }, "\nTarget Framerate: " + this.targetFrameRate);
		}

		if (this._dispatchReadyAfterFrame) {
			this._dispatchReadyAfterFrame = false;
			this.domElement.dispatchEvent(new CustomEvent("ready"));
			ContextRegistry.dispatchCallback(ContextEvent.ContextFirstFrameRendered, this);
		}
	}

	renderNow(camera?: Camera) {
		if (!camera) {
			camera = this.mainCamera as Camera;
			if (!camera) return false;
		}
		this.handleRendererContextLost();

		this._isRendering = true;
		this.renderRequiredTextures();

		if (this.renderer.toneMapping !== NoToneMapping)
			patchTonemapping(this);

		if (this.composer && !this.isInXR) {
			// if a camera is passed in we need to check if we need to update the composer's camera
			if (camera) {
				const currentPassesCamera = this.composer.passes[0]?.mainCamera;
				if (currentPassesCamera != camera)
					this.composer.setMainCamera(camera);
			}
			this.composer.render(this.time.deltaTime);
		}
		else if (camera) {
			// Workaround for issue on Vision Pro – 
			// depth buffer is not cleared between eye draws, despite the spec...
			if (this.isInXR && DeviceUtilities.isMacOS())
				this.renderer.clearDepth();
			this.renderer.render(this.scene, camera);
		}
		this._isRendering = false;
		return true;
	}

	private handleRendererContextLost() {
		if (this.renderer.getContext().isContextLost()) {
			console.warn("Attempting to recover WebGL context...");
			this.renderer.forceContextRestore();
		}
	}

	/** returns true if we should return out of the frame loop */
	private _wasPaused: boolean = false;
	private onHandlePaused(): boolean {
		const paused = this.evaluatePaused();
		if (this._wasPaused !== paused) {
			if (debugActive) console.log("Paused?", paused, "context:" + this.alias);
			for (let i = 0; i < this.scripts_pausedChanged.length; i++) {
				const script = this.scripts_pausedChanged[i];
				if (!script.activeAndEnabled) continue;
				if (script.onPausedChanged !== undefined) {
					Context.Current = this;
					script.onPausedChanged(paused, this._wasPaused);
				}
			}
		}
		this._wasPaused = paused;
		return paused;
	}

	private evaluatePaused(): boolean {
		if (this.isInXR) return false;
		if (this.isPaused) return true;
		// if the element is not visible use the runInBackground flag to determine if we should continue
		if (this.runInBackground) {
			return false;
		}
		const paused = !this.isVisibleToUser;
		return paused;
	}

	private renderRequiredTextures() {
		if (!this.mainCamera) return;
		if (!this._requireDepthTexture && !this._requireColorTexture) return;
		if (!this._renderTarget) {
			this._renderTarget = new WebGLRenderTarget(this.domWidth, this.domHeight);
			if (this._requireDepthTexture) {
				const dt = new DepthTexture(this.domWidth, this.domHeight);;
				this._renderTarget.depthTexture = dt;
			}
			if (this._requireColorTexture) {
				this._renderTarget.texture = new Texture();
				this._renderTarget.texture.generateMipmaps = false;
				this._renderTarget.texture.minFilter = NearestFilter;
				this._renderTarget.texture.magFilter = NearestFilter;
				this._renderTarget.texture.format = RGBAFormat;
			}
		}
		const rt = this._renderTarget;
		if (rt.texture) {
			rt.texture.colorSpace = this.renderer.outputColorSpace;
		}
		const prevTarget = this.renderer.getRenderTarget();
		this.renderer.setRenderTarget(rt);
		this.renderer.render(this.scene, this.mainCamera);
		this.renderer.setRenderTarget(prevTarget);
	}

	private executeCoroutines(evt: FrameEvent) {
		if (this.coroutines[evt]) {
			const evts = this.coroutines[evt];
			for (let i = 0; i < evts.length; i++) {
				try {
					const evt = evts[i];
					// TODO we might want to keep coroutines playing even if the component is disabled or inactive
					const remove = !evt.comp || evt.comp.destroyed || !evt.main || evt.comp["enabled"] === false;
					if (remove) {
						if (debugCoroutine) console.log("Removing coroutine", evt.comp, evt.comp["enabled"])
						evts.splice(i, 1);
						--i;
						continue;
					}
					const iter = evt.chained;
					if (iter && iter.length > 0) {
						const last: Generator = iter[iter.length - 1];
						const res = last.next();
						if (res.done) {
							iter.pop();
						}
						if (isGenerator(res)) {
							if (!evt.chained) evt.chained = [];
							evt.chained.push(res.value);
						}
						if (!res.done) continue;
					}

					const res = evt.main.next();
					if (res.done === true) {
						evts.splice(i, 1);
						--i;
						continue;
					}
					const val = res.value;
					if (isGenerator(val)) {
						// invoke once if its a generator
						// this means e.g. WaitForFrame(1) works and will capture
						// the frame it was created
						const gen = val as Generator;
						const res = gen.next();
						if (res.done) continue;
						if (!evt.chained) evt.chained = [];
						evt.chained.push(val as Generator);
					}
					else if (val instanceof Promise) {
						// If its a promise we want to wait for it to resolve
						const prom = val as Promise<any>;
						if (!evt.chained) evt.chained = [];
						const nested = WaitForPromise(prom);
						evt.chained?.push(nested);
						continue;
					}
				}
				catch (e) {
					console.error(e);
				}
			}
		}

		function isGenerator(val: any): boolean {
			if (val) {
				if (val.next && val.return) {
					return true;
				}
			}
			return false;
		}
	}

}


// const scene = new Scene();
// const useComposer = utils.getParam("postfx");
// const renderer = new WebGLRenderer({ antialias: true });
// const composer = useComposer ? new EffectComposer(renderer) : undefined;

// renderer.setClearColor(new Color('lightgrey'), 0)
// renderer.antialias = true;
// renderer.alpha = false;
// renderer.shadowMap.enabled = true;
// renderer.shadowMap.type = PCFSoftShadowMap;
// renderer.setSize(window.innerWidth, window.innerHeight);
// renderer.outputEncoding = sRGBEncoding;
// renderer.physicallyCorrectLights = true;
// document.body.appendChild(renderer.domElement);

// // generation pushes loading requests in this array
// const sceneData: {
//     mainCamera: Camera | undefined
// } = {
//     preparing: [],
//     resolving: [],
//     scripts: [],
//     raycastTargets: [],
//     mainCamera: undefined,
//     mainCameraComponent: undefined,
// };

// // contains a list of functions to be called after loading is done
// const post_setup_callbacks = [];

// const pre_render_Callbacks = [];
// const post_render_callbacks = [];

// const new_scripts = [];
// const new_scripts_post_setup_callbacks = [];
// const new_scripts_pre_setup_callbacks = [];

// export {
//     scene, renderer, composer,
//     new_scripts,
//     new_scripts_post_setup_callbacks, new_scripts_pre_setup_callbacks,
//     sceneData,
//     post_setup_callbacks,
//     pre_render_Callbacks,
//     post_render_callbacks
// }
