import { createLoaders } from "@needle-tools/gltf-progressive";
import { BoxGeometry, BufferGeometry, Color, ColorRepresentation, CylinderGeometry, DoubleSide, ExtrudeGeometry, Group, Material, Mesh, MeshBasicMaterial, MeshStandardMaterial, Object3D, PlaneGeometry, Shape, SphereGeometry, Sprite, SpriteMaterial, Texture, Vector2 } from "three"
import { TextGeometry } from "three/examples/jsm/geometries/TextGeometry.js";
import { Font, FontLoader } from "three/examples/jsm/loaders/FontLoader.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";

import type { Vec3 } from "./engine_types.js";

export enum PrimitiveType {
    /**
     * A quad with a width and height of 1 facing the positive Z axis
     */
    Quad = 0,
    /**
     * A cube with a width, height, and depth of 1
     */
    Cube = 1,
    Sphere = 2,
    Cylinder = 3,
    RoundedCube = 10,
}
export type PrimitiveTypeNames = keyof typeof PrimitiveType;

/**
 * Options to create an object. Used by {@link ObjectUtils.createPrimitive}
 */
export type ObjectOptions = {
    /**
     * The parent object to add the created object to
     */
    parent?: Object3D,
    /**
     * The name of the object
     */
    name?: string,
    /** The material to apply to the object */
    material?: Material,
    /** The color of the object. This color will only be used if no material is provided */
    color?: ColorRepresentation,
    /** The texture will applied to the material's main texture slot e.g. `material.map` if any is passed in */
    texture?: Texture,
    /**
     * The position of the object in local space
     */
    position?: Partial<Vec3> | [number, number, number],
    /** The rotation of the object in local space */
    rotation?: Partial<Vec3> | [number, number, number],
    /**
     * The scale of the object in local space
     */
    scale?: Partial<Vec3> | number | [number, number, number],
    /**
     * If the object should receive shadows
     * @default true
     */
    receiveShadow?: boolean,
    /**
     * If the object should cast shadows
     * @default true
     */
    castShadow?: boolean,
}

/**
 * Options to create a 3D text object. Used by {@link ObjectUtils.createText}
 */
export type TextOptions = Omit<ObjectOptions, "texture"> & {
    /**
     * Optional: The font to use for the text. If not provided, the default font will be used
     */
    font?: Font,
    /**
     * If the font is not provided, the familyFamily can be used to load a font from the default list
     */
    familyFamily?: "OpenSans" | "Helvetiker";// "Optimer" | "Gentilis" | "DroidSans"
    /**
     * Optional: The depth of the text.
     * @default .1
     */
    depth?: number;
    /**
     * Optional: If the text should have a bevel effect
     * @default false
    */
    bevel?: boolean;
    /**
     * Invoked when the font geometry is loaded
     */
    onGeometry?: (obj: Mesh) => void;
}

/**
 * Utility class to create primitive objects
 * @example
 * ```typescript
 * const cube = ObjectUtils.createPrimitive("Cube", { name: "Cube", position: { x: 0, y: 0, z: 0 } });
 * ```
 */
export class ObjectUtils {

