'use strict';

var d3 = require('@plotly/d3');

var Plots = require('../../plots/plots');
var Color = require('../color');
var Drawing = require('../drawing');
var Lib = require('../../lib');
var strTranslate = Lib.strTranslate;
var svgTextUtils = require('../../lib/svg_text_utils');
var arrayEditor = require('../../plot_api/plot_template').arrayEditor;

var constants = require('./constants');
var alignmentConstants = require('../../constants/alignment');
var LINE_SPACING = alignmentConstants.LINE_SPACING;
var FROM_TL = alignmentConstants.FROM_TL;
var FROM_BR = alignmentConstants.FROM_BR;

module.exports = function draw(gd) {
    var staticPlot = gd._context.staticPlot;
    var fullLayout = gd._fullLayout;
    var sliderData = makeSliderData(fullLayout, gd);

    // draw a container for *all* sliders:
    var sliders = fullLayout._infolayer
        .selectAll('g.' + constants.containerClassName)
        .data(sliderData.length > 0 ? [0] : []);

    sliders.enter().append('g')
        .classed(constants.containerClassName, true)
        .style('cursor', staticPlot ? null : 'ew-resize');

    function clearSlider(sliderOpts) {
        if(sliderOpts._commandObserver) {
            sliderOpts._commandObserver.remove();
            delete sliderOpts._commandObserver;
        }

        // Most components don't need to explicitly remove autoMargin, because
        // marginPushers does this - but slider updates don't go through
        // a full replot so we need to explicitly remove it.
        Plots.autoMargin(gd, autoMarginId(sliderOpts));
    }

    sliders.exit().each(function() {
        d3.select(this).selectAll('g.' + constants.groupClassName)
            .each(clearSlider);
    })
    .remove();

    // Return early if no menus visible:
    if(sliderData.length === 0) return;

    var sliderGroups = sliders.selectAll('g.' + constants.groupClassName)
        .data(sliderData, keyFunction);

    sliderGroups.enter().append('g')
        .classed(constants.groupClassName, true);

    sliderGroups.exit()
        .each(clearSlider)
        .remove();

    // Find the dimensions of the sliders:
    for(var i = 0; i < sliderData.length; i++) {
        var sliderOpts = sliderData[i];
        findDimensions(gd, sliderOpts);
    }

    sliderGroups.each(function(sliderOpts) {
        var gSlider = d3.select(this);

        computeLabelSteps(sliderOpts);

        Plots.manageCommandObserver(gd, sliderOpts, sliderOpts._visibleSteps, function(data) {
            // NB: Same as below. This is *not* always the same as sliderOpts since
            // if a new set of steps comes in, the reference in this callback would
            // be invalid. We need to refetch it from the slider group, which is
            // the join data that creates this slider. So if this slider still exists,
            // the group should be valid, *to the best of my knowledge.* If not,
            // we'd have to look it up by d3 data join index/key.
            var opts = gSlider.data()[0];

            if(opts.active === data.index) return;
            if(opts._dragging) return;

            setActive(gd, gSlider, opts, data.index, false, true);
        });

        drawSlider(gd, d3.select(this), sliderOpts);
    });
};

function autoMarginId(sliderOpts) {
    return constants.autoMarginIdRoot + sliderOpts._index;
}

// This really only just filters by visibility:
function makeSliderData(fullLayout, gd) {
    var contOpts = fullLayout[constants.name];
    var sliderData = [];

    for(var i = 0; i < contOpts.length; i++) {
        var item = contOpts[i];
        if(!item.visible) continue;
        item._gd = gd;
        sliderData.push(item);
    }

    return sliderData;
}

// This is set in the defaults step:
function keyFunction(opts) {
    return opts._index;
}

