'use strict';

var c = require('./constants');
var d3 = require('@plotly/d3');
var keyFun = require('../../lib/gup').keyFun;
var repeat = require('../../lib/gup').repeat;
var sortAsc = require('../../lib').sorterAsc;
var strTranslate = require('../../lib').strTranslate;

var snapRatio = c.bar.snapRatio;
function snapOvershoot(v, vAdjacent) { return v * (1 - snapRatio) + vAdjacent * snapRatio; }

var snapClose = c.bar.snapClose;
function closeToCovering(v, vAdjacent) { return v * (1 - snapClose) + vAdjacent * snapClose; }

// snap for the low end of a range on an ordinal scale
// on an ordinal scale, always show some overshoot from the exact value,
// so it's clear we're covering it
// find the interval we're in, and snap to 1/4 the distance to the next
// these two could be unified at a slight loss of readability / perf
function ordinalScaleSnap(isHigh, a, v, existingRanges) {
    if(overlappingExisting(v, existingRanges)) return v;

    var dir = isHigh ? -1 : 1;

    var first = 0;
    var last = a.length - 1;
    if(dir < 0) {
        var tmp = first;
        first = last;
        last = tmp;
    }

    var aHere = a[first];
    var aPrev = aHere;
    for(var i = first; dir * i < dir * last; i += dir) {
        var nextI = i + dir;
        var aNext = a[nextI];

        // very close to the previous - snap down to it
        if(dir * v < dir * closeToCovering(aHere, aNext)) return snapOvershoot(aHere, aPrev);
        if(dir * v < dir * aNext || nextI === last) return snapOvershoot(aNext, aHere);

        aPrev = aHere;
        aHere = aNext;
    }
}

function overlappingExisting(v, existingRanges) {
    for(var i = 0; i < existingRanges.length; i++) {
        if(v >= existingRanges[i][0] && v <= existingRanges[i][1]) return true;
    }
    return false;
}

function barHorizontalSetup(selection) {
    selection
        .attr('x', -c.bar.captureWidth / 2)
        .attr('width', c.bar.captureWidth);
}

function backgroundBarHorizontalSetup(selection) {
    selection
        .attr('visibility', 'visible')
        .style('visibility', 'visible')
        .attr('fill', 'yellow')
        .attr('opacity', 0);
}

function setHighlight(d) {
    if(!d.brush.filterSpecified) {
        return '0,' + d.height;
    }

    var pixelRanges = unitToPx(d.brush.filter.getConsolidated(), d.height);
    var dashArray = [0]; // we start with a 0 length selection as filter ranges are inclusive, not exclusive
    var p, sectionHeight, iNext;
    var currentGap = pixelRanges.length ? pixelRanges[0][0] : null;
    for(var i = 0; i < pixelRanges.length; i++) {
        p = pixelRanges[i];
        sectionHeight = p[1] - p[0];
        dashArray.push(currentGap);
        dashArray.push(sectionHeight);
        iNext = i + 1;
        if(iNext < pixelRanges.length) {
            currentGap = pixelRanges[iNext][0] - p[1];
        }
    }
    dashArray.push(d.height);
    // d.height is added at the end to ensure that (1) we have an even number of dasharray points, MDN page says
    // "If an odd number of values is provided, then the list of values is repeated to yield an even number of values."
    // and (2) it's _at least_ as long as the full height (even if range is minuscule and at the bottom) though this
    // may not be necessary, maybe duplicating the last point would do too. But no harm in a longer dasharray than line.
    return dashArray;
}

function unitToPx(unitRanges, height) {
    return unitRanges.map(function(pr) {
        return pr.map(function(v) { return Math.max(0, v * height); }).sort(sortAsc);
    });
}

