import {
    Color3,
    CubeTexture,
    CSG,
    HemisphericLight,
    Material,
    Matrix,
    Mesh,
    MeshBuilder,
    Node,
    PhotoDome,
    Quaternion,
    Scene,
    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<Mesh> = [];
export const activeTouchableMeshes: Array<Mesh> = [];

export function adjust(f: (... csgs: CSG[]) => CSG, ... ms: Mesh[]): Mesh {
    const csgs = ms.map(m => CSG.FromMesh(m));
    const c = f(... csgs);
    if (ms.length > 0) {
        ms.forEach(m => m.dispose());
        const scene = ms[0].getScene();
        const ans = c.toMesh(ms[0].name, null, scene, true);
        ans.material = ms[0].material;
        return ans;
    } else {
        return c.toMesh("adjusted");
    }
}

export const subtractMany = (a: CSG, ... bs: CSG[]) => bs.reduce((a, b) => a.subtract(b), a);

export class PhotoSemiDome extends PhotoDome {
    readonly _size: number;

    constructor(... args: ConstructorParameters<typeof PhotoDome>) {
        super(... args);
        this._size = args[2].size!;
        this._chopMesh();
    }

    _chopMesh() {
        const d = this._size;
        this._mesh.getChildMeshes()[0].dispose(); // remove the covering half-sphere
        this._mesh = adjust(subtractMany,
                            this._mesh,
                            box(this._mesh.name + "_chop", d, d, d, 0, 0, -d/2));
        this._mesh.parent = this;
    }
}

export function box(name: string, width: number, height: number, depth: number, x?: number, y?: number, z?: number): Mesh {
    const b = MeshBuilder.CreateBox(name, {});
    b.scaling = new Vector3(width, height, depth);
    b.position = new Vector3(x ?? 0, y ?? b.scaling.y/2, z ?? 0);
    return b;
}

//---------------------------------------------------------------------------

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

    constructor (
        public scene: Scene,
        public shape: Shapes.Shape,
        public node: N,
    ) {
        this.shapePreserve = Shapes.fromShape(this.shape);
        this.node.metadata = {};
    }

    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: m => m.node.metadata.spriteName = spriteName,
                collisions: m => m.node.checkCollisions = true,
            });
        }
    }

    remove() {
        this.node?.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<Mesh>) => void) };

function applyCustomizer(m: ShapeTree<Mesh>, 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 function buildMesh(name: string, scene: Scene | null, meshSpec: Shapes.Mesh): Mesh {
    switch (meshSpec._variant) {
        case "Sphere": return MeshBuilder.CreateSphere(name, {}, scene);
        case "Box": return MeshBuilder.CreateBox(name, {}, scene);
        case "Ground": {
            const v = v2(meshSpec.value.size);
            return MeshBuilder.CreateGround(name, { width: v.x, height: v.y }, scene ?? void 0);
        }
        case "Plane": return MeshBuilder.CreatePlane(name, {}, scene);
    }
}

export function build(name: string, scene: Scene, shape: Shapes.Shape, customize: MeshCustomizer): ShapeTree {
    switch (shape._variant) {
        case "Mesh": {
            const t = new ShapeTree<Mesh>(scene, shape, buildMesh(name, scene, shape.value));
            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.node.scaling = v3(shape.value.v);
            build(name + '.inner', scene, shape.value.shape, customize).node.parent = t.node;
            return t;
        }

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

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

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

        case "Texture": {
            const entry = buildTexture(name + '.texture', scene, shape.value.spec);
            const t = build(name + '.inner', scene, shape.value.shape, {
                ... customize,
                material: m => m.node.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: m => m.node.material = mat,
            });
        }

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

        case "Floor":
            return build(name, scene, shape.value.shape, {
                ... customize,
                floor: m => {
                    activeFloorMeshes.push(m.node);
                    m.cleanups.push(() => {
                        const i = activeFloorMeshes.indexOf(m.node);
                        if (i !== -1) activeFloorMeshes.splice(i, 1);
                    });
                },
            });

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

        case "Touchable":
            return build(name, scene, shape.value.shape, {
                ... customize,
                touchable: m => {
                    m.node.metadata.touchable = true;
                    activeTouchableMeshes.push(m.node);
                    m.cleanups.push(() => {
                        const i = activeTouchableMeshes.indexOf(m.node);
                        if (i !== -1) activeTouchableMeshes.splice(i, 1);
                    });
                }
            });

        case "CSG": {
            const t = new ShapeTree<Mesh>(
                scene, shape, buildCSG(shape.value.expr).toMesh(name, null, scene));
            applyCustomizer(t, customize);
            return t;
        }

        case "Skybox": {
            const t = new ShapeTree<Mesh>(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.node.material = mat;
            applyCustomizer(t, customize);
            return t;
        }

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

function emptyCSG(): CSG {
    const c = new CSG();
    c.matrix = Matrix.Identity();
    c.position = Vector3.Zero();
    c.rotation = Vector3.Zero();
    c.scaling = Vector3.Zero();
    c.rotationQuaternion = null;
    return c;
}

export function buildCSG(expr: Shapes.CSGExpr): CSG {
    function walk(expr: Shapes.CSGExpr, matrix: Matrix): CSG {
        switch (expr._variant) {
            case "mesh": {
                const mesh = buildMesh("", null, expr.shape);
                mesh.freezeWorldMatrix(matrix, true);
                mesh.unfreezeWorldMatrix();
                const c = CSG.FromMesh(mesh);
                mesh.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": {
                let c = walk(expr.base, matrix);
                expr.more.forEach(d => c.subtractInPlace(walk(d, matrix)));
                return c;
            }
            case "union": {
                let c = walk(expr.base, matrix);
                expr.more.forEach(d => c.unionInPlace(walk(d, matrix)));
                return c;
            }
            case "intersect": {
                let c = walk(expr.base, matrix);
                expr.more.forEach(d => c.intersectInPlace(walk(d, matrix)));
                return c;
            }
            case "invert": {
                let c = walk(expr.shape, matrix);
                c.inverseInPlace();
                return c;
            }
        }
    }
    return walk(expr, Matrix.Identity());
}

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