// Compute the dimensions (mutates sliderOpts):
function findDimensions(gd, sliderOpts) {
    var sliderLabels = Drawing.tester.selectAll('g.' + constants.labelGroupClass)
        .data(sliderOpts._visibleSteps);

    sliderLabels.enter().append('g')
        .classed(constants.labelGroupClass, true);

    // loop over fake buttons to find width / height
    var maxLabelWidth = 0;
    var labelHeight = 0;
    sliderLabels.each(function(stepOpts) {
        var labelGroup = d3.select(this);

        var text = drawLabel(labelGroup, {step: stepOpts}, sliderOpts);

        var textNode = text.node();
        if(textNode) {
            var bBox = Drawing.bBox(textNode);
            labelHeight = Math.max(labelHeight, bBox.height);
            maxLabelWidth = Math.max(maxLabelWidth, bBox.width);
        }
    });

    sliderLabels.remove();

    var dims = sliderOpts._dims = {};

    dims.inputAreaWidth = Math.max(
        constants.railWidth,
        constants.gripHeight
    );

    // calculate some overall dimensions - some of these are needed for
    // calculating the currentValue dimensions
    var graphSize = gd._fullLayout._size;
    dims.lx = graphSize.l + graphSize.w * sliderOpts.x;
    dims.ly = graphSize.t + graphSize.h * (1 - sliderOpts.y);

    if(sliderOpts.lenmode === 'fraction') {
        // fraction:
        dims.outerLength = Math.round(graphSize.w * sliderOpts.len);
    } else {
        // pixels:
        dims.outerLength = sliderOpts.len;
    }

    // The length of the rail, *excluding* padding on either end:
    dims.inputAreaStart = 0;
    dims.inputAreaLength = Math.round(dims.outerLength - sliderOpts.pad.l - sliderOpts.pad.r);

    var textableInputLength = dims.inputAreaLength - 2 * constants.stepInset;
    var availableSpacePerLabel = textableInputLength / (sliderOpts._stepCount - 1);
    var computedSpacePerLabel = maxLabelWidth + constants.labelPadding;
    dims.labelStride = Math.max(1, Math.ceil(computedSpacePerLabel / availableSpacePerLabel));
    dims.labelHeight = labelHeight;

    // loop over all possible values for currentValue to find the
    // area we need for it
    dims.currentValueMaxWidth = 0;
    dims.currentValueHeight = 0;
    dims.currentValueTotalHeight = 0;
    dims.currentValueMaxLines = 1;

    if(sliderOpts.currentvalue.visible) {
        // Get the dimensions of the current value label:
        var dummyGroup = Drawing.tester.append('g');

        sliderLabels.each(function(stepOpts) {
            var curValPrefix = drawCurrentValue(dummyGroup, sliderOpts, stepOpts.label);
            var curValSize = (curValPrefix.node() && Drawing.bBox(curValPrefix.node())) || {width: 0, height: 0};
            var lines = svgTextUtils.lineCount(curValPrefix);
            dims.currentValueMaxWidth = Math.max(dims.currentValueMaxWidth, Math.ceil(curValSize.width));
            dims.currentValueHeight = Math.max(dims.currentValueHeight, Math.ceil(curValSize.height));
            dims.currentValueMaxLines = Math.max(dims.currentValueMaxLines, lines);
        });

        dims.currentValueTotalHeight = dims.currentValueHeight + sliderOpts.currentvalue.offset;

        dummyGroup.remove();
    }

    dims.height = dims.currentValueTotalHeight + constants.tickOffset + sliderOpts.ticklen + constants.labelOffset + dims.labelHeight + sliderOpts.pad.t + sliderOpts.pad.b;

    var xanchor = 'left';
    if(Lib.isRightAnchor(sliderOpts)) {
        dims.lx -= dims.outerLength;
        xanchor = 'right';
    }
    if(Lib.isCenterAnchor(sliderOpts)) {
        dims.lx -= dims.outerLength / 2;
        xanchor = 'center';
    }

    var yanchor = 'top';
    if(Lib.isBottomAnchor(sliderOpts)) {
        dims.ly -= dims.height;
        yanchor = 'bottom';
    }
    if(Lib.isMiddleAnchor(sliderOpts)) {
        dims.ly -= dims.height / 2;
        yanchor = 'middle';
    }

    dims.outerLength = Math.ceil(dims.outerLength);
    dims.height = Math.ceil(dims.height);
    dims.lx = Math.round(dims.lx);
    dims.ly = Math.round(dims.ly);

    var marginOpts = {
        y: sliderOpts.y,
        b: dims.height * FROM_BR[yanchor],
        t: dims.height * FROM_TL[yanchor]
    };

    if(sliderOpts.lenmode === 'fraction') {
        marginOpts.l = 0;
        marginOpts.xl = sliderOpts.x - sliderOpts.len * FROM_TL[xanchor];
        marginOpts.r = 0;
        marginOpts.xr = sliderOpts.x + sliderOpts.len * FROM_BR[xanchor];
    } else {
        marginOpts.x = sliderOpts.x;
        marginOpts.l = dims.outerLength * FROM_TL[xanchor];
        marginOpts.r = dims.outerLength * FROM_BR[xanchor];
    }

    Plots.autoMargin(gd, autoMarginId(sliderOpts), marginOpts);
}

