import { BufferAttribute, BufferGeometry, Color, DoubleSide, Material, Mesh, MeshBasicMaterial, NearestFilter, SRGBColorSpace, Texture } from "three";

import { serializable, serializeable } from "../engine/engine_serialization_decorator.js";
import { getParam } from "../engine/engine_utils.js";
import { NEEDLE_progressive } from "../engine/extensions/NEEDLE_progressive.js";
import { RGBAColor } from "../engine/js-extensions/index.js";
import { Behaviour } from "./Component.js";

const debug = getParam("debugspriterenderer");
const showWireframe = getParam("wireframe");

/**
 * @internal
 */
class SpriteUtils {

    static cache: { [key: string]: BufferGeometry } = {};

    static getOrCreateGeometry(sprite: Sprite): BufferGeometry {
        if (sprite.__cached_geometry) return sprite.__cached_geometry;
        if (sprite.guid) {
            if (SpriteUtils.cache[sprite.guid]) {
                if (debug) console.log("Take cached geometry for sprite", sprite.guid);
                return SpriteUtils.cache[sprite.guid];
            }
        }
        const geo = new BufferGeometry();
        sprite.__cached_geometry = geo;
        const vertices = new Float32Array(sprite.triangles.length * 3);
        const uvs = new Float32Array(sprite.triangles.length * 2);
        for (let i = 0; i < sprite.triangles.length; i += 1) {
            const index = sprite.triangles[i];

            vertices[i * 3] = -sprite.vertices[index].x;
            vertices[i * 3 + 1] = sprite.vertices[index].y;

            vertices[i * 3 + 2] = 0;
            const uv = sprite.uv[index];
            uvs[i * 2] = uv.x;
            uvs[i * 2 + 1] = 1 - uv.y;
        }
        geo.setAttribute("position", new BufferAttribute(vertices, 3));
        geo.setAttribute("uv", new BufferAttribute(uvs, 2));
        if (sprite.guid)
            this.cache[sprite.guid] = geo;
        if (debug)
            console.log("Built sprite geometry", sprite, geo);
        return geo;
    }
}

/// <summary>
///   <para>SpriteRenderer draw mode.</para>
/// </summary>
export enum SpriteDrawMode {
    /// <summary>
    ///   <para>Displays the full sprite.</para>
    /// </summary>
    Simple = 0,
    /// <summary>
    ///   <para>The SpriteRenderer will render the sprite as a 9-slice image where the corners will remain constant and the other sections will scale.</para>
    /// </summary>
    Sliced = 1,
    /// <summary>
    ///   <para>The SpriteRenderer will render the sprite as a 9-slice image where the corners will remain constant and the other sections will tile.</para>
    /// </summary>
    Tiled = 2,
}

class Vec2 {
    x!: number;
    y!: number;
}

function updateTextureIfNecessary(tex: Texture) {
    if (!tex) return;
    if (tex.colorSpace != SRGBColorSpace) {
        tex.colorSpace = SRGBColorSpace;
        tex.needsUpdate = true;
    }
    if (tex.minFilter == NearestFilter && tex.magFilter == NearestFilter) {
        tex.anisotropy = 1;
        tex.needsUpdate = true;
    }
}

/**
 * A sprite is a mesh that represents a 2D image. Used by the {@link SpriteRenderer} to render 2D images in the scene.
 * @summary 2D image renderer 
 * @category Rendering
 * @group Components
 */
export class Sprite {

    constructor(texture?: Texture) {
        if (texture) {
            this.texture = texture;
            this.triangles = [0, 1, 2, 0, 2, 3];
            this.uv = [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 1, y: 1 }, { x: 0, y: 1 }];
            this.vertices = [{ x: -.5, y: -.5 }, { x: .5, y: -.5 }, { x: .5, y: .5 }, { x: -.5, y: .5 }];
        }
    }


    @serializable()
    guid?: string;
    @serializable(Texture)
    texture?: Texture;
    @serializeable()
    triangles!: Array<number>;
    @serializeable()
    uv!: Array<Vec2>;
    @serializeable()
    vertices!: Array<Vec2>;

    /** @internal */
    __cached_geometry?: BufferGeometry;

    /**
     * The mesh that represents the sprite
     */
    get mesh(): Mesh {
        if (!this._mesh) {
            this._mesh = new Mesh(SpriteUtils.getOrCreateGeometry(this), this.material);
        }
        return this._mesh;
    }
    private _mesh: Mesh | undefined;

    /**
     * The material used to render the sprite
     */
    get material() {
        if (!this._material) {
            if (this.texture) {
                updateTextureIfNecessary(this.texture);
            }
            this._material = new MeshBasicMaterial({
                map: this.texture,
                color: 0xffffff,
                side: DoubleSide,
                transparent: true
            });
        }
        return this._material;
    }
    private _material: MeshBasicMaterial | undefined;

    /**
     * The geometry of the sprite that can be used to create a mesh
     */
    getGeometry() {
        return SpriteUtils.getOrCreateGeometry(this);
    }
}

const $spriteTexOwner = Symbol("spriteOwner");

/**
 * @category Sprites
 */
export class SpriteSheet {

