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, IContext,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);
}

declare type SyncDestroyCallback = (guid: string, object: Object3D) => void;
const _onSyncDestroyCallbacks: SyncDestroyCallback[] = [];

/**
 * Register a callback that fires when a remote `syncDestroy` event is received.
 * The callback receives the guid and the resolved Object3D (or null if not found in the scene).
 * The callback fires **before** the object is destroyed, so you can still access its state.
 * @param callback Called with the guid and the Object3D about to be destroyed
 * @returns An unsubscribe function
 * @category Networking
 * @example
 * ```ts
 * const unsub = onSyncDestroy((guid, obj) => {
 *   console.log("Remote object destroyed:", guid, obj?.name);
 * });
 * // later: unsub();
 * ```
 */
export function onSyncDestroy(callback: SyncDestroyCallback): () => void {
    _onSyncDestroyCallbacks.push(callback);
    return () => {
        const idx = _onSyncDestroyCallbacks.indexOf(callback);
        if (idx >= 0) _onSyncDestroyCallbacks.splice(idx, 1);
    };
}

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) {
            // Notify listeners before destroying
            for (const cb of _onSyncDestroyCallbacks) {
                try { cb(data.guid, obj as Object3D); }
                catch (err) { console.error("Error in onSyncDestroy callback", err); }
            }
            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}
 * @category Networking
 * @see {@link syncInstantiate} - Instantiate objects across the network
 */
export type SyncInstantiateOptions = IInstantiateOptions & Pick<IModel, "deleteOnDisconnect">;

// #region Sync Instantiate

/**
 * Callback type for {@link onSyncInstantiate}
 * @param instance The instantiated object
 * @param model The network model data sent with the instantiate event
 * @param context The network context in which the instantiate event was received
 * @category Networking
 */
declare type SyncInstantiateCallback = (instance: GameObject, model: NewInstanceModel, context: IContext) => void;
/** Registered callbacks for remote instantiation events */
const _onSyncInstantiateCallbacks: SyncInstantiateCallback[] = [];

/**
 * Register a callback that fires when a remote `syncInstantiate` object is created on this client.
 * Use this to get references to objects spawned by other users.
 * @param callback Called with the instantiated Object3D, the network model data, and the Needle Engine context in which the instantiate event was received
 * @returns An unsubscribe function
 * @category Networking
 * @example
 * ```ts
 * const unsub = onSyncInstantiate((instance, model, context) => {
 *   console.log("Remote object created:", instance.name, model.originalGuid, context);
 * });
 * // later: unsub();
 * ```
 * @see {@link syncInstantiate} - Instantiate objects across the network
 * @see {@link syncDestroy} - Destroy objects across the network
 */
export function onSyncInstantiate(callback: SyncInstantiateCallback): () => void {
    _onSyncInstantiateCallbacks.push(callback);
    return () => {
        const idx = _onSyncInstantiateCallbacks.indexOf(callback);
        if (idx >= 0) _onSyncInstantiateCallbacks.splice(idx, 1);
    };
}

