// use for typesafe interface method calls
import { Quaternion, Vector2, Vector3, Vector4 } from "three";

declare type Vector = Vector2 | Vector3 | Vector4 | Quaternion;

import type { Context } from "./engine_context.js";
import { ContextRegistry } from "./engine_context_registry.js";
import { type SourceIdentifier } from "./engine_types.js";

// https://schneidenbach.gitbooks.io/typescript-cookbook/content/nameof-operator.html
/** @internal */
export const nameofFactory = <T>() => (name: keyof T) => name;

/** @internal */
export function nameof<T>(name: keyof T) {
    return nameofFactory<T>()(name);
}

type ParseNumber<T> = T extends `${infer U extends number}` ? U : never;
export type EnumToPrimitiveUnion<T> = `${T & string}` | ParseNumber<`${T & number}`>;

/** @internal */
export function isDebugMode(): boolean {
    return getParam("debug") ? true : false;
}


/**
 * The circular buffer class can be used to cache objects that don't need to be created every frame.    
 * This structure is used for e.g. Vector3 or Quaternion objects in the engine when calling `getTempVector3` or `getTempQuaternion`.
 * 
 * @example Create a circular buffer that caches Vector3 objects. Max size is 10.
 * ```typescript
 * const buffer = new CircularBuffer(() => new Vector3(), 10);
 * const vec = buffer.get();
 * ```
 * 
 * @example Create a circular buffer that caches Quaternion objects. Max size is 1000.
 * ```typescript
 * const buffer = new CircularBuffer(() => new Quaternion(), 1000);
 * const quat = buffer.get();
 * ```
 */
export class CircularBuffer<T> {
    private _factory: () => T;
    private _cache: T[] = [];
    private _maxSize: number;
    private _index: number = 0;

    constructor(factory: () => T, maxSize: number) {
        this._factory = factory;
        this._maxSize = maxSize;
    }

    get(): T {
        const i = this._index % this._maxSize;
        this._index++;
        if (this._cache.length <= i) {
            this._cache[i] = this._factory();
        }
        return this._cache[i];
    }
}

let showHelp: Param<"help"> = false;
const requestedParams: Array<string> = new Array();

if (typeof window !== "undefined") {
    setTimeout(() => {
        // const debugHelp = getParam("debughelp");
        if (showHelp) {
            const params = {};
            const url = new URL(window.location.href);
            const exampleUrl = new URL(url);
            exampleUrl.searchParams.append("console", "");
            const exampleUrlStr = exampleUrl.toString().replace(/=$|=(?=&)/g, '');
            // Filter the params we're interested in
            for (const param of requestedParams) {
                const url2 = new URL(url);
                url2.searchParams.append(param, "");
                // Save url with clean parameters (remove trailing = and empty parameters)
                params[param] = url2.toString().replace(/=$|=(?=&)/g, '');
            }
            console.log(
                "🌵 ?help: Debug Options for Needle Engine.\n" +
                "Append any of these parameters to the URL to enable specific debug options.\n" +
                `Example: ${exampleUrlStr} will show an onscreen console window.`);
            const postfix = showHelp === true ? "" : ` (containing "${showHelp}")`;
            console.group("Available URL parameters:" + postfix);
            for (const key of Object.keys(params).sort()) {
                // If ?help= is a string we only want to show the parameters that contain the string
                if (typeof showHelp === "string") {
                    if (!key.toLowerCase().includes(showHelp.toLowerCase())) continue;
                }
                console.groupCollapsed(key);
                // Needs to be a separate log, otherwise Safari doesn't turn the next line into a URL:
                console.log("Reload with this flag enabled:");
                console.log(params[key]);
                console.groupEnd();
            }
            console.groupEnd();
        }
    }, 100);
}

export function getUrlParams() {
    // "window" may not exist in node.js
    return new URLSearchParams(globalThis.location?.search);
}

// bit strange that we have to pass T in here as well but otherwise the type parameter is stripped it seems
type Param<T extends string> = string | boolean | number | T;

