import * as matrix from "wesleytabaka-rpi-led-matrix";
import { Canvas } from "./canvas";
import { CanvasSection } from "./canvassection";
import { DrawMode } from "./drawmode";
import { DrawModeOption } from "./drawmodeoption";
import { PaintingInstruction } from "./paintinginstruction";
import { Point } from "./point";
import { EffectType } from "./effecttype";
import { Effect } from ".";
import { Image } from "./image";
import Jimp from "jimp";
import { PaintingInstructionCache } from "./paintinginstructioncache";

export class Painter {
    private canvas: Canvas;
    private matrix: matrix.LedMatrixInstance;
    private fontCache: matrix.FontInstance[];
    private imageCache: Image[];
    private paintingInstructionCache: PaintingInstructionCache; // Handles effect updates
    private startTime: Date;
    private currentTime: Date;
    private duration: number; // milliseconds

    constructor(matrixOptions: matrix.MatrixOptions, runtimeOptions: matrix.RuntimeOptions){
        this.canvas = new Canvas(matrixOptions, runtimeOptions); // May come in handy for display size, etc.
        this.matrix = new matrix.LedMatrix(matrixOptions, runtimeOptions);
        this.fontCache = [] as matrix.FontInstance[];
        this.imageCache = [] as Image[];
        this.paintingInstructionCache = {} as PaintingInstructionCache;
        this.startTime = new Date();
        this.currentTime = new Date();
        this.duration = this.currentTime.getTime() - this.startTime.getTime();
    }

    private tick(): void {
        this.currentTime = new Date();
        this.duration = this.currentTime.getTime() - this.startTime.getTime();
    }

    public clear(): void {
        this.matrix.clear();
    }

    public resetClock(): void { // Use to start effects from "zero".
        this.startTime = new Date();
        this.tick();
    }

    public getCanvas(): Canvas{
        return this.canvas;
    };

    public getFontInstance(name: string, path: string): matrix.FontInstance {
        let cachedFont = this.fontCache.find((font: matrix.FontInstance) => {
            return font.name() == name && font.path() == path;
        });
        if(cachedFont == undefined){
            // Push font
            cachedFont = new matrix.Font(name, path);
            this.fontCache.push(cachedFont);
        }
        return cachedFont;
    }

    public getImageInstance(imagePath: string): Promise<Image> {
        return new Promise<Image>((resolve, reject) => {
            let cachedImage = this.imageCache.find((image: Image) => {
                return image.path == imagePath;
            });
            if(cachedImage != undefined){
                resolve(cachedImage);
            }
            if(cachedImage == undefined){
                let content: number[][];
                Jimp.read(imagePath)
                    .then((res: Jimp) => {
                        let width = res.getWidth();
                        let height = res.getHeight();
                        content = Array(width);
                        for(let x = 1; x <= width; x++){
                            content[x - 1] = Array(height);
                            for(let y = 1; y <= width; y++){
                                content[x - 1][y - 1] = (res.getPixelColor(x, y) >>> 0); // Convert to unsigned 32-bit int.
                            }
                        }
                        cachedImage = {
                            path: imagePath,
                            width: width,
                            height: height,
                            content: content
                        } as Image;
    
                        this.imageCache.push(cachedImage);
                        resolve(cachedImage);
                    }, (rej: any) => {
                        reject(rej);
                        console.log("Error reading image.");
                        throw Error("Error reading image.");
                    });
            }
        });
        
    }

    protected fillBlankCanvasSections(): void {
        // TODO will probably have to come back here to fix multiple boards
        let width = this.matrix.width();
        let height = this.matrix.height();
        let map: boolean[][] = [[]];

        for(let y = 0; y < height; y++){
            map[y] = Array(width);
            map[y].fill(false, 0, width);
        }
        

        let sections: CanvasSection[] = this.getCanvas().getCanvasSections();

        // TODO There's probably a better implementation for this, but for now...
        // Loop through sections and mark all pixels that are touched.

        this.matrix.fgColor(0x000000);

        sections.forEach(section => {
            for(let y = section.y; y < section.y + section.height; y++){
                map[y].fill(true, section.x, section.x + section.width);
            }
        });

        for(let y = 0; y < height; y++){
            for(let x = 0; x < width; x++){
                if(!map[y][x]){
                    this.matrix.setPixel(x, y);
                }
            }
        }

    }

