/*
    Animation library for sprites
*/
//% color="#03AA74" weight=100 icon="\uf021" block="Animation"
//% groups='["Animate", "Advanced"]'
//% weight=5
namespace animation {
    const stateNamespace = "__animation";

    interface AnimationState {
        animations: SpriteAnimation[];
    }

    export class Point {
        public x: number;
        public y: number;

        constructor(x: number, y: number) {
            this.x = x;
            this.y = y;
        }
    }

    //% fixedInstances blockId=animation_path block="path %pathString"
    export class PathPreset {
        constructor(public pathString: string) {
        }
    }

    export class Path {
        length: number;

        protected args: number[];
        protected currentCommand: string;
        protected lastControlX: number;
        protected lastControlY: number;

        protected startX: number;
        protected startY: number;

        protected lastX: number;
        protected lastY: number;

        protected strIndex: number;
        protected commandIndex: number;

        constructor(protected path: string) {
            this.strIndex = 0;

            // Run through the path once to get the length and check for errors
            this.length = 0;
            while (this.strIndex < this.path.length) {
                this.readNextCommand();
                if (this.currentCommand) this.length++;
            }

            this.reset();
        }

        protected readNextCommand() {
            if (this.strIndex >= this.path.length) {
                this.currentCommand = undefined;
                return;
            }

            this.currentCommand = this.readNextToken();

            if (!this.currentCommand) return;

            this.args = [];

            const numArgs = Path.commandToArgCount(this.currentCommand);

            if (numArgs === -1) throw "Unknown path command '" + this.currentCommand +"'";

            for (let i = 0; i < numArgs; i++) {
                this.args.push(parseFloat(this.readNextToken()))
            }

            for (const arg of this.args) {
                if (Number.isNaN(arg)) throw "Invalid argument for path command '" + this.currentCommand + "'";
            }
        }

        reset() {
            this.args = undefined;
            this.currentCommand = undefined;
            this.lastControlX = undefined;
            this.lastControlY = undefined;
            this.startX = undefined;
            this.startY = undefined;
            this.lastX = undefined;
            this.lastY = undefined;
            this.strIndex = 0;
            this.commandIndex = 0;
        }

        protected readNextToken() {
            while (this.path.charCodeAt(this.strIndex) === 32 && this.strIndex < this.path.length) {
                this.strIndex ++;
            }

            if (this.strIndex >= this.path.length) return undefined;

            const tokenStart = this.strIndex;

            while (this.path.charCodeAt(this.strIndex) !== 32 && this.strIndex < this.path.length) {
                this.strIndex++;
            }

            return this.path.substr(tokenStart, this.strIndex - tokenStart);
        }

        private static commandToArgCount(command: string): number {
            switch (command) {
                case "M": // moveTo
                case "m":
                    return 2;
                case "L": // lineTo
                case "l":
                    return 2;
                case "H": // horizontalLineTo
                case "h":
                    return 1;
                case "V": // verticalLineTo
                case "v":
                    return 1;
                case "Q": // quadraticCurveTo
                case "q":
                    return 4;
                case "T": // smoothQuadraticCurveTo
                case "t":
                    return 2;
                case "C": // cubicCurveTo
                case "c":
                    return 6;
                case "S": // smoothCubicCurveTo
                case "s":
                    return 4;
                case "A": // arcTo
                case "a":
                    return 7;
                case "Z": // closePath
                case "z":
                    return 0;
                default:
                    return -1;
            }
        }

        public run(interval: number, target: Sprite, runningTime: number): boolean {
            const nodeIndex = Math.floor(runningTime / interval); // The current node
            const nodeTime = runningTime % interval; // The time the current node has been animating

            if (this.startX === undefined) {
                this.startX = target.x;
                this.startY = target.y;
                this.lastX = target.x;
                this.lastY = target.y;
                this.commandIndex = 0;
                this.readNextCommand();
            }

            while (this.commandIndex < nodeIndex) {
                if (this.currentCommand) {
                    this.runCurrentCommand(target, interval, interval);
                    this.lastX = target.x;
                    this.lastY = target.y;
                }
                this.commandIndex++
                this.readNextCommand();
            }

            if (nodeIndex >= this.length) {
                return true;
            }

            this.runCurrentCommand(target, nodeTime, interval);
            return false;
        }