function drawSlider(gd, sliderGroup, sliderOpts) {
    // This is related to the other long notes in this file regarding what happens
    // when slider steps disappear. This particular fix handles what happens when
    // the *current* slider step is removed. The drawing functions will error out
    // when they fail to find it, so the fix for now is that it will just draw the
    // slider in the first position but will not execute the command.
    if(!((sliderOpts.steps[sliderOpts.active] || {}).visible)) {
        sliderOpts.active = sliderOpts._visibleSteps[0]._index;
    }

    // These are carefully ordered for proper z-ordering:
    sliderGroup
        .call(drawCurrentValue, sliderOpts)
        .call(drawRail, sliderOpts)
        .call(drawLabelGroup, sliderOpts)
        .call(drawTicks, sliderOpts)
        .call(drawTouchRect, gd, sliderOpts)
        .call(drawGrip, gd, sliderOpts);

    var dims = sliderOpts._dims;

    // Position the rectangle:
    Drawing.setTranslate(sliderGroup, dims.lx + sliderOpts.pad.l, dims.ly + sliderOpts.pad.t);

    sliderGroup.call(setGripPosition, sliderOpts, false);
    sliderGroup.call(drawCurrentValue, sliderOpts);
}

function drawCurrentValue(sliderGroup, sliderOpts, valueOverride) {
    if(!sliderOpts.currentvalue.visible) return;

    var dims = sliderOpts._dims;
    var x0, textAnchor;

    switch(sliderOpts.currentvalue.xanchor) {
        case 'right':
            // This is anchored left and adjusted by the width of the longest label
            // so that the prefix doesn't move. The goal of this is to emphasize
            // what's actually changing and make the update less distracting.
            x0 = dims.inputAreaLength - constants.currentValueInset - dims.currentValueMaxWidth;
            textAnchor = 'left';
            break;
        case 'center':
            x0 = dims.inputAreaLength * 0.5;
            textAnchor = 'middle';
            break;
        default:
            x0 = constants.currentValueInset;
            textAnchor = 'left';
    }

    var text = Lib.ensureSingle(sliderGroup, 'text', constants.labelClass, function(s) {
        s.attr({
            'text-anchor': textAnchor,
            'data-notex': 1
        });
    });

    var str = sliderOpts.currentvalue.prefix ? sliderOpts.currentvalue.prefix : '';

    if(typeof valueOverride === 'string') {
        str += valueOverride;
    } else {
        var curVal = sliderOpts.steps[sliderOpts.active].label;
        var _meta = sliderOpts._gd._fullLayout._meta;
        if(_meta) curVal = Lib.templateString(curVal, _meta);
        str += curVal;
    }

    if(sliderOpts.currentvalue.suffix) {
        str += sliderOpts.currentvalue.suffix;
    }

    text.call(Drawing.font, sliderOpts.currentvalue.font)
        .text(str)
        .call(svgTextUtils.convertToTspans, sliderOpts._gd);

    var lines = svgTextUtils.lineCount(text);

    var y0 = (dims.currentValueMaxLines + 1 - lines) *
        sliderOpts.currentvalue.font.size * LINE_SPACING;

    svgTextUtils.positionText(text, x0, y0);

    return text;
}

