import paper from '@scratch/paper';
import log from '../../log/log';
import BroadBrushHelper from './broad-brush-helper';
import SegmentBrushHelper from './segment-brush-helper';
import {MIXED, styleCursorPreview} from '../../helper/style-path';
import {clearSelection} from '../../helper/selection';
import {getGuideLayer} from '../../helper/layer';

/**
 * Shared code for the brush and eraser mode. Adds functions on the paper tool object
 * to handle mouse events, which are delegated to broad-brush-helper and segment-brush-helper
 * based on the brushSize in the state.
 */
class Blobbiness {
    static get BROAD () {
        return 'broadbrush';
    }
    static get SEGMENT () {
        return 'segmentbrush';
    }

    // If brush size >= threshold use segment brush, else use broadbrush
    // Segment brush has performance issues at low threshold, but broad brush has weird corners
    // which get more obvious the bigger it is
    static get THRESHOLD () {
        return 9;
    }

    /**
     * @param {function} onUpdateSvg call when the drawing has changed to let listeners know
     * @param {function} clearSelectedItems Callback to clear the set of selected items in the Redux state
     */
    constructor (onUpdateSvg, clearSelectedItems) {
        this.broadBrushHelper = new BroadBrushHelper();
        this.segmentBrushHelper = new SegmentBrushHelper();
        this.onUpdateSvg = onUpdateSvg;
        this.clearSelectedItems = clearSelectedItems;

        // The following are stored to check whether these have changed and the cursor preview needs to be redrawn.
        this.strokeColor = null;
        this.brushSize = null;
        this.fillColor = null;
    }
    
    /**
     * Set configuration options for a blob
     * @param {!object} options Configuration
     * @param {!number} options.brushSize Width of blob marking made by mouse
     * @param {!boolean} options.isEraser Whether the stroke should be treated as an erase path. If false,
     *     the stroke is an additive path.
     * @param {?string} options.fillColor Color of the brush stroke.
     * @param {?string} options.strokeColor Color of the brush outline.
     * @param {?number} options.strokeWidth Width of the brush outline.
     */
    setOptions (options) {
        const oldFillColor = this.options ? this.options.fillColor : 'black';
        const oldStrokeColor = this.options ? this.options.strokeColor : null;
        const oldStrokeWidth = this.options ? this.options.strokeWidth : null;
        // If values are mixed, it means the color was set by a selection contained multiple values.
        // In this case keep drawing with the previous values if any. (For stroke width, null indicates
        // mixed, because stroke width is required to be a number)
        this.options = {
            ...options,
            fillColor: options.fillColor === MIXED ? oldFillColor : options.fillColor,
            strokeColor: options.strokeColor === MIXED ? oldStrokeColor : options.strokeColor,
            strokeWidth: options.strokeWidth === null ? oldStrokeWidth : options.strokeWidth
        };
        this.resizeCursorIfNeeded();
    }

    /**
     * Adds handlers on the mouse tool to draw blobs. Initialize with configuration options for a blob.
     * @param {!object} options Configuration
     * @param {!number} options.brushSize Width of blob marking made by mouse
     * @param {!boolean} options.isEraser Whether the stroke should be treated as an erase path. If false,
     *     the stroke is an additive path.
     * @param {?string} options.fillColor Color of the brush stroke.
     * @param {?string} options.strokeColor Color of the brush outline.
     * @param {?number} options.strokeWidth Width of the brush outline.
     */
    activateTool (options) {
        this.tool = new paper.Tool();
        this.cursorPreviewLastPoint = new paper.Point(-10000, -10000);
        this.setOptions(options);
        this.tool.fixedDistance = 1;

        const blob = this;
        this.tool.onMouseMove = function (event) {
            blob.resizeCursorIfNeeded(event.point);
            styleCursorPreview(blob.cursorPreview, blob.options);
            blob.cursorPreview.bringToFront();
            blob.cursorPreview.position = event.point;
        };
        
        this.tool.onMouseDown = function (event) {
            blob.resizeCursorIfNeeded(event.point);
            if (event.event.button > 0) return; // only first mouse button

            if (blob.options.brushSize < Blobbiness.THRESHOLD) {
                blob.brush = Blobbiness.BROAD;
                blob.broadBrushHelper.onBroadMouseDown(event, blob.tool, blob.options);
            } else {
                blob.brush = Blobbiness.SEGMENT;
                blob.segmentBrushHelper.onSegmentMouseDown(event, blob.tool, blob.options);
            }
            blob.cursorPreview.bringToFront();
            blob.cursorPreview.position = event.point;
            paper.view.draw();
        };

        this.tool.onMouseDrag = function (event) {
            blob.resizeCursorIfNeeded(event.point);
            if (event.event.button > 0) return; // only first mouse button
            if (blob.brush === Blobbiness.BROAD) {
                blob.broadBrushHelper.onBroadMouseDrag(event, blob.tool, blob.options);
            } else if (blob.brush === Blobbiness.SEGMENT) {
                blob.segmentBrushHelper.onSegmentMouseDrag(event, blob.tool, blob.options);
            } else {
                log.warn(`Brush type does not exist: ${blob.brush}`);
            }

            blob.cursorPreview.bringToFront();
            blob.cursorPreview.position = event.point;
            paper.view.draw();
        };

        this.tool.onMouseUp = function (event) {
            blob.resizeCursorIfNeeded(event.point);
            if (event.event.button > 0) return; // only first mouse button
            
            let lastPath;
            if (blob.brush === Blobbiness.BROAD) {
                lastPath = blob.broadBrushHelper.onBroadMouseUp(event, blob.tool, blob.options);
            } else if (blob.brush === Blobbiness.SEGMENT) {
                lastPath = blob.segmentBrushHelper.onSegmentMouseUp(event, blob.tool, blob.options);
            } else {
                log.warn(`Brush type does not exist: ${blob.brush}`);
            }

            if (blob.options.isEraser) {
                blob.mergeEraser(lastPath);
            } else {
                blob.mergeBrush(lastPath);
            }

            blob.cursorPreview.visible = false;
            blob.onUpdateSvg();
            blob.cursorPreview.visible = true;
            blob.cursorPreview.bringToFront();
            blob.cursorPreview.position = event.point;

            // Reset
            blob.brush = null;
            this.fixedDistance = 1;
        };
        this.tool.activate();
    }

