import { Euler, Object3D, Quaternion, Scene, Vector3 } from "three";
// https://github.com/uuidjs/uuid
// v5 takes string and namespace
import { v5 } from 'uuid';

import { ContextEvent, ContextRegistry } from "../engine/engine_context_registry.js";
import { isDevEnvironment } from "./debug/index.js";
import { destroy, findByGuid, type IInstantiateOptions, instantiate } from "./engine_gameobject.js";
import { InstantiateOptions } from "./engine_gameobject.js";
import type { INetworkConnection } from "./engine_networking_types.js";
import type { IModel } from "./engine_networking_types.js";
import { SendQueue } from "./engine_networking_types.js";
import { Context } from "./engine_setup.js"
import type { IComponent as Component, IGameObject as GameObject } from "./engine_types.js"
import type { UIDProvider } from "./engine_types.js";
import * as utils from "./engine_utils.js"



ContextRegistry.registerCallback(ContextEvent.ContextCreated, evt => {
    const context = evt.context as Context;
    beginListenInstantiate(context);
    beginListenDestroy(context);
});



const debug = utils.getParam("debugcomponents");


const ID_NAMESPACE = 'eff8ba80-635d-11ec-90d6-0242ac120003';

export class InstantiateIdProvider implements UIDProvider {

    get seed() {
        return this._seed;
    }

    set seed(val: number) {
        this._seed = val;
    }

    private _originalSeed: number;
    private _seed: number;

    constructor(seed: string | number) {
        if (typeof seed === "string") {
            seed = InstantiateIdProvider.hash(seed);
        }
        this._originalSeed = seed;
        this._seed = seed;
    }

    reset() {
        this._seed = this._originalSeed;
    }

    generateUUID(str?: string): string {
        if (typeof str === "string") {
            return v5(str, ID_NAMESPACE);
        }
        const s = this._seed;
        this._seed -= 1;
        // console.log(s);
        return v5(s.toString(), ID_NAMESPACE);
    }

    initialize(strOrNumber: string | number) {
        if (typeof strOrNumber === "string") {
            this._seed = InstantiateIdProvider.hash(strOrNumber);
        }
        else {
            this._seed = strOrNumber;
        }
    }

    static createFromString(str: string) {
        return new InstantiateIdProvider(this.hash(str));
    }

    private static hash(str: string): number {
        let hash = 0;
        for (let i = 0; i < str.length; i++) {
            hash = str.charCodeAt(i) + ((hash << 5) - hash);
        }
        return hash;
    }
}

export enum InstantiateEvent {
    NewInstanceCreated = "new-instance-created",
    InstanceDestroyed = "instance-destroyed",
}


class DestroyInstanceModel implements IModel {
    guid: string;
    dontSave?: boolean;
    constructor(guid: string) {
        this.guid = guid;
    }
}

export interface IBeforeNetworkedDestroy {
    onBeforeNetworkedDestroy(networkIds: string[]): void;
}

declare type SyncDestroyOptions = {
    /** When true the state will be saved in the networking backend */
    saveInRoom?: boolean;
}

/**
 * Destroy an object across the network. See also {@link syncInstantiate}.
 * @param obj The object or component to be destroyed
 * @param con The network connection to send the destroy event to
 * @param recursive If true, all children will be destroyed as well. Default is true
 * @param opts Options for the destroy operation
 * @category Networking
 */
export function syncDestroy(obj: GameObject | Component, con: INetworkConnection, recursive: boolean = true, opts?: SyncDestroyOptions) {
    if (!obj) return;
    const go = obj as GameObject;
    destroy(obj, recursive);

    if (!con) {
        console.warn("Can not send destroy: No networking connection provided", obj.guid);
        return;
    }

    if (!con.isConnected) {
        if (isDevEnvironment())
            console.debug("Can not send destroy: not connected", obj.guid);
        return;
    }

    let guid: string | undefined | null = obj.guid;
    if (!guid && go.uuid) {
        guid = go.uuid;
    }
    if (!guid) {
        console.warn("Can not send destroy: failed to find guid", obj);
        return;
    }

    sendDestroyed(guid, con, opts);
}

export function sendDestroyed(guid: string, con: INetworkConnection, opts?: SyncDestroyOptions) {
    const model = new DestroyInstanceModel(guid);
    if (opts?.saveInRoom === false) {
        model.dontSave = true;
    }
    con.send(InstantiateEvent.InstanceDestroyed, model, SendQueue.Queued);
}

export function beginListenDestroy(context: Context) {
    context.connection.beginListen(InstantiateEvent.InstanceDestroyed, (data: DestroyInstanceModel) => {
        if (debug)
            console.log("[Remote] Destroyed", context.scene, data);
        // TODO: create global lookup table for guids
        const obj = findByGuid(data.guid, context.scene);
        if (obj) destroy(obj);
    });
}


// 