    private getPaintingInstructionSize(paintingInstruction: PaintingInstruction): {width: number, height: number} {
        switch (paintingInstruction.drawMode){
            case DrawMode.POLYGON: {
                let bounds = (paintingInstruction.points as Point[])
                    .map((value) => {return {minx: value.x, maxx: value.x, miny: value.y, maxy: value.y};})
                    .reduce((prev, curr) => {
                        return {minx: Math.min(prev.minx, curr.minx), maxx: Math.max(prev.maxx, curr.maxx), miny: Math.min(prev.miny, curr.miny), maxy: Math.max(prev.maxy, curr.maxy)};
                    });
                return {width: (bounds.maxx - bounds.minx + 1), height: (bounds.maxy - bounds.miny + 1)};
            }
            case DrawMode.PIXEL: {
                let bounds = (paintingInstruction.points as Point[])
                    .map((value) => {return {minx: value.x, maxx: value.x, miny: value.y, maxy: value.y};})
                    .reduce((prev, curr) => {
                        return {minx: Math.min(prev.minx, curr.minx), maxx: Math.max(prev.maxx, curr.maxx), miny: Math.min(prev.miny, curr.miny), maxy: Math.max(prev.maxy, curr.maxy)};
                    });
                return {width: (bounds.maxx - bounds.minx + 1), height: (bounds.maxy - bounds.miny + 1)};
            }
            case DrawMode.TEXT: {
                let text = (paintingInstruction.text as string);
                let font = this.getFontInstance((((paintingInstruction as PaintingInstruction).drawModeOptions as DrawModeOption).font as string), (((paintingInstruction as PaintingInstruction).drawModeOptions as DrawModeOption).fontPath as string));
                let textwidth = font.stringWidth(text);
                let textheight = font.height();
                return {width: textwidth, height: textheight};
            }
            case DrawMode.IMAGE: {
                this.getImageInstance(paintingInstruction.imagePath as string)
                    .then((res) => {
                        return {width: res.width, height: res.height};
                    }, (rej) => {
                        return {width: 0, height: 0};
                    });
            }
            default: {
                return {width: paintingInstruction?.width as number, height: paintingInstruction?.height as number};
            }
        }
    }

