// ==UserScript==
// @name         OSRT Staging Move Drag-n-Drop
// @namespace    openSUSE/openSUSE-release-tools
// @version      0.2.0
// @description  Provide staging request moving interface on staging dashboard.
// @author       Jimmy Berry
// @match        */project/staging_projects/*
// @require      https://code.jquery.com/jquery-3.3.1.min.js
// @require      https://raw.githubusercontent.com/p34eu/selectables/master/selectables.js
// @grant        none
// ==/UserScript==

// Uses a combination of two sources:
// - https://www.sitepoint.com/accessible-drag-drop/ (modified slightly)
// - https://github.com/p34eu/selectables (used directly with abuse of modifier key option)

(function()
{
    // Exclude not usable browsers.
    if (!document.querySelectorAll || !('draggable' in document.createElement('span'))) {
        return;
    }

    // Ensure user is logged in.
    if (!document.querySelector('#link-to-user-home')) {
        return;
    }

    // Add explanation of trigger shortcut to legend box.
    var explanation = document.createElement('div');
    explanation.id = 'osrt-explanation';
    explanation.innerText = 'enter move mode';
    explanation.setAttribute('title', 'ctrl + m');
    explanation.onclick = function() {
        initMoveInterface();
        this.onclick = null;
    };
    document.querySelector('#legends').appendChild(explanation);

    window.onkeyup = function(e) {
        if (e.keyCode == 77 && e.ctrlKey) {
            initMoveInterface();
        }
    }

    // Include CSS immediately for explanation.
    $('head').append(`<style>
#osrt-explanation
{
    padding: 10px;
    background-color: #d9b200;
    color: white;
    cursor: pointer;
}

#osrt-explanation.osrt-active
{
    cursor: default;
}

#osrt-summary
{
    position: sticky;
    z-index: 10000;
    bottom: 0;
    left: 0;
    width: 100%%;
    height: 2em;
    padding-top: 0.7em;
    text-align: center;
    background-color: white;
}

#osrt-summary button
{
    margin-left: 10px;
}

#osrt-summary progress
{
    display: none;
}

#osrt-summary.osrt-progress progress
{
    display: inline;
}

#osrt-summary.osrt-progress span,
#osrt-summary.osrt-progress button,
#osrt-summary.osrt-success button
{
    display: none;
}

#osrt-summary.osrt-failed
{
    background-color: red;
    color: white;
}

#osrt-summary.osrt-failed span
{
    display: inline;
}

#osrt-summary.osrt-failed progress
{
    display: none;
}

#osrt-summary.osrt-success
{
    background-color: green;
    color: white;
}

/* drop target state */
[data-draggable="target"][aria-dropeffect="move"]
{
    border-color: #68b;
}

[data-draggable="target"][aria-dropeffect="move"]:focus,
[data-draggable="target"][aria-dropeffect="move"].dragover
{
    box-shadow:0 0 0 1px #fff, 0 0 0 3px #68b;
}

[data-draggable="item"]:focus
{
    box-shadow: 0 0 0 2px #68b, inset 0 0 0 1px #ddd;
}

[data-draggable="item"][aria-grabbed="true"],
.color-legend .selected
{
    background: #8ad;
    color: #fff;
}

.color-legend .moved,
.osrt-moved
{
    background: repeating-linear-gradient(
        45deg,
        #606dbc,
        #606dbc 10px,
        #465298 10px,
        #465298 20px
    );
}
</style>`);

})();