/**
 * Checks if a URL parameter exists and returns its value.  
 * Useful for debugging, feature flags, and configuration.  
 *
 * @param paramName The URL parameter name to check
 * @returns
 * - `true` if the parameter exists without a value (e.g. `?debug`)
 * - `false` if the parameter doesn't exist or is set to `0`
 * - The numeric value if it's a number (e.g. `?level=5` returns `5`)
 * - The string value otherwise (e.g. `?name=test` returns `"test"`)
 *
 * @example Check debug mode
 * ```ts
 * if (getParam("debug")) {
 *   console.log("Debug mode enabled");
 * }
 * ```
 * @example Get a numeric value
 * ```ts
 * const level = getParam("level"); // Returns number if ?level=5
 * ```
 */
export function getParam<T extends string>(paramName: T): Param<T> {
    if (showHelp && !requestedParams.includes(paramName))
        requestedParams.push(paramName);
    const urlParams = getUrlParams();
    if (urlParams.has(paramName)) {
        const val = urlParams.get(paramName);
        if (val) {
            const num = Number(val);
            if (!isNaN(num)) return num;
            return val;
        }
        else return true;
    }
    return false;
}
showHelp = getParam("help");

export function setParam(paramName: string, paramValue: string): void {
    const urlParams = getUrlParams();
    if (urlParams.has(paramName)) {
        urlParams.set(paramName, paramValue);
    }
    else
        urlParams.append(paramName, paramValue);
    document.location.search = urlParams.toString();
}

/** Sets an URL parameter without reloading the website */
export function setParamWithoutReload(paramName: string, paramValue: string | null, appendHistory = true): void {
    const urlParams = getUrlParams();
    if (urlParams.has(paramName)) {
        if (paramValue === null) urlParams.delete(paramName);
        else urlParams.set(paramName, paramValue);
    }
    else if (paramValue !== null)
        urlParams.append(paramName, paramValue);
    if (appendHistory) pushState(paramName, urlParams);
    else setState(paramName, urlParams);
}

/** Sets or adds an URL query parameter */
export function setOrAddParamsToUrl(url: URLSearchParams, paramName: string, paramValue: string | number): void {
    if (url.has(paramName)) {
        url.set(paramName, paramValue.toString());
    }
    else
        url.append(paramName, paramValue.toString());
}

/** Adds an entry to the browser history. Internally uses `window.history.pushState` */
export function pushState(title: string, urlParams: URLSearchParams, state?: any) {
    window.history.pushState(state, title, "?" + urlParams.toString());
}

/** Replaces the current entry in the browser history. Internally uses `window.history.replaceState` */
export function setState(title: string, urlParams: URLSearchParams, state?: any) {
    window.history.replaceState(state, title, "?" + urlParams.toString());
}

// for room id
/** Generates a random id string of the given length */
export function makeId(length): string {
    var result = '';
    var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
    var charactersLength = characters.length;
    for (var i = 0; i < length; i++) {
        result += characters.charAt(Math.floor(Math.random() *
            charactersLength));
    }
    return result;
}

/** Generates a random number
 * @deprecated use Mathf.random(min, max)
 */
export function randomNumber(min: number, max: number) {
    return Math.floor(Math.random() * (max - min + 1)) + min;
}

const adjectives = ["smol", "tiny", "giant", "interesting", "smart", "bright", "dull", "extreme", "beautiful", "pretty", "dark", "epic", "salty", "silly", "funny", "lame", "lazy", "loud", "lucky", "mad", "mean", "mighty", "mysterious", "nasty", "odd", "old", "powerful", "quiet", "rapid", "scary", "shiny", "shy", "silly", "smooth", "sour", "spicy", "stupid", "sweet", "tasty", "terrible", "ugly", "unusual", "vast", "wet", "wild", "witty", "wrong", "zany", "zealous", "zippy", "zombie", "zorro"];
const nouns = ["cat", "dog", "mouse", "pig", "cow", "horse", "sheep", "chicken", "duck", "goat", "panda", "tiger", "lion", "elephant", "monkey", "bird", "fish", "snake", "frog", "turtle", "hamster", "penguin", "kangaroo", "whale", "dolphin", "crocodile", "snail", "ant", "bee", "beetle", "butterfly", "dragon", "eagle", "fish", "giraffe", "lizard", "panda", "penguin", "rabbit", "snake", "spider", "tiger", "zebra"]
/** Generates a random id string from a list of adjectives and nouns */
export function makeIdFromRandomWords(): string {
    const randomAdjective = adjectives[Math.floor(Math.random() * adjectives.length)];
    const randomNoun = nouns[Math.floor(Math.random() * nouns.length)];
    return randomAdjective + "_" + randomNoun;
}