// is the cursor over the north, middle, or south of a bar?
// the end handles extend over the last 10% of the bar
function getRegion(fPix, y) {
    var pad = c.bar.handleHeight;
    if(y > fPix[1] + pad || y < fPix[0] - pad) return;
    if(y >= 0.9 * fPix[1] + 0.1 * fPix[0]) return 'n';
    if(y <= 0.9 * fPix[0] + 0.1 * fPix[1]) return 's';
    return 'ns';
}

function clearCursor() {
    d3.select(document.body)
        .style('cursor', null);
}

function styleHighlight(selection) {
    // stroke-dasharray is used to minimize the number of created DOM nodes, because the requirement calls for up to
    // 1000 individual selections on an axis, and there can be 60 axes per parcoords, and multiple parcoords per
    // dashboard. The technique is similar to https://codepen.io/monfera/pen/rLYqWR and using a `polyline` with
    // multiple sections, or a `path` element via its `d` attribute would also be DOM-sparing alternatives.
    selection.attr('stroke-dasharray', setHighlight);
}

function renderHighlight(root, tweenCallback) {
    var bar = d3.select(root).selectAll('.highlight, .highlight-shadow');
    var barToStyle = tweenCallback ? bar.transition().duration(c.bar.snapDuration).each('end', tweenCallback) : bar;
    styleHighlight(barToStyle);
}

function getInterval(d, y) {
    var b = d.brush;
    var active = b.filterSpecified;
    var closestInterval = NaN;
    var out = {};
    var i;

    if(active) {
        var height = d.height;
        var intervals = b.filter.getConsolidated();
        var pixIntervals = unitToPx(intervals, height);
        var hoveredInterval = NaN;
        var previousInterval = NaN;
        var nextInterval = NaN;
        for(i = 0; i <= pixIntervals.length; i++) {
            var p = pixIntervals[i];
            if(p && p[0] <= y && y <= p[1]) {
                // over a bar
                hoveredInterval = i;
                break;
            } else {
                // between bars, or before/after the first/last bar
                previousInterval = i ? i - 1 : NaN;
                if(p && p[0] > y) {
                    nextInterval = i;
                    break; // no point continuing as intervals are non-overlapping and sorted; could use log search
                }
            }
        }

        closestInterval = hoveredInterval;
        if(isNaN(closestInterval)) {
            if(isNaN(previousInterval) || isNaN(nextInterval)) {
                closestInterval = isNaN(previousInterval) ? nextInterval : previousInterval;
            } else {
                closestInterval = (y - pixIntervals[previousInterval][1] < pixIntervals[nextInterval][0] - y) ?
                    previousInterval : nextInterval;
            }
        }

        if(!isNaN(closestInterval)) {
            var fPix = pixIntervals[closestInterval];
            var region = getRegion(fPix, y);

            if(region) {
                out.interval = intervals[closestInterval];
                out.intervalPix = fPix;
                out.region = region;
            }
        }
    }

    if(d.ordinal && !out.region) {
        var a = d.unitTickvals;
        var unitLocation = d.unitToPaddedPx.invert(y);
        for(i = 0; i < a.length; i++) {
            var rangei = [
                a[Math.max(i - 1, 0)] * 0.25 + a[i] * 0.75,
                a[Math.min(i + 1, a.length - 1)] * 0.25 + a[i] * 0.75
            ];
            if(unitLocation >= rangei[0] && unitLocation <= rangei[1]) {
                out.clickableOrdinalRange = rangei;
                break;
            }
        }
    }

    return out;
}

