// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
/* exported WindowPreview */

const { Atk, Clutter, GLib, GObject,
        Graphene, Meta, Pango, Shell, St } = imports.gi;

const DND = imports.ui.dnd;

var WINDOW_DND_SIZE = 256;

var WINDOW_OVERLAY_IDLE_HIDE_TIMEOUT = 750;
var WINDOW_OVERLAY_FADE_TIME = 200;

var DRAGGING_WINDOW_OPACITY = 100;

var WindowPreviewLayout = GObject.registerClass({
    Properties: {
        'bounding-box': GObject.ParamSpec.boxed(
            'bounding-box', 'Bounding box', 'Bounding box',
            GObject.ParamFlags.READABLE,
            Clutter.ActorBox.$gtype),
    },
}, class WindowPreviewLayout extends Clutter.LayoutManager {
    _init() {
        super._init();

        this._container = null;
        this._boundingBox = new Clutter.ActorBox();
        this._windows = new Map();
    }

    _layoutChanged() {
        let frameRect;

        for (const windowInfo of this._windows.values()) {
            const frame = windowInfo.metaWindow.get_frame_rect();
            frameRect = frameRect?.union(frame) ?? frame;
        }

        if (!frameRect)
            frameRect = new Meta.Rectangle();

        const oldBox = this._boundingBox.copy();
        this._boundingBox.set_origin(frameRect.x, frameRect.y);
        this._boundingBox.set_size(frameRect.width, frameRect.height);

        if (!this._boundingBox.equal(oldBox))
            this.notify('bounding-box');

        // Always call layout_changed(), a size or position change of an
        // attached dialog might not affect the boundingBox
        this.layout_changed();
    }

    vfunc_set_container(container) {
        this._container = container;
    }

    vfunc_get_preferred_height(_container, _forWidth) {
        return [0, this._boundingBox.get_height()];
    }

    vfunc_get_preferred_width(_container, _forHeight) {
        return [0, this._boundingBox.get_width()];
    }

    vfunc_allocate(container, box) {
        // If the scale isn't 1, we weren't allocated our preferred size
        // and have to scale the children allocations accordingly.
        const scaleX = this._boundingBox.get_width() > 0
            ? box.get_width() / this._boundingBox.get_width()
            : 1;
        const scaleY = this._boundingBox.get_height() > 0
            ? box.get_height() / this._boundingBox.get_height()
            : 1;

        const childBox = new Clutter.ActorBox();

        for (const child of container) {
            if (!child.visible)
                continue;

            const windowInfo = this._windows.get(child);
            if (windowInfo) {
                const bufferRect = windowInfo.metaWindow.get_buffer_rect();
                childBox.set_origin(
                    bufferRect.x - this._boundingBox.x1,
                    bufferRect.y - this._boundingBox.y1);

                const [, , natWidth, natHeight] = child.get_preferred_size();
                childBox.set_size(natWidth, natHeight);

                childBox.x1 *= scaleX;
                childBox.x2 *= scaleX;
                childBox.y1 *= scaleY;
                childBox.y2 *= scaleY;

                child.allocate(childBox);
            } else {
                child.allocate_preferred_size(0, 0);
            }
        }
    }

    /**
     * addWindow:
     * @param {Meta.Window} window: the MetaWindow instance
     *
     * Creates a ClutterActor drawing the texture of @window and adds it
     * to the container. If @window is already part of the preview, this
     * function will do nothing.
     *
     * @returns {Clutter.Actor} The newly created actor drawing @window
     */
    addWindow(window) {
        const index = [...this._windows.values()].findIndex(info =>
            info.metaWindow === window);

        if (index !== -1)
            return null;

        const windowActor = window.get_compositor_private();
        const actor = new Clutter.Clone({ source: windowActor });

        this._windows.set(actor, {
            metaWindow: window,
            windowActor,
            sizeChangedId: window.connect('size-changed', () =>
                this._layoutChanged()),
            positionChangedId: window.connect('position-changed', () =>
                this._layoutChanged()),
            windowActorDestroyId: windowActor.connect('destroy', () =>
                actor.destroy()),
            destroyId: actor.connect('destroy', () =>
                this.removeWindow(window)),
        });

        this._container.add_child(actor);

        this._layoutChanged();

        return actor;
    }

    /**
     * removeWindow:
     * @param {Meta.Window} window: the window to remove from the preview
     *
     * Removes a MetaWindow @window from the preview which has been added
     * previously using addWindow(). If @window is not part of preview,
     * this function will do nothing.
     */
    removeWindow(window) {
        const entry = [...this._windows].find(
            ([, i]) => i.metaWindow === window);

        if (!entry)
            return;

        const [actor, windowInfo] = entry;

        windowInfo.metaWindow.disconnect(windowInfo.sizeChangedId);
        windowInfo.metaWindow.disconnect(windowInfo.positionChangedId);
        windowInfo.windowActor.disconnect(windowInfo.windowActorDestroyId);
        actor.disconnect(windowInfo.destroyId);

        this._windows.delete(actor);
        this._container.remove_child(actor);

        this._layoutChanged();
    }

    /**
     * getWindows:
     *
     * Gets an array of all MetaWindows that were added to the layout
     * using addWindow(), ordered by the insertion order.
     *
     * @returns {Array} An array including all windows
     */
    getWindows() {
        return [...this._windows.values()].map(i => i.metaWindow);
    }

    // eslint-disable-next-line camelcase
    get bounding_box() {
        return this._boundingBox;
    }
});