// for url parameters
export function sanitizeString(str): string {
    str = str.replace(/[^a-z0-9áéíóúñü \.,_-]/gim, "");
    return str.trim();
}


// TODO: taken from scene utils
/** 
 * @param globalObjectIdentifier The guid of the object to find
 * @param obj The object to search in
 * @param recursive If true the search will be recursive
 * @param searchComponents If true the search will also search components
 * @returns the first object that has the globalObjectIdentifier as a guid */
export function tryFindObject(globalObjectIdentifier: string, obj, recursive: boolean = true, searchComponents: boolean = false) {
    if (obj === undefined || obj === null) return null;

    if (obj.userData && obj.userData.guid === globalObjectIdentifier) return obj;
    else if (obj.guid == globalObjectIdentifier) return obj;

    if (searchComponents) {
        if (obj.userData?.components) {
            for (const comp of obj.userData.components) {
                if (comp.guid === globalObjectIdentifier) return comp;
            }
        }
    }

    if (recursive) {
        if (obj.scenes) {
            for (const i in obj.scenes) {
                const scene = obj.scenes[i];
                const found = tryFindObject(globalObjectIdentifier, scene, recursive, searchComponents);
                if (found) return found;
            }
        }
        if (obj.children) {
            for (const i in obj.children) {
                const child = obj.children[i];
                const found = tryFindObject(globalObjectIdentifier, child, recursive, searchComponents);
                if (found) return found;
            }
        }
    }
}

declare type deepClonePredicate = (owner: any, propertyName: string, current: any) => boolean;

/** Deep clones an object
 * @param obj The object to clone
 * @param predicate A function that can be used to skip certain properties from being cloned
 * @returns The cloned object
 * @example
 * const clone = deepClone(obj, (owner, propertyName, current) => {
 *    if (propertyName === "dontCloneMe") return false;
 *   return true;
 * });
 * */
export function deepClone(obj: any, predicate?: deepClonePredicate): any {
    if (obj !== null && obj !== undefined && typeof obj === "object") {
        let clone;
        if (Array.isArray(obj)) clone = [];
        else {
            clone = Object.create(obj);
            Object.assign(clone, obj);
        }
        for (const key of Object.keys(obj)) {
            const val = obj[key];
            if (predicate && !predicate(obj, key, val)) {
                // console.log("SKIP", val);
                clone[key] = val;
            }
            else if (val?.clone !== undefined && typeof val.clone === "function")
                clone[key] = val.clone();
            else
                clone[key] = deepClone(val, predicate);
        }
        return clone;
    }
    return obj;
}

/** Wait for a specific amount of milliseconds to pass
 * @returns a promise that resolves after a certain amount of milliseconds   
 * @example
 * ```typescript
 * await delay(1000);
 * ```
*/
export function delay(milliseconds: number): Promise<void> {
    return new Promise((resolve, _reject) => {
        setTimeout(resolve, milliseconds);
    });
}

/** Will wait for a specific amount of frames to pass
 * @param frameCount The amount of frames to wait for
 * @param context The context to use, if not provided the current context will be used 
 * @returns a promise that resolves after a certain amount of frames 
 * @example
 * ```typescript
 * await delayForFrames(10);
 * ```
*/
export function delayForFrames(frameCount: number, context?: Context): Promise<void> {

    if (frameCount <= 0) return Promise.resolve();
    if (!context) context = ContextRegistry.Current as Context;
    if (!context) return Promise.reject("No context");

    const endFrame = context.time.frameCount + frameCount;
    return new Promise((resolve, reject) => {
        if (!context) return reject("No context");
        const cb = () => {
            if (context!.time.frameCount >= endFrame) {
                context!.pre_update_callbacks.splice(context!.pre_update_callbacks.indexOf(cb), 1);
                resolve();
            }
        }
        context!.pre_update_callbacks.push(cb);
    });
}

