/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */

import { arcToPath } from '../geo/paths.js';
import { consts } from './consts.js';
import { newPoint } from '../geo/point.js';
import { util } from '../geo/base.js';

const { numOrDefault } = util;
const { beltfact } = consts;

const XAXIS = new THREE.Vector3(1,0,0);
const DEG2RAD = Math.PI / 180;

class Print {
    constructor(settings, widgets, id) {
        this.id = id || new Date().getTime().toString(36);
        this.settings = settings;
        this.widgets = widgets;
        this.lastPoint = null;
        this.lastPoly = null;
        this.lastEmit = null;
        this.lastOut = null;
        this.lastPos = null;
        this.tools = {};
        // set to 1 to enable flow rate analysis (console)
        this.debugE = settings ? (settings.controller.devel ? 1 : 0) : 0;
    }

    setType(type) {
        this.nextType = type;
    }

    // allows for gcode object id annotations enabling
    // discrete object cancellation during print (bambu)
    setWidget(widget) {
        this.widget = widget;
    }

    /**
     * addOutput - add a new point to the output gcode array
     * @param  {any[]} array  - the output gcode array
     * @param  {Point} point  - the new point
     * @param  {number} emit   - the extrusion value
     * @param  {number} speed  - the feed rate
     * @param  {string} tool  - the tool id
     * @param  {"lerp"|string} opts.type  - the output type
     * @param  {Point} opts.center  - the center of the arc
     * @param  {Point[]} opts.arcPoints  - point based approximation of arc used for rendering
     * @param  {unknown} opts.retract  - the retraction value used for FDM
     * @return {Output}       - the new output object
     */
    addOutput(array, point, emit, speed, tool, opts) {
        const { type, retract, center, arcPoints} = opts ?? {};
        let { lastPoint, lastEmit, lastOut } = this;
        let arc = emit == 2 || emit == 3;
        // drop duplicates (usually intruced by FDM bisections)
        if (lastPoint && point && !arc && type !== 'lerp') {
            // nested due to uglify confusing browser
            const { x, y, z } = lastPoint;
            if (point.x == x && point.y == y && point.z == z && lastEmit == emit) {
                return lastOut;
            }
        }
        // if (emit && emit < 1) console.log(emit);
        this.lastPoint = point;
        this.lastEmit = emit;
        this.lastOut = lastOut = new Output(point, emit, speed, tool, {
            type: type ?? this.nextType,
            center,
            arcPoints,
        });
        if (tool !== undefined) {
            this.tools[tool] = true;
        }
        lastOut.retract = retract;
        lastOut.widget = this.widget;
        array.push(lastOut);
        // console.log("addOutput Called", structuredClone({lastOut,array}))
        this.nextType = undefined;
        return lastOut;
    }

    addPrintPoints(input, output, startPoint, tool) {
        if (this.startPoint && input.length > 0) {
            this.lastPoint = this.startPoint;
            // TODO: revisit seek to origin as the first move
            // addOutput(output, startPoint, 0, undefined, tool);
        }
        output.appendAll(input);
    }
    