function dragstart(lThis, d) {
    d3.event.sourceEvent.stopPropagation();
    var y = d.height - d3.mouse(lThis)[1] - 2 * c.verticalPadding;
    var unitLocation = d.unitToPaddedPx.invert(y);
    var b = d.brush;
    var interval = getInterval(d, y);
    var unitRange = interval.interval;
    var s = b.svgBrush;
    s.wasDragged = false; // we start assuming there won't be a drag - useful for reset
    s.grabbingBar = interval.region === 'ns';
    if(s.grabbingBar) {
        var pixelRange = unitRange.map(d.unitToPaddedPx);
        s.grabPoint = y - pixelRange[0] - c.verticalPadding;
        s.barLength = pixelRange[1] - pixelRange[0];
    }
    s.clickableOrdinalRange = interval.clickableOrdinalRange;
    s.stayingIntervals = (d.multiselect && b.filterSpecified) ? b.filter.getConsolidated() : [];
    if(unitRange) {
        s.stayingIntervals = s.stayingIntervals.filter(function(int2) {
            return int2[0] !== unitRange[0] && int2[1] !== unitRange[1];
        });
    }
    s.startExtent = interval.region ? unitRange[interval.region === 's' ? 1 : 0] : unitLocation;
    d.parent.inBrushDrag = true;
    s.brushStartCallback();
}

function drag(lThis, d) {
    d3.event.sourceEvent.stopPropagation();
    var y = d.height - d3.mouse(lThis)[1] - 2 * c.verticalPadding;
    var s = d.brush.svgBrush;
    s.wasDragged = true;
    s._dragging = true;

    if(s.grabbingBar) { // moving the bar
        s.newExtent = [y - s.grabPoint, y + s.barLength - s.grabPoint].map(d.unitToPaddedPx.invert);
    } else { // south/north drag or new bar creation
        s.newExtent = [s.startExtent, d.unitToPaddedPx.invert(y)].sort(sortAsc);
    }

    d.brush.filterSpecified = true;
    s.extent = s.stayingIntervals.concat([s.newExtent]);
    s.brushCallback(d);
    renderHighlight(lThis.parentNode);
}

function dragend(lThis, d) {
    var brush = d.brush;
    var filter = brush.filter;
    var s = brush.svgBrush;

    if(!s._dragging) { // i.e. click
        // mock zero drag
        mousemove(lThis, d);
        drag(lThis, d);
        // remember it is a click not a drag
        d.brush.svgBrush.wasDragged = false;
    }
    s._dragging = false;

    var e = d3.event;
    e.sourceEvent.stopPropagation();
    var grabbingBar = s.grabbingBar;
    s.grabbingBar = false;
    s.grabLocation = undefined;
    d.parent.inBrushDrag = false;
    clearCursor(); // instead of clearing, a nicer thing would be to set it according to current location
    if(!s.wasDragged) { // a click+release on the same spot (ie. w/o dragging) means a bar or full reset
        s.wasDragged = undefined; // logic-wise unneeded, just shows `wasDragged` has no longer a meaning
        if(s.clickableOrdinalRange) {
            if(brush.filterSpecified && d.multiselect) {
                s.extent.push(s.clickableOrdinalRange);
            } else {
                s.extent = [s.clickableOrdinalRange];
                brush.filterSpecified = true;
            }
        } else if(grabbingBar) {
            s.extent = s.stayingIntervals;
            if(s.extent.length === 0) {
                brushClear(brush);
            }
        } else {
            brushClear(brush);
        }
        s.brushCallback(d);
        renderHighlight(lThis.parentNode);
        s.brushEndCallback(brush.filterSpecified ? filter.getConsolidated() : []);
        return; // no need to fuse intervals or snap to ordinals, so we can bail early
    }

    var mergeIntervals = function() {
        // Key piece of logic: once the button is released, possibly overlapping intervals will be fused:
        // Here it's done immediately on click release while on ordinal snap transition it's done at the end
        filter.set(filter.getConsolidated());
    };

    if(d.ordinal) {
        var a = d.unitTickvals;
        if(a[a.length - 1] < a[0]) a.reverse();
        s.newExtent = [
            ordinalScaleSnap(0, a, s.newExtent[0], s.stayingIntervals),
            ordinalScaleSnap(1, a, s.newExtent[1], s.stayingIntervals)
        ];
        var hasNewExtent = s.newExtent[1] > s.newExtent[0];
        s.extent = s.stayingIntervals.concat(hasNewExtent ? [s.newExtent] : []);
        if(!s.extent.length) {
            brushClear(brush);
        }
        s.brushCallback(d);
        if(hasNewExtent) {
            // merging intervals post the snap tween
            renderHighlight(lThis.parentNode, mergeIntervals);
        } else {
            // if no new interval, don't animate, just redraw the highlight immediately
            mergeIntervals();
            renderHighlight(lThis.parentNode);
        }
    } else {
        mergeIntervals(); // merging intervals immediately
    }
    s.brushEndCallback(brush.filterSpecified ? filter.getConsolidated() : []);
}