    /**
     * Creates a 3D text object
     * @param text The text to display
     * @param opts Options to create the object
     */
    static createText(text: string, opts?: TextOptions): Mesh {

        let geometry: BufferGeometry | null = null;
        const font: Font | Promise<Font> = opts?.font || loadFont(opts?.familyFamily || null);

        if (font instanceof Font) {
            geometry = this.#createTextGeometry(text, font, opts);
        }
        else if (geometry == null) {
            geometry = new BufferGeometry();
        }
        const color = opts?.color || 0xffffff;
        const mesh = new Mesh(geometry, opts?.material ?? new MeshStandardMaterial({ color: color }));
        this.applyDefaultObjectOptions(mesh, opts);
        if (font instanceof Promise) {
            font.then(res => {
                mesh.geometry = this.#createTextGeometry(text, res, opts);
                if (opts?.onGeometry) opts.onGeometry(mesh);
            });
        }
        else {
            if (opts?.onGeometry) opts.onGeometry(mesh);
        }
        return mesh;
    }
    static #createTextGeometry(text: string, font: Font, opts?: TextOptions) {
        const depth = opts?.depth || .1;
        const geo = new TextGeometry(text, {
            font,
            size: 1,
            depth: depth,
            height: depth,
            bevelEnabled: opts?.bevel || false,
            bevelThickness: .01,
            bevelOffset: .01,
            bevelSize: .01,
        });
        return geo;
    }

    /**
     * Creates an occluder object that only render depth but not color
     * @param type The type of primitive to create
     * @returns The created object
     */
    static createOccluder(type: PrimitiveTypeNames): Mesh {
        const occluderMaterial = new MeshBasicMaterial({ colorWrite: false, depthWrite: true, side: DoubleSide });
        return this.createPrimitive(type, { material: occluderMaterial });
    }

    /** Creates a primitive object like a Cube or Sphere 
     * @param type The type of primitive to create
     * @param opts Options to create the object
     * @returns The created object
    */
    static createPrimitive(type: "ShaderBall", opts?: ObjectOptions): Group;
    static createPrimitive(type: PrimitiveType | PrimitiveTypeNames, opts?: ObjectOptions): Mesh;
    static createPrimitive(type: PrimitiveType | PrimitiveTypeNames | "ShaderBall", opts?: ObjectOptions): Mesh | Group {
        let obj: Mesh | Group;
        const color = opts?.color || 0xffffff;
        switch (type) {
            case "Quad":
            case PrimitiveType.Quad:
                {
                    const quadGeo = new PlaneGeometry(1, 1, 1, 1);
                    const mat = opts?.material ?? new MeshStandardMaterial({ color: color });
                    if (opts?.texture && "map" in mat) mat.map = opts.texture;
                    obj = new Mesh(quadGeo, mat);
                    obj.name = "Quad";
                }
                break;
            case "Cube":
            case PrimitiveType.Cube:
                {
                    const boxGeo = new BoxGeometry(1, 1, 1);
                    const mat = opts?.material ?? new MeshStandardMaterial({ color: color });
                    if (opts?.texture && "map" in mat) mat.map = opts.texture;
                    obj = new Mesh(boxGeo, mat);
                    obj.name = "Cube";
                }
                break;
            case PrimitiveType.RoundedCube:
            case "RoundedCube":
                {
                    const boxGeo = createBoxWithRoundedEdges(1, 1, 1, .1, 2);
                    const mat = opts?.material ?? new MeshStandardMaterial({ color: color });
                    if (opts?.texture && "map" in mat) mat.map = opts.texture;
                    obj = new Mesh(boxGeo, mat);
                    obj.name = "RoundedCube";
                }
                break;
            case "Sphere":
            case PrimitiveType.Sphere:
                {
                    const sphereGeo = new SphereGeometry(.5, 16, 16);
                    const mat = opts?.material ?? new MeshStandardMaterial({ color: color });
                    if (opts?.texture && "map" in mat) mat.map = opts.texture;
                    obj = new Mesh(sphereGeo, mat);
                    obj.name = "Sphere";
                }
                break;

            case "Cylinder":
            case PrimitiveType.Cylinder:
                {
                    const geo = new CylinderGeometry(.5, .5, 1, 32);
                    const mat = opts?.material ?? new MeshStandardMaterial({ color: color });
                    if (opts?.texture && "map" in mat) mat.map = opts.texture;
                    obj = new Mesh(geo, mat);
                    obj.name = "Cylinder";
                }
                break;

            case "ShaderBall":
                {
                    obj = new Group();
                    obj.name = "ShaderBall";
                    loadShaderball(obj, opts);
                }
                break;
        }
        this.applyDefaultObjectOptions(obj, opts);
        return obj;
    }

    /**
     * Creates a Sprite object  
     * @param opts Options to create the object
     * @returns The created object
     */
    static createSprite(opts?: Omit<ObjectOptions, "material">): Sprite {
        const color = 0xffffff;
        const mat = new SpriteMaterial({ color: color });
        if (opts?.texture && "map" in mat) mat.map = opts.texture;
        const sprite = new Sprite(mat);
        this.applyDefaultObjectOptions(sprite, opts);
        return sprite;
    }

    private static applyDefaultObjectOptions(obj: Object3D, opts?: ObjectOptions) {
        obj.receiveShadow = true;
        obj.castShadow = true;

        if (opts?.name)
            obj.name = opts.name;
        if (opts?.position) {
            if (Array.isArray(opts.position))
                obj.position.set(opts.position[0], opts.position[1], opts.position[2]);
            else {
                obj.position.set(opts.position.x || 0, opts.position.y || 0, opts.position.z || 0);
            }
        }
        if (opts?.rotation) {
            if (Array.isArray(opts.rotation))
                obj.rotation.set(opts.rotation[0], opts.rotation[1], opts.rotation[2]);
            else
                obj.rotation.set(opts.rotation.x || 0, opts.rotation.y || 0, opts.rotation.z || 0);
        }
        if (opts?.scale) {
            if (typeof opts.scale === "number")
                obj.scale.set(opts.scale, opts.scale, opts.scale);
            else if (Array.isArray(opts.scale)) {
                obj.scale.set(opts.scale[0], opts.scale[1], opts.scale[2]);
            }
            else {
                obj.scale.set(opts.scale.x || 1, opts.scale.y || 1, opts.scale.z || 1);

            }
        }

        if (opts?.receiveShadow != undefined) {
            obj.receiveShadow = opts.receiveShadow;
        }
        if (opts?.castShadow != undefined) {
            obj.castShadow = opts.castShadow;
        }

        if (opts?.parent) {
            opts.parent.add(obj);
        }
    }
}