function drawGrip(sliderGroup, gd, sliderOpts) {
    var grip = Lib.ensureSingle(sliderGroup, 'rect', constants.gripRectClass, function(s) {
        s.call(attachGripEvents, gd, sliderGroup, sliderOpts)
            .style('pointer-events', 'all');
    });

    grip.attr({
        width: constants.gripWidth,
        height: constants.gripHeight,
        rx: constants.gripRadius,
        ry: constants.gripRadius,
    })
    .call(Color.stroke, sliderOpts.bordercolor)
    .call(Color.fill, sliderOpts.bgcolor)
    .style('stroke-width', sliderOpts.borderwidth + 'px');
}

function drawLabel(item, data, sliderOpts) {
    var text = Lib.ensureSingle(item, 'text', constants.labelClass, function(s) {
        s.attr({
            'text-anchor': 'middle',
            'data-notex': 1
        });
    });

    var tx = data.step.label;
    var _meta = sliderOpts._gd._fullLayout._meta;
    if(_meta) tx = Lib.templateString(tx, _meta);

    text.call(Drawing.font, sliderOpts.font)
        .text(tx)
        .call(svgTextUtils.convertToTspans, sliderOpts._gd);

    return text;
}

function drawLabelGroup(sliderGroup, sliderOpts) {
    var labels = Lib.ensureSingle(sliderGroup, 'g', constants.labelsClass);
    var dims = sliderOpts._dims;

    var labelItems = labels.selectAll('g.' + constants.labelGroupClass)
        .data(dims.labelSteps);

    labelItems.enter().append('g')
        .classed(constants.labelGroupClass, true);

    labelItems.exit().remove();

    labelItems.each(function(d) {
        var item = d3.select(this);

        item.call(drawLabel, d, sliderOpts);

        Drawing.setTranslate(item,
            normalizedValueToPosition(sliderOpts, d.fraction),
            constants.tickOffset +
                sliderOpts.ticklen +
                // position is the baseline of the top line of text only, even
                // if the label spans multiple lines
                sliderOpts.font.size * LINE_SPACING +
                constants.labelOffset +
                dims.currentValueTotalHeight
        );
    });
}

function handleInput(gd, sliderGroup, sliderOpts, normalizedPosition, doTransition) {
    var quantizedPosition = Math.round(normalizedPosition * (sliderOpts._stepCount - 1));
    var quantizedIndex = sliderOpts._visibleSteps[quantizedPosition]._index;

    if(quantizedIndex !== sliderOpts.active) {
        setActive(gd, sliderGroup, sliderOpts, quantizedIndex, true, doTransition);
    }
}

function setActive(gd, sliderGroup, sliderOpts, index, doCallback, doTransition) {
    var previousActive = sliderOpts.active;
    sliderOpts.active = index;

    // due to templating, it's possible this slider doesn't even exist yet
    arrayEditor(gd.layout, constants.name, sliderOpts)
        .applyUpdate('active', index);

    var step = sliderOpts.steps[sliderOpts.active];

    sliderGroup.call(setGripPosition, sliderOpts, doTransition);
    sliderGroup.call(drawCurrentValue, sliderOpts);

    gd.emit('plotly_sliderchange', {
        slider: sliderOpts,
        step: sliderOpts.steps[sliderOpts.active],
        interaction: doCallback,
        previousActive: previousActive
    });

    if(step && step.method && doCallback) {
        if(sliderGroup._nextMethod) {
            // If we've already queued up an update, just overwrite it with the most recent:
            sliderGroup._nextMethod.step = step;
            sliderGroup._nextMethod.doCallback = doCallback;
            sliderGroup._nextMethod.doTransition = doTransition;
        } else {
            sliderGroup._nextMethod = {step: step, doCallback: doCallback, doTransition: doTransition};
            sliderGroup._nextMethodRaf = window.requestAnimationFrame(function() {
                var _step = sliderGroup._nextMethod.step;
                if(!_step.method) return;

                if(_step.execute) {
                    Plots.executeAPICommand(gd, _step.method, _step.args);
                }

                sliderGroup._nextMethod = null;
                sliderGroup._nextMethodRaf = null;
            });
        }
    }
}

