import Clutter from 'gi://Clutter';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Meta from 'gi://Meta';
import Shell from 'gi://Shell';
import St from 'gi://St';
import * as Signals from '../misc/signals.js';

// Time for initial animation going into Overview mode;
// this is defined here to make it available in imports.
export const ANIMATION_TIME = 250;

import * as DND from './dnd.js';
import * as LayoutManager from './layout.js';
import * as Main from './main.js';
import * as OverviewControls from './overviewControls.js';
import * as SwipeTracker from './swipeTracker.js';
import * as WindowManager from './windowManager.js';
import * as WorkspaceThumbnail from './workspaceThumbnail.js';

const DND_WINDOW_SWITCH_TIMEOUT = 750;

const OVERVIEW_ACTIVATION_TIMEOUT = 0.5;

const OverviewActor = GObject.registerClass(
class OverviewActor extends St.BoxLayout {
    _init() {
        super._init({
            name: 'overview',
            /* Translators: This is the main view to select
                activities. See also note for "Activities" string. */
            accessible_name: _('Overview'),
            orientation: Clutter.Orientation.VERTICAL,
        });

        this.add_constraint(new LayoutManager.MonitorConstraint({primary: true}));

        this._controls = new OverviewControls.ControlsManager();
        this.add_child(this._controls);
    }

    prepareToEnterOverview() {
        this._controls.prepareToEnterOverview();
    }

    prepareToLeaveOverview() {
        this._controls.prepareToLeaveOverview();
    }

    animateToOverview(state, callback) {
        this._controls.animateToOverview(state, callback);
    }

    animateFromOverview(callback) {
        this._controls.animateFromOverview(callback);
    }

    async runStartupAnimation() {
        await this._controls.runStartupAnimation();
    }

    get dash() {
        return this._controls.dash;
    }

    get searchController() {
        return this._controls.searchController;
    }

    get searchEntry() {
        return this._controls.searchEntry;
    }

    get controls() {
        return this._controls;
    }
});

const OverviewShownState = {
    HIDDEN: 'HIDDEN',
    HIDING: 'HIDING',
    SHOWING: 'SHOWING',
    SHOWN: 'SHOWN',
};

const OVERVIEW_SHOWN_TRANSITIONS = {
    [OverviewShownState.HIDDEN]: {
        signal: 'hidden',
        allowedTransitions: [OverviewShownState.SHOWING],
    },
    [OverviewShownState.HIDING]: {
        signal: 'hiding',
        allowedTransitions:
            [OverviewShownState.HIDDEN, OverviewShownState.SHOWING],
    },
    [OverviewShownState.SHOWING]: {
        signal: 'showing',
        allowedTransitions:
            [OverviewShownState.SHOWN, OverviewShownState.HIDING],
    },
    [OverviewShownState.SHOWN]: {
        signal: 'shown',
        allowedTransitions: [OverviewShownState.HIDING],
    },
};

export class Overview extends Signals.EventEmitter {
    constructor() {
        super();

        this._initCalled = false;
        this._visible = false;

        Main.sessionMode.connect('updated', this._sessionUpdated.bind(this));
        this._sessionUpdated();
    }

    get dash() {
        return this._overview.dash;
    }

    get dashIconSize() {
        logError(new Error('Usage of Overview.\'dashIconSize\' is deprecated, ' +
            'use \'dash.iconSize\' property instead'));
        return this.dash.iconSize;
    }

    get animationInProgress() {
        return this._animationInProgress;
    }

    get visible() {
        return this._visible;
    }

    get visibleTarget() {
        return this._visibleTarget;
    }

    get closing() {
        return this._animationInProgress && !this._visibleTarget;
    }

    _createOverview() {
        if (this._overview)
            return;

        if (this.isDummy)
            return;

        this._activationTime = 0;

        this._visible = false;          // animating to overview, in overview, animating out
        this._shown = false;            // show() and not hide()
        this._modal = false;            // have a modal grab
        this._animationInProgress = false;
        this._visibleTarget = false;
        this._shownState = OverviewShownState.HIDDEN;

        // During transitions, we raise this to the top to avoid having the overview
        // area be reactive; it causes too many issues such as double clicks on
        // Dash elements, or mouseover handlers in the workspaces.
        this._coverPane = new Clutter.Actor({
            opacity: 0,
            reactive: true,
        });
        Main.layoutManager.overviewGroup.add_child(this._coverPane);
        this._coverPane.hide();

        // XDND
        this._dragMonitor = {
            dragMotion: this._onDragMotion.bind(this),
        };


        Main.layoutManager.overviewGroup.connect('scroll-event',
            this._onScrollEvent.bind(this));
        Main.xdndHandler.connect('drag-begin', this._onDragBegin.bind(this));
        Main.xdndHandler.connect('drag-end', this._onDragEnd.bind(this));

        global.display.connect('restacked', this._onRestacked.bind(this));

        this._windowSwitchTimeoutId = 0;
        this._windowSwitchTimestamp = 0;
        this._lastActiveWorkspaceIndex = -1;
        this._lastHoveredWindow = null;

        if (this._initCalled)
            this.init();
    }