    resizeCursorIfNeeded (point) {
        if (!this.options) {
            return;
        }

        if (typeof point === 'undefined') {
            point = this.cursorPreviewLastPoint;
        } else {
            this.cursorPreviewLastPoint = point;
        }

        if (this.cursorPreview && this.cursorPreview.parent &&
                this.brushSize === this.options.brushSize &&
                this.fillColor === this.options.fillColor &&
                this.strokeColor === this.options.strokeColor) {
            return;
        }
        const newPreview = new paper.Path.Circle({
            center: point,
            radius: this.options.brushSize / 2
        });
        newPreview.parent = getGuideLayer();
        newPreview.data.isHelperItem = true;
        if (this.cursorPreview) {
            this.cursorPreview.remove();
        }
        this.brushSize = this.options.brushSize;
        this.fillColor = this.options.fillColor;
        this.strokeColor = this.options.strokeColor;
        this.cursorPreview = newPreview;
        styleCursorPreview(this.cursorPreview, this.options);
    }

    mergeBrush (lastPath) {
        const blob = this;

        // Get all path items to merge with
        const paths = paper.project.getItems({
            match: function (item) {
                return blob.isMergeable(lastPath, item) &&
                    item.parent instanceof paper.Layer; // don't merge with nested in group
            }
        });

        let mergedPath = lastPath;
        let i;
        // Move down z order to first overlapping item
        for (i = paths.length - 1; i >= 0 && !this.touches(paths[i], lastPath); i--) {
            continue;
        }
        let mergedPathIndex = i;
        for (; i >= 0; i--) {
            if (!this.touches(paths[i], lastPath)) {
                continue;
            }
            if (!paths[i].getFillColor()) {
                // Ignore for merge. Paths without fill need to be in paths though,
                // since they can visibly change if z order changes
            } else if (this.colorMatch(paths[i], lastPath)) {
                // Make sure the new shape isn't overlapped by anything that would
                // visibly change if we change its z order
                for (let j = mergedPathIndex; j > i; j--) {
                    if (this.touches(paths[j], paths[i])) {
                        continue;
                    }
                }
                // Merge same fill color
                const tempPath = mergedPath.unite(paths[i]);
                tempPath.strokeColor = paths[i].strokeColor;
                tempPath.strokeWidth = paths[i].strokeWidth;
                if (mergedPath === lastPath) {
                    tempPath.insertAbove(paths[i]); // First intersected path determines z position of the new path
                } else {
                    tempPath.insertAbove(mergedPath); // Rest of merges join z index of merged path
                    mergedPathIndex--; // Removed an item, so the merged path index decreases
                }
                mergedPath.remove();
                mergedPath = tempPath;
                paths[i].remove();
                paths.splice(i, 1);
            }
        }
    }