/** 
 * When a file is instantiated via some server (e.g. via file drop) we also want to send the info where the file can be downloaded. 
 * @internal 
 */
export class HostData {
    /** File to download */
    filename: string;
    /** Checksum to verify its the correct file */
    hash: string;
    /** Expected size of the referenced file and its dependencies */
    size: number;

    constructor(filename: string, hash: string, size: number) {
        this.filename = filename;
        this.hash = hash;
        this.size = size;
    }
}

export class NewInstanceModel implements IModel {
    guid: string;
    originalGuid: string;
    seed: number | undefined;
    visible: boolean | undefined;
    hostData: HostData | undefined;
    dontSave?: boolean | undefined;

    parent: string | undefined;
    position: { x: number, y: number, z: number } | undefined;
    rotation: { x: number, y: number, z: number, w: number } | undefined;
    scale: { x: number, y: number, z: number } | undefined;

    /** Set to true to prevent this model from being instantiated */
    preventCreation?: boolean = undefined;

    /**
     * When set this will delete the server state when the user disconnects
     */
    deleteStateOnDisconnect?: boolean | undefined;

    constructor(originalGuid: string, newGuid: string) {
        this.originalGuid = originalGuid;
        this.guid = newGuid;
    }
}

/**
 * Instantiation options for {@link syncInstantiate}
 */
export type SyncInstantiateOptions = IInstantiateOptions & Pick<IModel, "deleteOnDisconnect">;

// #region Sync Instantiate
/**
 * Instantiate an object across the network. See also {@link syncDestroy}.
 * @category Networking
 */
export function syncInstantiate(object: GameObject | Object3D, opts: SyncInstantiateOptions, hostData?: HostData, save?: boolean): GameObject | null {

    const obj: GameObject = object as GameObject;

    if (!obj.guid) {
        console.warn("Can not instantiate: No guid", obj);
        return null;
    }

    if (!opts.context) opts.context = Context.Current;

    if (!opts.context) {
        console.error("Missing network instantiate options / reference to network connection in sync instantiate");
        return null;
    }

    const originalOpts = opts ? { ...opts } : null;
    const { instance, seed } = instantiateSeeded(obj, opts);
    if (instance) {
        const go = instance as GameObject;
        // if (go.guid) {
        //     const listener = GameObject.addNewComponent(go, DestroyListener);
        //     listener.target = go;
        // }
        if (go.guid) {

            if (debug) console.log("[Local] new instance", "gameobject:", instance?.guid);
            const model = new NewInstanceModel(obj.guid, go.guid);
            model.seed = seed;
            if (opts.deleteOnDisconnect === true)
                model.deleteStateOnDisconnect = true;
            if (originalOpts) {
                if (originalOpts.position) {
                    if (Array.isArray(originalOpts.position)) {
                        model.position = { x: originalOpts.position[0], y: originalOpts.position[1], z: originalOpts.position[2] };
                    }
                    else model.position = { x: originalOpts.position.x, y: originalOpts.position.y, z: originalOpts.position.z };
                }
                if (originalOpts.rotation) {
                    if (originalOpts.rotation instanceof Euler) {
                        originalOpts.rotation = new Quaternion().setFromEuler(originalOpts.rotation);
                    }
                    else if (originalOpts.rotation instanceof Array) {
                        originalOpts.rotation = new Quaternion().fromArray(originalOpts.rotation);
                    }
                    model.rotation = { x: originalOpts.rotation.x, y: originalOpts.rotation.y, z: originalOpts.rotation.z, w: originalOpts.rotation.w };
                }
                if (originalOpts.scale) {
                    if (Array.isArray(originalOpts.scale)) {
                        model.scale = { x: originalOpts.scale[0], y: originalOpts.scale[1], z: originalOpts.scale[2] };
                    }
                    else model.scale = { x: originalOpts.scale.x, y: originalOpts.scale.y, z: originalOpts.scale.z };
                }
            }
            if (!model.position)
                model.position = { x: go.position.x, y: go.position.y, z: go.position.z };
            if (!model.rotation)
                model.rotation = { x: go.quaternion.x, y: go.quaternion.y, z: go.quaternion.z, w: go.quaternion.w };
            if (!model.scale)
                model.scale = { x: go.scale.x, y: go.scale.y, z: go.scale.z };

            model.visible = obj.visible;
            if (originalOpts?.parent) {
                if (typeof originalOpts.parent === "string")
                    model.parent = originalOpts.parent;
                else if (originalOpts.parent?.["guid"]) {
                    model.parent = originalOpts.parent["guid"]
                }
                else if (originalOpts.parent instanceof Scene) {
                    model.parent = "scene";
                }
                else console.warn("Unsupported parent type in sync instantiate options: " + originalOpts.parent?.name);
            }
            model.hostData = hostData;
            if (save === false) model.dontSave = true;
            const con = opts?.context?.connection;
            if (!con && isDevEnvironment())
                console.debug("Object will be instantiated but it will not be synced: not connected", obj.guid);

            if (opts.context.connection.isInRoom) syncedInstantiated.push(new WeakRef(go));
            opts?.context?.connection.send(InstantiateEvent.NewInstanceCreated, model);
        }
        else console.warn("Missing guid, can not send new instance event", go);
    }
    return instance;
}

