/*
 * Copyright (C) 2013 Google Inc. All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are
 * met:
 *
 *     * Redistributions of source code must retain the above copyright
 * notice, this list of conditions and the following disclaimer.
 *     * Redistributions in binary form must reproduce the above
 * copyright notice, this list of conditions and the following disclaimer
 * in the documentation and/or other materials provided with the
 * distribution.
 *     * Neither the name of Google Inc. nor the names of its
 * contributors may be used to endorse or promote products derived from
 * this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

/**
 * @interface
 */
WebInspector.SuggestBoxDelegate = function()
{
}

WebInspector.SuggestBoxDelegate.prototype = {
    /**
     * @param {string} suggestion
     * @param {boolean=} isIntermediateSuggestion
     */
    applySuggestion: function(suggestion, isIntermediateSuggestion) { },

    /**
     * acceptSuggestion will be always called after call to applySuggestion with isIntermediateSuggestion being equal to false.
     */
    acceptSuggestion: function() { },
}

/**
 * @constructor
 * @param {!WebInspector.SuggestBoxDelegate} suggestBoxDelegate
 * @param {number=} maxItemsHeight
 */
WebInspector.SuggestBox = function(suggestBoxDelegate, maxItemsHeight)
{
    this._suggestBoxDelegate = suggestBoxDelegate;
    this._length = 0;
    this._selectedIndex = -1;
    this._selectedElement = null;
    this._maxItemsHeight = maxItemsHeight;
    this._maybeHideBound = this._maybeHide.bind(this);
    this._container = createElementWithClass("div", "suggest-box-container");
    this._element = this._container.createChild("div", "suggest-box");
    this._element.addEventListener("mousedown", this._onBoxMouseDown.bind(this), true);
    this._detailsPopup = this._container.createChild("div", "suggest-box details-popup monospace");
    this._detailsPopup.classList.add("hidden");
    this._asyncDetailsCallback = null;
    /** @type {!Map<number, !Promise<{detail: string, description: string}>>} */
    this._asyncDetailsPromises = new Map();
}

/**
 * @typedef Array.<{title: string, className: (string|undefined)}>
 */
WebInspector.SuggestBox.Suggestions;