    _sessionUpdated() {
        const {hasOverview} = Main.sessionMode;
        if (!hasOverview)
            this.hide();

        this.isDummy = !hasOverview;
        this._createOverview();
    }

    // The members we construct that are implemented in JS might
    // want to access the overview as Main.overview to connect
    // signal handlers and so forth. So we create them after
    // construction in this init() method.
    init() {
        this._initCalled = true;

        if (this.isDummy)
            return;

        this._overview = new OverviewActor();
        this._overview._delegate = this;
        Main.layoutManager.overviewGroup.add_child(this._overview);

        Main.layoutManager.connect('monitors-changed', this._relayout.bind(this));
        this._relayout();

        Main.wm.addKeybinding(
            'toggle-overview',
            new Gio.Settings({schema_id: WindowManager.SHELL_KEYBINDINGS_SCHEMA}),
            Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
            Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW,
            this.toggle.bind(this));

        const swipeTracker = new SwipeTracker.SwipeTracker(global.stage,
            Clutter.Orientation.VERTICAL,
            Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW,
            {
                allowDrag: false,
                allowScroll: false,
                name: 'Overview swipe tracker',
                phase: Clutter.EventPhase.CAPTURE,
            });
        swipeTracker.orientation = Clutter.Orientation.VERTICAL;
        swipeTracker.connect('begin', this._gestureBegin.bind(this));
        swipeTracker.connect('update', this._gestureUpdate.bind(this));
        swipeTracker.connect('end', this._gestureEnd.bind(this));
        this._swipeTracker = swipeTracker;
    }

    _changeShownState(state) {
        const {allowedTransitions} =
            OVERVIEW_SHOWN_TRANSITIONS[this._shownState];

        if (!allowedTransitions.includes(state)) {
            throw new Error('Invalid overview shown transition from ' +
                `${this._shownState} to ${state}`);
        }

        if (this._shownState === OverviewShownState.HIDDEN)
            global.compositor.disable_unredirect();
        else if (state === OverviewShownState.HIDDEN)
            global.compositor.enable_unredirect();

        this._shownState = state;
        this.emit(OVERVIEW_SHOWN_TRANSITIONS[state].signal);
    }

    _onDragBegin() {
        this._inXdndDrag = true;

        DND.addDragMonitor(this._dragMonitor);
        // Remember the workspace we started from
        let workspaceManager = global.workspace_manager;
        this._lastActiveWorkspaceIndex = workspaceManager.get_active_workspace_index();
    }

    _onDragEnd() {
        this._inXdndDrag = false;

        // In case the drag was canceled while in the overview
        // we have to go back to where we started and hide
        // the overview
        if (this._shown) {
            let workspaceManager = global.workspace_manager;
            workspaceManager.get_workspace_by_index(this._lastActiveWorkspaceIndex)
                .activate(global.get_current_time());
            this.hide();
        }
        this._resetWindowSwitchTimeout();
        this._lastHoveredWindow = null;
        DND.removeDragMonitor(this._dragMonitor);
        this.endItemDrag();
    }

    _resetWindowSwitchTimeout() {
        if (this._windowSwitchTimeoutId !== 0) {
            GLib.source_remove(this._windowSwitchTimeoutId);
            this._windowSwitchTimeoutId = 0;
        }
    }

    _onDragMotion(dragEvent) {
        let targetIsWindow = dragEvent.targetActor &&
                             dragEvent.targetActor._delegate &&
                             dragEvent.targetActor._delegate.metaWindow &&
                             !(dragEvent.targetActor._delegate instanceof WorkspaceThumbnail.WindowClone);

        this._windowSwitchTimestamp = global.get_current_time();

        if (targetIsWindow &&
            dragEvent.targetActor._delegate.metaWindow === this._lastHoveredWindow)
            return DND.DragMotionResult.CONTINUE;

        this._lastHoveredWindow = null;

        this._resetWindowSwitchTimeout();

        if (targetIsWindow) {
            this._lastHoveredWindow = dragEvent.targetActor._delegate.metaWindow;
            this._windowSwitchTimeoutId = GLib.timeout_add(
                GLib.PRIORITY_DEFAULT,
                DND_WINDOW_SWITCH_TIMEOUT,
                () => {
                    this._windowSwitchTimeoutId = 0;
                    Main.activateWindow(dragEvent.targetActor._delegate.metaWindow,
                        this._windowSwitchTimestamp);
                    this.hide();
                    this._lastHoveredWindow = null;
                    return GLib.SOURCE_REMOVE;
                });
            GLib.Source.set_name_by_id(this._windowSwitchTimeoutId, '[gnome-shell] Main.activateWindow');
        }

        return DND.DragMotionResult.CONTINUE;
    }