// 1) if a timeline is exported via menu item the audio clip path is relative to the glb (same folder)
// we need to detect that here and build the new audio source path relative to the new glb location
// the same is/might be true for any file that is/will be exported via menu item
// 2) if the needle.config assetDirectory is modified (from e.g. /assets to /needle/assets) when building a distributable our vite transform and copy plugin will move the files to dist/assets hence we cannot use project-relative paths (because the path changes). What we do instead if make all paths serialized in a glb relative to the glb. The rel: prefix is used to detect urls that need to be resolved.
const debugGetPath = getParam("debugresolveurl");

export const relativePathPrefix = "rel:";

/** @deprecated use resolveUrl instead */
export function getPath(source: SourceIdentifier | undefined, uri: string): string {
    return resolveUrl(source, uri);
}
/**
 * Use to resolve a url serialized in a glTF file
 * @param source The uri of the loading file
 * @param uri The uri of the file to resolve, can be absolute or relative
 * @returns The resolved uri
 */
export function resolveUrl(source: SourceIdentifier | undefined, uri: string): string {
    if (uri === undefined) {
        if (debugGetPath) console.warn("getPath: uri is undefined, returning uri", uri);
        return uri;
    }
    if (uri.startsWith("./")) {
        return uri;
    }
    if (uri.startsWith("http")) {
        if (debugGetPath) console.warn("getPath: uri is absolute, returning uri", uri);
        return uri;
    }
    if (source === undefined) {
        if (debugGetPath) console.warn("getPath: source is undefined, returning uri", uri);
        return uri;
    }
    if (uri.startsWith(relativePathPrefix)) {
        uri = uri.substring(4);
    }
    const pathIndex = source.lastIndexOf("/");
    if (pathIndex >= 0) {
        // Take the source uri as the base path
        const basePath = source.substring(0, pathIndex + 1);
        // make sure we don't have double slashes
        while (basePath.endsWith("/") && uri.startsWith("/")) uri = uri.substring(1);
        // Append the relative uri
        const newUri = basePath + uri;
        // newUri = new URL(newUri, globalThis.location.href).href;
        if (debugGetPath) console.log("source:", source, "changed uri \nfrom", uri, "\nto ", newUri, "\nbasePath: " + basePath);
        return newUri;
    }
    return uri;
}

export function toSourceId(src: string | null): SourceIdentifier | undefined {
    if (!src) return undefined;
    src = src.trim();
    src = src.split("?")[0]?.split("#")[0];
    return src;
}

// export function getPath(glbLocation: SourceIdentifier | undefined, path: string) {
//     if (path && glbLocation && !path.includes("/")) {
//         // get directory of glb and prepend it to the audio file path
//         const pathIndex = glbLocation.lastIndexOf("/");
//         if (pathIndex >= 0) {
//             const newPath = glbLocation.substring(0, pathIndex + 1) + path;
//             return newPath;
//         }
//     }
//     return path;
// }


export type WriteCallback = (data: any, prop: string) => void;

export interface IWatch {
    subscribeWrite(callback: WriteCallback);
    unsubscribeWrite(callback: WriteCallback);
    apply();
    revoke();
    dispose();
}


// TODO: make it possible to add multiple watches to the same object property
class WatchImpl implements IWatch {
    subscribeWrite(callback: WriteCallback) {
        this.writeCallbacks.push(callback);
    }
    unsubscribeWrite(callback: WriteCallback) {
        const i = this.writeCallbacks.indexOf(callback);
        if (i === -1) return;
        this.writeCallbacks.splice(i, 1);
    }
    private writeCallbacks: (WriteCallback)[] = [];