function attachGripEvents(item, gd, sliderGroup) {
    if(gd._context.staticPlot) return;

    var node = sliderGroup.node();
    var $gd = d3.select(gd);

    // NB: This is *not* the same as sliderOpts itself! These callbacks
    // are in a closure so this array won't actually be correct if the
    // steps have changed since this was initialized. The sliderGroup,
    // however, has not changed since that *is* the slider, so it must
    // be present to receive mouse events.
    function getSliderOpts() {
        return sliderGroup.data()[0];
    }

    function mouseDownHandler() {
        var sliderOpts = getSliderOpts();
        gd.emit('plotly_sliderstart', {slider: sliderOpts});

        var grip = sliderGroup.select('.' + constants.gripRectClass);

        d3.event.stopPropagation();
        d3.event.preventDefault();
        grip.call(Color.fill, sliderOpts.activebgcolor);

        var normalizedPosition = positionToNormalizedValue(sliderOpts, d3.mouse(node)[0]);
        handleInput(gd, sliderGroup, sliderOpts, normalizedPosition, true);
        sliderOpts._dragging = true;

        function mouseMoveHandler() {
            var sliderOpts = getSliderOpts();
            var normalizedPosition = positionToNormalizedValue(sliderOpts, d3.mouse(node)[0]);
            handleInput(gd, sliderGroup, sliderOpts, normalizedPosition, false);
        }

        $gd.on('mousemove', mouseMoveHandler);
        $gd.on('touchmove', mouseMoveHandler);

        function mouseUpHandler() {
            var sliderOpts = getSliderOpts();
            sliderOpts._dragging = false;
            grip.call(Color.fill, sliderOpts.bgcolor);
            $gd.on('mouseup', null);
            $gd.on('mousemove', null);
            $gd.on('touchend', null);
            $gd.on('touchmove', null);

            gd.emit('plotly_sliderend', {
                slider: sliderOpts,
                step: sliderOpts.steps[sliderOpts.active]
            });
        }

        $gd.on('mouseup', mouseUpHandler);
        $gd.on('touchend', mouseUpHandler);
    }

    item.on('mousedown', mouseDownHandler);
    item.on('touchstart', mouseDownHandler);
}

function drawTicks(sliderGroup, sliderOpts) {
    var tick = sliderGroup.selectAll('rect.' + constants.tickRectClass)
        .data(sliderOpts._visibleSteps);
    var dims = sliderOpts._dims;

    tick.enter().append('rect')
        .classed(constants.tickRectClass, true);

    tick.exit().remove();

    tick.attr({
        width: sliderOpts.tickwidth + 'px',
        'shape-rendering': 'crispEdges'
    });

    tick.each(function(d, i) {
        var isMajor = i % dims.labelStride === 0;
        var item = d3.select(this);

        item
            .attr({height: isMajor ? sliderOpts.ticklen : sliderOpts.minorticklen})
            .call(Color.fill, isMajor ? sliderOpts.tickcolor : sliderOpts.tickcolor);

        Drawing.setTranslate(item,
            normalizedValueToPosition(sliderOpts, i / (sliderOpts._stepCount - 1)) - 0.5 * sliderOpts.tickwidth,
            (isMajor ? constants.tickOffset : constants.minorTickOffset) + dims.currentValueTotalHeight
        );
    });
}