    private applyEffects(paintingInstruction: PaintingInstruction, canvasSection: CanvasSection): PaintingInstruction | null { // Updates a given PaintingInstruction to transpose over time.
        let newPaintingInstruction = paintingInstruction;
        let dimensions: {width: number, height: number} = this.getPaintingInstructionSize(newPaintingInstruction);
        let delta_x: number = 0;
        let delta_y: number = 0;
        let color: number = paintingInstruction.color;
        let draw: boolean = true;

        newPaintingInstruction.drawModeOptions?.effects?.forEach((effect) => {
            switch(effect.effectType){
                case EffectType.SCROLLLEFT: {
                    if((this.paintingInstructionCache[paintingInstruction.id].points as Point).x + dimensions.width < canvasSection.x){
                        delta_x = canvasSection.width; // Wrap text to right edge.
                    }
                    else {
                        delta_x = canvasSection.width - ((this.duration / effect.effectOptions.rate) % (dimensions.width + canvasSection.width)); // rate expressed as ms/pixel.
                    }
                    break;
                }
                case EffectType.SCROLLRIGHT: {
                    if((this.paintingInstructionCache[paintingInstruction.id].points as Point).x > (canvasSection.x + canvasSection.width)){
                        delta_x = (dimensions.width); // Wrap text to left edge.
                    }
                    else {
                        delta_x = ((this.duration / effect.effectOptions.rate) % (dimensions.width + canvasSection.width)) - dimensions.width; // rate expressed as ms/pixel.
                    }
                    break;
                }
                case EffectType.SCROLLUP: { 
                    if((this.paintingInstructionCache[paintingInstruction.id].points as Point).y < (canvasSection.y - canvasSection.height)){
                        delta_y = (canvasSection.height); // Wrap text to left edge.
                    }
                    else {
                        delta_y = canvasSection.height + ((this.duration / effect.effectOptions.rate) % (dimensions.height + canvasSection.height)); // rate expressed as ms/pixel.
                    }
                    break;
                }
                case EffectType.SCROLLDOWN: {
                    if((this.paintingInstructionCache[paintingInstruction.id].points as Point).y > (canvasSection.y + canvasSection.height)){
                        delta_y = 0 - (dimensions.height); // Wrap text to left edge.
                    }
                    else {
                        delta_y = ((this.duration / effect.effectOptions.rate) % (dimensions.height + canvasSection.height)) - dimensions.height; // rate expressed as ms/pixel.
                    }
                    break;
                }
                case EffectType.BLINK: {
                    if(Math.floor(this.duration / effect.effectOptions.rate) % 2 == 1){
                        draw = false;
                    }
                    break;
                }
                case EffectType.PULSE: {
                    let r = (Math.floor(((color & 0xFF0000) >>> 0) * ((effect.effectOptions.rate - (this.duration % effect.effectOptions.rate)) / effect.effectOptions.rate)) >>> 16);
                    let g = (Math.floor(((color & 0x00FF00) >>> 0) * ((effect.effectOptions.rate - (this.duration % effect.effectOptions.rate)) / effect.effectOptions.rate)) >>> 8);
                    let b = Math.floor(((color & 0x0000FF) >>> 0) * ((effect.effectOptions.rate - (this.duration % effect.effectOptions.rate)) / effect.effectOptions.rate));

                    newPaintingInstruction.color = (r << 16) + (g << 8) + b;
                    break;
                }
            }
        });

        // Apply delta to all Points
        if(paintingInstruction.drawMode == DrawMode.POLYGON || paintingInstruction.drawMode == DrawMode.PIXEL){
            (newPaintingInstruction.points as Point[]).forEach((point) => {
                point.x += delta_x;
                point.y += delta_y;
            });
        }
        else {
            (newPaintingInstruction.points as Point).x += delta_x;
            (newPaintingInstruction.points as Point).y += delta_y;
        }

        return draw ? newPaintingInstruction : null;
    }