        protected runCurrentCommand(target: Sprite, nodeTime: number, intervalTime: number) {
            switch (this.currentCommand) {
                case "M": // M x y
                    this.lastControlX = undefined;
                    this.lastControlY = undefined;
                    moveTo(
                        target,
                        nodeTime,
                        intervalTime,
                        this.args[0],
                        this.args[1]
                    );
                    break;
                case "m": // m dx dy
                    this.lastControlX = undefined;
                    this.lastControlY = undefined;
                    moveTo(
                        target,
                        nodeTime,
                        intervalTime,
                        this.args[0] + this.lastX,
                        this.args[1] + this.lastY
                    );
                    break;
                case "L": // L x y
                    this.lastControlX = undefined;
                    this.lastControlY = undefined;
                    lineTo(
                        target,
                        nodeTime,
                        intervalTime,
                        this.lastX,
                        this.lastY,
                        this.args[0],
                        this.args[1]
                    );
                    break;
                case "l": // l dx dy
                    this.lastControlX = undefined;
                    this.lastControlY = undefined;
                    lineTo(
                        target,
                        nodeTime,
                        intervalTime,
                        this.lastX,
                        this.lastY,
                        this.args[0] + this.lastX,
                        this.args[1] + this.lastY
                    );
                    break;
                case "H": // H x
                    this.lastControlX = undefined;
                    this.lastControlY = undefined;
                    lineTo(
                        target,
                        nodeTime,
                        intervalTime,
                        this.lastX,
                        this.lastY,
                        this.args[0],
                        this.lastY
                    );
                    break;
                case "h": // h dx
                    this.lastControlX = undefined;
                    this.lastControlY = undefined;
                    lineTo(
                        target,
                        nodeTime,
                        intervalTime,
                        this.lastX,
                        this.lastY,
                        this.args[0] + this.lastX,
                        this.lastY
                    );
                    break;
                case "V": // V y
                    this.lastControlX = undefined;
                    this.lastControlY = undefined;
                    lineTo(
                        target,
                        nodeTime,
                        intervalTime,
                        this.lastX,
                        this.lastY,
                        this.lastX,
                        this.args[0]
                    );
                    break;
                case "v": // v dy
                    this.lastControlX = undefined;
                    this.lastControlY = undefined;
                    lineTo(
                        target,
                        nodeTime,
                        intervalTime,
                        this.lastX,
                        this.lastY,
                        this.lastX,
                        this.args[0] + this.lastY
                    );
                    break;
                case "Q": // Q x1 y1 x2 y2
                    this.lastControlX = this.args[0];
                    this.lastControlY = this.args[1];
                    quadraticCurveTo(
                        target,
                        nodeTime,
                        intervalTime,
                        this.lastX,
                        this.lastY,
                        this.args[0],
                        this.args[1],
                        this.args[2],
                        this.args[3]
                    )
                    break;
                case "q": // q dx1 dy1 dx2 dy2
                    this.lastControlX = this.args[0] + this.lastX;
                    this.lastControlY = this.args[1] + this.lastY;
                    quadraticCurveTo(
                        target,
                        nodeTime,
                        intervalTime,
                        this.lastX,
                        this.lastY,
                        this.args[0] + this.lastX,
                        this.args[1] + this.lastY,
                        this.args[2] + this.lastX,
                        this.args[3] + this.lastY
                    );
                    break;
                case "T": // T x2 y2
                    this.ensureControlPoint();
                    quadraticCurveTo(
                        target,
                        nodeTime,
                        intervalTime,
                        this.lastX,
                        this.lastY,
                        this.lastX + this.lastX - this.lastControlX,
                        this.lastY + this.lastY - this.lastControlY,
                        this.args[0],
                        this.args[1],
                    );
                    if (nodeTime === intervalTime) {
                        this.lastControlX = this.lastX + this.lastX - this.lastControlX;
                        this.lastControlY = this.lastY + this.lastY - this.lastControlY;
                    }
                    break;
                case "t": // t dx2 dy2
                    this.ensureControlPoint();
                    quadraticCurveTo(
                        target,
                        nodeTime,
                        intervalTime,
                        this.lastX,
                        this.lastY,
                        this.lastX + this.lastX - this.lastControlX,
                        this.lastY + this.lastY - this.lastControlY,
                        this.args[0] + this.lastX,
                        this.args[1] + this.lastY,
                    );
                    if (nodeTime === intervalTime) {
                        this.lastControlX = this.lastX + this.lastX - this.lastControlX;
                        this.lastControlY = this.lastY + this.lastY - this.lastControlY;
                    }
                    break;
                case "C": // C x1 y1 x2 y2 x3 y3
                    this.lastControlX = this.args[2];
                    this.lastControlY = this.args[3];
                    cubicCurveTo(
                        target,
                        nodeTime,
                        intervalTime,
                        this.lastX,
                        this.lastY,
                        this.args[0],
                        this.args[1],
                        this.args[2],
                        this.args[3],
                        this.args[4],
                        this.args[5],
                    );
                    break;
                case "c": // c dx1 dy1 dx2 dy2 dx3 dy3
                    this.lastControlX = this.args[2] + this.lastX;
                    this.lastControlY = this.args[3] + this.lastY;
                    cubicCurveTo(
                        target,
                        nodeTime,
                        intervalTime,
                        this.lastX,
                        this.lastY,
                        this.args[0] + this.lastX,
                        this.args[1] + this.lastY,
                        this.args[2] + this.lastX,
                        this.args[3] + this.lastY,
                        this.args[4] + this.lastX,
                        this.args[5] + this.lastY,
                    );
                    break;
                case "S": // S x2 y2 x3 y3
                    this.ensureControlPoint();
                    cubicCurveTo(
                        target,
                        nodeTime,
                        intervalTime,
                        this.lastX,
                        this.lastY,
                        this.lastX + this.lastX - this.lastControlX,
                        this.lastY + this.lastY - this.lastControlY,
                        this.args[0],
                        this.args[1],
                        this.args[2],
                        this.args[3]
                    );
                    if (nodeTime === intervalTime) {
                        this.lastControlX = this.args[0];
                        this.lastControlY = this.args[1];
                    }
                    break;
                case "s": // s dx2 dy2 dx3 dy3
                    this.ensureControlPoint();
                    cubicCurveTo(
                        target,
                        nodeTime,
                        intervalTime,
                        this.lastX,
                        this.lastY,
                        this.lastX + this.lastX - this.lastControlX,
                        this.lastY + this.lastY - this.lastControlY,
                        this.args[0] + this.lastX,
                        this.args[1] + this.lastY,
                        this.args[2] + this.lastX,
                        this.args[3] + this.lastY,
                    );
                    if (nodeTime === intervalTime) {
                        this.lastControlX = this.args[0] + this.lastX;
                        this.lastControlY = this.args[1] + this.lastY;
                    }
                    break;
                case "Z": // Z
                case "z": // z
                    this.lastControlX = undefined;
                    this.lastControlY = undefined;
                    lineTo(
                        target,
                        nodeTime,
                        intervalTime,
                        this.lastX,
                        this.lastY,
                        this.startX,
                        this.startY
                    );
                    break;
            }
        }