function computeLabelSteps(sliderOpts) {
    var dims = sliderOpts._dims;
    dims.labelSteps = [];
    var nsteps = sliderOpts._stepCount;

    for(var i = 0; i < nsteps; i += dims.labelStride) {
        dims.labelSteps.push({
            fraction: i / (nsteps - 1),
            step: sliderOpts._visibleSteps[i]
        });
    }
}

function setGripPosition(sliderGroup, sliderOpts, doTransition) {
    var grip = sliderGroup.select('rect.' + constants.gripRectClass);

    var quantizedIndex = 0;
    for(var i = 0; i < sliderOpts._stepCount; i++) {
        if(sliderOpts._visibleSteps[i]._index === sliderOpts.active) {
            quantizedIndex = i;
            break;
        }
    }

    var x = normalizedValueToPosition(sliderOpts, quantizedIndex / (sliderOpts._stepCount - 1));

    // If this is true, then *this component* is already invoking its own command
    // and has triggered its own animation.
    if(sliderOpts._invokingCommand) return;

    var el = grip;
    if(doTransition && sliderOpts.transition.duration > 0) {
        el = el.transition()
            .duration(sliderOpts.transition.duration)
            .ease(sliderOpts.transition.easing);
    }

    // Drawing.setTranslate doesn't work here because of the transition duck-typing.
    // It's also not necessary because there are no other transitions to preserve.
    el.attr('transform', strTranslate(x - constants.gripWidth * 0.5, sliderOpts._dims.currentValueTotalHeight));
}

// Convert a number from [0-1] to a pixel position relative to the slider group container:
function normalizedValueToPosition(sliderOpts, normalizedPosition) {
    var dims = sliderOpts._dims;
    return dims.inputAreaStart + constants.stepInset +
        (dims.inputAreaLength - 2 * constants.stepInset) * Math.min(1, Math.max(0, normalizedPosition));
}

// Convert a position relative to the slider group to a nubmer in [0, 1]
function positionToNormalizedValue(sliderOpts, position) {
    var dims = sliderOpts._dims;
    return Math.min(1, Math.max(0, (position - constants.stepInset - dims.inputAreaStart) / (dims.inputAreaLength - 2 * constants.stepInset - 2 * dims.inputAreaStart)));
}

function drawTouchRect(sliderGroup, gd, sliderOpts) {
    var dims = sliderOpts._dims;
    var rect = Lib.ensureSingle(sliderGroup, 'rect', constants.railTouchRectClass, function(s) {
        s.call(attachGripEvents, gd, sliderGroup, sliderOpts)
            .style('pointer-events', 'all');
    });

    rect.attr({
        width: dims.inputAreaLength,
        height: Math.max(dims.inputAreaWidth, constants.tickOffset + sliderOpts.ticklen + dims.labelHeight)
    })
        .call(Color.fill, sliderOpts.bgcolor)
        .attr('opacity', 0);

    Drawing.setTranslate(rect, 0, dims.currentValueTotalHeight);
}

function drawRail(sliderGroup, sliderOpts) {
    var dims = sliderOpts._dims;
    var computedLength = dims.inputAreaLength - constants.railInset * 2;
    var rect = Lib.ensureSingle(sliderGroup, 'rect', constants.railRectClass);

    rect.attr({
        width: computedLength,
        height: constants.railWidth,
        rx: constants.railRadius,
        ry: constants.railRadius,
        'shape-rendering': 'crispEdges'
    })
    .call(Color.stroke, sliderOpts.bordercolor)
    .call(Color.fill, sliderOpts.bgcolor)
    .style('stroke-width', sliderOpts.borderwidth + 'px');

    Drawing.setTranslate(rect,
        constants.railInset,
        (dims.inputAreaWidth - constants.railWidth) * 0.5 + dims.currentValueTotalHeight
    );
}
