/*
 * Copyright (C) 2007, 2008, 2013, 2015 Apple Inc.  All rights reserved.
 * Copyright (C) 2008 Matt Lilek <webkit@mattlilek.com>
 * Copyright (C) 2009 Joseph Pecoraro
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 *
 * 1.  Redistributions of source code must retain the above copyright
 *     notice, this list of conditions and the following disclaimer.
 * 2.  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.
 * 3.  Neither the name of Apple Inc. ("Apple") 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 APPLE AND ITS 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 APPLE OR ITS 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.
 */

WI.DOMTreeOutline = class DOMTreeOutline extends WI.TreeOutline
{
    constructor({selectable, omitRootDOMNode, excludeRevealElementContextMenu, showInspectedNode, showBadges} = {})
    {
        super(selectable);

        this.element.addEventListener("mousedown", this._onmousedown.bind(this), false);
        this.element.addEventListener("mousemove", this._onmousemove.bind(this), false);
        this.element.addEventListener("mouseout", this._onmouseout.bind(this), false);
        this.element.addEventListener("dragstart", this._ondragstart.bind(this), false);
        this.element.addEventListener("dragover", this._ondragover.bind(this), false);
        this.element.addEventListener("dragleave", this._ondragleave.bind(this), false);
        this.element.addEventListener("drop", this._ondrop.bind(this), false);
        this.element.addEventListener("dragend", this._ondragend.bind(this), false);

        this.element.classList.add("dom", WI.SyntaxHighlightedStyleClassName);
        this.element.dir = "ltr";
        this.element.role = "tree";

        this._includeRootDOMNode = !omitRootDOMNode;
        this._excludeRevealElementContextMenu = excludeRevealElementContextMenu;
        this._rootDOMNode = null;
        this._selectedDOMNode = null;
        this._treeElementsToRemove = null;

        this._editable = false;
        this._editing = false;
        this._visible = false;
        this._usingLocalDOMNode = false;

        this._hideElementsKeyboardShortcut = new WI.KeyboardShortcut(null, "H", this._hideElements.bind(this), this.element);
        this._hideElementsKeyboardShortcut.implicitlyPreventsDefault = false;

        this._showInspectedNode = !!showInspectedNode;
        if (this._showInspectedNode)
            WI.domManager.addEventListener(WI.DOMManager.Event.InspectedNodeChanged, this._handleInspectedNodeChanged, this);

        this._showBadges = !!showBadges;
    }

    // Public

    wireToDomAgent()
    {
        this._elementsTreeUpdater = new WI.DOMTreeUpdater(this);
    }

    close()
    {
        if (this._elementsTreeUpdater) {
            this._elementsTreeUpdater.close();
            this._elementsTreeUpdater = null;
        }
    }

    setVisible(visible, omitFocus)
    {
        this._visible = visible;
        if (!this._visible)
            return;

        this._updateModifiedNodes();

        if (this._selectedDOMNode)
            this._revealAndSelectNode(this._selectedDOMNode, omitFocus);

        this.update();
    }

    get rootDOMNode()
    {
        return this._rootDOMNode;
    }

    set rootDOMNode(x)
    {
        if (this._rootDOMNode === x)
            return;

        this._rootDOMNode = x;

        this._isXMLMimeType = x && x.isXMLNode();

        this.update();
    }

    get isXMLMimeType()
    {
        return this._isXMLMimeType;
    }

    markAsUsingLocalDOMNode()
    {
        this._editable = false;
        this._usingLocalDOMNode = true;
    }

    selectedDOMNode()
    {
        return this._selectedDOMNode;
    }

    selectDOMNode(node, focus)
    {
        if (this._selectedDOMNode === node) {
            this._revealAndSelectNode(node, !focus);
            return;
        }

        this._selectedDOMNode = node;
        this._revealAndSelectNode(node, !focus);

        // The _revealAndSelectNode() method might find a different element if there is inlined text,
        // and the select() call would change the selectedDOMNode and reenter this setter. So to
        // avoid calling _selectedNodeChanged() twice, first check if _selectedDOMNode is the same
        // node as the one passed in.
        // Note that _revealAndSelectNode will not do anything for a null node.
        if (!node || this._selectedDOMNode === node)
            this._selectedNodeChanged();
    }

    get editable()
    {
        return this._editable && this.rootDOMNode && !this.rootDOMNode.destroyed;
    }

    set editable(x)
    {
        this._editable = x;
    }

    get editing()
    {
        return this._editing;
    }

    update()
    {
        if (!this.rootDOMNode)
            return;

        let selectedTreeElements = this.selectedTreeElements;

        this.removeChildren();

        const elementCloseTag = false;

        var treeElement;
        if (this._includeRootDOMNode) {
            treeElement = new WI.DOMTreeElement(this.rootDOMNode, elementCloseTag, {showBadges: this._showBadges});
            treeElement.selectable = this.selectable;
            this.appendChild(treeElement);
        } else {
            // FIXME: this could use findTreeElement to reuse a tree element if it already exists
            var node = this.rootDOMNode.firstChild;
            while (node) {
                treeElement = new WI.DOMTreeElement(node, elementCloseTag, {showBadges: this._showBadges});
                treeElement.selectable = this.selectable;
                this.appendChild(treeElement);
                node = node.nextSibling;

                if (treeElement.expandable && !treeElement.expanded)
                    treeElement.expand();
            }
        }

        if (this._showInspectedNode && WI.domManager.inspectedNode) {
            let inspectedNodeTreeElement = this.findTreeElement(WI.domManager.inspectedNode);
            if (inspectedNodeTreeElement) {
                inspectedNodeTreeElement.reveal();
                inspectedNodeTreeElement.listItemElement.classList.add("inspected-node");
            }
        }

        if (!selectedTreeElements.length)
            return;

        // The selection cannot be restored from represented objects alone,
        // since a closing tag DOMTreeElement has the same represented object
        // as its parent.
        selectedTreeElements = selectedTreeElements.map((oldTreeElement) => {
            let treeElement = this.findTreeElement(oldTreeElement.representedObject);
            if (treeElement && oldTreeElement.isCloseTag()) {
                console.assert(treeElement.closeTagTreeElement, "Missing close tag TreeElement.", treeElement);
                if (treeElement.closeTagTreeElement)
                    treeElement = treeElement.closeTagTreeElement;
            }
            return treeElement;
        });

        // It's possible that a previously selected node will no longer exist (e.g. after navigation).
        selectedTreeElements = selectedTreeElements.filter((x) => !!x);

        if (!selectedTreeElements.length)
            return;

        this.selectTreeElements(selectedTreeElements);

        if (this.selectedTreeElement)
            this.selectedTreeElement.reveal();
    }

    updateSelectionArea()
    {
        // This will miss updating selection areas used for the hovered tree element and
        // and those used to show forced pseudo class indicators, but this should be okay.
        // The hovered element will update when user moves the mouse, and indicators don't need the
        // selection area height to be accurate since they use ::before to place the indicator.
        let selectedTreeElements = this.selectedTreeElements;
        for (let treeElement of selectedTreeElements)
            treeElement.updateSelectionArea();
    }

    toggleSelectedElementsVisibility(forceHidden)
    {
        for (let treeElement of this.selectedTreeElements)
            treeElement.toggleElementVisibility(forceHidden);
    }

    _selectedNodeChanged()
    {
        this.dispatchEventToListeners(WI.DOMTreeOutline.Event.SelectedNodeChanged);
    }

    findTreeElement(node)
    {
        let isAncestorNode = (ancestor, node) => ancestor.isAncestor(node);
        let parentNode = (node) => node.parentNode;
        let treeElement = super.findTreeElement(node, isAncestorNode, parentNode);
        if (!treeElement && node.nodeType() === Node.TEXT_NODE) {
            // The text node might have been inlined if it was short, so try to find the parent element.
            treeElement = super.findTreeElement(node.parentNode, isAncestorNode, parentNode);
        }

        return treeElement;
    }

    createTreeElementFor(node)
    {
        var treeElement = this.findTreeElement(node);
        if (treeElement)
            return treeElement;

        if (!node.parentNode)
            return null;

        treeElement = this.createTreeElementFor(node.parentNode);
        if (!treeElement)
            return null;

        return treeElement.showChildNode(node);
    }

    set suppressRevealAndSelect(x)
    {
        if (this._suppressRevealAndSelect === x)
            return;
        this._suppressRevealAndSelect = x;
    }

    populateContextMenu(contextMenu, event, treeElement)
    {
        let subMenus = {
            add: new WI.ContextSubMenuItem(contextMenu, WI.UIString("Add")),
            edit: new WI.ContextSubMenuItem(contextMenu, WI.UIString("Edit")),
            copy: new WI.ContextSubMenuItem(contextMenu, WI.UIString("Copy")),
            delete: new WI.ContextSubMenuItem(contextMenu, WI.UIString("Delete")),
        };

        if (this.editable && treeElement.selected && this.selectedTreeElements.length > 1) {
            subMenus.delete.appendItem(WI.UIString("Nodes"), () => {
                this.ondelete();
            });
        }

        if (treeElement.populateDOMNodeContextMenu)
            treeElement.populateDOMNodeContextMenu(contextMenu, subMenus, event, subMenus);

        let options = {
            disallowEditing: !this.editable,
            usingLocalDOMNode: this._usingLocalDOMNode,
            excludeRevealElement: this._excludeRevealElementContextMenu,
            copySubMenu: subMenus.copy,
            popoverTargetElement: treeElement.statusImageElement,
        };

        if (treeElement.bindRevealDescendantBreakpointsMenuItemHandler)
            options.revealDescendantBreakpointsMenuItemHandler = treeElement.bindRevealDescendantBreakpointsMenuItemHandler();

        WI.appendContextMenuItemsForDOMNode(contextMenu, treeElement.representedObject, options);

        super.populateContextMenu(contextMenu, event, treeElement);
    }

    adjustCollapsedRange()
    {
    }

    ondelete()
    {
        if (!this.editable)
            return false;

        this._treeElementsToRemove = this.selectedTreeElements;

        // Reveal all of the elements being deleted so that if the node is hidden (e.g. the parent
        // is collapsed), we can select its siblings instead of the parent itself.
        for (let treeElement of this._treeElementsToRemove)
            treeElement.reveal();

        this._selectionController.removeSelectedItems();

        let levelMap = new Map;

        function getLevel(treeElement) {
            let level = levelMap.get(treeElement);
            if (isNaN(level)) {
                level = 0;
                let current = treeElement;
                while (current = current.parent)
                    level++;
                levelMap.set(treeElement, level);
            }
            return level;
        }

        // Sort in descending order by node level. This ensures that child nodes
        // are removed before their ancestors.
        this._treeElementsToRemove.sort((a, b) => getLevel(b) - getLevel(a));

        // Track removed elements, since the opening and closing tags for the
        // same WI.DOMNode can both be selected.
        let removedDOMNodes = new Set;

        for (let treeElement of this._treeElementsToRemove) {
            if (removedDOMNodes.has(treeElement.representedObject))
                continue;
            removedDOMNodes.add(treeElement.representedObject);
            treeElement.remove();
        }

        this._treeElementsToRemove = null;

        if (this.selectedTreeElement && !this.selectedTreeElement.isCloseTag()) {
            console.assert(this.selectedTreeElements.length === 1);
            this.selectedTreeElement.reveal();
        }

        return true;
    }

    // SelectionController delegate overrides

    selectionControllerPreviousSelectableItem(controller, item)
    {
        let treeElement = this.getCachedTreeElement(item);
        console.assert(treeElement, "Missing TreeElement for representedObject.", item);
        if (!treeElement)
            return null;

        if (this._treeElementsToRemove) {
            // When deleting, force the SelectionController to check siblings in
            // the opposite direction before searching up the parent chain.
            if (!treeElement.previousSelectableSibling && treeElement.nextSelectableSibling)
                return null;
        }

        return super.selectionControllerPreviousSelectableItem(controller, item);
    }

    // Protected

    canSelectTreeElement(treeElement)
    {
        if (!super.canSelectTreeElement(treeElement))
            return false;

        let willRemoveAncestorOrSelf = false;
        if (this._treeElementsToRemove) {
            while (treeElement && !willRemoveAncestorOrSelf) {
                willRemoveAncestorOrSelf = this._treeElementsToRemove.includes(treeElement);
                treeElement = treeElement.parent;
            }
        }

        return !willRemoveAncestorOrSelf;
    }

    objectForSelection(treeElement)
    {
        if (treeElement instanceof WI.DOMTreeElement && treeElement.isCloseTag()) {
            // SelectionController requires every selectable item to be unique.
            // The DOMTreeElement for a close tag has the same represented object
            // as it's parent (the open tag). Return a proxy object associated
            // with the tree element for the close tag so it can be selected.
            if (!treeElement.__closeTagProxyObject)
                treeElement.__closeTagProxyObject = {__proxyObjectTreeElement: treeElement};
            return treeElement.__closeTagProxyObject;
        }

        return super.objectForSelection(treeElement);
    }

    // Private

    _revealAndSelectNode(node, omitFocus)
    {
        if (!node || this._suppressRevealAndSelect)
            return;

        var treeElement = this.createTreeElementFor(node);
        if (!treeElement)
            return;

        treeElement.revealAndSelect(omitFocus);
    }

    _onmousedown(event)
    {
        let element = this.treeElementFromEvent(event);
        if (!element || element.isEventWithinDisclosureTriangle(event)) {
            event.preventDefault();
            return;
        }
    }

    _onmousemove(event)
    {
        if (this._usingLocalDOMNode)
            return;

        let element = this.treeElementFromEvent(event);
        if (element && this._previousHoveredElement === element)
            return;

        if (this._previousHoveredElement) {
            this._previousHoveredElement.hovered = false;
            this._previousHoveredElement = null;
        }

        if (element) {
            element.hovered = true;
            this._previousHoveredElement = element;

            if (element.representedObject) {
                // Lazily compute tag-specific tooltips.
                if (!element.tooltip && element._createTooltipForNode)
                    element._createTooltipForNode();

                element.representedObject.highlight();
            } else
                WI.domManager.hideDOMNodeHighlight();
        } else
            WI.domManager.hideDOMNodeHighlight();
    }

    _onmouseout(event)
    {
        if (this._usingLocalDOMNode)
            return;

        var nodeUnderMouse = document.elementFromPoint(event.pageX, event.pageY);
        if (nodeUnderMouse && this.element.contains(nodeUnderMouse))
            return;

        if (this._previousHoveredElement) {
            this._previousHoveredElement.hovered = false;
            this._previousHoveredElement = null;
        }

        WI.domManager.hideDOMNodeHighlight();
    }

    _ondragstart(event)
    {
        if (!this.editable)
            return false;

        let treeElement = this.treeElementFromEvent(event);
        if (!treeElement)
            return false;

        event.dataTransfer.effectAllowed = "copyMove";
        event.dataTransfer.setData(DOMTreeOutline.DOMNodeIdDragType, treeElement.representedObject.id);

        if (!this._isValidDragSourceOrTarget(treeElement))
            return false;

        if (treeElement.representedObject.nodeName() === "BODY" || treeElement.representedObject.nodeName() === "HEAD")
            return false;

        event.dataTransfer.setData("text/plain", treeElement.listItemElement.textContent);
        this._nodeBeingDragged = treeElement.representedObject;

        WI.domManager.hideDOMNodeHighlight();

        return true;
    }

    _ondragover(event)
    {
        if (!this.editable)
            return false;

        if (event.dataTransfer.types.includes(WI.GeneralStyleDetailsSidebarPanel.ToggledClassesDragType)) {
            event.preventDefault();
            event.dataTransfer.dropEffect = "copy";
            return false;
        }

        if (!this._nodeBeingDragged)
            return false;

        let treeElement = this.treeElementFromEvent(event);
        if (!this._isValidDragSourceOrTarget(treeElement))
            return false;

        let node = treeElement.representedObject;
        while (node) {
            if (node === this._nodeBeingDragged)
                return false;
            node = node.parentNode;
        }

        this.dragOverTreeElement = treeElement;
        treeElement.listItemElement.classList.add("elements-drag-over");
        treeElement.updateSelectionArea();

        event.preventDefault();
        event.dataTransfer.dropEffect = "move";
        return false;
    }

    _ondragleave(event)
    {
        if (!this.editable)
            return false;

        this._clearDragOverTreeElementMarker();
        event.preventDefault();
        return false;
    }

    _isValidDragSourceOrTarget(treeElement)
    {
        if (!treeElement)
            return false;

        var node = treeElement.representedObject;
        if (!(node instanceof WI.DOMNode))
            return false;

        if (!node.parentNode || node.parentNode.nodeType() !== Node.ELEMENT_NODE)
            return false;

        return true;
    }

    _ondrop(event)
    {
        if (!this.editable)
            return;

        event.preventDefault();

        function callback(error, newNodeId)
        {
            if (error)
                return;

            this._updateModifiedNodes();
            var newNode = WI.domManager.nodeForId(newNodeId);
            if (newNode)
                this.selectDOMNode(newNode, true);
        }

        let treeElement = this.treeElementFromEvent(event);
        if (this._nodeBeingDragged && treeElement) {
            let parentNode = null;
            let anchorNode = null;

            if (treeElement._elementCloseTag) {
                // Drop onto closing tag -> insert as last child.
                parentNode = treeElement.representedObject;
            } else {
                let dragTargetNode = treeElement.representedObject;
                parentNode = dragTargetNode.parentNode;
                anchorNode = dragTargetNode;
            }

            this._nodeBeingDragged.moveTo(parentNode, anchorNode, callback.bind(this));
        } else {
            let className = event.dataTransfer.getData(WI.GeneralStyleDetailsSidebarPanel.ToggledClassesDragType);
            if (className && treeElement)
                treeElement.representedObject.toggleClass(className, true);
        }

        delete this._nodeBeingDragged;
    }

    _ondragend(event)
    {
        if (!this.editable)
            return;

        event.preventDefault();
        this._clearDragOverTreeElementMarker();
        delete this._nodeBeingDragged;
    }

    _clearDragOverTreeElementMarker()
    {
        if (this.dragOverTreeElement) {
            let element = this.dragOverTreeElement;
            this.dragOverTreeElement = null;

            element.listItemElement.classList.remove("elements-drag-over");
            element.updateSelectionArea();
        }
    }

    _updateModifiedNodes()
    {
        if (this._elementsTreeUpdater)
            this._elementsTreeUpdater._updateModifiedNodes();
    }

    _handleInspectedNodeChanged(event)
    {
        let {lastInspectedNode} = event.data;

        if (lastInspectedNode) {
            let lastInspectedNodeTreeElement = this.findTreeElement(lastInspectedNode);
            if (lastInspectedNodeTreeElement)
                lastInspectedNodeTreeElement.listItemElement.classList.remove("inspected-node");
        }

        let inspectedNodeTreeElement = this.findTreeElement(WI.domManager.inspectedNode);
        if (inspectedNodeTreeElement)
            inspectedNodeTreeElement.listItemElement.classList.add("inspected-node");
    }

    _hideElements(event, keyboardShortcut)
    {
        if (!this.editable)
            return;

        if (!this.selectedTreeElement || WI.isEditingAnyField())
            return;

        event.preventDefault();

        let forceHidden = !this.selectedTreeElements.every((treeElement) => treeElement.isNodeHidden);
        this.toggleSelectedElementsVisibility(forceHidden);
    }
};

WI.DOMTreeOutline.Event = {
    SelectedNodeChanged: "dom-tree-outline-selected-node-changed"
};

WI.DOMTreeOutline.DOMNodeIdDragType = "web-inspector/dom-node-id";