        protected ensureControlPoint() {
            if (this.lastControlX === undefined) throw "Invalid path command. S/s and T/t must follow either Q/q or C/c"
        }
    }

    function moveTo(target: Sprite, nodeTime: number, interval: number, x: number, y: number) {
        if (nodeTime >= interval) target.setPosition(x, y);
    }

    function lineTo(target: Sprite, nodeTime: number, interval: number, x0: number, y0: number, x1: number, y1: number) {
        target.setPosition(
            Math.round(((x1 - x0) / interval) * nodeTime) + x0,
            Math.round(((y1 - y0) / interval) * nodeTime) + y0
        );
    }

    function quadraticCurveTo(target: Sprite, nodeTime: number, interval: number, x0: number, y0: number, x1: number, y1: number, x2: number, y2: number) {
        const progress = nodeTime / interval;
        const diff = 1 - progress;
        const a = diff * diff;
        const b = 2 * diff * progress;
        const c = progress * progress;

        target.setPosition(
            Math.round(a * x0 + b * x1 + c * x2),
            Math.round(a * y0 + b * y1 + c * y2)
        );
    }

    function cubicCurveTo(target: Sprite, nodeTime: number, interval: number, x0: number, y0: number, x1: number, y1: number, x2: number, y2: number, x3: number, y3: number) {
        const progress = nodeTime / interval;
        const diff = 1 - progress;
        const a = diff * diff * diff;
        const b = 3 * diff * diff * progress;
        const c = 3 * diff * progress * progress;
        const d = progress * progress * progress;

        target.setPosition(
            Math.round(a * x0 + b * x1 + c * x2 + d * x3),
            Math.round(a * y0 + b * y1 + c * y2 + d * y3)
        );
    }

    export abstract class SpriteAnimation {
        protected elapsedTime: number;

        constructor(public sprite: Sprite, protected loop: boolean) {
            this.elapsedTime = 0;
        }

