import {
    AbstractMesh,
    Color3,
    CubeTexture,
    CSG,
    HemisphericLight,
    Material,
    Matrix,
    Mesh,
    MeshBuilder,
    Node,
    Quaternion,
    Scene,
    SceneLoader,
    Sound,
    StandardMaterial,
    Texture,
    TransformNode,
    Vector2,
    Vector3,
} from '@babylonjs/core/Legacy/legacy';
import { KeyedDictionary, Value, is } from "@syndicate-lang/core";

import * as Shapes from './gen/shapes.js';

export const activeFloorMeshes: Array<AbstractMesh> = [];
export const activeTouchableMeshes: Array<AbstractMesh> = [];

export class ShapeTree<N extends Node = Node> {
    shapePreserve: Value;
    cleanups: Array<() => void> = [];
    subnodes: Promise<N[]>;

    constructor (
        public scene: Scene,
        public shape: Shapes.Shape,
        public rootnode: N,
        subnodes?: Promise<N[]>,
    ) {
        this.shapePreserve = Shapes.fromShape(this.shape);
        const metadata = {};
        this.rootnode.metadata = metadata;
        this.subnodes = subnodes ?? Promise.resolve([]);
        this.subnodes.then(ns => ns.forEach(n => n.metadata = metadata));
    }

    get allnodes(): Promise<N[]> {
        return this.subnodes.then(ns => [this.rootnode, ... ns]);
    }

    reconcile(spriteName: string, name: string, shape: Shapes.Shape): ShapeTree {
        if (is(Shapes.fromShape(shape), this.shapePreserve)) {
            return this;
        } else {
            this.remove();
            return build(name, this.scene, shape, {
                spriteName: async m => m.rootnode.metadata.spriteName = spriteName,
                collisions: async m => (await m.allnodes).forEach(n => n.checkCollisions = true),
            });
        }
    }

    remove() {
        this.allnodes.then(ns => ns.forEach(n => n.dispose()));
    }

    static empty(name: string, scene: Scene): ShapeTree {
        return ShapeTree.transform(name, scene, Shapes.Shape.many([]));
    }

    static transform(name: string, scene: Scene, shape: Shapes.Shape): ShapeTree<TransformNode> {
        return new ShapeTree(scene, shape, new TransformNode(name, scene));
    }
}

export function v2(v: Shapes.Vector2): Vector2 {
    return new Vector2(v.x, v.y);
}

export function v3(v: Shapes.Vector3): Vector3 {
    return new Vector3(v.x, v.y, v.z);
}

export function scale3(v: Shapes.Vector3, scale: number): Shapes.Vector3 {
    return Shapes.Vector3({ x: v.x * scale, y: v.y * scale, z: v.z * scale });
}

export function q(q: Shapes.Quaternion): Quaternion {
    return new Quaternion(q.a, q.b, q.c, q.d);
}

export type MeshCustomizer = { [key: string]: ((m: ShapeTree<AbstractMesh>) => void) };

function applyCustomizer(m: ShapeTree<AbstractMesh>, c: MeshCustomizer) {
    Object.values(c).forEach(f => f(m));
}

type CachedTexture = {
    key: Value,
    referenceCount: number,
    material: Material,
};
const textureCache = new KeyedDictionary<Value, CachedTexture>();

function buildTexture(name: string, scene: Scene, spec: Shapes.TextureSpec): CachedTexture {
    const cacheKey = Shapes.fromTextureSpec(spec);
    const entry = textureCache.get(cacheKey);
    if (entry !== void 0) {
        entry.referenceCount++;
        return entry;
    }
    const mat = new StandardMaterial(name, scene);
    const tex = new Texture(spec.path, scene);
    mat.diffuseTexture = tex;
    switch (spec._variant) {
        case "simple":
            break;
        case "uvAlpha":
            mat.alpha = spec.alpha;
            tex.hasAlpha = true;
            /* FALL THROUGH */
        case "uv": {
            const scale = v2(spec.scale);
            const offset = v2(spec.offset);
            tex.uScale = scale.x;
            tex.vScale = scale.y;
            tex.uOffset = offset.x;
            tex.vOffset = offset.y;
            break;
        }
    }
    const newEntry = {
        key: cacheKey,
        referenceCount: 1,
        material: mat,
    };
    textureCache.set(cacheKey, newEntry);
    return newEntry;
}