    constructor(object: object, prop: string) {
        this._object = object;
        this._prop = prop;
        this._wrapperProp = Symbol("$" + prop);
        this.apply();
    }

    private _applied: boolean = false;
    private _object: any;
    private _prop: string;
    private _wrapperProp: symbol;

    apply() {
        if (this._applied) return;
        if (!this._object) return;
        const object = this._object;
        const prop = this._prop;
        if (object[prop] === undefined) return;
        this._applied = true;

        if (object[this._wrapperProp] !== undefined) {
            console.warn("Watcher is being applied to an object that already has a wrapper property. This is not (yet) supported");
        }

        // create a wrapper property
        const current = object[prop];
        object[this._wrapperProp] = current;
        // create wrapper methods
        const getter = () => {
            return object[this._wrapperProp];
        }
        const setter = (value) => {
            object[this._wrapperProp] = value;
            for (const write of this.writeCallbacks) {
                write(value, this._prop);
            }
        }
        // add the wrapper to the object
        Object.defineProperty(object, prop, {
            get: getter,
            set: setter
        });
    }

    revoke() {
        if (!this._applied) return;
        if (!this._object) return;
        this._applied = false;
        const object = this._object;
        const prop = this._prop;
        Reflect.deleteProperty(object, prop);
        const current = object[this._wrapperProp];
        object[prop] = current;
        Reflect.deleteProperty(object, this._wrapperProp);
    }

    dispose() {
        this.revoke();
        this.writeCallbacks.length = 0;
        this._object = null;
    }
}

export class Watch implements IWatch {

    private readonly _watches: IWatch[] = [];

    constructor(object: object, str: string[] | string) {
        if (Array.isArray(str)) {
            for (const s of str) {
                this._watches.push(new Watch(object, s));
            }
        }
        else {
            this._watches.push(new WatchImpl(object, str));
        }
    }

    subscribeWrite(callback: WriteCallback) {
        for (const w of this._watches) {
            w.subscribeWrite(callback);
        }
    }
    unsubscribeWrite(callback: WriteCallback) {
        for (const w of this._watches) {
            w.unsubscribeWrite(callback);
        }
    }

    apply() {
        for (const w of this._watches) {
            w.apply();
        }
    }

    revoke() {
        for (const w of this._watches) {
            w.revoke();
        }
    }

    dispose() {
        for (const w of this._watches) {
            w.dispose();
        }
        this._watches.length = 0;
    }
}

const watchesKey = Symbol("needle:watches");
/** Subscribe to an object being written to
 * Currently supporting Vector3
 */
export function watchWrite(vec: Vector, cb: Function) {
    if (!vec[watchesKey]) {
        if (vec instanceof Vector2) {
            vec[watchesKey] = new Watch(vec, ["x", "y"]);
        }
        else if (vec instanceof Vector3) {
            vec[watchesKey] = new Watch(vec, ["x", "y", "z"]);
        }
        else if (vec instanceof Vector4 || vec instanceof Quaternion) {
            vec[watchesKey] = new Watch(vec, ["x", "y", "z", "w"]);
        }
        else {
            return false;
        }
    }
    vec[watchesKey].subscribeWrite(cb);
    return true;
}
export function unwatchWrite(vec: Vector, cb: Function) {
    if (!vec) return;
    const watch = vec[watchesKey];
    if (!watch) return;
    watch.unsubscribeWrite(cb);
};


declare global {
    interface NavigatorUAData {
        platform: string;
    }
    interface Navigator {
        userAgentData?: NavigatorUAData;
    }
}

/**
 * Utility functions to detect certain device types (mobile, desktop), browsers, or capabilities.
 * @category Utilities
 */
export namespace DeviceUtilities {

    let _isDesktop: boolean | undefined;
    /** @returns `true` for MacOS or Windows devices. `false` for Hololens and other headsets. */
    export function isDesktop() {
        if (_isDesktop !== undefined) return _isDesktop;
        const ua = window.navigator.userAgent;
        const standalone = /Windows|MacOS|Mac OS/.test(ua);
        const isHololens = /Windows NT/.test(ua) && /Edg/.test(ua) && !/Win64/.test(ua);
        return _isDesktop = standalone && !isHololens && !isiOS();
    }