    @serializable(Sprite)
    sprites: Sprite[];

    constructor() {
        this.sprites = [];
    }
}

/**
 * Used by the {@link SpriteRenderer} to hold the sprite sheet and the currently active sprite index.
 * 
 * @category Sprites
 */
export class SpriteData {

    static create() {
        const i = new SpriteData();
        i.spriteSheet = new SpriteSheet();
        return i;
    }

    // we don't assign anything here because it's used by the serialization system.
    // there's currently a limitation in the serializer when e.g. spriteSheet is already assigned it will not be overriden by the serializer
    // hence the spriteSheet field is undefined by default
    constructor() { }

    clone() {
        const i = new SpriteData();
        i.index = this.index;
        i.spriteSheet = this.spriteSheet;
        return i;
    }

    /**
     * Set the sprite to be rendered in the currently assigned sprite sheet at the currently active index {@link index}
     */
    set sprite(sprite: Sprite | undefined) {
        if (!sprite) {
            return;
        }
        if (!this.spriteSheet) {
            this.spriteSheet = new SpriteSheet();
            this.spriteSheet.sprites = [sprite];
            this.index = 0;
        }
        else {
            if (this.index === null || this.index === undefined) this.index = 0;
            this.spriteSheet.sprites[this.index] = sprite;
        }
    }
    /** The currently active sprite */
    get sprite(): Sprite | undefined {
        if (!this.spriteSheet) return undefined;
        return this.spriteSheet.sprites[this.index];
    }

    /**
     * The spritesheet holds all sprites that can be rendered by the sprite renderer
     */
    @serializable(SpriteSheet)
    spriteSheet?: SpriteSheet;

    /**
     * The index of the sprite to be rendered in the currently assigned sprite sheet
     */
    @serializable()
    index: number = 0;

    update(material: Material | undefined) {
        if (!this.spriteSheet) return;
        const index = this.index;
        if (index < 0 || index >= this.spriteSheet.sprites.length)
            return;

        const sprite = this.spriteSheet.sprites[index];
        const tex = sprite?.texture;
        if (!tex) return;
        updateTextureIfNecessary(tex);

        if (!sprite["__hasLoadedProgressive"]) {
            sprite["__hasLoadedProgressive"] = true;
            const previousTexture = tex;
            NEEDLE_progressive.assignTextureLOD(tex, 0).then(res => {
                if (res instanceof Texture) {
                    sprite.texture = res;
                    const shouldUpdateInMaterial = material?.["map"] === previousTexture;
                    if (shouldUpdateInMaterial) {
                        material["map"] = res;
                        material.needsUpdate = true;
                    }
                }
            });
        }
    }
}

/**
 * The sprite renderer renders a sprite on a GameObject using an assigned spritesheet ({@link SpriteData}).   
 * 
 * - Example: https://engine.needle.tools/samples/spritesheet-animation
 * 
 * @category Sprites
 * @group Components
 */
export class SpriteRenderer extends Behaviour {

    /** @internal The draw mode of the sprite renderer */
    @serializable()
    drawMode: SpriteDrawMode = SpriteDrawMode.Simple;

    /** @internal Used when drawMode is set to Tiled */
    @serializable(Vec2)
    size: Vec2 = { x: 1, y: 1 };

    @serializable(RGBAColor)
    color?: RGBAColor;

    /**
     * The material that is used to render the sprite
     */
    @serializable(Material)
    sharedMaterial?: Material;

    // additional data
    @serializable()
    transparent: boolean = true;
    @serializable()
    cutoutThreshold: number = 0;
    @serializable()
    castShadows: boolean = false;
    @serializable()
    renderOrder: number = 0;
    @serializable()
    toneMapped: boolean = true;

    /**
     * Assign a new texture to the currently active sprite
     */
    set texture(value: Texture | undefined) {
        if (!this._spriteSheet) return;
        const currentSprite = this._spriteSheet.spriteSheet?.sprites[this.spriteIndex];
        if (!currentSprite) return;
        currentSprite.texture = value;
        this.updateSprite();
    }

    /**
     * Add a new sprite to the currently assigned sprite sheet. The sprite will be added to the end of the sprite sheet.
     * Note that the sprite will not be rendered by default - set the `spriteIndex` to the index of the sprite to be rendered.
     * @param sprite The sprite to be added
     * @returns The index of the sprite in the sprite sheet
     * @example
     * ```typescript
     * const spriteRenderer = gameObject.addComponent(SpriteRenderer);
     * const index = spriteRenderer.addSprite(mySprite);
     * if(index >= 0)
     *   spriteRenderer.spriteIndex = index;
     * ```
     */
    addSprite(sprite: Sprite, setActive: boolean = false): number {
        if (!this._spriteSheet) {
            this._spriteSheet = SpriteData.create();
        }
        if (!this._spriteSheet.spriteSheet) return -1;
        this._spriteSheet.spriteSheet?.sprites.push(sprite);
        const index = this._spriteSheet.spriteSheet?.sprites.length - 1;
        if (setActive) {
            this.spriteIndex = index;
        }
        return index;
    }