function releaseTexture(entry: CachedTexture) {
    if (--entry.referenceCount === 0) {
        textureCache.delete(entry.key);
        entry.material.dispose();
    }
}

export type BuiltMesh = {
    rootnode: AbstractMesh,
    subnodes?: Promise<AbstractMesh[]>,
};

export function buildMesh(
    name: string,
    scene: Scene | null,
    meshSpec: Shapes.Mesh,
): BuiltMesh {
    switch (meshSpec._variant) {
        case "Sphere": return { rootnode: MeshBuilder.CreateSphere(name, {}, scene) };
        case "Box": return { rootnode: MeshBuilder.CreateBox(name, {}, scene) };
        case "Ground": {
            const v = v2(meshSpec.value.size);
            return {
                rootnode: MeshBuilder.CreateGround(
                    name, { width: v.x, height: v.y }, scene ?? void 0),
            };
        }
        case "Plane": return { rootnode: MeshBuilder.CreatePlane(name, {}, scene) };
        case "External": {
            const rootnode = new Mesh(name, scene);
            return {
                rootnode,
                subnodes: new Promise(async resolve => {
                    const r =
                        await SceneLoader.ImportMeshAsync("", meshSpec.value.path, void 0, scene);
                    r.meshes.forEach(m => m.parent = rootnode);
                    resolve(r.meshes);
                }),
            };
        }
    }
}

