/*
 * Copyright (C) 2013, 2015 Apple 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:
 * 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.
 *
 * THIS SOFTWARE IS PROVIDED BY APPLE INC. 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 INC. 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.Popover = class Popover extends WI.Object
{
    constructor(delegate)
    {
        super();

        this.delegate = delegate;
        this._edge = null;
        this._frame = new WI.Rect;
        this._content = null;
        this._targetFrame = new WI.Rect;
        this._anchorPoint = new WI.Point;
        this._preferredEdges = null;
        this._resizeHandler = null;

        this._contentNeedsUpdate = false;
        this._dismissing = false;

        this._element = document.createElement("div");
        this._element.className = "popover";
        this._element.addEventListener("transitionend", this, true);

        this._container = this._element.appendChild(document.createElement("div"));
        this._container.className = "container";

        this._drawBackgroundAnimationIdentifier = undefined;
    }

    // Public

    get element() { return this._element; }

    get visible()
    {
        return this._element.parentNode === document.body && !this._element.classList.contains(WI.Popover.FadeOutClassName);
    }

    get frame()
    {
        return this._frame;
    }

    set frame(frame)
    {
        this._element.style.left = frame.minX() + "px";
        this._element.style.top = frame.minY() + "px";
        this._element.style.width = frame.size.width + "px";
        this._element.style.height = frame.size.height + "px";
        this._element.style.backgroundSize = frame.size.width + "px " + frame.size.height + "px";
        this._frame = frame;
    }

    set content(content)
    {
        if (content === this._content)
            return;

        this._content = content;

        this._contentNeedsUpdate = true;

        if (this.visible)
            this._update(true);
    }

    set windowResizeHandler(resizeHandler)
    {
        console.assert(typeof resizeHandler === "function");
        this._resizeHandler = resizeHandler;
    }

    resize()
    {
        if (this.visible && this._resizeHandler)
            this._resizeHandler();
    }

    update(shouldAnimate = true)
    {
        if (!this.visible)
            return;

        var previouslyFocusedElement = document.activeElement;

        this._contentNeedsUpdate = true;
        this._update(shouldAnimate);

        if (previouslyFocusedElement)
            previouslyFocusedElement.focus();
    }

    present(targetFrame, preferredEdges, {updateContent, shouldAnimate} = {})
    {
        this._targetFrame = targetFrame;
        this._preferredEdges = preferredEdges;

        if (!this._content)
            return;

        this._addListenersIfNeeded();

        if (updateContent && this.visible)
            this.update(shouldAnimate);
        else
            this._update(shouldAnimate);
    }

    presentNewContentWithFrame(content, targetFrame, preferredEdges)
    {
        this._content = content;
        this._contentNeedsUpdate = true;

        this._targetFrame = targetFrame;
        this._preferredEdges = preferredEdges;

        this._addListenersIfNeeded();

        var shouldAnimate = this.visible;
        this._update(shouldAnimate);
    }

    dismiss()
    {
        if (this._dismissing || this._element.parentNode !== document.body)
            return;

        this._dismissing = true;

        console.assert(this._isListeningForPopoverEvents);
        this._isListeningForPopoverEvents = false;

        window.removeEventListener("mousedown", this, true);
        window.removeEventListener("scroll", this, true);
        window.removeEventListener("resize", this, true);
        window.removeEventListener("keypress", this, true);

        this._prefersDarkColorSchemeMediaQueryList.removeListener(this._boundUpdate);

        WI.quickConsole.keyboardShortcutDisabled = false;

        this._element.classList.add(WI.Popover.FadeOutClassName);

        if (this.delegate && typeof this.delegate.willDismissPopover === "function")
            this.delegate.willDismissPopover(this);
    }

    handleEvent(event)
    {
        switch (event.type) {
        case "mousedown":
        case "scroll":
            if (!this._element.contains(event.target) && !event.target.closest("." + WI.Popover.IgnoreAutoDismissClassName)
                && !event[WI.Popover.EventPreventDismissSymbol]) {
                this.dismiss();
            }
            break;
        case "resize":
            this.resize();
            break;
        case "keypress":
            if (event.keyCode === WI.KeyboardShortcut.Key.Escape.keyCode)
                this.dismiss();
            break;
        case "transitionend":
            if (event.target === this._element) {
                document.body.removeChild(this._element);
                this._element.classList.remove(WI.Popover.FadeOutClassName);
                this._container.textContent = "";
                if (this.delegate && typeof this.delegate.didDismissPopover === "function")
                    this.delegate.didDismissPopover(this);

                this._dismissing = false;
                break;
            }
            break;
        }
    }

    // Private

    static _getCanvasContext(width, height)
    {
        let context = WI.Popover._canvasContext?.deref();
        if (!context) {
            context = document.createElement("canvas").getContext("2d");
            context.canvas.className = "background-canvas";
            WI.Popover._canvasContext = new WeakRef(context);
        }

        context.canvas.width = width;
        context.canvas.height = height;
        return context;
    }

    _update(shouldAnimate)
    {
        if (shouldAnimate)
            var previousEdge = this._edge;

        var targetFrame = this._targetFrame;
        var preferredEdges = this._preferredEdges;

        // Ensure our element is on display so that its metrics can be resolved
        // or interrupt any pending transition to remove it from display.
        if (this._element.parentNode !== document.body)
            document.body.appendChild(this._element);
        else
            this._element.classList.remove(WI.Popover.FadeOutClassName);

        this._dismissing = false;

        if (this._edge !== null)
            this._element.classList.remove(this._cssClassNameForEdge());

        if (this._contentNeedsUpdate) {
            // Reset CSS properties on element so that the element may be sized to fit its content.
            this._element.style.removeProperty("left");
            this._element.style.removeProperty("top");
            this._element.style.removeProperty("width");
            this._element.style.removeProperty("height");

            // Add the content in place of the wrapper to get the raw metrics.
            this._container.replaceWith(this._content);

            // Get the ideal size for the popover to fit its content.
            var popoverBounds = this._element.getBoundingClientRect();
            this._preferredSize = new WI.Size(Math.ceil(popoverBounds.width), Math.ceil(popoverBounds.height));
        }

        var titleBarOffset = WI.undockedTitleAreaHeight();
        var containerFrame = new WI.Rect(0, titleBarOffset, window.innerWidth, window.innerHeight - titleBarOffset);
        // The frame of the window with a little inset to make sure we have room for shadows.
        containerFrame = containerFrame.inset(WI.Popover.ShadowEdgeInsets);

        // Work out the metrics for all edges.
        var metrics = new Array(preferredEdges.length);
        for (var edgeName in WI.RectEdge) {
            var edge = WI.RectEdge[edgeName];
            var item = {
                edge,
                metrics: this._bestMetricsForEdge(this._preferredSize, targetFrame, containerFrame, edge)
            };
            var preferredIndex = preferredEdges.indexOf(edge);
            if (preferredIndex !== -1)
                metrics[preferredIndex] = item;
            else
                metrics.push(item);
        }

        function area(size)
        {
            return Math.max(0, size.width) * Math.max(0, size.height);
        }

        // Find if any of those fit better than the frame for the preferred edge.
        var bestEdge = metrics[0].edge;
        var bestMetrics = metrics[0].metrics;
        for (var i = 1; i < metrics.length; i++) {
            var itemMetrics = metrics[i].metrics;
            if (area(itemMetrics.contentSize) > area(bestMetrics.contentSize)) {
                bestEdge = metrics[i].edge;
                bestMetrics = itemMetrics;
            }
        }

        console.assert(area(bestMetrics.contentSize) > 0);

        var anchorPoint;
        var bestFrame = bestMetrics.frame.round();

        this._edge = bestEdge;

        if (bestFrame === WI.Rect.ZERO_RECT) {
            // The target for the popover is offscreen.
            this.dismiss();
        } else {
            switch (bestEdge) {
            case WI.RectEdge.MIN_X: // Displayed on the left of the target, arrow points right.
                anchorPoint = new WI.Point(bestFrame.size.width - WI.Popover.ShadowPadding, targetFrame.midY() - bestFrame.minY());
                break;
            case WI.RectEdge.MAX_X: // Displayed on the right of the target, arrow points left.
                anchorPoint = new WI.Point(WI.Popover.ShadowPadding, targetFrame.midY() - bestFrame.minY());
                break;
            case WI.RectEdge.MIN_Y: // Displayed above the target, arrow points down.
                anchorPoint = new WI.Point(targetFrame.midX() - bestFrame.minX(), bestFrame.size.height - WI.Popover.ShadowPadding);
                break;
            case WI.RectEdge.MAX_Y: // Displayed below the target, arrow points up.
                anchorPoint = new WI.Point(targetFrame.midX() - bestFrame.minX(), WI.Popover.ShadowPadding);
                break;
            }

            this._element.classList.add(this._cssClassNameForEdge());

            if (shouldAnimate && this._edge === previousEdge)
                this._animateFrame(bestFrame, anchorPoint);
            else {
                 this.frame = bestFrame;
                 this._setAnchorPoint(anchorPoint);
                 this._drawBackground();
            }

            // Make sure content is centered in case either of the dimension is smaller than the minimal bounds.
            if (this._preferredSize.width < WI.Popover.MinWidth || this._preferredSize.height < WI.Popover.MinHeight)
                this._container.classList.add("center");
            else
                this._container.classList.remove("center");
        }

        // Wrap the content in the container so that it's located correctly.
        if (this._contentNeedsUpdate) {
            this._container.textContent = "";
            this._content.replaceWith(this._container);
            this._container.appendChild(this._content);
        }

        this._contentNeedsUpdate = false;
    }

    _cssClassNameForEdge()
    {
        switch (this._edge) {
        case WI.RectEdge.MIN_X: // Displayed on the left of the target, arrow points right.
            return "arrow-right";
        case WI.RectEdge.MAX_X: // Displayed on the right of the target, arrow points left.
            return "arrow-left";
        case WI.RectEdge.MIN_Y: // Displayed above the target, arrow points down.
            return "arrow-down";
        case WI.RectEdge.MAX_Y: // Displayed below the target, arrow points up.
            return "arrow-up";
        }
        console.error("Unknown edge.");
        return "arrow-up";
    }

    _setAnchorPoint(anchorPoint)
    {
        anchorPoint.x = Math.floor(anchorPoint.x);
        anchorPoint.y = Math.floor(anchorPoint.y);
        this._anchorPoint = anchorPoint;
    }

    _animateFrame(toFrame, toAnchor)
    {
        var startTime = Date.now();
        var duration = 350;
        var epsilon = 1 / (200 * duration);
        var spline = new WI.CubicBezier(0.25, 0.1, 0.25, 1);

        var fromFrame = this._frame.copy();
        var fromAnchor = this._anchorPoint.copy();

        function animatedValue(from, to, progress)
        {
            return from + (to - from) * progress;
        }

        function drawBackground()
        {
            var progress = spline.solve(Math.min((Date.now() - startTime) / duration, 1), epsilon);

            this.frame = new WI.Rect(
                animatedValue(fromFrame.minX(), toFrame.minX(), progress),
                animatedValue(fromFrame.minY(), toFrame.minY(), progress),
                animatedValue(fromFrame.size.width, toFrame.size.width, progress),
                animatedValue(fromFrame.size.height, toFrame.size.height, progress)
            ).round();

            this._setAnchorPoint(new WI.Point(
                animatedValue(fromAnchor.x, toAnchor.x, progress),
                animatedValue(fromAnchor.y, toAnchor.y, progress)
            ));

            this._drawBackground();

            if (progress < 1)
                this._drawBackgroundAnimationIdentifier = requestAnimationFrame(drawBackground.bind(this));
        }

        drawBackground.call(this);
    }

    _drawBackground()
    {
        if (this._drawBackgroundAnimationIdentifier) {
            cancelAnimationFrame(this._drawBackgroundAnimationIdentifier);
            this._drawBackgroundAnimationIdentifier = undefined;
        }

        let scaleFactor = window.devicePixelRatio;

        let width = this._frame.size.width;
        let height = this._frame.size.height;
        let scaledWidth = width * scaleFactor;
        let scaledHeight = height * scaleFactor;

        // Bounds of the path don't take into account the arrow, but really only the tight bounding box
        // of the content contained within the frame.
        let bounds;
        switch (this._edge) {
        case WI.RectEdge.MIN_X: // Displayed on the left of the target, arrow points right.
            bounds = new WI.Rect(0, 0, width - WI.Popover.AnchorSize, height);
            break;
        case WI.RectEdge.MAX_X: // Displayed on the right of the target, arrow points left.
            bounds = new WI.Rect(WI.Popover.AnchorSize, 0, width - WI.Popover.AnchorSize, height);
            break;
        case WI.RectEdge.MIN_Y: // Displayed above the target, arrow points down.
            bounds = new WI.Rect(0, 0, width, height - WI.Popover.AnchorSize);
            break;
        case WI.RectEdge.MAX_Y: // Displayed below the target, arrow points up.
            bounds = new WI.Rect(0, WI.Popover.AnchorSize, width, height - WI.Popover.AnchorSize);
            break;
        }

        bounds = bounds.inset(WI.Popover.ShadowEdgeInsets);
        let computedStyle = window.getComputedStyle(this._element, null);

        let context = WI.Popover._getCanvasContext(scaledWidth, scaledHeight);
        this._element.appendChild(context.canvas);

        context.clearRect(0, 0, scaledWidth, scaledHeight);

        function isolate(callback) {
            context.save();
            callback();
            context.restore();
        }

        isolate(() => {
            context.scale(scaleFactor, scaleFactor);
            this._drawFrame(context, bounds, this._edge, this._anchorPoint);

            isolate(() => {
                context.shadowBlur = 4;
                context.shadowColor = computedStyle.getPropertyValue("--popover-shadow-color").trim();

                context.strokeStyle = computedStyle.getPropertyValue("--popover-border-color").trim();
                context.lineWidth = 2;
                context.stroke();
            });

            isolate(() => {
                context.fillStyle = computedStyle.getPropertyValue("--popover-background-color").trim();
                context.fill();
            });
        });
    }

    _bestMetricsForEdge(preferredSize, targetFrame, containerFrame, edge)
    {
        var x, y;
        var width = preferredSize.width + (WI.Popover.ShadowPadding * 2) + (WI.Popover.ContentPadding * 2);
        var height = preferredSize.height + (WI.Popover.ShadowPadding * 2) + (WI.Popover.ContentPadding * 2);

        switch (edge) {
        case WI.RectEdge.MIN_X: // Displayed on the left of the target, arrow points right.
            width += WI.Popover.AnchorSize;
            x = targetFrame.origin.x - width + WI.Popover.ShadowPadding;
            y = targetFrame.origin.y - (height - targetFrame.size.height) / 2;
            break;
        case WI.RectEdge.MAX_X: // Displayed on the right of the target, arrow points left.
            width += WI.Popover.AnchorSize;
            x = targetFrame.origin.x + targetFrame.size.width - WI.Popover.ShadowPadding;
            y = targetFrame.origin.y - (height - targetFrame.size.height) / 2;
            break;
        case WI.RectEdge.MIN_Y: // Displayed above the target, arrow points down.
            height += WI.Popover.AnchorSize;
            x = targetFrame.origin.x - (width - targetFrame.size.width) / 2;
            y = targetFrame.origin.y - height + WI.Popover.ShadowPadding;
            break;
        case WI.RectEdge.MAX_Y: // Displayed below the target, arrow points up.
            height += WI.Popover.AnchorSize;
            x = targetFrame.origin.x - (width - targetFrame.size.width) / 2;
            y = targetFrame.origin.y + targetFrame.size.height - WI.Popover.ShadowPadding;
            break;
        }

        if (edge !== WI.RectEdge.MIN_X && x < containerFrame.minX())
            x = containerFrame.minX();
        if (edge !== WI.RectEdge.MAX_X && x + width > containerFrame.maxX())
            x = containerFrame.maxX() - width;
        if (edge !== WI.RectEdge.MIN_Y && y < containerFrame.minY())
            y = containerFrame.minY();
        if (edge !== WI.RectEdge.MAX_Y && y + height > containerFrame.maxY())
            y = containerFrame.maxY() - height;

        var preferredFrame = new WI.Rect(x, y, width, height);
        var bestFrame = preferredFrame.intersectionWithRect(containerFrame);

        width = bestFrame.size.width - (WI.Popover.ShadowPadding * 2);
        height = bestFrame.size.height - (WI.Popover.ShadowPadding * 2);

        switch (edge) {
        case WI.RectEdge.MIN_X: // Displayed on the left of the target, arrow points right.
        case WI.RectEdge.MAX_X: // Displayed on the right of the target, arrow points left.
            width -= WI.Popover.AnchorSize;
            break;
        case WI.RectEdge.MIN_Y: // Displayed above the target, arrow points down.
        case WI.RectEdge.MAX_Y: // Displayed below the target, arrow points up.
            height -= WI.Popover.AnchorSize;
            break;
        }

        return {
            frame: bestFrame,
            contentSize: new WI.Size(width, height)
        };
    }

    _drawFrame(ctx, bounds, anchorEdge)
    {
        let cornerRadius = WI.Popover.CornerRadius;
        let anchorPoint = this._anchorPoint;

        // Prevent the arrow from being positioned against one of the popover's rounded corners.
        let arrowPadding = cornerRadius + WI.Popover.AnchorSize;
        if (anchorEdge === WI.RectEdge.MIN_Y || anchorEdge === WI.RectEdge.MAX_Y)
            anchorPoint.x = Number.constrain(anchorPoint.x, bounds.minX() + arrowPadding, bounds.maxX() - arrowPadding);
        else
            anchorPoint.y = Number.constrain(anchorPoint.y, bounds.minY() + arrowPadding, bounds.maxY() - arrowPadding);

        ctx.beginPath();
        switch (anchorEdge) {
        case WI.RectEdge.MIN_X: // Displayed on the left of the target, arrow points right.
            ctx.moveTo(bounds.maxX(), bounds.minY() + cornerRadius);
            ctx.lineTo(bounds.maxX(), anchorPoint.y - WI.Popover.AnchorSize);
            ctx.lineTo(anchorPoint.x, anchorPoint.y);
            ctx.lineTo(bounds.maxX(), anchorPoint.y + WI.Popover.AnchorSize);
            ctx.arcTo(bounds.maxX(), bounds.maxY(), bounds.minX(), bounds.maxY(), cornerRadius);
            ctx.arcTo(bounds.minX(), bounds.maxY(), bounds.minX(), bounds.minY(), cornerRadius);
            ctx.arcTo(bounds.minX(), bounds.minY(), bounds.maxX(), bounds.minY(), cornerRadius);
            ctx.arcTo(bounds.maxX(), bounds.minY(), bounds.maxX(), bounds.maxY(), cornerRadius);
            break;
        case WI.RectEdge.MAX_X: // Displayed on the right of the target, arrow points left.
            ctx.moveTo(bounds.minX(), bounds.maxY() - cornerRadius);
            ctx.lineTo(bounds.minX(), anchorPoint.y + WI.Popover.AnchorSize);
            ctx.lineTo(anchorPoint.x, anchorPoint.y);
            ctx.lineTo(bounds.minX(), anchorPoint.y - WI.Popover.AnchorSize);
            ctx.arcTo(bounds.minX(), bounds.minY(), bounds.maxX(), bounds.minY(), cornerRadius);
            ctx.arcTo(bounds.maxX(), bounds.minY(), bounds.maxX(), bounds.maxY(), cornerRadius);
            ctx.arcTo(bounds.maxX(), bounds.maxY(), bounds.minX(), bounds.maxY(), cornerRadius);
            ctx.arcTo(bounds.minX(), bounds.maxY(), bounds.minX(), bounds.minY(), cornerRadius);
            break;
        case WI.RectEdge.MIN_Y: // Displayed above the target, arrow points down.
            ctx.moveTo(bounds.maxX() - cornerRadius, bounds.maxY());
            ctx.lineTo(anchorPoint.x + WI.Popover.AnchorSize, bounds.maxY());
            ctx.lineTo(anchorPoint.x, anchorPoint.y);
            ctx.lineTo(anchorPoint.x - WI.Popover.AnchorSize, bounds.maxY());
            ctx.arcTo(bounds.minX(), bounds.maxY(), bounds.minX(), bounds.minY(), cornerRadius);
            ctx.arcTo(bounds.minX(), bounds.minY(), bounds.maxX(), bounds.minY(), cornerRadius);
            ctx.arcTo(bounds.maxX(), bounds.minY(), bounds.maxX(), bounds.maxY(), cornerRadius);
            ctx.arcTo(bounds.maxX(), bounds.maxY(), bounds.minX(), bounds.maxY(), cornerRadius);
            break;
        case WI.RectEdge.MAX_Y: // Displayed below the target, arrow points up.
            ctx.moveTo(bounds.minX() + cornerRadius, bounds.minY());
            ctx.lineTo(anchorPoint.x - WI.Popover.AnchorSize, bounds.minY());
            ctx.lineTo(anchorPoint.x, anchorPoint.y);
            ctx.lineTo(anchorPoint.x + WI.Popover.AnchorSize, bounds.minY());
            ctx.arcTo(bounds.maxX(), bounds.minY(), bounds.maxX(), bounds.maxY(), cornerRadius);
            ctx.arcTo(bounds.maxX(), bounds.maxY(), bounds.minX(), bounds.maxY(), cornerRadius);
            ctx.arcTo(bounds.minX(), bounds.maxY(), bounds.minX(), bounds.minY(), cornerRadius);
            ctx.arcTo(bounds.minX(), bounds.minY(), bounds.maxX(), bounds.minY(), cornerRadius);
            break;
        }
        ctx.closePath();
    }

    _addListenersIfNeeded()
    {
        if (!this._isListeningForPopoverEvents) {
            this._isListeningForPopoverEvents = true;

            window.addEventListener("mousedown", this, true);
            window.addEventListener("scroll", this, true);
            window.addEventListener("resize", this, true);
            window.addEventListener("keypress", this, true);

            if (!this._boundUpdate)
                this._boundUpdate = this._update.bind(this);

            if (!this._prefersDarkColorSchemeMediaQueryList)
                this._prefersDarkColorSchemeMediaQueryList = window.matchMedia("(prefers-color-scheme: dark)");
            this._prefersDarkColorSchemeMediaQueryList.addListener(this._boundUpdate);

            WI.quickConsole.keyboardShortcutDisabled = true;
        }
    }
};

WI.Popover.FadeOutClassName = "fade-out";
WI.Popover.CornerRadius = 5;
WI.Popover.MinWidth = 40;
WI.Popover.MinHeight = 40;
WI.Popover.ShadowPadding = 5;
WI.Popover.ContentPadding = 5;
WI.Popover.AnchorSize = 11;
WI.Popover.ShadowEdgeInsets = new WI.EdgeInsets(WI.Popover.ShadowPadding);
WI.Popover.IgnoreAutoDismissClassName = "popover-ignore-auto-dismiss";
WI.Popover.EventPreventDismissSymbol = Symbol("popover-event-prevent-dismiss");