        public init() {
            let state: AnimationState = game.currentScene().data[stateNamespace];

            // Register animation updates to fire when frames are rendered
            if (!state) {
                state = game.currentScene().data[stateNamespace] = {
                    animations: []
                } as AnimationState;

                game.eventContext().registerFrameHandler(scene.ANIMATION_UPDATE_PRIORITY, () => {
                    state.animations = state.animations.filter((anim: SpriteAnimation) => {
                        if (anim.sprite.flags & sprites.Flag.Destroyed)
                            return false;
                        return !anim.update(); // If update returns true, the animation is done and will be removed
                    });
                });
            }

            // Remove any other animations of this type and attached to this sprite
            state.animations = state.animations.filter((anim: SpriteAnimation) => {
                return !(anim.sprite === this.sprite &&
                    ((anim instanceof ImageAnimation && this instanceof ImageAnimation) ||
                    (anim instanceof MovementAnimation && this instanceof MovementAnimation)));
            });

            state.animations.push(this);
        }

        public update(): boolean {
            // This should be implemented by subclasses
            return false;
        }
    }

    export class ImageAnimation extends SpriteAnimation {
        private lastFrame: number;

        constructor(sprite: Sprite, private frames: Image[], private frameInterval: number, loop?: boolean) {
            super(sprite, loop);
            this.lastFrame = -1;
        }

        public update(): boolean {
            this.elapsedTime += game.eventContext().deltaTimeMillis;

            const frameIndex = Math.floor(this.elapsedTime / this.frameInterval);

            if (this.lastFrame != frameIndex && this.frames.length) {
                if (!this.loop && frameIndex >= this.frames.length) {
                    return true;
                }
                const newImage = this.frames[frameIndex % this.frames.length];
                if (this.sprite.image !== newImage) {
                    this.sprite.setImage(newImage);
                }
            }
            this.lastFrame = frameIndex;
            return false;
        }
    }

    export class MovementAnimation extends SpriteAnimation {
        protected startX: number;
        protected startY: number;

        constructor(sprite: Sprite, private path: Path, private nodeInterval: number, loop?: boolean) {
            super(sprite, loop);
            this.startX = sprite.x;
            this.startY = sprite.y;
            this.elapsedTime = 0;
        }

        public update(): boolean {
            this.elapsedTime += game.eventContext().deltaTimeMillis;

            let result = this.path.run(this.nodeInterval, this.sprite, this.elapsedTime);
            if (result) {
                if (!this.loop) return true;
                this.elapsedTime = 0;
                this.path.reset();
                this.sprite.x = this.startX;
                this.sprite.y = this.startY;
            }
            return false;
        }
    }

    /**
     * Create and run an image animation on a sprite
     * @param frames the frames to animate through
     * @param sprite the sprite to animate on
     * @param frameInterval the time between changes, eg: 500
     */
    //% blockId=run_image_animation
    //% block="animate $sprite=variables_get(mySprite) frames $frames=animation_editor interval (ms) $frameInterval=timePicker loop $loop=toggleOnOff"
    //% sprite.defl=mySprite
    //% group="Animate"
    //% weight=100
    //% help=animation/run-image-animation
    export function runImageAnimation(sprite: Sprite, frames: Image[], frameInterval?: number, loop?: boolean) {
        const anim = new ImageAnimation(sprite, frames, frameInterval || 500, !!loop);
        anim.init();
    }

    /**
     * Create and run a movement animation on a sprite
     * @param sprite the sprite to move
     * @param pathString the SVG path to animate
     * @param duration how long the animation should play for, eg: 500
     */
    //% blockId=run_movement_animation
    //% block="animate $sprite=variables_get(mySprite) with $pathString=animation_path for (ms) $duration=timePicker loop $loop=toggleOnOff"
    //% sprite.defl=mySprite
    //% duration.defl=2000
    //% weight=80
    //% group="Animate"
    //% help=animation/run-movement-animation
    export function runMovementAnimation(sprite: Sprite, pathString: string, duration?: number, loop?: boolean) {
        const path = new Path(pathString);
        const anim = new MovementAnimation(sprite, path, duration / path.length, !!loop);
        anim.init();
    }

    export enum AnimationTypes {
        //% block="all"
        All,
        //% block="frame"
        ImageAnimation,
        //% block="path"
        MovementAnimation
    }