var WindowPreview = GObject.registerClass({
    Properties: {
        'overlay-enabled': GObject.ParamSpec.boolean(
            'overlay-enabled', 'overlay-enabled', 'overlay-enabled',
            GObject.ParamFlags.READWRITE,
            true),
    },
    Signals: {
        'drag-begin': {},
        'drag-cancelled': {},
        'drag-end': {},
        'selected': { param_types: [GObject.TYPE_UINT] },
        'show-chrome': {},
        'size-changed': {},
    },
}, class WindowPreview extends St.Widget {
    _init(metaWindow, workspace) {
        this.metaWindow = metaWindow;
        this.metaWindow._delegate = this;
        this._windowActor = metaWindow.get_compositor_private();
        this._workspace = workspace;

        super._init({
            reactive: true,
            can_focus: true,
            accessible_role: Atk.Role.PUSH_BUTTON,
            offscreen_redirect: Clutter.OffscreenRedirect.AUTOMATIC_FOR_OPACITY,
        });

        this._windowContainer = new Clutter.Actor();
        // gjs currently can't handle setting an actors layout manager during
        // the initialization of the actor if that layout manager keeps track
        // of its container, so set the layout manager after creating the
        // container
        this._windowContainer.layout_manager = new WindowPreviewLayout();
        this.add_child(this._windowContainer);

        this._addWindow(metaWindow);

        this._delegate = this;

        this._stackAbove = null;

        this._windowContainer.layout_manager.connect(
            'notify::bounding-box', layout => {
                // A bounding box of 0x0 means all windows were removed
                if (layout.bounding_box.get_area() > 0)
                    this.emit('size-changed');
            });

        this._windowDestroyId =
            this._windowActor.connect('destroy', () => this.destroy());

        this._updateAttachedDialogs();

        let clickAction = new Clutter.ClickAction();
        clickAction.connect('clicked', () => this._activate());
        clickAction.connect('long-press', this._onLongPress.bind(this));
        this.add_action(clickAction);
        this.connect('destroy', this._onDestroy.bind(this));

        this._draggable = DND.makeDraggable(this,
                                            { restoreOnSuccess: true,
                                              manualMode: true,
                                              dragActorMaxSize: WINDOW_DND_SIZE,
                                              dragActorOpacity: DRAGGING_WINDOW_OPACITY });
        this._draggable.connect('drag-begin', this._onDragBegin.bind(this));
        this._draggable.connect('drag-cancelled', this._onDragCancelled.bind(this));
        this._draggable.connect('drag-end', this._onDragEnd.bind(this));
        this.inDrag = false;

        this._selected = false;
        this._overlayEnabled = true;
        this._closeRequested = false;
        this._idleHideOverlayId = 0;

        this._border = new St.Widget({
            visible: false,
            style_class: 'window-clone-border',
        });
        this._borderConstraint = new Clutter.BindConstraint({
            source: this._windowContainer,
            coordinate: Clutter.BindCoordinate.SIZE,
        });
        this._border.add_constraint(this._borderConstraint);
        this._border.add_constraint(new Clutter.AlignConstraint({
            source: this._windowContainer,
            align_axis: Clutter.AlignAxis.BOTH,
            factor: 0.5,
        }));
        this._borderCenter = new Clutter.Actor();
        this._border.bind_property('visible', this._borderCenter, 'visible',
            GObject.BindingFlags.SYNC_CREATE);
        this._borderCenterConstraint = new Clutter.BindConstraint({
            source: this._windowContainer,
            coordinate: Clutter.BindCoordinate.SIZE,
        });
        this._borderCenter.add_constraint(this._borderCenterConstraint);
        this._borderCenter.add_constraint(new Clutter.AlignConstraint({
            source: this._windowContainer,
            align_axis: Clutter.AlignAxis.BOTH,
            factor: 0.5,
        }));
        this._border.connect('style-changed',
            this._onBorderStyleChanged.bind(this));

        this._title = new St.Label({
            visible: false,
            style_class: 'window-caption',
            text: this._getCaption(),
            reactive: true,
        });
        this._title.add_constraint(new Clutter.BindConstraint({
            source: this._borderCenter,
            coordinate: Clutter.BindCoordinate.POSITION,
        }));
        this._title.add_constraint(new Clutter.AlignConstraint({
            source: this._borderCenter,
            align_axis: Clutter.AlignAxis.X_AXIS,
            factor: 0.5,
        }));
        this._title.add_constraint(new Clutter.AlignConstraint({
            source: this._borderCenter,
            align_axis: Clutter.AlignAxis.Y_AXIS,
            pivot_point: new Graphene.Point({ x: -1, y: 0.5 }),
            factor: 1,
        }));
        this._title.clutter_text.ellipsize = Pango.EllipsizeMode.END;
        this.label_actor = this._title;
        this._updateCaptionId = this.metaWindow.connect('notify::title', () => {
            this._title.text = this._getCaption();
        });

        const layout = Meta.prefs_get_button_layout();
        this._closeButtonSide =
            layout.left_buttons.includes(Meta.ButtonFunction.CLOSE)
                ? St.Side.LEFT : St.Side.RIGHT;

        this._closeButton = new St.Button({
            visible: false,
            style_class: 'window-close',
            child: new St.Icon({ icon_name: 'window-close-symbolic' }),
        });
        this._closeButton.add_constraint(new Clutter.BindConstraint({
            source: this._borderCenter,
            coordinate: Clutter.BindCoordinate.POSITION,
        }));
        this._closeButton.add_constraint(new Clutter.AlignConstraint({
            source: this._borderCenter,
            align_axis: Clutter.AlignAxis.X_AXIS,
            pivot_point: new Graphene.Point({ x: 0.5, y: -1 }),
            factor: this._closeButtonSide === St.Side.LEFT ? 0 : 1,
        }));
        this._closeButton.add_constraint(new Clutter.AlignConstraint({
            source: this._borderCenter,
            align_axis: Clutter.AlignAxis.Y_AXIS,
            pivot_point: new Graphene.Point({ x: -1, y: 0.5 }),
            factor: 0,
        }));
        this._closeButton.connect('clicked', () => this._deleteAll());

        this.add_child(this._borderCenter);
        this.add_child(this._border);
        this.add_child(this._title);
        this.add_child(this._closeButton);

        this.connect('notify::realized', () => {
            if (!this.realized)
                return;

            this._border.ensure_style();
            this._title.ensure_style();
        });
    }

    vfunc_get_preferred_width(forHeight) {
        const themeNode = this.get_theme_node();

        // Only include window previews in size request, not chrome
        const [minWidth, natWidth] =
            this._windowContainer.get_preferred_width(
                themeNode.adjust_for_height(forHeight));

        return themeNode.adjust_preferred_width(minWidth, natWidth);
    }

    vfunc_get_preferred_height(forWidth) {
        const themeNode = this.get_theme_node();
        const [minHeight, natHeight] =
            this._windowContainer.get_preferred_height(
                themeNode.adjust_for_width(forWidth));

        return themeNode.adjust_preferred_height(minHeight, natHeight);
    }

    vfunc_allocate(box) {
        this.set_allocation(box);

        for (const child of this)
            child.allocate_available_size(0, 0, box.get_width(), box.get_height());
    }

    _onBorderStyleChanged() {
        let borderNode = this._border.get_theme_node();
        this._borderSize = borderNode.get_border_width(St.Side.TOP);

        // Increase the size of the border actor so the border outlines
        // the bounding box
        this._borderConstraint.offset = this._borderSize * 2;
        this._borderCenterConstraint.offset = this._borderSize;
    }

    _windowCanClose() {
        return this.metaWindow.can_close() &&
               !this._hasAttachedDialogs();
    }

    _getCaption() {
        if (this.metaWindow.title)
            return this.metaWindow.title;

        let tracker = Shell.WindowTracker.get_default();
        let app = tracker.get_window_app(this.metaWindow);
        return app.get_name();
    }

    chromeHeights() {
        const [, closeButtonHeight] = this._closeButton.get_preferred_height(-1);
        const [, titleHeight] = this._title.get_preferred_height(-1);

        const topOversize = (this._borderSize / 2) + (closeButtonHeight / 2);
        const bottomOversize = Math.max(
            this._borderSize,
            (titleHeight / 2) + (this._borderSize / 2));

        return [topOversize, bottomOversize];
    }

    chromeWidths() {
        const [, closeButtonWidth] = this._closeButton.get_preferred_width(-1);

        const leftOversize = this._closeButtonSide === St.Side.LEFT
            ? (this._borderSize / 2) + (closeButtonWidth / 2)
            : this._borderSize;
        const rightOversize = this._closeButtonSide === St.Side.LEFT
            ? this._borderSize
            : (this._borderSize / 2) + (closeButtonWidth / 2);

        return [leftOversize, rightOversize];
    }

    showOverlay(animate) {
        if (!this._overlayEnabled)
            return;

        const ongoingTransition = this._border.get_transition('opacity');

        // Don't do anything if we're fully visible already
        if (this._border.visible && !ongoingTransition)
            return;

        // If we're supposed to animate and an animation in our direction
        // is already happening, let that one continue
        if (animate &&
            ongoingTransition &&
            ongoingTransition.get_interval().peek_final_value() === 255)
            return;

        const toShow = this._windowCanClose()
            ? [this._border, this._title, this._closeButton]
            : [this._border, this._title];

        toShow.forEach(a => {
            a.opacity = 0;
            a.show();
            a.ease({
                opacity: 255,
                duration: animate ? WINDOW_OVERLAY_FADE_TIME : 0,
                mode: Clutter.AnimationMode.EASE_OUT_QUAD,
            });
        });

        this.emit('show-chrome');
    }

    hideOverlay(animate) {
        const ongoingTransition = this._border.get_transition('opacity');

        // Don't do anything if we're fully hidden already
        if (!this._border.visible && !ongoingTransition)
            return;

        // If we're supposed to animate and an animation in our direction
        // is already happening, let that one continue
        if (animate &&
            ongoingTransition &&
            ongoingTransition.get_interval().peek_final_value() === 0)
            return;

        [this._border, this._title, this._closeButton].forEach(a => {
            a.opacity = 255;
            a.ease({
                opacity: 0,
                duration: animate ? WINDOW_OVERLAY_FADE_TIME : 0,
                mode: Clutter.AnimationMode.EASE_OUT_QUAD,
                onComplete: () => a.hide(),
            });
        });
    }

    _addWindow(metaWindow) {
        const clone = this._windowContainer.layout_manager.addWindow(metaWindow);
        if (!clone)
            return;

        // We expect this to be used for all interaction rather than
        // the ClutterClone; as the former is reactive and the latter
        // is not, this just works for most cases. However, for DND all
        // actors are picked, so DND operations would operate on the clone.
        // To avoid this, we hide it from pick.
        Shell.util_set_hidden_from_pick(clone, true);
    }

    vfunc_has_overlaps() {
        return this._hasAttachedDialogs();
    }

    _deleteAll() {
        const windows = this._windowContainer.layout_manager.getWindows();

        // Delete all windows, starting from the bottom-most (most-modal) one
        for (const window of windows.reverse())
            window.delete(global.get_current_time());

        this._closeRequested = true;
    }

    addDialog(win) {
        let parent = win.get_transient_for();
        while (parent.is_attached_dialog())
            parent = parent.get_transient_for();

        // Display dialog if it is attached to our metaWindow
        if (win.is_attached_dialog() && parent == this.metaWindow)
            this._addWindow(win);

        // The dialog popped up after the user tried to close the window,
        // assume it's a close confirmation and leave the overview
        if (this._closeRequested)
            this._activate();
    }

    _hasAttachedDialogs() {
        return this._windowContainer.layout_manager.getWindows().length > 1;
    }

    _updateAttachedDialogs() {
        let iter = win => {
            let actor = win.get_compositor_private();

            if (!actor)
                return false;
            if (!win.is_attached_dialog())
                return false;

            this._addWindow(win);
            win.foreach_transient(iter);
            return true;
        };
        this.metaWindow.foreach_transient(iter);
    }

    get boundingBox() {
        const box = this._windowContainer.layout_manager.bounding_box;

        return {
            x: box.x1,
            y: box.y1,
            width: box.get_width(),
            height: box.get_height(),
        };
    }

    get windowCenter() {
        const box = this._windowContainer.layout_manager.bounding_box;

        return new Graphene.Point({
            x: box.get_x() + box.get_width() / 2,
            y: box.get_y() + box.get_height() / 2,
        });
    }

    // eslint-disable-next-line camelcase
    get overlay_enabled() {
        return this._overlayEnabled;
    }

    // eslint-disable-next-line camelcase
    set overlay_enabled(enabled) {
        if (this._overlayEnabled === enabled)
            return;

        this._overlayEnabled = enabled;
        this.notify('overlay-enabled');

        if (!enabled)
            this.hideOverlay(false);
        else if (this['has-pointer'] || global.stage.key_focus === this)
            this.showOverlay(true);
    }

    // Find the actor just below us, respecting reparenting done by DND code
    _getActualStackAbove() {
        if (this._stackAbove == null)
            return null;

        if (this.inDrag) {
            if (this._stackAbove._delegate)
                return this._stackAbove._delegate._getActualStackAbove();
            else
                return null;
        } else {
            return this._stackAbove;
        }
    }

    setStackAbove(actor) {
        this._stackAbove = actor;
        if (this.inDrag)
            // We'll fix up the stack after the drag
            return;

        let parent = this.get_parent();
        let actualAbove = this._getActualStackAbove();
        if (actualAbove == null)
            parent.set_child_below_sibling(this, null);
        else
            parent.set_child_above_sibling(this, actualAbove);
    }

    _onDestroy() {
        this._windowActor.disconnect(this._windowDestroyId);

        this.metaWindow._delegate = null;
        this._delegate = null;

        this.metaWindow.disconnect(this._updateCaptionId);

        if (this._longPressLater) {
            Meta.later_remove(this._longPressLater);
            delete this._longPressLater;
        }

        if (this._idleHideOverlayId > 0) {
            GLib.source_remove(this._idleHideOverlayId);
            this._idleHideOverlayId = 0;
        }

        if (this.inDrag) {
            this.emit('drag-end');
            this.inDrag = false;
        }
    }

    _activate() {
        this._selected = true;
        this.emit('selected', global.get_current_time());
    }

    vfunc_enter_event(crossingEvent) {
        this.showOverlay(true);
        return super.vfunc_enter_event(crossingEvent);
    }

    vfunc_leave_event(crossingEvent) {
        if (this._idleHideOverlayId > 0)
            GLib.source_remove(this._idleHideOverlayId);

        this._idleHideOverlayId = GLib.timeout_add(
            GLib.PRIORITY_DEFAULT,
            WINDOW_OVERLAY_IDLE_HIDE_TIMEOUT, () => {
                if (this._closeButton['has-pointer'] ||
                    this._title['has-pointer'])
                    return GLib.SOURCE_CONTINUE;

                if (!this['has-pointer'])
                    this.hideOverlay(true);

                this._idleHideOverlayId = 0;
                return GLib.SOURCE_REMOVE;
            });

        GLib.Source.set_name_by_id(this._idleHideOverlayId, '[gnome-shell] this._idleHideOverlayId');

        return super.vfunc_leave_event(crossingEvent);
    }

    vfunc_key_focus_in() {
        super.vfunc_key_focus_in();
        this.showOverlay(true);
    }

    vfunc_key_focus_out() {
        super.vfunc_key_focus_out();
        this.hideOverlay(true);
    }

    vfunc_key_press_event(keyEvent) {
        let symbol = keyEvent.keyval;
        let isEnter = symbol == Clutter.KEY_Return || symbol == Clutter.KEY_KP_Enter;
        if (isEnter) {
            this._activate();
            return true;
        }

        return super.vfunc_key_press_event(keyEvent);
    }

    _onLongPress(action, actor, state) {
        // Take advantage of the Clutter policy to consider
        // a long-press canceled when the pointer movement
        // exceeds dnd-drag-threshold to manually start the drag
        if (state == Clutter.LongPressState.CANCEL) {
            let event = Clutter.get_current_event();
            this._dragTouchSequence = event.get_event_sequence();

            if (this._longPressLater)
                return true;

            // A click cancels a long-press before any click handler is
            // run - make sure to not start a drag in that case
            this._longPressLater = Meta.later_add(Meta.LaterType.BEFORE_REDRAW, () => {
                delete this._longPressLater;
                if (this._selected)
                    return;
                let [x, y] = action.get_coords();
                action.release();
                this._draggable.startDrag(x, y, global.get_current_time(), this._dragTouchSequence, event.get_device());
            });
        } else {
            this.showOverlay(true);
        }
        return true;
    }

    _onDragBegin(_draggable, _time) {
        this.inDrag = true;
        this.hideOverlay(false);
        this.emit('drag-begin');
    }

    handleDragOver(source, actor, x, y, time) {
        return this._workspace.handleDragOver(source, actor, x, y, time);
    }

    acceptDrop(source, actor, x, y, time) {
        return this._workspace.acceptDrop(source, actor, x, y, time);
    }

    _onDragCancelled(_draggable, _time) {
        this.emit('drag-cancelled');
    }

    _onDragEnd(_draggable, _time, _snapback) {
        this.inDrag = false;

        // We may not have a parent if DnD completed successfully, in
        // which case our clone will shortly be destroyed and replaced
        // with a new one on the target workspace.
        let parent = this.get_parent();
        if (parent !== null) {
            if (this._stackAbove == null)
                parent.set_child_below_sibling(this, null);
            else
                parent.set_child_above_sibling(this, this._stackAbove);
        }

        if (this['has-pointer'])
            this.showOverlay(true);

        this.emit('drag-end');
    }
});