WebInspector.SuggestBox.prototype = {
    /**
     * @return {boolean}
     */
    visible: function()
    {
        return !!this._container.parentElement;
    },

    /**
     * @param {!AnchorBox} anchorBox
     */
    setPosition: function(anchorBox)
    {
        this._updateBoxPosition(anchorBox);
    },

    /**
     * @param {!AnchorBox} anchorBox
     */
    _updateBoxPosition: function(anchorBox)
    {
        console.assert(this._overlay);
        if (this._lastAnchorBox && this._lastAnchorBox.equals(anchorBox))
            return;
        this._lastAnchorBox = anchorBox;

        // Position relative to main DevTools element.
        var container = WebInspector.Dialog.modalHostView().element;
        anchorBox = anchorBox.relativeToElement(container);
        var totalHeight = container.offsetHeight;
        var aboveHeight = anchorBox.y;
        var underHeight = totalHeight - anchorBox.y - anchorBox.height;

        this._overlay.setLeftOffset(anchorBox.x);

        var under = underHeight >= aboveHeight;
        if (under)
            this._overlay.setVerticalOffset(anchorBox.y + anchorBox.height, true);
        else
            this._overlay.setVerticalOffset(totalHeight - anchorBox.y, false);

        /** const */ var rowHeight = 17;
        /** const */ var spacer = 6;
        var maxHeight = this._maxItemsHeight ? this._maxItemsHeight * rowHeight : Math.max(underHeight, aboveHeight) - spacer;
        this._element.style.maxHeight = maxHeight + "px";
    },

    /**
     * @param {!Event} event
     */
    _onBoxMouseDown: function(event)
    {
        if (this._hideTimeoutId) {
            window.clearTimeout(this._hideTimeoutId);
            delete this._hideTimeoutId;
        }
        event.preventDefault();
    },

    _maybeHide: function()
    {
        if (!this._hideTimeoutId)
            this._hideTimeoutId = window.setTimeout(this.hide.bind(this), 0);
    },

    /**
     * // FIXME: make SuggestBox work for multiple documents.
     * @suppressGlobalPropertiesCheck
     */
    _show: function()
    {
        if (this.visible())
            return;
        this._bodyElement = document.body;
        this._bodyElement.addEventListener("mousedown", this._maybeHideBound, true);
        this._overlay = new WebInspector.SuggestBox.Overlay();
        this._overlay.setContentElement(this._container);
    },

    hide: function()
    {
        if (!this.visible())
            return;

        this._bodyElement.removeEventListener("mousedown", this._maybeHideBound, true);
        delete this._bodyElement;
        this._container.remove();
        this._overlay.dispose();
        delete this._overlay;
        delete this._selectedElement;
        this._selectedIndex = -1;
        delete this._lastAnchorBox;
    },

    removeFromElement: function()
    {
        this.hide();
    },

    /**
     * @param {boolean=} isIntermediateSuggestion
     */
    _applySuggestion: function(isIntermediateSuggestion)
    {
        if (!this.visible() || !this._selectedElement)
            return false;

        var suggestion = this._selectedElement.__fullValue;
        if (!suggestion)
            return false;

        this._suggestBoxDelegate.applySuggestion(suggestion, isIntermediateSuggestion);
        return true;
    },

    /**
     * @return {boolean}
     */
    acceptSuggestion: function()
    {
        var result = this._applySuggestion();
        this.hide();
        if (!result)
            return false;

        this._suggestBoxDelegate.acceptSuggestion();

        return true;
    },

    /**
     * @param {number} shift
     * @param {boolean=} isCircular
     * @return {boolean} is changed
     */
    _selectClosest: function(shift, isCircular)
    {
        if (!this._length)
            return false;

        if (this._selectedIndex === -1 && shift < 0)
            shift += 1;

        var index = this._selectedIndex + shift;

        if (isCircular)
            index = (this._length + index) % this._length;
        else
            index = Number.constrain(index, 0, this._length - 1);

        this._selectItem(index, true);
        this._applySuggestion(true);
        return true;
    },

    /**
     * @param {!Event} event
     */
    _onItemMouseDown: function(event)
    {
        this._selectedElement = event.currentTarget;
        this.acceptSuggestion();
        event.consume(true);
    },

    /**
     * @param {string} prefix
     * @param {string} text
     * @param {string|undefined} className
     * @param {number} index
     */
    _createItemElement: function(prefix, text, className, index)
    {
        var element = createElementWithClass("div", "suggest-box-content-item source-code " + (className || ""));
        element.tabIndex = -1;
        if (prefix && prefix.length && !text.indexOf(prefix)) {
            element.createChild("span", "prefix").textContent = prefix;
            element.createChild("span", "suffix").textContent = text.substring(prefix.length).trimEnd(50);
        } else {
            element.createChild("span", "suffix").textContent = text.trimEnd(50);
        }
        element.__fullValue = text;
        element.createChild("span", "spacer");
        element.addEventListener("mousedown", this._onItemMouseDown.bind(this), false);
        return element;
    },

    /**
     * @param {!WebInspector.SuggestBox.Suggestions} items
     * @param {string} userEnteredText
     * @param {function(number): !Promise<{detail:string, description:string}>=} asyncDetails
     */
    _updateItems: function(items, userEnteredText, asyncDetails)
    {
        this._length = items.length;
        this._asyncDetailsPromises.clear();
        this._asyncDetailsCallback = asyncDetails;
        this._element.removeChildren();
        delete this._selectedElement;

        for (var i = 0; i < items.length; ++i) {
            var item = items[i];
            var currentItemElement = this._createItemElement(userEnteredText, item.title, item.className, i);
            this._element.appendChild(currentItemElement);
        }
    },

    /**
     * @param {number} index
     * @return {!Promise<?{detail: string, description: string}>}
     */
    _asyncDetails: function(index)
    {
        if (!this._asyncDetailsCallback)
            return Promise.resolve(/** @type {?{description: string, detail: string}} */(null));
        if (!this._asyncDetailsPromises.has(index))
            this._asyncDetailsPromises.set(index, this._asyncDetailsCallback(index));
        return /** @type {!Promise<?{detail: string, description: string}>} */(this._asyncDetailsPromises.get(index));
    },

    /**
     * @param {?{detail: string, description: string}} details
     */
    _showDetailsPopup: function(details)
    {
        this._detailsPopup.removeChildren();
        if (!details)
            return;
        this._detailsPopup.createChild("section", "detail").createTextChild(details.detail);
        this._detailsPopup.createChild("section", "description").createTextChild(details.description);
        this._detailsPopup.classList.remove("hidden");
    },

    /**
     * @param {number} index
     * @param {boolean} scrollIntoView
     */
    _selectItem: function(index, scrollIntoView)
    {
        if (this._selectedElement)
            this._selectedElement.classList.remove("selected");

        this._selectedIndex = index;
        if (index < 0)
            return;

        this._selectedElement = this._element.children[index];
        this._selectedElement.classList.add("selected");
        this._detailsPopup.classList.add("hidden");
        var elem = this._selectedElement;
        this._asyncDetails(index).then(showDetails.bind(this), function(){});

        if (scrollIntoView)
            this._selectedElement.scrollIntoViewIfNeeded(false);

        /**
         * @param {?{detail: string, description: string}} details
         * @this {WebInspector.SuggestBox}
         */
        function showDetails(details)
        {
            if (elem === this._selectedElement)
                this._showDetailsPopup(details);
        }
    },

    /**
     * @param {!WebInspector.SuggestBox.Suggestions} completions
     * @param {boolean} canShowForSingleItem
     * @param {string} userEnteredText
     */
    _canShowBox: function(completions, canShowForSingleItem, userEnteredText)
    {
        if (!completions || !completions.length)
            return false;

        if (completions.length > 1)
            return true;

        // Do not show a single suggestion if it is the same as user-entered prefix, even if allowed to show single-item suggest boxes.
        return canShowForSingleItem && completions[0].title !== userEnteredText;
    },

    _ensureRowCountPerViewport: function()
    {
        if (this._rowCountPerViewport)
            return;
        if (!this._element.firstChild)
            return;

        this._rowCountPerViewport = Math.floor(this._element.offsetHeight / this._element.firstChild.offsetHeight);
    },

    /**
     * @param {!AnchorBox} anchorBox
     * @param {!WebInspector.SuggestBox.Suggestions} completions
     * @param {number} selectedIndex
     * @param {boolean} canShowForSingleItem
     * @param {string} userEnteredText
     * @param {function(number): !Promise<{detail:string, description:string}>=} asyncDetails
     */
    updateSuggestions: function(anchorBox, completions, selectedIndex, canShowForSingleItem, userEnteredText, asyncDetails)
    {
        if (this._canShowBox(completions, canShowForSingleItem, userEnteredText)) {
            this._updateItems(completions, userEnteredText, asyncDetails);
            this._show();
            this._updateBoxPosition(anchorBox);
            this._selectItem(selectedIndex, selectedIndex > 0);
            delete this._rowCountPerViewport;
        } else
            this.hide();
    },

    /**
     * @param {!KeyboardEvent} event
     * @return {boolean}
     */
    keyPressed: function(event)
    {
        switch (event.key) {
        case "ArrowUp":
            return this.upKeyPressed();
        case "ArrowDown":
            return this.downKeyPressed();
        case "PageUp":
            return this.pageUpKeyPressed();
        case "PageDown":
            return this.pageDownKeyPressed();
        case "Enter":
            return this.enterKeyPressed();
        }
        return false;
    },

    /**
     * @return {boolean}
     */
    upKeyPressed: function()
    {
        return this._selectClosest(-1, true);
    },

    /**
     * @return {boolean}
     */
    downKeyPressed: function()
    {
        return this._selectClosest(1, true);
    },

    /**
     * @return {boolean}
     */
    pageUpKeyPressed: function()
    {
        this._ensureRowCountPerViewport();
        return this._selectClosest(-this._rowCountPerViewport, false);
    },

    /**
     * @return {boolean}
     */
    pageDownKeyPressed: function()
    {
        this._ensureRowCountPerViewport();
        return this._selectClosest(this._rowCountPerViewport, false);
    },

    /**
     * @return {boolean}
     */
    enterKeyPressed: function()
    {
        var hasSelectedItem = !!this._selectedElement;
        this.acceptSuggestion();

        // Report the event as non-handled if there is no selected item,
        // to commit the input or handle it otherwise.
        return hasSelectedItem;
    }
}