    /**
     * Stop one type or all animations (simple and looping) on a sprite
     * @param type the animation type to stop
     * @param sprite the sprite to filter animations by
     */
    //% blockId=stop_animations
    //% block="stop %type animations on %sprite=variables_get(mySprite)"
    //% sprite.defl=mySprite
    //% group="Animate"
    //% weight=60
    //% help=animation/stop-animation
    export function stopAnimation(type: AnimationTypes, sprite: Sprite) {
        let state: AnimationState = game.currentScene().data[stateNamespace];
        if (state && state.animations) {
            state.animations = state.animations.filter((anim: SpriteAnimation) => {
                if (anim.sprite === sprite) {
                    switch (type) {
                        case AnimationTypes.ImageAnimation:
                            if (anim instanceof ImageAnimation) return false;
                            break;
                        case AnimationTypes.MovementAnimation:
                            if (anim instanceof MovementAnimation) return false;
                            break;
                        case AnimationTypes.All:
                            return false;
                    }
                }
                return true;
            });
        }
        if (type == AnimationTypes.All || type == AnimationTypes.ImageAnimation) {
            //stop state based animation if any as well
            sprite._action = -1
        }
    }

    //% fixedInstance whenUsed block="fly to center"
    export const flyToCenter = new PathPreset("L 80 60");

    //% fixedInstance whenUsed block="shake"
    export const shake = new PathPreset("m 4 -1 m 1 2 m -6 2 m -4 -8 m 8 8 m 2 -4 m -8 0 m 6 3 m -3 -2");

    //% fixedInstance whenUsed block="bounce (right)"
    export const bounceRight = new PathPreset("q 7 0 15 40 q 10 -30 15 -25 q 10 5 15 25 q 5 -25 10 0 q 4 -15 8 0 q 2 -10 4 0 q 1 -5 1 0 q 0 -2 1 0");

    //% fixedInstance whenUsed block="bounce (left)"
    export const bounceLeft = new PathPreset("q -7 0 -15 40 q -10 -30 -15 -25 q -10 5 -15 25 q -5 -25 -10 0 q -4 -15 -8 0 q -2 -10 -4 0 q -1 -5 -1 0 q 0 -2 -1 0");

    //% fixedInstance whenUsed block="parachute (right)"
    export const parachuteRight = new PathPreset("q 20 10 40 5 q 2 -2 0 0 q -15 10 -30 5 q -2 -2 0 0 q 10 10 20 5 q 2 -2 0 0 q -5 5 -10 3 q -1 -1 0 0 q 2 2 5 1 l 0 2 l 0 2 l 0 2");

    //% fixedInstance whenUsed block="parachute (left)"
    export const parachuteLeft = new PathPreset("q -20 10 -40 5 q -2 -2 0 0 q 15 10 30 5 q 2 -2 0 0 q -10 10 -20 5 q -2 -2 0 0 q 5 5 10 3 q 1 -1 0 0 q -2 2 -5 1 l 0 2 l 0 2 l 0 2");

    //% fixedInstance whenUsed block="ease (right)"
    export const easeRight = new PathPreset("h 5 h 10 h 20 h 30 h 20 h 10 h 5");

    //% fixedInstance whenUsed block="ease (left)"
    export const easeLeft = new PathPreset("h -5 h -10 h -20 h -30 h -20 h -10 h -5");

    //% fixedInstance whenUsed block="ease (down)"
    export const easeDown = new PathPreset("v 5 v 10 v 20 v 30 v 20 v 10 v 5");

    //% fixedInstance whenUsed block="ease (up)"
    export const easeUp = new PathPreset("v -5 v -10 v -20 v -30 v -20 v -10 v -5");

    //% fixedInstance whenUsed block="wave (right)"
    export const waveRight = new PathPreset("c 25 -15 15 -5 20 0");

    //% fixedInstance whenUsed block="wave (left)"
    export const waveLeft = new PathPreset("c -25 -15 -15 -5 -20 0");

    //% fixedInstance whenUsed block="bobbing (in place)"
    export const bobbing = new PathPreset("c 0 -20 0 20 0 0");

    //% fixedInstance whenUsed block="bobbing (right)"
    export const bobbingRight = new PathPreset("c 5 -20 15 20 20 0");

    //% fixedInstance whenUsed block="bobbing (left)"
    export const bobbingLeft = new PathPreset("c -5 -20 -15 20 -20 0");

    /**
     * Generates a path string for preset animation
     * @param animationPath The preset path
     */
    //% blockId=animation_path
    //% block="%animationPath"
    //% group="Animate"
    //% blockHidden=1
    export function animationPresets(animationPath: PathPreset) {
        return animationPath.pathString;
    }


    //% blockId=animation_editor block="%frames"
    //% shim=TD_ID
    //% frames.fieldEditor="animation"
    //% frames.fieldOptions.decompileLiterals="true"
    //% frames.fieldOptions.filter="!tile !dialog !background"
    //% weight=40
    //% group="Animate" duplicateShadowOnDrag
    //% help=animation/animation-frames
    export function _animationFrames(frames: Image[]) {
        return frames
    }
}