export function generateSeed(): number {
    return Math.random() * 9_999_999;// Number.MAX_VALUE;;
}

const syncedInstantiated = new Array<WeakRef<Object3D>>();

export function beginListenInstantiate(context: Context) {


    const cb1 = context.connection.beginListen(InstantiateEvent.NewInstanceCreated, async (model: NewInstanceModel) => {
        const obj: GameObject | null = await tryResolvePrefab(model.originalGuid, context.scene) as GameObject;
        if (model.preventCreation === true) {
            return;
        }
        if (!obj) {
            console.warn("could not find object that was instantiated: " + model.guid);
            return;
        }
        const options = new InstantiateOptions();
        if (model.position)
            options.position = new Vector3(model.position.x, model.position.y, model.position.z);
        if (model.rotation)
            options.rotation = new Quaternion(model.rotation.x, model.rotation.y, model.rotation.z, model.rotation.w);
        if (model.scale)
            options.scale = new Vector3(model.scale.x, model.scale.y, model.scale.z);
        options.parent = model.parent;
        if (model.seed)
            options.idProvider = new InstantiateIdProvider(model.seed);
        options.visible = model.visible;
        options.context = context;
        if (debug && context.alias)
            console.log("[Remote] instantiate in: " + context.alias);
        const inst = instantiate(obj as GameObject, options);
        syncedInstantiated.push(new WeakRef(inst));

        if (inst) {
            if (model.parent === "scene")
                context.scene.add(inst);
            if (debug)
                console.log("[Remote] new instance", "gameobject:", inst?.guid, obj);
        }
    });
    const cb2 = context.connection.beginListen("left-room", () => {
        if (syncedInstantiated.length > 0)
            console.debug(`Left networking room, cleaning up ${syncedInstantiated.length} instantiated objects`);
        for (const prev of syncedInstantiated) {
            const obj = prev.deref();
            if (obj) obj.destroy();
        }
        syncedInstantiated.length = 0;
    });

    return () => {
        context.connection.stopListen(InstantiateEvent.NewInstanceCreated, cb1);
        context.connection.stopListen("left-room", cb2);
    }
}


function instantiateSeeded(obj: GameObject, opts: IInstantiateOptions | null): { instance: GameObject | null, seed: number } {
    const seed = generateSeed();
    const options = opts ?? new InstantiateOptions();
    options.idProvider = new InstantiateIdProvider(seed);
    const instance = instantiate(obj, options);
    return { seed: seed, instance: instance };
}

export declare type PrefabProviderCallback = (guid: string) => Promise<Object3D | null>;

const registeredPrefabProviders: { [key: string]: PrefabProviderCallback } = {};

//** e.g. provide a function that can return a instantiated object when instantiation event is received */
export function registerPrefabProvider(key: string, fn: PrefabProviderCallback) {
    registeredPrefabProviders[key] = fn;
}

async function tryResolvePrefab(guid: string, obj: Object3D): Promise<Object3D | null> {
    const prov = registeredPrefabProviders[guid];
    if (prov !== null && prov !== undefined) {
        const res = await prov(guid);
        if (res) return res;
    }
    return tryFindObjectByGuid(guid, obj) as Object3D;
}

function tryFindObjectByGuid(guid: string, obj: Object3D): Object3D | null {
    if (obj === null) return null;
    if (!guid) return null;
    if (obj["guid"] === guid) {
        return obj;
    }

    if (obj.children) {
        for (const ch of obj.children) {
            const found = tryFindObjectByGuid(guid, ch);
            if (found) {
                return found;
            }
        }
    }

    return null;
}


// class DestroyListener extends Behaviour {

//     target: GameObject | Component | null = null;

//     private destroyCallback: any;

//     awake(): void {
//         if (!this.target) {
//             console.log("Missing target to watch", this);
//             return;
//         }
//         this.destroyCallback = this.onObjectDestroyed.bind(this);
//         this.context.connection.beginListen(InstantiateEvent.InstanceDestroyed, this.destroyCallback);
//     }

//     onDestroy(): void {
//         this.context.connection.stopListening(InstantiateEvent.InstanceDestroyed, this.destroyCallback);
//         if (this.target && this.target.guid && this.gameObject.guid === this.target.guid) {
//             sendDestroyed(this.target.guid, this.context.connection);
//         }
//     }

//     private onObjectDestroyed(evt: DestroyInstanceModel) {
//         if (evt.guid === this.target?.guid) {
//             if (debug)
//                 console.log("RECEIVED destroyed event", evt.guid);
//             GameObject.destroy(this.target);
//         }
//     }
// }