export function build(name: string, scene: Scene, shape: Shapes.Shape, customize: MeshCustomizer): ShapeTree {
    switch (shape._variant) {
        case "Mesh": {
            const m = buildMesh(name, scene, shape.value);
            const t = new ShapeTree<AbstractMesh>(scene, shape, m.rootnode, m.subnodes);
            applyCustomizer(t, customize);
            return t;
        }

        case "Light":
            return new ShapeTree(
                scene,
                shape,
                new HemisphericLight(name, v3(shape.value.v), scene));

        case "Scale": {
            const t = ShapeTree.transform(name, scene, shape);
            t.rootnode.scaling = v3(shape.value.v);
            build(name + '.inner', scene, shape.value.shape, customize).rootnode.parent = t.rootnode;
            return t;
        }

        case "Move": {
            const t = ShapeTree.transform(name, scene, shape);
            t.rootnode.position = v3(shape.value.v);
            build(name + '.inner', scene, shape.value.shape, customize).rootnode.parent = t.rootnode;
            return t;
        }

        case "Rotate": {
            const t = ShapeTree.transform(name, scene, shape);
            switch (shape.value._variant) {
                case "euler":
                    t.rootnode.rotation = v3(shape.value.v);
                    t.rootnode.rotation.scaleInPlace(2 * Math.PI);
                    break;
                case "quaternion":
                    t.rootnode.rotationQuaternion = q(shape.value.q);
                    break;
            }
            build(name + '.inner', scene, shape.value.shape, customize).rootnode.parent = t.rootnode;
            return t;
        }

        case "many": {
            const t = ShapeTree.transform(name, scene, shape);
            shape.value.forEach((s, i) => {
                build(name + '[' + i + ']', scene, s, customize).rootnode.parent = t.rootnode;
            });
            return t;
        }

        case "Texture": {
            const entry = buildTexture(name + '.texture', scene, shape.value.spec);
            const t = build(name + '.inner', scene, shape.value.shape, {
                ... customize,
                material: async m => (await m.allnodes).forEach(n => n.material = entry.material),
            });
            t.cleanups.push(() => releaseTexture(entry));
            return t;
        }

        case "Color": {
            const mat = new StandardMaterial(name + '.texture', scene);
            mat.diffuseColor = new Color3(shape.value.r, shape.value.g, shape.value.b);
            if (shape.value._variant === "transparent") mat.alpha = shape.value.alpha;
            return build(name + '.inner', scene, shape.value.shape, {
                ... customize,
                material: async m => (await m.allnodes).forEach(n => n.material = mat),
            });
        }

        case "Sound": {
            const sound = new Sound(
                name + ".sound",
                shape.value.url,
                scene,
                null,
                {
                    distanceModel: "inverse",
                    loop: true,
                    rolloffFactor: 0.25,
                    autoplay: true,
                    streaming: true,
                    skipCodecCheck: true,
                });
            console.log(sound);
            return build(name + ".inner", scene, shape.value.shape, {
                ... customize,
                sound: async m => sound.attachToMesh(m.rootnode),
            });
        }

        case "Name":
            return build(name + '.' + shape.value.base, scene, shape.value.shape, customize);

        case "Floor":
            return build(name, scene, shape.value.shape, {
                ... customize,
                floor: async m => {
                    const nodes = await m.allnodes;
                    activeFloorMeshes.push(... nodes);
                    m.cleanups.push(() => {
                        const i = activeFloorMeshes.indexOf(nodes[0]);
                        if (i !== -1) activeFloorMeshes.splice(i, nodes.length);
                    });
                },
            });

        case "Nonphysical":
            return build(name, scene, shape.value.shape, {
                ... customize,
                collisions: async m => (await m.allnodes).forEach(n => n.checkCollisions = false),
            });

        case "Touchable":
            return build(name, scene, shape.value.shape, {
                ... customize,
                touchable: async m => {
                    const nodes = await m.allnodes;
                    m.rootnode.metadata.touchable = true;
                    activeTouchableMeshes.push(... nodes);
                    m.cleanups.push(() => {
                        const i = activeTouchableMeshes.indexOf(nodes[0]);
                        if (i !== -1) activeTouchableMeshes.splice(i, nodes.length);
                    });
                }
            });

        case "CSG": {
            const m = buildCSG(name, scene, shape.value.expr);
            const t = new ShapeTree<AbstractMesh>(scene, shape, m.rootnode, m.subnodes);
            applyCustomizer(t, customize);
            return t;
        }

        case "Skybox": {
            const t = new ShapeTree<AbstractMesh>(scene, shape, MeshBuilder.CreateBox(name, { size: 2000 }, scene));
            const mat = new StandardMaterial(name, scene);
            mat.backFaceCulling = false;
            mat.reflectionTexture = new CubeTexture(shape.value.path, scene);
            mat.reflectionTexture.coordinatesMode = Texture.SKYBOX_MODE;
            mat.diffuseColor = new Color3(0, 0, 0);
            mat.specularColor = new Color3(0, 0, 0);
            t.rootnode.material = mat;
            applyCustomizer(t, customize);
            return t;
        }

        default:
            ((_shape: never) => {
                console.error('Unsupported shape variant', shape);
                throw new Error("Unsupported shape variant");
            })(shape);
    }
}