    let _ismobile: boolean | undefined;
    /** @returns `true` if it's a phone or tablet */
    export function isMobileDevice() {
        if (_ismobile !== undefined) return _ismobile;
        // eslint-disable-next-line @typescript-eslint/no-deprecated
        if ((typeof window.orientation !== "undefined") || (navigator.userAgent.indexOf('IEMobile') !== -1)) {
            return _ismobile = true;
        }
        return _ismobile = /iPhone|iPad|iPod|Android|IEMobile/i.test(navigator.userAgent);
    }

    /** @deprecated use {@link isiPad} instead */
    export function isIPad() {
        return isiPad();
    }

    let __isiPad: boolean | undefined;
    /** @returns `true` if we're currently on an iPad */
    export function isiPad() {
        if (__isiPad !== undefined) return __isiPad;
        const userAgent = navigator.userAgent.toLowerCase();
        return __isiPad = /iPad/.test(navigator.userAgent) || userAgent.includes("macintosh") && "ontouchend" in document;
    }

    let __isAndroidDevice: boolean | undefined;
    /** @returns `true` if we're currently on an Android device */
    export function isAndroidDevice() {
        if (__isAndroidDevice !== undefined) return __isAndroidDevice;
        return __isAndroidDevice = /Android/.test(navigator.userAgent);
    }

    let __isMozillaXR: boolean | undefined;
    /** @returns `true` if we're currently using the Mozilla XR Browser (only available for iOS) */
    export function isMozillaXR() {
        if (__isMozillaXR !== undefined) return __isMozillaXR;
        return __isMozillaXR = /WebXRViewer\//i.test(navigator.userAgent);
    }

    let __isNeedleAppClip: boolean | undefined;
    /** @returns `true` if we're currently in the Needle App Clip */
    export function isNeedleAppClip() {
        if (__isNeedleAppClip !== undefined) return __isNeedleAppClip;
        return __isNeedleAppClip = /NeedleAppClip\//i.test(navigator.userAgent);
    }

    let __isMacOS: boolean | undefined;
    // https://developer.mozilla.org/en-US/docs/Web/API/Navigator/userAgentData
    /** @returns `true` for MacOS devices */
    export function isMacOS() {
        if (__isMacOS !== undefined) return __isMacOS;
        if (isiOS() || isiPad()) return __isMacOS = false;
        const userAgent = navigator.userAgent.toLowerCase();
        if (navigator.userAgentData) {
            // Use modern UA Client Hints API if available
            return __isMacOS = navigator.userAgentData.platform === 'macOS';
        } else {
            // Fallback to user agent string parsing
            return __isMacOS = userAgent.includes('mac os x') || userAgent.includes('macintosh');
        }
    }

    let __isVisionOS: boolean | undefined;
    /** @returns `true` for VisionOS devices */
    export function isVisionOS() {
        if (__isVisionOS !== undefined) return __isVisionOS;
        return __isVisionOS = (isiPad() && "xr" in navigator && supportsQuickLookAR());
    }

    let __isiOS: boolean | undefined;
    const iosDevices = ['iPad Simulator', 'iPhone Simulator', 'iPod Simulator', 'iPad', 'iPhone', 'iPod'];

    /** @returns `true` for mobile Apple devices like iPad, iPhone, iPod, Vision Pro, ... */
    export function isiOS() {
        if (__isiOS !== undefined) return __isiOS;
        // eslint-disable-next-line @typescript-eslint/no-deprecated
        return __isiOS = iosDevices.includes(navigator.platform)
            // iPad on iOS 13 detection
            || (navigator.userAgent.includes("Mac") && "ontouchend" in document)
    }

    let __isSafari: boolean | undefined;
    /** @returns `true` if we're currently on safari */
    export function isSafari() {
        if (__isSafari !== undefined) return __isSafari;
        __isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
        return __isSafari;
    }