    public paint(): void {
        // How can I crop the CanvasSection if there's overflow?
        // How about before we draw each CanvasSection we do a fill with black on the section?  It would work for the next section being drawn...
        // In other words, if there are empty portions of the canvas, we should fill them in with black as well.  Some clever maths will help.
        this.tick();
	    this.matrix.clear();

        let instructionPromises: Promise<PaintingInstruction | null>[] = [] as Promise<PaintingInstruction | null>[];

        this.getCanvas().getCanvasSections().sort((a, b) => {return a.z - b.z;}).forEach((canvasSection: CanvasSection) => {
            // Blank out the CanvasSection.
            this.matrix.fgColor(0x000000);
            this.matrix.fill(canvasSection.x, canvasSection.y, canvasSection.x + canvasSection.width - 1, canvasSection.y + canvasSection.height - 1);
            // this.matrix.sync();

            // TODO Use promise.all() so we know everything's been drawn to the screen.
            canvasSection.get().representation.sort((a, b) => {return a.layer - b.layer}).forEach(paintingInstruction => {     
                let dereferencedPaintingInstruction: PaintingInstruction = JSON.parse(JSON.stringify(paintingInstruction));

                instructionPromises.push(new Promise((resolve, reject) => {
                    if(this.paintingInstructionCache[dereferencedPaintingInstruction.id] == undefined){
                        this.paintingInstructionCache[dereferencedPaintingInstruction.id] = dereferencedPaintingInstruction;
                    } 

                    // Do stuff here.
                    switch (dereferencedPaintingInstruction.drawMode){
                        case DrawMode.LINE: {
                            let newPaintingInstruction = this.applyEffects(dereferencedPaintingInstruction, canvasSection);
                            let x0 = (newPaintingInstruction?.points as Point[])[0].x + canvasSection.x;
                            let y0 = (newPaintingInstruction?.points as Point[])[0].y + canvasSection.y;
                            let x1 = (newPaintingInstruction?.points as Point[])[1].x + canvasSection.x;
                            let y1 = (newPaintingInstruction?.points as Point[])[1].y + canvasSection.y;
                            let color = newPaintingInstruction?.color;

                            this.matrix.fgColor(color!);
                            this.matrix.drawLine(x0, y0, x1, y1);
                            resolve(newPaintingInstruction);
                            break;
                        }
                        case DrawMode.RECTANGLE: { 
                            let newPaintingInstruction = this.applyEffects(dereferencedPaintingInstruction, canvasSection);
                            let x = (newPaintingInstruction?.points as Point).x + canvasSection.x;
                            let y = (newPaintingInstruction?.points as Point).y + canvasSection.y;
                            let width = newPaintingInstruction?.width as number;
                            let height = newPaintingInstruction?.height as number;
                            let color = newPaintingInstruction?.color;
                            let fill = newPaintingInstruction?.drawModeOptions?.fill || false;
                            this.matrix.fgColor(color!);
                            if(fill){
                                this.matrix.drawFilledRect(x, y, width, height);
                            }
                            else {
                                this.matrix.drawRect(x, y, width, height);
                            }
                            resolve(newPaintingInstruction);
                            break;
                        }
                        case DrawMode.CIRCLE: { 
                            let newPaintingInstruction = this.applyEffects(dereferencedPaintingInstruction, canvasSection);
                            let x = (newPaintingInstruction?.points as Point).x + canvasSection.x;
                            let y = (newPaintingInstruction?.points as Point).y + canvasSection.y;
                            let r = (newPaintingInstruction?.width as number) / 2;
                            let color = newPaintingInstruction?.color;
                            let fill = newPaintingInstruction?.drawModeOptions?.fill || false;
                            this.matrix.fgColor(color!); 
                            if(fill){
                                this.matrix.drawFilledCircle(x, y, r);
                            }
                            else {
                                this.matrix.drawCircle(x, y, r);
                            }
                            resolve(newPaintingInstruction);
                            break;
                        }
                        case DrawMode.ELLIPSE: {
                            console.error("Not implemented.");
                            resolve(dereferencedPaintingInstruction);
                            break;
                        }
                        case DrawMode.POLYGON: {
                            let newPaintingInstruction = this.applyEffects(dereferencedPaintingInstruction, canvasSection);
                            let color = newPaintingInstruction?.color;
                            let fill = newPaintingInstruction?.drawModeOptions?.fill || false;
                            let coordinateArray: number[] = [];
                            (newPaintingInstruction?.points as Point[]).forEach((point: Point) => {
                                coordinateArray.push(point.x + canvasSection.x);
                                coordinateArray.push(point.y + canvasSection.y);
                            });
                            this.matrix.fgColor(color!);
                            if(fill){
                                this.matrix.drawFilledPolygon(coordinateArray);
                            }
                            else {
                                this.matrix.drawPolygon(coordinateArray);
                            }
                            resolve(newPaintingInstruction);
                            break;
                        }
                        case DrawMode.PIXEL: { 
                            let newPaintingInstruction: PaintingInstruction = this.applyEffects(dereferencedPaintingInstruction, canvasSection) as PaintingInstruction;
                            this.matrix.fgColor(newPaintingInstruction.color);
                            if(newPaintingInstruction != null){
                                (newPaintingInstruction.points as Point[]).forEach((point: Point) => {
                                    this.matrix.setPixel(point.x + canvasSection.x, point.y + canvasSection.y);
                                });
                            }
                            resolve(newPaintingInstruction);
                            break;
                        }
                        case DrawMode.TEXT: {
                            let text = (dereferencedPaintingInstruction.text as string);
                            let x = (dereferencedPaintingInstruction.points as Point).x + canvasSection.x;
                            let y = (dereferencedPaintingInstruction.points as Point).y + canvasSection.y;
                            let color = (dereferencedPaintingInstruction.color);
                            let font = this.getFontInstance((((dereferencedPaintingInstruction as PaintingInstruction).drawModeOptions as DrawModeOption).font as string), (((dereferencedPaintingInstruction as PaintingInstruction).drawModeOptions as DrawModeOption).fontPath as string));
                            let textwidth = font.stringWidth(text);
                            let textheight = font.height();
                            let draw: boolean = true;
                           
                            let newPaintingInstruction = this.applyEffects(dereferencedPaintingInstruction, canvasSection);


                            if(newPaintingInstruction != null){
                                color = newPaintingInstruction.color;
                                x = (newPaintingInstruction?.points as Point).x + canvasSection.x;
                                y = (newPaintingInstruction?.points as Point).y + canvasSection.y;
                            }
                            else {
                                draw = false;
                            }
                            
                            if(draw){
                                this.matrix.font(font);
                                this.matrix.fgColor(color!);
                                let running_x = x;
                                for(let c = 0; c < text.length; c++){
                                    this.matrix.drawText(text.charAt(c), running_x, y);
                                    running_x += font.stringWidth(text.charAt(c));
                                }
                            }
                            resolve(newPaintingInstruction);
                            break;
                        }
                        case DrawMode.IMAGE: { 
                            // Loop through image points and use SetPixel.
                            let newPaintingInstruction: PaintingInstruction = this.applyEffects(dereferencedPaintingInstruction, canvasSection) as PaintingInstruction;
                            this.getImageInstance(dereferencedPaintingInstruction.imagePath!)
                                .then((res: Image) => {
                                    
                                    let imageInstance: Image = res;
                                    let x = (newPaintingInstruction.points as Point).x + canvasSection.x;
                                    let y = (newPaintingInstruction.points as Point).y + canvasSection.y;

                                    for(let img_y = 0; img_y < imageInstance.height; img_y++){
                                        for(let img_x = 0; img_x < imageInstance.width; img_x++){
                                            if((imageInstance.content[img_x][img_y] & 0x000000FF) != 0){ // Alpha 0 is not drawn.
                                                this.matrix.fgColor(imageInstance.content[img_x][img_y] >>> 8);
                                                this.matrix.setPixel(x + img_x, y + img_y);
                                            }
                                        }
                                    }
                                    // this.matrix.sync();
                                }, (rej) => {
                                    console.log(rej);
                                });
                                resolve(newPaintingInstruction);
                                break;
                        }

                        // case DrawMode.BUFFER: {
                        //     this.matrix.drawBuffer(paintingInstruction.buffer!, paintingInstruction.width!, paintingInstruction.height!); // TODO better definition.
                        // }
                    }
                }));
            });
            // Debugging...
            // this.matrix.fgColor(0x00FF00); // Draw bound on CanvasSection
            // this.matrix.setPixel(canvasSection.x, canvasSection.y);
            // this.matrix.setPixel(canvasSection.x + canvasSection.width - 1, canvasSection.y);
            // this.matrix.setPixel(canvasSection.x + canvasSection.width - 1, canvasSection.y + canvasSection.height - 1);
            // this.matrix.setPixel(canvasSection.x, canvasSection.y + canvasSection.height - 1);
        });

        Promise.all(instructionPromises)
            .then((res) => {
                this.fillBlankCanvasSections();
                this.matrix.sync();
            }, (rej) => {
                this.fillBlankCanvasSections();
                this.matrix.sync();
	    });
    }

}