    mergeEraser (lastPath) {
        const blob = this;

        // Get all path items to merge with
        // If there are selected items, try to erase from amongst those.
        let items = paper.project.getItems({
            match: function (item) {
                return item.selected && blob.isMergeable(lastPath, item) && blob.touches(lastPath, item);
            }
        });
        // Eraser didn't hit anything selected, so assume they meant to erase from all instead of from subset
        // and deselect the selection
        if (items.length === 0) {
            clearSelection(this.clearSelectedItems);
            items = paper.project.getItems({
                match: function (item) {
                    return blob.isMergeable(lastPath, item) && blob.touches(lastPath, item);
                }
            });
        }
        
        for (let i = items.length - 1; i >= 0; i--) {
            // TODO handle compound paths
            if (items[i] instanceof paper.Path && (!items[i].fillColor || items[i].fillColor._alpha === 0)) {
                // Gather path segments
                const subpaths = [];
                const firstSeg = items[i];
                const intersections = firstSeg.getIntersections(lastPath);
                for (let j = intersections.length - 1; j >= 0; j--) {
                    const split = firstSeg.splitAt(intersections[j]);
                    if (split) {
                        split.insertAbove(firstSeg);
                        subpaths.push(split);
                    }
                }
                subpaths.push(firstSeg);

                // Remove the ones that are within the eraser stroke boundary
                for (let k = subpaths.length - 1; k >= 0; k--) {
                    const segMidpoint = subpaths[k].getLocationAt(subpaths[k].length / 2).point;
                    if (lastPath.contains(segMidpoint)) {
                        subpaths[k].remove();
                        subpaths.splice(k, 1);
                    }
                }
                lastPath.remove();
                continue;
            }
            // Erase
            const newPath = items[i].subtract(lastPath);
            newPath.insertBelow(items[i]);

            // Gather path segments
            const subpaths = [];
            // TODO: Handle compound path
            if (items[i] instanceof paper.Path && !items[i].closed) {
                const firstSeg = items[i].clone();
                const intersections = firstSeg.getIntersections(lastPath);
                // keep first and last segments
                for (let j = intersections.length - 1; j >= 0; j--) {
                    const split = firstSeg.splitAt(intersections[j]);
                    split.insertAbove(firstSeg);
                    subpaths.push(split);
                }
                subpaths.push(firstSeg);
            }

            // Remove the ones that are within the eraser stroke boundary, or are already part of new path.
            // This way subpaths only remain if they didn't get turned into a shape by subtract.
            for (let k = subpaths.length - 1; k >= 0; k--) {
                const segMidpoint = subpaths[k].getLocationAt(subpaths[k].length / 2).point;
                if (lastPath.contains(segMidpoint) || newPath.contains(segMidpoint)) {
                    subpaths[k].remove();
                    subpaths.splice(k, 1);
                }
            }

            // Divide topologically separate shapes into their own compound paths, instead of
            // everything being stuck together.
            // Assume that result of erase operation returns clockwise paths for positive shapes
            const clockwiseChildren = [];
            const ccwChildren = [];
            if (newPath.children) {
                for (let j = newPath.children.length - 1; j >= 0; j--) {
                    const child = newPath.children[j];
                    if (child.isClockwise()) {
                        clockwiseChildren.push(child);
                    } else {
                        ccwChildren.push(child);
                    }
                }
                for (let j = 0; j < clockwiseChildren.length; j++) {
                    const cw = clockwiseChildren[j];
                    cw.copyAttributes(newPath);
                    cw.fillColor = newPath.fillColor;
                    cw.strokeColor = newPath.strokeColor;
                    cw.strokeWidth = newPath.strokeWidth;
                    cw.insertAbove(items[i]);
                    
                    // Go backward since we are deleting elements
                    let newCw = cw;
                    for (let k = ccwChildren.length - 1; k >= 0; k--) {
                        const ccw = ccwChildren[k];
                        if (this.firstEnclosesSecond(ccw, cw) || this.firstEnclosesSecond(cw, ccw)) {
                            const temp = newCw.subtract(ccw);
                            temp.insertAbove(newCw);
                            newCw.remove();
                            newCw = temp;
                            ccw.remove();
                            ccwChildren.splice(k, 1);
                        }
                    }
                }
                newPath.remove();
            }
            items[i].remove();
        }
        lastPath.remove();
    }

    colorMatch (existingPath, addedPath) {
        // Note: transparent fill colors do notdetect as touching
        return existingPath.getFillColor().equals(addedPath.getFillColor()) &&
                (addedPath.getStrokeColor() === existingPath.getStrokeColor() || // both null
                    (addedPath.getStrokeColor() &&
                        addedPath.getStrokeColor().equals(existingPath.getStrokeColor()))) &&
                addedPath.getStrokeWidth() === existingPath.getStrokeWidth() &&
                this.touches(existingPath, addedPath);
    }

    touches (path1, path2) {
        // Two shapes are touching if their paths intersect
        if (path1 && path2 && path1.intersects(path2)) {
            return true;
        }
        return this.firstEnclosesSecond(path1, path2) || this.firstEnclosesSecond(path2, path1);
    }

    firstEnclosesSecond (path1, path2) {
        // Two shapes are also touching if one is completely inside the other
        if (path1 && path2 && path2.firstSegment && path2.firstSegment.point &&
                path1.hitTest(path2.firstSegment.point)) {
            return true;
        }
        // TODO: clean up these no point paths
        return false;
    }

    isMergeable (newPath, existingPath) {
        return existingPath instanceof paper.PathItem && // path or compound path
            existingPath !== this.cursorPreview && // don't merge with the mouse preview
            existingPath !== newPath; // don't merge with self
    }

    deactivateTool () {
        this.cursorPreview.remove();
        this.cursorPreview = null;
        this.tool.remove();
        this.tool = null;
    }
}

export default Blobbiness;