function createBoxWithRoundedEdges(width: number, height: number, _depth: number, radius0: number, smoothness: number) {
    const shape = new Shape();
    const eps = 0.00001;
    const radius = radius0 - eps;
    shape.absarc(eps, eps, eps, -Math.PI / 2, -Math.PI, true);
    shape.absarc(eps, height - radius * 2, eps, Math.PI, Math.PI / 2, true);
    shape.absarc(width - radius * 2, height - radius * 2, eps, Math.PI / 2, 0, true);
    shape.absarc(width - radius * 2, eps, eps, 0, -Math.PI / 2, true);
    const geometry = new ExtrudeGeometry(shape, {
        bevelEnabled: true,
        bevelSegments: smoothness * 2,
        steps: 1,
        bevelSize: radius,
        bevelThickness: radius0,
        curveSegments: smoothness,
        UVGenerator: {
            generateTopUV: (_, vertices: number[]) => {
                const uvs: Vector2[] = [];
                for (let i = 0; i < vertices.length; i += 3) {
                    uvs.push(new Vector2(vertices[i] / width, vertices[i + 1] / height));
                }
                return uvs;
            },
            generateSideWallUV: (_, vertices: number[], indexA: number, indexB: number, indexC: number, indexD: number) => {
                const uvs: Vector2[] = [];
                uvs.push(new Vector2(vertices[indexA] / width, vertices[indexA + 1] / height));
                uvs.push(new Vector2(vertices[indexB] / width, vertices[indexB + 1] / height));
                uvs.push(new Vector2(vertices[indexC] / width, vertices[indexC + 1] / height));
                uvs.push(new Vector2(vertices[indexD] / width, vertices[indexD + 1] / height));
                return uvs;
            }
        },
    });
    geometry.scale(1, 1, 1 - radius0)
    geometry.center();
    // Ensure we have an index buffer
    if (!geometry.index)
        geometry.setIndex(Array.from({ length: geometry.attributes.position.count }, (_, i) => i));
    geometry.computeVertexNormals();
    return geometry;
}