export function buildCSG(name: string, scene: Scene, expr: Shapes.CSGExpr): BuiltMesh {
    async function walk(expr: Shapes.CSGExpr, matrix: Matrix): Promise<CSG> {
        switch (expr._variant) {
            case "mesh": {
                const m = buildMesh("", null, expr.shape);
                const nodes: Mesh[] =
                    ([m.rootnode, ... (m.subnodes ? await m.subnodes : [])]
                         .filter(n => n instanceof Mesh)) as Mesh[];
                nodes.forEach(n => {
                    n.freezeWorldMatrix(matrix, true);
                    n.unfreezeWorldMatrix();
                });
                const c = CSG.FromMesh(nodes[0]);
                nodes.slice(1).forEach(n => c.unionInPlace(CSG.FromMesh(n)));
                nodes.forEach(n => n.dispose());
                return c;
            }
            case "scale":
                return walk(expr.shape, matrix.multiply(
                    Matrix.Scaling(expr.v.x, expr.v.y, expr.v.z)));
            case "move":
                return walk(expr.shape, matrix.multiply(
                    Matrix.Translation(expr.v.x, expr.v.y, expr.v.z)));
            case "rotate":
                return walk(expr.shape, matrix.multiply(
                    Matrix.RotationYawPitchRoll(expr.v.y * 2 * Math.PI,
                                                expr.v.x * 2 * Math.PI,
                                                expr.v.z * 2 * Math.PI)));
            case "subtract": {
                const c = await walk(expr.base, matrix);
                for (const d of expr.more) {
                    c.subtractInPlace(await walk(d, matrix));
                }
                return c;
            }
            case "union": {
                const c = await walk(expr.base, matrix);
                for (const d of expr.more) {
                    c.unionInPlace(await walk(d, matrix));
                }
                return c;
            }
            case "intersect": {
                const c = await walk(expr.base, matrix);
                for (const d of expr.more) {
                    c.intersectInPlace(await walk(d, matrix));
                }
                return c;
            }
            case "invert": {
                const c = await walk(expr.shape, matrix);
                c.inverseInPlace();
                return c;
            }
        }
    }
    const rootnode = new Mesh(name, scene);
    return {
        rootnode,
        subnodes: new Promise(async resolve => {
            const c = await walk(expr, Matrix.Identity());
            const m = c.toMesh(name + ".csg", null, scene);
            m.parent = rootnode;
            resolve([m]);
        }),
    };
}

export const builder: { [key: string]: (... args: any[]) => Shapes.Shape } = {
    sphere: () => Shapes.Shape.Mesh(Shapes.Mesh.Sphere(Shapes.Sphere())),
    box: () => Shapes.Shape.Mesh(Shapes.Mesh.Box(Shapes.Box())),
    plane: () => Shapes.Shape.Mesh(Shapes.Mesh.Plane(Shapes.Plane())),
    ground: (v: Shapes.Vector2) => Shapes.Shape.Mesh(Shapes.Mesh.Ground(Shapes.Ground(Shapes.Vector2(v)))),
    light: (v: Shapes.Vector3) => Shapes.Shape.Light(Shapes.Light(Shapes.Vector3(v))),
    scale: (v: Shapes.Vector3, shape: Shapes.Shape) => Shapes.Shape.Scale(Shapes.Scale({ v: Shapes.Vector3(v), shape })),
    move: (v: Shapes.Vector3, shape: Shapes.Shape) => Shapes.Shape.Move(Shapes.Move({ v: Shapes.Vector3(v), shape })),
    rotate: (v: Shapes.Vector3, shape: Shapes.Shape) => Shapes.Shape.Rotate(Shapes.Rotate.euler({ v: Shapes.Vector3(v), shape })),
    many: (shapes: Shapes.Shape[]) => Shapes.Shape.many(shapes),
    texture: (spec: Shapes.TextureSpec, shape: Shapes.Shape) => Shapes.Shape.Texture(Shapes.Texture({ spec, shape })),
    color: (r: number, g: number, b: number, shape: Shapes.Shape, alpha = 1.0) => {
        return Shapes.Shape.Color((alpha === 1.0)
            ? Shapes.Color.opaque({ r, g, b, shape })
            : Shapes.Color.transparent({ r, g, b, alpha, shape }));
    },
    name: (base: string, shape: Shapes.Shape) => Shapes.Shape.Name(Shapes.Name({ base, shape })),
    floor: (shape: Shapes.Shape) => Shapes.Shape.Floor(Shapes.Floor(shape)),
    nonphysical: (shape: Shapes.Shape) => Shapes.Shape.Nonphysical(Shapes.Nonphysical(shape)),
};