    /**
     * Prints a polygon to a given output array, possibly with a given extrude factor,
     * and starting from a given point. The last point is returned.
     * used for FDM and laser
     * @param {Polygon} poly - the polygon to print
     * @param {Point} startPoint - the point to start printing from
     * @param {Array} output - the array to print to
     * @param {Object} [options] - optional parameters
     * @param {boolean} [options.ccw] - set the polygon to be counter-clockwise
     * @param {number} [options.extrude] - extrude factor for the polygon
     * @param {number} [options.rate] - print speed in mm/s
     * @param {number} [options.coast] - distance to coast at the end of the polygon
     * @param {number} [options.simple] - if true, use the first point of the polygon
     * @param {number} [options.open] - if true, don't close the polygon
     * @param {number} [options.tool] - the tool to use
     * @param {function} [options.onfirst] - called with the first point of the polygon
     * @param {function} [options.onfirstout] - called with the first output point
     * @returns {Point} the last point of the polygon
     */
    polyPrintPath(poly, startPoint, output, options = {}) {
        if (options.ccw) {
            poly.setCounterClockwise();
        } else {
            poly.setClockwise();
        }

        const scope = this;
        const { settings } = scope;
        const { process } = settings;

        let shortDist = process.outputShortDistance,
            shellMult = numOrDefault(options.extrude, process.outputShellMult),
            printSpeed = options.rate || process.outputFeedrate,
            moveSpeed = process.outputSeekrate,
            minSpeed = process.outputMinSpeed,
            coastDist = options.coast || 0,
            closest = options.simple ? poly.first() : poly.findClosestPointTo(startPoint),
            perimeter = poly.perimeter(),
            close = !options.open,
            tool = options.tool,
            last = startPoint,
            first = true;

        // if short, use calculated print speed based on sliding scale
        if (perimeter < process.outputShortPoly) {
            printSpeed = minSpeed + (printSpeed - minSpeed) * (perimeter / process.outputShortPoly);
        }

        poly.forEachPoint((point, pos, points, count) => {
            if (first) {
                if (options.onfirst) {
                    options.onfirst(point);
                }
                // move to first output point on poly
                let out = scope.addOutput(output, point, 0, moveSpeed, tool);
                if (options.onfirstout) {
                    options.onfirstout(out);
                }
                first = false;
            } else {
                let seglen = last.distTo2D(point);
                if (coastDist && shellMult && perimeter - seglen <= coastDist) {
                    let delta = perimeter - coastDist;
                    let offset = seglen - delta;
                    let offPoint = last.offsetPointFrom(point, offset)
                    scope.addOutput(output, offPoint, shellMult, printSpeed, tool);
                    shellMult = 0;
                }
                perimeter -= seglen;
                scope.addOutput(output, point, shellMult, printSpeed, tool);
            }
            last = point;
        }, close, closest.index);

        this.lastPoly = poly;

        return output.last().point;
    }

    constReplace(str, consts, start, pad, short) {
        let cs = str.indexOf("{", start || 0),
            ce = str.indexOf("}", cs),
            tok, nutok, nustr;
        if (cs >=0 && ce > cs) {
            tok = str.substring(cs+1,ce);
            let eva = [];
            for (let [k,v] of Object.entries(consts)) {
                switch (typeof v) {
                    case 'object':
                        eva.push(`let ${k} = ${JSON.stringify(v)};`);
                        break;
                    case 'number':
                    case 'boolean':
                        eva.push(`let ${k} = ${v};`);
                        break;
                    default:
                        if (v === undefined) v = '';
                        eva.push(`let ${k} = "${v.replace(/\"/g,"\\\"")}";`);
                        break;
                }
            }
            eva.push(`function range(a,b) { return (a + (layer / layers) * (b-a)).round(4) }`);
            eva.push(`try {( ${tok} )} catch (e) {console.log(e);0}`);
            let scr = eva.join('');
            let evl = eval(`{ ${scr} }`);
            nutok = evl;
            if (pad === 666) {
                return evl;
            }
            if (pad) {
                nutok = nutok.toString();
                let oldln = ce-cs+1;
                let tokln = nutok.length;
                if (tokln < oldln) {
                    short = (short || 1) + (oldln - tokln);
                }
            }
            nustr = str.replace("{"+tok+"}",nutok);
            return this.constReplace(nustr, consts, ce+1+(nustr.length-str.length), pad, short);
        } else {
            // insert compensating spaces for accumulated replace string shortages
            if (short) {
                let si = str.indexOf(';');
                if (si > 0) {
                    str = str.replace(';', ';'.padStart(short,' '));
                }
            }
            return str;
        }
    }

    parseSVG(code, offset) {
        let scope = this,
            svg = new DOMParser().parseFromString(code, 'text/xml'),
            lines = [...svg.getElementsByTagName('polyline')],
            output = scope.output = [],
            bounds = scope.bounds = {
                max: { x:-Infinity, y:-Infinity, z:-Infinity},
                min: { x:Infinity, y:Infinity, z:Infinity}
            };
        lines.forEach(line => {
            let seq = [];
            let points = [...line.points];
            points.forEach(point => {
                if (offset) {
                    point.x += offset.x;
                    point.y += offset.y;
                }
                if (point.x) bounds.min.x = Math.min(bounds.min.x, point.x);
                if (point.x) bounds.max.x = Math.max(bounds.max.x, point.x);
                if (point.y) bounds.min.y = Math.min(bounds.min.y, point.y);
                if (point.y) bounds.max.y = Math.max(bounds.max.y, point.y);
                if (point.z) bounds.min.z = Math.min(bounds.min.z, point.z);
                if (point.z) bounds.max.z = Math.max(bounds.max.z, point.z);
                const { x, y, z } = point; // SVGPoint is not serializable
                scope.addOutput(seq, { x, y, z }, seq.length > 0);
            });
            output.push(seq);
        });
        scope.imported = code;
        scope.lines = lines.length;
        scope.bytes = code.length;
        return scope.output;
    };