    /**
     * Get the currently active sprite
     */
    @serializable(SpriteData)
    get sprite(): SpriteData | undefined {
        return this._spriteSheet;
    }
    /**
     * Set the sprite to be rendered in the currently assigned sprite sheet at the currently active index {@link spriteIndex}
     */
    set sprite(value: Sprite | SpriteData | undefined | number) {
        if (value === this._spriteSheet) return;
        if (typeof value === "number") {
            // the value if interpolated is sometimes *slightly* off (e.g. 0.999999 or 1.000001) so we round it
            const index = Math.round(value);
            if (debug) console.log("[SpriteSheet] Set index to " + index + " (was " + this.spriteIndex + ")", value);
            this.spriteIndex = index;
        }
        else if (value instanceof Sprite) {
            if (!this._spriteSheet) {
                this._spriteSheet = SpriteData.create();
            }
            if (this._spriteSheet.sprite != value) {
                this._spriteSheet.sprite = value;
            }
            this.updateSprite();
        }
        else if (value != this._spriteSheet) {
            this._spriteSheet = value;
            this.updateSprite();
        }
    }

    /**
     * Set the index of the sprite to be rendered in the currently assigned sprite sheet
     */
    set spriteIndex(value: number) {
        if (!this._spriteSheet) return;
        this._spriteSheet.index = value;
        this.updateSprite();
    }
    get spriteIndex(): number {
        return this._spriteSheet?.index ?? 0;
    }
    /**
     * Get the number of sprites in the currently assigned sprite sheet
     */
    get spriteFrames(): number {
        return this._spriteSheet?.spriteSheet?.sprites.length ?? 0;
    }

    private _spriteSheet?: SpriteData;
    private _currentSprite?: Mesh;


    /** @internal */
    awake(): void {
        this._currentSprite = undefined;

        if (!this._spriteSheet) {
            this._spriteSheet = SpriteData.create();
        }
        else {
            // Ensure each SpriteRenderer has a unique spritesheet instance for cases where sprite renderer are cloned at runtime and then different sprites are assigned to each instance
            this._spriteSheet = this._spriteSheet.clone();
        }

        if (debug) {
            console.log("Awake", this.name, this, this.sprite);
        }
    }

    /** @internal */
    start() {
        if (!this._currentSprite)
            this.updateSprite();
        else if (this.gameObject)
            this.gameObject.add(this._currentSprite);
    }

    /**
     * Update the sprite. Modified properties will be applied to the sprite mesh. This method is called automatically when the sprite is changed.
     * @param force If true, the sprite will be forced to update.
     * @returns True if the sprite was updated successfully
     */
    updateSprite(force: boolean = false): boolean {
        if (!this.__didAwake && !force) return false;
        const data = this._spriteSheet;
        if (!data?.spriteSheet?.sprites) {
            console.warn("SpriteRenderer has no data or spritesheet assigned...");
            return false;
        }
        const sprite = data.spriteSheet.sprites[this.spriteIndex];
        if (!sprite) {
            if (debug)
                console.warn("Sprite not found", this.spriteIndex, data.spriteSheet.sprites);
            return false;
        }
        if (!this._currentSprite) {
            const mat = new MeshBasicMaterial({ color: 0xffffff, side: DoubleSide });
            if (showWireframe)
                mat.wireframe = true;
            if (this.color) {
                if (!mat["color"]) mat["color"] = new Color();
                mat["color"].copy(this.color);
                mat["opacity"] = this.color.alpha;
            }
            mat.transparent = true;
            mat.toneMapped = this.toneMapped;
            mat.depthWrite = false;

            if (sprite.texture && !mat.wireframe) {
                let tex = sprite.texture;
                // the sprite renderer modifies the texture offset and scale
                // so we need to clone the texture
                // if the same texture is used multiple times
                if (tex[$spriteTexOwner] !== undefined && tex[$spriteTexOwner] !== this && this.spriteFrames > 1) {
                    tex = sprite!.texture = tex.clone();
                }
                tex[$spriteTexOwner] = this;
                mat["map"] = tex;
            }
            this.sharedMaterial = mat;
            this._currentSprite = new Mesh(SpriteUtils.getOrCreateGeometry(sprite), mat);
            this._currentSprite.renderOrder = Math.round(this.renderOrder);
            NEEDLE_progressive.assignTextureLOD(mat, 0);
        }
        else {
            this._currentSprite.geometry = SpriteUtils.getOrCreateGeometry(sprite);
            this._currentSprite.material["map"] = sprite.texture;
        }

        if (this._currentSprite.parent !== this.gameObject) {
            if (this.drawMode === SpriteDrawMode.Tiled)
                this._currentSprite.scale.set(this.size.x, this.size.y, 1);
            if (this.gameObject)
                this.gameObject.add(this._currentSprite);
        }

        if (this._currentSprite) {
            this._currentSprite.layers.set(this.layer)
        }

        if (this.sharedMaterial) {
            this.sharedMaterial.alphaTest = this.cutoutThreshold;
            this.sharedMaterial.transparent = this.transparent;
        }
        this._currentSprite.castShadow = this.castShadows;
        data?.update(this.sharedMaterial);
        return true;
    }
}