    let __isQuest: boolean | undefined;
    /** @returns `true` for Meta Quest devices and browser. */
    export function isQuest() {
        if (__isQuest !== undefined) return __isQuest;
        return __isQuest = navigator.userAgent.includes("OculusBrowser");
    }

    let __supportsQuickLookAR: boolean | undefined;
    /** @returns `true` if the browser has `<a rel="ar">` support, which indicates USDZ QuickLook support. */
    export function supportsQuickLookAR() {
        if (__supportsQuickLookAR !== undefined) return __supportsQuickLookAR;
        const a = document.createElement("a") as HTMLAnchorElement;
        __supportsQuickLookAR = a.relList.supports("ar");
        return __supportsQuickLookAR;
    }

    /** @returns `true` if the user allowed to use the microphone */
    export async function microphonePermissionsGranted() {
        try {
            //@ts-ignore
            const res = await navigator.permissions.query({ name: 'microphone' });
            if (res.state === "denied") {
                return false;
            }
            return true;
        }
        catch (err) {
            console.error("Error querying `microphone` permissions.", err);
            return false;
        }
    }

    let __iOSVersion: string | null | undefined;
    export function getiOSVersion() {
        if (__iOSVersion !== undefined) return __iOSVersion;
        const match = navigator.userAgent.match(/iPhone OS (\d+_\d+)/);
        if (match) __iOSVersion = match[1].replace("_", ".");
        if (!__iOSVersion) {
            // Look for "(Macintosh;" or "(iPhone;" or "(iPad;" and then check Version/18.0
            const match2 = navigator.userAgent.match(/(?:\(Macintosh;|iPhone;|iPad;).*Version\/(\d+\.\d+)/);
            if (match2) __iOSVersion = match2[1];
        }
        // if we dont have any match we set it to null to avoid running the check again
        if (!__iOSVersion) {
            __iOSVersion = null;
        }
        return __iOSVersion;
    }

    let __chromeVersion: string | null | undefined;
    export function getChromeVersion() {
        if (__chromeVersion !== undefined) return __chromeVersion;
        const match = navigator.userAgent.match(/(?:CriOS|Chrome)\/(\d+\.\d+\.\d+\.\d+)/);
        if (match) {
            const result = match[1].replace("_", ".");
            __chromeVersion = result;
        }
        else __chromeVersion = null;
        return __chromeVersion;
    }

    let __safariVersion: string | null | undefined;
    export function getSafariVersion() {
        if (__safariVersion !== undefined) return __safariVersion;
        const match = navigator.userAgent.match(/Version\/(\d+\.\d+)/);
        if (match && isSafari()) {
            __safariVersion = match[1];
        } else __safariVersion = null;
        return __safariVersion;
    }
}

/**
 * @deprecated use {@link DeviceUtilities.isDesktop} instead
 */
export function isDesktop() {
    return DeviceUtilities.isDesktop();
}

/** 
 * @deprecated use {@link DeviceUtilities.isMobileDevice} instead
 */
export function isMobileDevice() {
    return DeviceUtilities.isMobileDevice();
}

/** @deprecated use {@link DeviceUtilities.isiPad} instead */
export function isIPad() {
    return DeviceUtilities.isiPad();
}

/** @deprecated use {@link DeviceUtilities.isiPad} instead */
export function isiPad() {
    return DeviceUtilities.isiPad();
}

/** @deprecated use {@link DeviceUtilities.isAndroidDevice} instead */
export function isAndroidDevice() {
    return DeviceUtilities.isAndroidDevice();
}

/** @deprecated use {@link DeviceUtilities.isMozillaXR} instead */
export function isMozillaXR() {
    return DeviceUtilities.isMozillaXR();
}

// https://developer.mozilla.org/en-US/docs/Web/API/Navigator/userAgentData
/** @deprecated use {@link DeviceUtilities.isMacOS} instead */
export function isMacOS() {
    return DeviceUtilities.isMacOS();
}

/** @deprecated use {@link DeviceUtilities.isiOS} instead */
export function isiOS() {
    return DeviceUtilities.isiOS();
}

/** @deprecated use {@link DeviceUtilities.isSafari} instead */
export function isSafari() {
    return DeviceUtilities.isSafari();
}

/** @deprecated use {@link DeviceUtilities.isQuest} instead */
export function isQuest() {
    return DeviceUtilities.isQuest();
}

/** @deprecated use {@link DeviceUtilities.microphonePermissionsGranted} instead */
export async function microphonePermissionsGranted() {
    return DeviceUtilities.microphonePermissionsGranted();
}



declare type AttributeChangeCallback = (value: string | null) => void;
declare type HtmlElementExtra = {
    observer: MutationObserver,
    attributeChangedListeners: Map<string, Array<AttributeChangeCallback>>,
}
const mutationObserverMap = new WeakMap<HTMLElement, HtmlElementExtra>();

/** 
 * Register a callback when an {@link HTMLElement} attribute changes.   
 * This is used, for example, by the Skybox component to watch for changes to the environment-* and skybox-* attributes.
 * @returns A function that can be used to unregister the callback
*/
export function addAttributeChangeCallback(domElement: HTMLElement, name: string, callback: AttributeChangeCallback) {
    if (!mutationObserverMap.get(domElement)) {
        const observer = new MutationObserver((mutations) => {
            handleMutations(domElement, mutations);
        });
        mutationObserverMap.set(domElement, {
            observer,
            attributeChangedListeners: new Map<string, Array<AttributeChangeCallback>>(),
        });
        observer.observe(domElement, { attributes: true });
    }

    const listeners = mutationObserverMap.get(domElement)!.attributeChangedListeners;
    if (!listeners.has(name)) {
        listeners.set(name, []);
    }
    listeners.get(name)!.push(callback);
    return () => {
        removeAttributeChangeCallback(domElement, name, callback);
    }
};

/** 
 * Unregister a callback previously registered with {@link addAttributeChangeCallback}.   
*/
export function removeAttributeChangeCallback(domElement: HTMLElement, name: string, callback: AttributeChangeCallback) {
    if (!mutationObserverMap.get(domElement)) return;
    const listeners = mutationObserverMap.get(domElement)!.attributeChangedListeners;
    if (!listeners.has(name)) return;
    const arr = listeners.get(name);
    const index = arr!.indexOf(callback);
    if (index === -1) return;
    arr!.splice(index, 1);
    if (arr!.length <= 0) {
        listeners.delete(name);
        const entry = mutationObserverMap.get(domElement);
        entry?.observer.disconnect();
        mutationObserverMap.delete(domElement);
    }
}

function handleMutations(domElement: HTMLElement, mutations: Array<MutationRecord>) {
    const listeners = mutationObserverMap.get(domElement)!.attributeChangedListeners;
    for (const mut of mutations) {
        if (mut.type === "attributes") {
            const name = mut.attributeName!;
            const value = domElement.getAttribute(name);
            if (listeners.has(name)) {
                for (const listener of listeners.get(name)!) {
                    listener(value);
                }
            }
        }
    }

}


/** Used by `PromiseAllWithErrors` */
export class PromiseErrorResult {
    readonly reason: string;
    constructor(reason: string) {
        this.reason = reason;
    }
}

/** Can be used to simplify Promise error handling and if errors are acceptable. 
 * Promise.all will just fail if any of the provided promises fails and not return or cancel pending promises or partial results
 * Using Promise.allSettled (or this method) instead will return a result for each promise and not automatically fail if any of the promises fails.
 * Instead it will return a promise containing information if any of the promises failed
 * and the actual results will be available as `results` array
 **/
export async function PromiseAllWithErrors<T>(promise: Promise<T>[]): Promise<{
    anyFailed: boolean,
    results: Array<T | PromiseErrorResult>
}> {
    const results = await Promise.allSettled(promise).catch(err => {
        return [
            new PromiseErrorResult(err.message)
        ];
    })
    let anyFailed: boolean = false;
    const res = results.map(x => {
        if ("value" in x) return x.value;
        anyFailed = true;
        return new PromiseErrorResult(x.reason);
    });
    return {
        anyFailed: anyFailed,
        results: res,
    };
}