    _onScrollEvent(actor, event) {
        this.emit('scroll-event', event);
        return Clutter.EVENT_PROPAGATE;
    }

    _relayout() {
        // To avoid updating the position and size of the workspaces
        // we just hide the overview. The positions will be updated
        // when it is next shown.
        this.hide();

        this._coverPane.set_position(0, 0);
        this._coverPane.set_size(global.screen_width, global.screen_height);
    }

    _onRestacked() {
        let stack = global.get_window_actors();
        let stackIndices = {};

        for (let i = 0; i < stack.length; i++) {
            // Use the stable sequence for an integer to use as a hash key
            stackIndices[stack[i].get_meta_window().get_stable_sequence()] = i;
        }

        this.emit('windows-restacked', stackIndices);
    }

    _gestureBegin(tracker) {
        this._overview.controls.gestureBegin(tracker);
    }

    _gestureUpdate(tracker, progress) {
        if (progress === 0)
            return;

        if (!this._shown) {
            this._shown = true;
            this._visible = true;
            this._visibleTarget = true;
            this._animationInProgress = true;

            Main.layoutManager.overviewGroup.set_child_above_sibling(
                this._coverPane, null);
            this._coverPane.show();
            this._changeShownState(OverviewShownState.SHOWING);

            Main.layoutManager.showOverview();
            this._syncGrab();
        }

        this._overview.controls.gestureProgress(progress);
    }

    _gestureEnd(tracker, duration, endProgress) {
        let onComplete;
        if (endProgress === 0) {
            this._shown = false;
            this._visibleTarget = false;
            this._changeShownState(OverviewShownState.HIDING);
            Main.panel.style = `transition-duration: ${duration}ms;`;
            onComplete = () => this._hideDone();
        } else {
            onComplete = () => this._showDone();
        }

        this._overview.controls.gestureEnd(endProgress, duration, onComplete);
    }

    beginItemDrag(source) {
        this.emit('item-drag-begin', source);
        this._inItemDrag = true;
    }

    cancelledItemDrag(source) {
        this.emit('item-drag-cancelled', source);
    }

    endItemDrag(source) {
        if (!this._inItemDrag)
            return;
        this.emit('item-drag-end', source);
        this._inItemDrag = false;
    }

    beginWindowDrag(window) {
        this.emit('window-drag-begin', window);
        this._inWindowDrag = true;
    }

    cancelledWindowDrag(window) {
        this.emit('window-drag-cancelled', window);
    }

    endWindowDrag(window) {
        if (!this._inWindowDrag)
            return;
        this.emit('window-drag-end', window);
        this._inWindowDrag = false;
    }

    focusSearch() {
        this.show();
        this._overview.searchEntry.grab_key_focus();
    }

    // Checks if the Activities button is currently sensitive to
    // clicks. The first call to this function within the
    // OVERVIEW_ACTIVATION_TIMEOUT time of the hot corner being
    // triggered will return false. This avoids opening and closing
    // the overview if the user both triggered the hot corner and
    // clicked the Activities button.
    shouldToggleByCornerOrButton() {
        if (this._animationInProgress)
            return false;
        if (this._inItemDrag || this._inWindowDrag)
            return false;
        if (!this._activationTime ||
            GLib.get_monotonic_time() / GLib.USEC_PER_SEC - this._activationTime > OVERVIEW_ACTIVATION_TIMEOUT)
            return true;
        return false;
    }

    _syncGrab() {
        // We delay grab changes during animation so that when removing the
        // overview we don't have a problem with the release of a press/release
        // going to an application.
        if (this._animationInProgress)
            return true;

        if (this._shown) {
            let shouldBeModal = !this._inXdndDrag;
            if (shouldBeModal && !this._modal) {
                if (global.display.is_grabbed()) {
                    this.hide();
                    return false;
                }

                const grab = Main.pushModal(global.stage, {
                    actionMode: Shell.ActionMode.OVERVIEW,
                });
                if (grab.get_seat_state() !== Clutter.GrabState.ALL) {
                    Main.popModal(grab);
                    this.hide();
                    return false;
                }

                this._grab = grab;
                this._modal = true;
            }
        } else {
            // eslint-disable-next-line no-lonely-if
            if (this._modal) {
                Main.popModal(this._grab);
                this._grab = false;
                this._modal = false;
            }
        }
        return true;
    }