    parseGCode(gcode, offset, progress, done, opts = {}) {
        const fdm = opts.fdm;
        const cam = opts.cam;
        const belt = opts.belt;
        const lines = gcode
            .toUpperCase()
            .replaceAll("X", " X")
            .replaceAll("Y", " Y")
            .replaceAll("Z", " Z")
            .replaceAll("A", " A")
            .replaceAll("E", " E")
            .replaceAll("F", " F")
            .replaceAll("G", " G")
            .replaceAll("I", " I")
            .replaceAll("J", " J")
            .replaceAll("  ", " ")
            .split("\n");

        const scope = this,
            // morph = false,
            morph = true,
            bounds = scope.bounds = {
                max: { x:-Infinity, y:-Infinity, z:-Infinity},
                min: { x:Infinity, y:Infinity, z:Infinity}
            },
            pos = {
                X: 0.0,
                Y: 0.0,
                Z: 0.0,
                A: 0.0,
                F: 0.0,
                E: 0.0
            },
            off = {
                x: offset ? offset.x || 0 : 0,
                y: offset ? offset.y || 0 : 0,
                z: offset ? offset.z || 0 : 0
            },
            xoff = {
                x: 0,
                y: 0,
                z: 0
            };

        let dz = 0,
            abs = true,
            absE = true,
            defh = 0,
            height = 0,
            factor = 1,
            tool = 0,
            time = 0,
            minf = Infinity,
            maxf = 0,
            seq = [],
            autolayer = true,
            newlayer = false,
            hasmoved = false,
            lastG = 'G1';

        const output = scope.output = [ seq ];
        const beltaxis = { X: "X", Y: "Z", Z: "Y", E: "E", F: "F" };

        function LOG() {
            console.log(...[...arguments].map(o => Object.clone(o)));
        }

        /**
         * @function processLine
         * @description parses a line of g-code into individual axis movements
         * @param {string[]} line - the line of g-code as an array of strings,
         *                          each representing a single axis movement
         * @param {Object} axes - an object to store the axis values
         * @returns {Object} an object containing the current and previous points
         */
        function processLine(line, axes) {
            const prevPoint = newPoint(
                factor * pos.X ,
                factor * pos.Y ,
                factor * pos.Z + dz
            )
            .add(xoff)
            .add(off);

            // apply origin offset
            // for (let layer of output) {
            //     for (let rec of layer) {
            //         let point = rec.point;
            //         point.x += off.X;
            //         point.y += off.Y;
            //         point.z += off.Z;
            //     }
            // }

            const point = prevPoint.clone()

            line.forEach(tok => {
                let axis = tok.charAt(0).toUpperCase();
                if (morph && belt) {
                    axis = beltaxis[axis];
                }
                // console.log("position updated",structuredClone(pos))

                let val = parseFloat(tok.substring(1));
                axes[axis] = val;
                // if( axis == 'I' || axis == "J") return
                if (abs) {
                    pos[axis] = val;
                    
                    if (axis == "X") point.x = factor * pos.X + xoff.x + off.x
                    else if (axis == "Y") point.y = factor * pos.Y + xoff.y + off.y
                    else if (axis == "Z") point.z = factor * pos.Z + xoff.z + off.z + dz
                    

                } else {
                    mov[axis] = val;
                    pos[axis] += val;
                }
                // console.log("position updated",structuredClone(pos))
            });

            let center;
            if(axes.I !== undefined && axes.J !== undefined) {
                center = newPoint(
                    factor* axes.I+ xoff.x,
                    factor* axes.J+ xoff.y,
                    0,
                );
            }else if(axes.R !== undefined) {
                center = newPoint(
                    factor* Math.cos(axes.R * DEG2RAD),
                    factor* Math.sin(axes.R * DEG2RAD),
                    0,
                );
            }
            if(center){
                center = center.add(prevPoint);
                center.setZ((prevPoint.z+point.z)/2+dz);
            }
            
            return {
                center,
                point,
                prevPoint
            };
        }

        function outputPoint(point,lastP,emit,{center,arcPoints,retract}) {
            
            // non-move in a new plane means burp out
            // the old sequence and start a new one
            
            if (newlayer || (autolayer && seq.z != point.z)) {
                newlayer = false;
                let dz = point.z - seq.z;
                let nh = dz > 0 ? dz : defh;
                seq = [];
                seq.height = height = nh;
                if (fdm) dz = -height / 2;
                output.push(seq);
            }

            if (!hasmoved) {
                seq.height = seq.z = pos.Z;
                hasmoved = true;
            }

            // debug extrusion rate
            const lastPos = scope.lastPos;
            if (scope.debugE && fdm && lastPos && pos.E) {
                // extruder move
                let dE = (absE ? pos.E - scope.lastPosE : pos.E);
                // distance moved in XY
                let dV = point.distTo2D(lastP);
                // debug print time
                time += (dV * pos.F) / 1000;
                // filament per mm
                let dR = (dE / dV);
                if (dV > 2 && dE > 0.001) {
                    let lab = (absE ? 'aA' : 'rR')[scope.debugE++ % 2];
                    console.log(lab, height.toFixed(2), dV.toFixed(2), dE.toFixed(3), dR.toFixed(4), pos.F.toFixed(0));
                }
            }
            // add point to current sequence
            scope.addOutput(seq, point, emit, pos.F, tool,{retract,arcPoints});
            scope.lastPos = Object.assign({}, pos);
            scope.lastPosE = pos.E;
        }

        /**
         * Handles G2 and G3 arcs, which are circular arcs.
         * @param {boolean} g2 - Whether this is a G2 or G3 arc. G2 is a clockwise arc, G3 is a counter-clockwise arc.
         * @param {string[]} line - The line of the g-code file that contains the G2 or G3 command.
         * @param {number} index - The line number of the g-code file that contains the G2 or G3 command.
         */
        function G2G3(g2, line, index) {
            const axes = {};
            const {point, prevPoint, center} = processLine(line,axes);

            // console.log(structuredClone({point,prevPoint,center}));

            let arcPoints = arcToPath( prevPoint, point, 64,{ clockwise:g2,center}) ?? []
            let emit = g2 ? 2 : 3;

            // console.log("clone point",structuredClone({point,prevPoint,center,arcPoints,emit}));
            // console.log("pointer point",{point,prevPoint,center,arcPoints,emit});

            outputPoint(point,prevPoint,emit,{center,arcPoints});
            // scope.addOutput(seq, point, emit, pos.F, tool,{center,arcPoints});
        }

        function G0G1(g0, line) {
            const mov = {};
            const axes = {};

            lastG = g0 ? 'G0' : 'G1';
            const {point, prevPoint} = processLine(line,axes);

            if (morph && belt) {
                point.y -= point.z * beltfact;
                point.z *= beltfact;
            }

            if (pos.A) {
                let ip = new THREE.Vector3(pos.X, pos.Y, pos.Z)
                    .applyAxisAngle(XAXIS, -pos.A * DEG2RAD);
                point.x = ip.x;
                point.y = ip.y;
                point.z = ip.z;
            }

            const retract = (fdm && pos.E < 0) || undefined;
            const moving = g0 || (fdm && (pos.E <= 0 || !(axes.X || axes.Y || axes.Z)));

            if (!moving && point.x) bounds.min.x = Math.min(bounds.min.x, point.x);
            if (!moving && point.x) bounds.max.x = Math.max(bounds.max.x, point.x);
            if (!moving && point.y) bounds.min.y = Math.min(bounds.min.y, point.y);
            if (!moving && point.y) bounds.max.y = Math.max(bounds.max.y, point.y);
            if (!moving && point.z) bounds.min.z = Math.min(bounds.min.z, point.z);
            if (!moving && point.z) bounds.max.z = Math.max(bounds.max.z, point.z);

            // update max speed
            if (pos.F) minf = Math.min(minf, pos.F);
            maxf = Math.max(maxf, pos.F);

            // always add moves to the current sequence
            if (moving) {
                // console.log("move",structuredClone(point))
                scope.addOutput(seq, point, 0, pos.F, tool,{retract})
                scope.lastPos = Object.assign({}, pos);
                return;
            }
            if (seq.z === undefined) {
                seq.z = point.z;
            }
            if (fdm && height === 0) {
                seq.height = defh = height = pos.Z;
            }

            outputPoint(point,prevPoint,1,{retract})
        }

        const linemod = cam ? Math.ceil(lines.length / 2500) : 0;

        lines.forEach((line, idx) => {
            if (linemod && idx % linemod === 0) {
                newlayer = true;
                autolayer = false;
            }
            if (line.indexOf(';LAYER:') === 0) {
                newlayer = true;
                autolayer = false;
            }
            if (line.indexOf('- LAYER ') > 0) {
                seq.height = defh;
                const hd = line.replace('(','').replace(')','').split(' ');
                defh = parseFloat(hd[4]);
                if (fdm) dz = -defh / 2;
                newlayer = true;
                autolayer = false;
            }
            // if (["X","Y","Z"].indexOf(line.charAt(0)) >= 0) {
            //     line = `G0${line}`;
            // }
            line = line.trim().split(";")[0].split(" ").filter(v => v);
            if (!line.length) return;
            const c0 = line[0].charAt(0);
            let cmd = ["X","Y","Z"].indexOf(c0) >= 0 ? lastG : line.shift();
            if (!cmd) return;
            if (cmd.charAt(0) === 'T') {
                let ext = scope.settings.device.extruders;
                let pos = parseInt(cmd.charAt(1));
                if (ext && ext[pos]) {
                    xoff.X = -ext[pos].extOffsetX;
                    xoff.Y = -ext[pos].extOffsetY;
                }
            }

            pos.E = 0.0;
            switch (cmd) {
                case 'M82':
                    absE = true;
                    break;
                case 'M83':
                    absE = false;
                    break;
                case 'G20':
                    factor = 25.4;
                    break;
                case 'G21':
                    factor = 1;
                    break;
                case 'G90':
                    // absolute positioning
                    abs = true;
                    break;
                case 'G91':
                    // relative positioning
                    abs = false;
                    break;
                case 'G92':
                    line.forEach(tok => {
                        pos[tok.charAt(0)] = parseFloat(tok.substring(1));
                    });
                    break;
                case 'G10':
                    if (seq && seq.length) {
                        seq.last().retract = true;
                    }
                    break;
                case 'G11':
                    break;
                case 'G0':
                    G0G1(1, line);
                    break;
                case 'G1':
                    G0G1(0, line);
                    break;
                case 'G2':
                    // turn arc into a series of points
                    G2G3(true, line, idx)
                    break;
                case 'G3':
                    // turn arc into a series of points
                    G2G3(false, line, idx);
                    break;
                case 'M6':
                    tool = parseInt(line[0].substring(1));
                    break;
            }
        });
        scope.imported = gcode;
        scope.lines = lines.length;
        scope.bytes = gcode.length;
        scope.minSpeed = Math.floor(minf / 60);
        scope.maxSpeed = Math.floor(maxf / 60);
        scope.belt = belt;

        if (scope.debugE) {
            console.log({ bounds, print_time: time.round(2) });
        }

        console.log(scope.output)
        done({ output: scope.output });
    }
}