/**
 * @constructor
 * // FIXME: make SuggestBox work for multiple documents.
 * @suppressGlobalPropertiesCheck
 */
WebInspector.SuggestBox.Overlay = function()
{
    this.element = createElementWithClass("div", "suggest-box-overlay");
    var root = WebInspector.createShadowRootWithCoreStyles(this.element, "ui/suggestBox.css");
    this._leftSpacerElement = root.createChild("div", "suggest-box-left-spacer");
    this._horizontalElement = root.createChild("div", "suggest-box-horizontal");
    this._topSpacerElement = this._horizontalElement.createChild("div", "suggest-box-top-spacer");
    this._bottomSpacerElement = this._horizontalElement.createChild("div", "suggest-box-bottom-spacer");
    this._resize();
    document.body.appendChild(this.element);
}

WebInspector.SuggestBox.Overlay.prototype = {
    /**
     * @param {number} offset
     */
    setLeftOffset: function(offset)
    {
        this._leftSpacerElement.style.flexBasis = offset + "px";
    },

    /**
     * @param {number} offset
     * @param {boolean} isTopOffset
     */
    setVerticalOffset: function(offset, isTopOffset)
    {
        this.element.classList.toggle("under-anchor", isTopOffset);

        if (isTopOffset) {
            this._bottomSpacerElement.style.flexBasis = "auto";
            this._topSpacerElement.style.flexBasis = offset + "px";
        } else {
            this._bottomSpacerElement.style.flexBasis = offset + "px";
            this._topSpacerElement.style.flexBasis = "auto";
        }
    },

    /**
     * @param {!Element} element
     */
    setContentElement: function(element)
    {
        this._horizontalElement.insertBefore(element, this._bottomSpacerElement);
    },

    _resize: function()
    {
        var container = WebInspector.Dialog.modalHostView().element;
        var containerBox = container.boxInWindow(container.ownerDocument.defaultView);

        this.element.style.left = containerBox.x + "px";
        this.element.style.top = containerBox.y + "px";
        this.element.style.height = containerBox.height + "px";
        this.element.style.width = containerBox.width + "px";
    },

    dispose: function()
    {
        this.element.remove();
    }
}