const fontsDict = new Map<string, Font | Promise<Font>>();
function loadFont(family: string | null): Font | Promise<Font> {
    let url: string = "";
    switch (family) {
        default:
        case "OpenSans":
            url = "https://cdn.needle.tools/static/fonts/facetype/Open Sans_Regular_ascii.json";
            break;
        case "Helvetiker":
            url = "https://cdn.needle.tools/static/fonts/facetype/Helvetiker_Regular_ascii.json";
            break;
    }
    if (fontsDict.has(url)) {
        const res = fontsDict.get(url);
        if (res) return res;
    }
    const loader = new FontLoader();
    const promise = new Promise<Font>((resolve, reject) => {
        loader.load(url, res => {
            fontsDict.set(url, res);
            resolve(res);
        }, undefined, reject);
    });
    fontsDict.set(url, promise);
    return promise;
}



let __shaderballIsLoading = false;
let __shaderballPromise: Promise<Object3D> | null = null;
/** Loads the shaderball mesh in the background and assigns it to the provided mesh */
function loadShaderball(group: Group, opts?: ObjectOptions) {
    // invoke the loading process ONCE
    if (__shaderballPromise === null) {
        // to make the autofit work we need to insert a placeholder the first time this is invoked
        const url = "https://cdn.needle.tools/static/models/shaderball.glb";
        const loader = new GLTFLoader();
        const loaders = createLoaders(null);
        loader.setDRACOLoader(loaders.dracoLoader);
        loader.setKTX2Loader(loaders.ktx2Loader);
        __shaderballIsLoading = true;
        __shaderballPromise = loader.loadAsync(url)
            .then(res => {
                const scene = res.scene;
                // the shaderball pivot is on the bottom and has a size of 1x1x1
                // to match the behaviour from all other object's we move it down by 0.5
                scene.position.y -= .5;
                return scene;
            })
            .catch(err => {
                // Handle case if the shaderball mesh fails to load or is unavailable for any reason
                console.warn("Failed to load shaderball mesh: " + err.message);
                return createShaderballPlaceholder();
            })
            .finally(() => {
                __shaderballIsLoading = false;
            });
    }

    if (__shaderballIsLoading) {
        // if the shaderball is still loading, insert a placeholder
        // this is mainly so that the autofit works correctly
        const placeholder = createShaderballPlaceholder();
        placeholder.name = "ShaderBall-Placeholder";
        const mesh = placeholder.children[0] as Mesh;
        if (mesh?.type === "Mesh")
            updateShaderballMaterial(mesh, opts);
        group.add(placeholder);
    }

    // and for every object that needs it, clone the loaded mesh
    __shaderballPromise.then(res => {
        // remove placeholders
        group.children.forEach(c => {
            if (c.name === "ShaderBall-Placeholder") group.remove(c);
        });
        // clone the loaded mesh
        const instance = res.clone();
        const mesh = instance.children[0] as Mesh;
        if (mesh?.type === "Mesh") {
            // Ensure we have tangents for the Shaderball mesh
            if (!mesh.geometry.attributes.tangent)
                mesh.geometry.computeTangents();
            updateShaderballMaterial(mesh, opts);
        }
        group.add(instance);
    });
}

function updateShaderballMaterial(mesh: Mesh, opts?: ObjectOptions) {
    const needCustomMaterial = opts?.color || opts?.material || opts?.texture;
    if (needCustomMaterial) {
        const mat = opts?.material ?? (mesh.material as Material)?.clone() ?? new MeshStandardMaterial();
        if (opts.color && "color" in mat && mat.color instanceof Color)
            mat.color.set(opts.color);
        if (opts?.texture && "map" in mat) mat.map = opts.texture;
        mesh.material = mat;
    }
}

function createShaderballPlaceholder() {
    return new Group().add(ObjectUtils.createPrimitive("Sphere", { material: new MeshBasicMaterial({ transparent: true, opacity: .1 }) }));
}