class Output {
    /**
     * Construct a new output element.
     * 
     * in cam, emit is the G code number (G0, G1, G2, G3)
     *
     * @param {Point} point point to emit, with x, y, and z properties
     * @param {number} emit emit (feed for printers, power for lasers, cut for cam)
     * @param {number} speed speed in mm/min
     * @param {number} tool tool id
     * @param {Object} options options object
     * @param {string} [options.type] type of point
     * @param {Point} [options.center] the center of the arc
     * @param {Point[]} [options.arcPoints] point based approximation of arc
     */
    constructor(point, emit, speed, tool, options) {

        const { type, center, arcPoints } = (options ?? {});
        //speed, tool, type, center, arcPoints
        this.point = point; 
        this.emit = Number(emit); //convert bools into 0/1
        this.speed = speed;
        this.tool = tool;
        this.type = type;
        this.center = center;
        this.arcPoints = arcPoints;
        // this.where = new Error().stack.split("\n");
    }

    clone(z) {
        let o = new Output(
            this.point.clone(),
            this.emit,
            this.speed,
            this.tool,
            this.type
        );
        if (z !== undefined) {
            o.point.setZ(z);
        }
        return o;
    }

    set_retract() {
        this.retract = true;
        return this;
    }
}

function newPrint(settings, widgets, id) {
    return new Print(settings, widgets, id);
};

export {
    Print,
    newPrint
};