/**
 * Instantiate an object across the network. The object is cloned locally and a network message
 * is sent so all connected clients create the same clone. Late joiners receive the message
 * via room state replay (unless `deleteOnDisconnect` is set or `save` is false).
 *
 * ## How it works internally
 * 1. The prefab is cloned locally using a seeded {@link InstantiateIdProvider}
 * 2. The seed ensures all clients generate **identical deterministic guids** for the clone
 *    and all its children — no need to send individual guids over the network
 * 3. A {@link NewInstanceModel} message is sent containing the prefab's `originalGuid`,
 *    the clone's `guid`, the `seed`, and transform data
 * 4. On receiving clients, the prefab is resolved via {@link registerPrefabProvider} or
 *    by searching the scene for an object with matching guid, then cloned with the same seed
 *
 * ## Runtime-created prefabs (no GLB)
 * If the object has a `guid` but no prefab provider is registered for it, `syncInstantiate`
 * will **auto-register** the object as a prefab provider. This means for code-only prefabs
 * you just need to set a `guid` — no manual `registerPrefabProvider` call needed, as long as
 * all clients run the same setup code that creates the same prefab with the same guid.
 *
 * @param object The object to instantiate. Must have a `guid` property (set one for runtime objects).
 * @param opts Options for the instantiation, including the network context to send the instantiate event to
 * @param hostData Optional data about a file to download when this object is instantiated (e.g. when instantiated via file drop)
 * @param save When false, the state of this instance will not be saved in the networking backend. Default is true.
 * @returns The instantiated object, or null if instantiation failed (e.g. missing guid or network context)
 * @see {@link syncDestroy} - Destroy objects across the network
 * @see {@link onSyncInstantiate} - Register a callback to get references to remotely instantiated objects
 * @see {@link registerPrefabProvider} - Manually register a prefab provider (auto-registered by syncInstantiate)
 * @see {@link unregisterPrefabProvider} - Remove a registered prefab provider
 * @category Networking
 *
 * @example Basic usage with a runtime-created prefab
 * ```ts
 * const cookie = ObjectUtils.createPrimitive("Cube", { color: 0xff8c00 });
 * cookie.guid = "cookie-prefab";
 * // No need to call registerPrefabProvider — syncInstantiate auto-registers it
 * syncInstantiate(cookie, { parent: ctx.scene, deleteOnDisconnect: false });
 * ```
 *
 * @example With deterministic seed (advanced)
 * ```ts
 * const idProvider = new InstantiateIdProvider("my-seed");
 * const instance = syncInstantiate(prefab, { context, idProvider });
 * ```
 * The seed generates deterministic guids via UUID v5, so all clients produce identical
 * identifiers for the clone and its children without sending them over the network.
 */
export function syncInstantiate(object: GameObject | Object3D, opts: SyncInstantiateOptions, hostData?: HostData, save?: boolean): GameObject | null {

    const obj: GameObject = object as GameObject;

    if (!obj.guid) {
        // Auto-assign guid from object name if available.
        // The name must be the same on all clients (which it is when both run the same setup code).
        if (obj.name) {
            obj.guid = obj.name;
        }
        else {
            console.error("[syncInstantiate] Can not instantiate: a 'guid' is required. For runtime-created objects, either set a name (`obj.name = 'my-prefab'`) or a guid (`obj.guid = 'my-prefab-id'`). The identifier must be the same on all clients.", obj);
            return null;
        }
    }

    // Auto-register the prefab provider if none exists for this guid.
    // This allows runtime-created objects to work with syncInstantiate without
    // manual Prefabs.register calls, as long as all clients create the
    // same prefab with the same guid in their setup code.
    if (!Prefabs.has(obj.guid)) {
        Prefabs.register(obj.guid, async () => obj);
    }

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

    if (!opts.context) {
        console.error("[syncInstantiate] Missing network instantiate options / reference to network connection in sync instantiate. Please pass in the Needle Engine context.");
        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);
            // Notify listeners about the remote instantiation
            for (const cb of _onSyncInstantiateCallbacks) {
                try { cb(inst, model, context); }
                catch (err) { console.error("Error in onSyncInstantiate callback", err); }
            }
        }
    });
    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 () => {
        cb1();
        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 { type PrefabProviderCallback,Prefabs } from "./engine_networking_prefabs.js";
import { Prefabs } from "./engine_networking_prefabs.js";

/**
 * Register a prefab provider. Forwards to {@link Prefabs.register}.
 * @category Networking
 */
export function registerPrefabProvider(key: string, fn: (guid: string) => Promise<Object3D | null>) {
    Prefabs.register(key, fn);
}

/**
 * Unregister a prefab provider. Forwards to {@link Prefabs.unregister}.
 * @category Networking
 */
export function unregisterPrefabProvider(key: string) {
    Prefabs.unregister(key);
}

async function tryResolvePrefab(guid: string, obj: Object3D): Promise<Object3D | null> {
    const res = await Prefabs.resolve(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);
//         }
//     }
// }