function mousemove(lThis, d) {
    var y = d.height - d3.mouse(lThis)[1] - 2 * c.verticalPadding;
    var interval = getInterval(d, y);

    var cursor = 'crosshair';
    if(interval.clickableOrdinalRange) cursor = 'pointer';
    else if(interval.region) cursor = interval.region + '-resize';
    d3.select(document.body)
        .style('cursor', cursor);
}

function attachDragBehavior(selection) {
    // There's some fiddling with pointer cursor styling so that the cursor preserves its shape while dragging a brush
    // even if the cursor strays from the interacting bar, which is bound to happen as bars are thin and the user
    // will inevitably leave the hotspot strip. In this regard, it does something similar to what the D3 brush would do.
    selection
        .on('mousemove', function(d) {
            d3.event.preventDefault();
            if(!d.parent.inBrushDrag) mousemove(this, d);
        })
        .on('mouseleave', function(d) {
            if(!d.parent.inBrushDrag) clearCursor();
        })
        .call(d3.behavior.drag()
            .on('dragstart', function(d) { dragstart(this, d); })
            .on('drag', function(d) { drag(this, d); })
            .on('dragend', function(d) { dragend(this, d); })
        );
}

function startAsc(a, b) { return a[0] - b[0]; }

function renderAxisBrush(axisBrush, paperColor, gd) {
    var isStatic = gd._context.staticPlot;

    var background = axisBrush.selectAll('.background').data(repeat);

    background.enter()
        .append('rect')
        .classed('background', true)
        .call(barHorizontalSetup)
        .call(backgroundBarHorizontalSetup)
        .style('pointer-events', isStatic ? 'none' : 'auto') // parent pointer events are disabled; we must have it to register events
        .attr('transform', strTranslate(0, c.verticalPadding));

    background
        .call(attachDragBehavior)
        .attr('height', function(d) {
            return d.height - c.verticalPadding;
        });

    var highlightShadow = axisBrush.selectAll('.highlight-shadow').data(repeat); // we have a set here, can't call it `extent`

    highlightShadow.enter()
        .append('line')
        .classed('highlight-shadow', true)
        .attr('x', -c.bar.width / 2)
        .attr('stroke-width', c.bar.width + c.bar.strokeWidth)
        .attr('stroke', paperColor)
        .attr('opacity', c.bar.strokeOpacity)
        .attr('stroke-linecap', 'butt');

    highlightShadow
        .attr('y1', function(d) { return d.height; })
        .call(styleHighlight);

    var highlight = axisBrush.selectAll('.highlight').data(repeat); // we have a set here, can't call it `extent`

    highlight.enter()
        .append('line')
        .classed('highlight', true)
        .attr('x', -c.bar.width / 2)
        .attr('stroke-width', c.bar.width - c.bar.strokeWidth)
        .attr('stroke', c.bar.fillColor)
        .attr('opacity', c.bar.fillOpacity)
        .attr('stroke-linecap', 'butt');

    highlight
        .attr('y1', function(d) { return d.height; })
        .call(styleHighlight);
}

function ensureAxisBrush(axisOverlays, paperColor, gd) {
    var axisBrush = axisOverlays.selectAll('.' + c.cn.axisBrush)
        .data(repeat, keyFun);

    axisBrush.enter()
        .append('g')
        .classed(c.cn.axisBrush, true);

    renderAxisBrush(axisBrush, paperColor, gd);
}