    // show:
    //
    // Animates the overview visible and grabs mouse and keyboard input
    show(state = OverviewControls.ControlsState.WINDOW_PICKER) {
        if (state === OverviewControls.ControlsState.HIDDEN)
            throw new Error('Invalid state, use hide() to hide');

        if (this.isDummy)
            return;
        if (this._shown)
            return;
        this._shown = true;

        if (!this._syncGrab())
            return;

        Main.layoutManager.showOverview();
        this._animateVisible(state);
    }


    _animateVisible(state) {
        if (this._visible || this._animationInProgress)
            return;

        this._visible = true;
        this._animationInProgress = true;
        this._visibleTarget = true;
        this._activationTime = GLib.get_monotonic_time() / GLib.USEC_PER_SEC;

        Main.layoutManager.overviewGroup.set_child_above_sibling(
            this._coverPane, null);
        this._coverPane.show();

        this._overview.prepareToEnterOverview();
        this._changeShownState(OverviewShownState.SHOWING);
        this._overview.animateToOverview(state, () => this._showDone());
    }

    _showDone() {
        this._animationInProgress = false;
        this._coverPane.hide();

        if (this._shownState !== OverviewShownState.SHOWN)
            this._changeShownState(OverviewShownState.SHOWN);

        // Handle any calls to hide* while we were showing
        if (!this._shown)
            this._animateNotVisible();

        this._syncGrab();
    }

    // hide:
    //
    // Reverses the effect of show()
    hide() {
        if (this.isDummy)
            return;

        if (!this._shown)
            return;

        let event = Clutter.get_current_event();
        if (event) {
            let type = event.type();
            const button =
                type === Clutter.EventType.BUTTON_PRESS ||
                type === Clutter.EventType.BUTTON_RELEASE;
            let ctrl = (event.get_state() & Clutter.ModifierType.CONTROL_MASK) !== 0;
            if (button && ctrl)
                return;
        }

        this._shown = false;

        this._animateNotVisible();
        this._syncGrab();
    }

    _animateNotVisible() {
        if (!this._visible || this._animationInProgress)
            return;

        this._animationInProgress = true;
        this._visibleTarget = false;

        Main.layoutManager.overviewGroup.set_child_above_sibling(
            this._coverPane, null);
        this._coverPane.show();

        this._overview.prepareToLeaveOverview();
        this._changeShownState(OverviewShownState.HIDING);
        this._overview.animateFromOverview(() => this._hideDone());
    }

    _hideDone() {
        this._coverPane.hide();

        this._visible = false;
        this._animationInProgress = false;

        // Handle any calls to show* while we were hiding
        if (this._shown) {
            this._changeShownState(OverviewShownState.HIDDEN);
            this._animateVisible(OverviewControls.ControlsState.WINDOW_PICKER);
        } else {
            Main.layoutManager.hideOverview();
            this._changeShownState(OverviewShownState.HIDDEN);
        }

        Main.panel.style = null;

        this._syncGrab();
    }

    toggle() {
        if (this.isDummy)
            return;

        if (this._visible)
            this.hide();
        else
            this.show();
    }

    showApps() {
        this.show(OverviewControls.ControlsState.APP_GRID);
    }

    selectApp(id) {
        this.showApps();
        this._overview.controls.appDisplay.selectApp(id);
    }

    async runStartupAnimation() {
        Main.panel.style = 'transition-duration: 0ms;';

        this._shown = true;
        this._visible = true;
        this._visibleTarget = true;
        Main.layoutManager.showOverview();
        // We should call this._syncGrab() here, but moved it to happen after
        // the animation because of a race in the xserver where the grab
        // fails when requested very early during startup.

        this._changeShownState(OverviewShownState.SHOWING);

        await this._overview.runStartupAnimation();

        // Overview got hidden during startup animation
        if (this._shownState !== OverviewShownState.SHOWING)
            return;

        if (!this._syncGrab()) {
            this.hide();
            return;
        }

        Main.panel.style = null;
        this._changeShownState(OverviewShownState.SHOWN);
    }

    getShowAppsButton() {
        logError(new Error('Usage of Overview.\'getShowAppsButton\' is deprecated, ' +
            'use \'dash.showAppsButton\' property instead'));

        return this.dash.showAppsButton;
    }

    get searchController() {
        return this._overview.searchController;
    }

    get searchEntry() {
        return this._overview.searchEntry;
    }
}