var initMoveInterface = function() {
    // Update explanation text and add new legend entries.
    function addLegend(type)
    {
        var listItem = document.createElement('li');
        var span = document.createElement('span');
        span.classList.add(type.toLowerCase());
        listItem.appendChild(span);
        listItem.appendChild(document.createTextNode(type));
        document.querySelector('ul.color-legend').appendChild(listItem);
    }

    addLegend('Moved');
    addLegend('Selected');

    var explanation = document.querySelector('#osrt-explanation');
    explanation.innerText = 'drag box around requests or ctrl/shift + click requests to select and drag a request to another staging.';
    explanation.setAttribute('title', 'move mode activated');
    explanation.classList.add('osrt-active');

    // @resource will not work since served without proper MIME type.
    $.get('https://raw.githubusercontent.com/p34eu/selectables/master/selectables.css', function(data, status) {
        $('head').append('<style>' + data + '</style>');
    });

    // Mark the drag targets and draggable items.
    // Preferable to use the tr element as target, but mouse events not handled properly.
    // Avoid making expand/collapse links selectable and avoid forcing them to be expanded.
    // The pointer-events changes only seem to work properly from script.
    $('table.staging-dashboard td').attr('data-draggable', 'target');
    $('table.staging-dashboard ul.packages-list li.request:not(:has(a.staging_expand, a.staging_collapse))').attr('data-draggable', 'item').css('pointer-events', 'all');

    // Disable mouse events on the request links as that makes it nearly impossible to drag them.
    $('table.staging-dashboard ul.packages-list li.request:not(:has(a.staging_expand, a.staging_collapse)) a').css('pointer-events', 'none');

    // Configure selectables to play nice with drag-n-drop code.
    new Selectables({
        elements: 'ul.packages-list li[data-draggable="item"]',
        zone: 'body',
        start: function (e) {
            e.osrtContinue = (e.target.getAttribute('data-draggable') != 'item' &&
                              e.target.tagName != 'A' &&
                              e.target.tagName != 'LABEL' &&
                              !e.target.id.startsWith('osrt-'));
        },
        // Abuse key option by setting the value in start callback whic is run
        // first and the value determines if drag selection is started.
        key: 'osrtContinue',
        onSelect: function (e) {
            addSelection(e);
        },
        onDeselect: function (e) {
            removeSelection(e);
        }
    });

    function getStaging(item)
    {
        var parent;
        if (item.tagName == 'TD') {
            parent = item.parentElement;
        } else {
            parent = item.parentElement.parentElement.parentElement;
        }
        if (item.parentElement.classList.contains('staging_collapsible')) {
            // An additional layer since in hidden container.
            parent = parent.parentElement;
        }
        return parent.querySelector('div.letter a').innerText;
    }

    var summary = {};
    function updateSummary()
    {
        var summaryElement = document.querySelector('div#osrt-summary');
        if (!summaryElement) {
            summaryElement = document.createElement('div');
            summaryElement.id = 'osrt-summary';
            summaryElement.appendChild(document.createElement('span'))

            var button = document.createElement('button');
            button.innerText = 'Apply';
            button.onclick = applyChanges;
            summaryElement.appendChild(button);

            summaryElement.appendChild(document.createElement('progress'))
            document.body.appendChild(summaryElement);
        }

        var elements = document.querySelectorAll('.osrt-moved');
        summary = {};
        var staging;
        for (var i = 0; i < elements.length; i++) {
            staging = getStaging(elements[i]);
            if (!isNaN(staging)) {
                staging = 'adi:' + staging;
            }
            if (!(staging in summary)) {
                summary[staging] = [];
            }
            summary[staging].push(elements[i].children[0].innerText.trim());
        }

        summaryElement.children[0].innerText = elements.length + ' request(s) to move affecting ' + Object.keys(summary).length + ' stagings(s)';
        summaryElement.children[2].setAttribute('max', elements.length);
    }

    function applyChanges()
    {
        var summaryElement = document.querySelector('div#osrt-summary');
        summaryElement.classList.add('osrt-progress');

        var user = document.querySelector('#link-to-user-home').innerText.trim();
        var pathParts = window.location.pathname.split('/');
        var project = pathParts[pathParts.length - 1];

        var data = JSON.stringify({'user': user, 'project': project, 'move': true, 'selection': summary});
        var domain_parent = window.location.hostname.split('.').splice(-2).join('.');
        var subdomain = domain_parent.endsWith('suse.de') ? 'tortuga' : 'operator';
        var url = 'https://' + subdomain + '.' + domain_parent + '/staging/select';
        $.post({url: url, data: data, crossDomain: true, xhrFields: {withCredentials: true},
                success: applyChangesSuccess}).fail(applyChangesFailed);
    }

    function applyChangesSuccess(data)
    {
        // Could provide link to this in UI.
        console.log(data);

        var summaryElement = document.querySelector('div#osrt-summary');
        summaryElement.classList.add('osrt-complete');
        if (data.trim().endsWith('failed')) {
            applyChangesFailed();
            return;
        }

        var expected = summaryElement.children[2].getAttribute('max');
        if ((data.match(/\(\d+\/\d+\)/g) || []).length == expected) {
            summaryElement.children[0].innerText = 'Moved ' + expected + ' request(s).';
            summaryElement.children[2].setAttribute('value', expected);
            summaryElement.classList.add('osrt-success');
            summaryElement.classList.remove('osrt-progress');

            // Could reset UI in a more elegant way.
            reloadShortly();
            return;
        }

        applyChangesFailed();
    }

    function applyChangesFailed()
    {
        var summaryElement = document.querySelector('div#osrt-summary');
        summaryElement.children[0].innerText = 'Failed to move requests.';
        summaryElement.classList.add('osrt-failed');
    }

    function reloadShortly()
    {
        setTimeout(function() { window.location.reload(); }, 3000);
    }

    //get the collection of draggable targets and add their draggable attribute
    for(var
        targets = document.querySelectorAll('[data-draggable="target"]'),
        len = targets.length,
        i = 0; i < len; i ++)
    {
        targets[i].setAttribute('aria-dropeffect', 'none');
    }

    //get the collection of draggable items and add their draggable attributes
    for(var
        items = document.querySelectorAll('[data-draggable="item"]'),
        len = items.length,
        i = 0; i < len; i ++)
    {
        items[i].setAttribute('draggable', 'true');
        items[i].setAttribute('aria-grabbed', 'false');
        items[i].setAttribute('tabindex', '0');

        // OSRT modification: keep track of original staging.
        items[i].setAttribute('data-staging-origin', getStaging(items[i]));
    }

    //dictionary for storing the selections data
    //comprising an array of the currently selected items
    //a reference to the selected items' owning container
    //and a refernce to the current drop target container
    var selections =
    {
        items      : [],
        owner      : null,
        droptarget : null
    };

    //function for selecting an item
    function addSelection(item)
    {
        //if the owner reference is still null, set it to this item's parent
        //so that further selection is only allowed within the same container
        if(!selections.owner)
        {
            selections.owner = item.parentNode;
        }

        //or if that's already happened then compare it with this item's parent
        //and if they're not the same container, return to prevent selection
        else if(selections.owner != item.parentNode)
        {
            return;
        }

        //set this item's grabbed state
        item.setAttribute('aria-grabbed', 'true');

        //add it to the items array
        selections.items.push(item);
    }

    //function for unselecting an item
    function removeSelection(item)
    {
        //reset this item's grabbed state
        item.setAttribute('aria-grabbed', 'false');

        //then find and remove this item from the existing items array
        for(var len = selections.items.length, i = 0; i < len; i ++)
        {
            if(selections.items[i] == item)
            {
                selections.items.splice(i, 1);
                break;
            }
        }
    }

    //function for resetting all selections
    function clearSelections()
    {
        //if we have any selected items
        if(selections.items.length)
        {
            //reset the owner reference
            selections.owner = null;

            //reset the grabbed state on every selected item
            for(var len = selections.items.length, i = 0; i < len; i ++)
            {
                selections.items[i].setAttribute('aria-grabbed', 'false');
            }

            //then reset the items array
            selections.items = [];
        }
    }

    //shorctut function for testing whether a selection modifier is pressed
    function hasModifier(e)
    {
        return (e.ctrlKey || e.metaKey || e.shiftKey);
    }

    //function for applying dropeffect to the target containers
    function addDropeffects()
    {
        //apply aria-dropeffect and tabindex to all targets apart from the owner
        for(var len = targets.length, i = 0; i < len; i ++)
        {
            if
            (
                targets[i] != selections.owner
                &&
                targets[i].getAttribute('aria-dropeffect') == 'none'
            )
            {
                targets[i].setAttribute('aria-dropeffect', 'move');
                targets[i].setAttribute('tabindex', '0');
            }
        }

        //remove aria-grabbed and tabindex from all items inside those containers
        for(var len = items.length, i = 0; i < len; i ++)
        {
            if
            (
                items[i].parentNode != selections.owner
                &&
                items[i].getAttribute('aria-grabbed')
            )
            {
                items[i].removeAttribute('aria-grabbed');
                items[i].removeAttribute('tabindex');
            }
        }
    }

    //function for removing dropeffect from the target containers
    function clearDropeffects()
    {
        //if we have any selected items
        if(selections.items.length)
        {
            //reset aria-dropeffect and remove tabindex from all targets
            for(var len = targets.length, i = 0; i < len; i ++)
            {
                if(targets[i].getAttribute('aria-dropeffect') != 'none')
                {
                    targets[i].setAttribute('aria-dropeffect', 'none');
                    targets[i].removeAttribute('tabindex');
                }
            }

            //restore aria-grabbed and tabindex to all selectable items
            //without changing the grabbed value of any existing selected items
            for(var len = items.length, i = 0; i < len; i ++)
            {
                if(!items[i].getAttribute('aria-grabbed'))
                {
                    items[i].setAttribute('aria-grabbed', 'false');
                    items[i].setAttribute('tabindex', '0');
                }
                else if(items[i].getAttribute('aria-grabbed') == 'true')
                {
                    items[i].setAttribute('tabindex', '0');
                }
            }
        }
    }

    //shortcut function for identifying an event element's target container
    function getContainer(element)
    {
        do
        {
            if(element.nodeType == 1 && element.getAttribute('aria-dropeffect'))
            {
                return element;
            }
        }
        while(element = element.parentNode);

        return null;
    }

    //mousedown event to implement single selection
    document.addEventListener('mousedown', function(e)
    {
        //if the element is a draggable item
        if(e.target.getAttribute('draggable'))
        {
            //clear dropeffect from the target containers
            clearDropeffects();

            //if the multiple selection modifier is not pressed
            //and the item's grabbed state is currently false
            if
            (
                !hasModifier(e)
                &&
                e.target.getAttribute('aria-grabbed') == 'false'
            )
            {
                //clear all existing selections
                clearSelections();

                //then add this new selection
                addSelection(e.target);
            }
        }

        //else [if the element is anything else]
        //and the selection modifier is not pressed
        else if(!hasModifier(e))
        {
            //clear dropeffect from the target containers
            clearDropeffects();

            //clear all existing selections
            clearSelections();
        }

        //else [if the element is anything else and the modifier is pressed]
        else
        {
            //clear dropeffect from the target containers
            clearDropeffects();
        }

    }, false);

    //mouseup event to implement multiple selection
    document.addEventListener('mouseup', function(e)
    {
        //if the element is a draggable item
        //and the multipler selection modifier is pressed
        if(e.target.getAttribute('draggable') && hasModifier(e))
        {
            //if the item's grabbed state is currently true
            if(e.target.getAttribute('aria-grabbed') == 'true')
            {
                //unselect this item
                removeSelection(e.target);

                //if that was the only selected item
                //then reset the owner container reference
                if(!selections.items.length)
                {
                    selections.owner = null;
                }
            }

            //else [if the item's grabbed state is false]
            else
            {
                //add this additional selection
                addSelection(e.target);
            }
        }

    }, false);

    //dragstart event to initiate mouse dragging
    document.addEventListener('dragstart', function(e)
    {
        //if the element's parent is not the owner, then block this event
        if(selections.owner != e.target.parentNode)
        {
            e.preventDefault();
            return;
        }

        //[else] if the multiple selection modifier is pressed
        //and the item's grabbed state is currently false
        if
        (
            hasModifier(e)
            &&
            e.target.getAttribute('aria-grabbed') == 'false'
        )
        {
            //add this additional selection
            addSelection(e.target);
        }

        //we don't need the transfer data, but we have to define something
        //otherwise the drop action won't work at all in firefox
        //most browsers support the proper mime-type syntax, eg. "text/plain"
        //but we have to use this incorrect syntax for the benefit of IE10+
        e.dataTransfer.setData('text', '');

        //apply dropeffect to the target containers
        addDropeffects();

    }, false);

    //related variable is needed to maintain a reference to the
    //dragleave's relatedTarget, since it doesn't have e.relatedTarget
    var related = null;

    //dragenter event to set that variable
    document.addEventListener('dragenter', function(e)
    {
        related = e.target;

    }, false);

    //dragleave event to maintain target highlighting using that variable
    document.addEventListener('dragleave', function(e)
    {
        //get a drop target reference from the relatedTarget
        var droptarget = getContainer(related);

        //if the target is the owner then it's not a valid drop target
        if(droptarget == selections.owner)
        {
            droptarget = null;
        }

        //if the drop target is different from the last stored reference
        //(or we have one of those references but not the other one)
        if(droptarget != selections.droptarget)
        {
            //if we have a saved reference, clear its existing dragover class
            if(selections.droptarget)
            {
                selections.droptarget.className =
                    selections.droptarget.className.replace(/ dragover/g, '');
            }

            //apply the dragover class to the new drop target reference
            if(droptarget)
            {
                droptarget.className += ' dragover';
            }

            //then save that reference for next time
            selections.droptarget = droptarget;
        }

    }, false);

    //dragover event to allow the drag by preventing its default
    document.addEventListener('dragover', function(e)
    {
        //if we have any selected items, allow them to be dragged
        if(selections.items.length)
        {
            e.preventDefault();
        }

    }, false);

    //dragend event to implement items being validly dropped into targets,
    //or invalidly dropped elsewhere, and to clean-up the interface either way
    document.addEventListener('dragend', function(e)
    {
        //if we have a valid drop target reference
        //(which implies that we have some selected items)
        if(selections.droptarget)
        {
            // OSRT modification: only move if location is changing.
            if (getStaging(selections.droptarget) == getStaging(selections.items[0])) {
                e.preventDefault();
                return;
            }

            // OSRT modification: place requests back in package list.
            var target = selections.droptarget.parentElement.querySelector('ul.packages-list');

            //append the selected items to the end of the target container
            for(var len = selections.items.length, i = 0; i < len; i ++)
            {
                // OSRT modification: place in package list and determine if moved from origin.
                // selections.droptarget.appendChild(selections.items[i]);
                target.appendChild(selections.items[i]);
                if (getStaging(selections.items[i]) != selections.items[i].getAttribute('data-staging-origin'))
                {
                    selections.items[i].classList.add('osrt-moved');
                }
                else {
                    selections.items[i].classList.remove('osrt-moved');
                }
            }

            // OSRT modification: after drag update overall summary of moves.
            updateSummary();

            //prevent default to allow the action
            e.preventDefault();
        }

        //if we have any selected items
        if(selections.items.length)
        {
            //clear dropeffect from the target containers
            clearDropeffects();

            //if we have a valid drop target reference
            if(selections.droptarget)
            {
                //reset the selections array
                clearSelections();

                //reset the target's dragover class
                selections.droptarget.className =
                    selections.droptarget.className.replace(/ dragover/g, '');

                //reset the target reference
                selections.droptarget = null;
            }
        }

    }, false);
};