function getBrushExtent(brush) {
    return brush.svgBrush.extent.map(function(e) {return e.slice();});
}

function brushClear(brush) {
    brush.filterSpecified = false;
    brush.svgBrush.extent = [[-Infinity, Infinity]];
}

function axisBrushMoved(callback) {
    return function axisBrushMoved(dimension) {
        var brush = dimension.brush;
        var extent = getBrushExtent(brush);
        var newExtent = extent.slice();
        brush.filter.set(newExtent);
        callback();
    };
}

function dedupeRealRanges(intervals) {
    // Fuses elements of intervals if they overlap, yielding discontiguous intervals, results.length <= intervals.length
    // Currently uses closed intervals, ie. dedupeRealRanges([[400, 800], [300, 400]]) -> [300, 800]
    var queue = intervals.slice();
    var result = [];
    var currentInterval;
    var current = queue.shift();
    while(current) { // [].shift === undefined, so we don't descend into an empty array
        currentInterval = current.slice();
        while((current = queue.shift()) && current[0] <= /* right-open interval would need `<` */ currentInterval[1]) {
            currentInterval[1] = Math.max(currentInterval[1], current[1]);
        }
        result.push(currentInterval);
    }

    if(
        result.length === 1 &&
        result[0][0] > result[0][1]
    ) {
        // discard result
        result = [];
    }

    return result;
}

function makeFilter() {
    var filter = [];
    var consolidated;
    var bounds;
    return {
        set: function(a) {
            filter = a
                .map(function(d) { return d.slice().sort(sortAsc); })
                .sort(startAsc);

            // handle unselected case
            if(filter.length === 1 &&
                filter[0][0] === -Infinity &&
                filter[0][1] === Infinity) {
                filter = [[0, -1]];
            }

            consolidated = dedupeRealRanges(filter);
            bounds = filter.reduce(function(p, n) {
                return [Math.min(p[0], n[0]), Math.max(p[1], n[1])];
            }, [Infinity, -Infinity]);
        },
        get: function() { return filter.slice(); },
        getConsolidated: function() { return consolidated; },
        getBounds: function() { return bounds; }
    };
}

function makeBrush(state, rangeSpecified, initialRange, brushStartCallback, brushCallback, brushEndCallback) {
    var filter = makeFilter();
    filter.set(initialRange);
    return {
        filter: filter,
        filterSpecified: rangeSpecified, // there's a difference between not filtering and filtering a non-proper subset
        svgBrush: {
            extent: [], // this is where the svgBrush writes contents into
            brushStartCallback: brushStartCallback,
            brushCallback: axisBrushMoved(brushCallback),
            brushEndCallback: brushEndCallback
        }
    };
}

// for use by supplyDefaults, but it needed tons of pieces from here so
// seemed to make more sense just to put the whole routine here
function cleanRanges(ranges, dimension) {
    if(Array.isArray(ranges[0])) {
        ranges = ranges.map(function(ri) { return ri.sort(sortAsc); });

        if(!dimension.multiselect) ranges = [ranges[0]];
        else ranges = dedupeRealRanges(ranges.sort(startAsc));
    } else ranges = [ranges.sort(sortAsc)];

    // ordinal snapping
    if(dimension.tickvals) {
        var sortedTickVals = dimension.tickvals.slice().sort(sortAsc);
        ranges = ranges.map(function(ri) {
            var rSnapped = [
                ordinalScaleSnap(0, sortedTickVals, ri[0], []),
                ordinalScaleSnap(1, sortedTickVals, ri[1], [])
            ];
            if(rSnapped[1] > rSnapped[0]) return rSnapped;
        })
        .filter(function(ri) { return ri; });

        if(!ranges.length) return;
    }
    return ranges.length > 1 ? ranges : ranges[0];
}

module.exports = {
    makeBrush: makeBrush,
    ensureAxisBrush: ensureAxisBrush,
    cleanRanges: cleanRanges
};
