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

import * as History from '../misc/history.js';
import {ExtensionState} from '../misc/extensionUtils.js';
import * as PopupMenu from './popupMenu.js';
import * as ShellEntry from './shellEntry.js';
import * as Main from './main.js';
import * as JsParse from '../misc/jsParse.js';

const CHEVRON = '>>> ';

/* Imports...feel free to add here as needed */
const commandHeader = `
    const {Clutter, Gio, GLib, GObject, Meta, Shell, St} = imports.gi;
    const Main = await import('resource:///org/gnome/shell/ui/main.js');

    /* Utility functions...we should probably be able to use these
     * in the shell core code too. */
    const stage = global.stage;

    /* Special lookingGlass functions */
    const inspect = Main.lookingGlass.inspect.bind(Main.lookingGlass);
    const it = Main.lookingGlass.getIt();
    const r = Main.lookingGlass.getResult.bind(Main.lookingGlass);
    `;
const AsyncFunction = async function () {}.constructor;

const HISTORY_KEY = 'looking-glass-history';
// Time between tabs for them to count as a double-tab event

const AUTO_COMPLETE_DOUBLE_TAB_DELAY = 500;
const AUTO_COMPLETE_SHOW_COMPLETION_ANIMATION_DURATION = 200;
const AUTO_COMPLETE_GLOBAL_KEYWORDS = _getAutoCompleteGlobalKeywords();

const LG_ANIMATION_TIME = 500;

const CLUTTER_DEBUG_FLAG_CATEGORIES = new Map([
    // Paint debugging can easily result in a non-responsive session
    ['DebugFlag', {argPos: 0, exclude: ['PAINT']}],
    ['DrawDebugFlag', {argPos: 1, exclude: []}],
    // Exluded due to the only current option likely to result in shooting ones
    // foot
    // ['PickDebugFlag', { argPos: 2, exclude: [] }],
]);

function _getAutoCompleteGlobalKeywords() {
    const keywords = ['true', 'false', 'null', 'new'];
    // Don't add the private properties of globalThis (i.e., ones starting with '_')
    const windowProperties = Object.getOwnPropertyNames(globalThis).filter(
        a => a.charAt(0) !== '_');
    const headerProperties = JsParse.getDeclaredConstants(commandHeader);

    return keywords.concat(windowProperties).concat(headerProperties);
}

class AutoComplete extends Signals.EventEmitter {
    constructor(entry) {
        super();

        this._entry = entry;
        this._entry.connect('key-press-event', this._entryKeyPressEvent.bind(this));
        this._lastTabTime = global.get_current_time();
    }

    _processCompletionRequest(event) {
        if (event.completions.length === 0)
            return;

        // Unique match = go ahead and complete; multiple matches + single tab = complete the common starting string;
        // multiple matches + double tab = emit a suggest event with all possible options
        if (event.completions.length === 1) {
            this.additionalCompletionText(event.completions[0], event.attrHead);
            this.emit('completion', {completion: event.completions[0], type: 'whole-word'});
        } else if (event.completions.length > 1 && event.tabType === 'single') {
            let commonPrefix = JsParse.getCommonPrefix(event.completions);

            if (commonPrefix.length > 0) {
                this.additionalCompletionText(commonPrefix, event.attrHead);
                this.emit('completion', {completion: commonPrefix, type: 'prefix'});
                this.emit('suggest', {completions: event.completions});
            }
        } else if (event.completions.length > 1 && event.tabType === 'double') {
            this.emit('suggest', {completions: event.completions});
        }
    }

    async _handleCompletions(text, time) {
        const [completions, attrHead] =
            await JsParse.getCompletions(text, commandHeader, AUTO_COMPLETE_GLOBAL_KEYWORDS);

        const tabType = (time - this._lastTabTime) < AUTO_COMPLETE_DOUBLE_TAB_DELAY
            ? 'double' : 'single';

        this._processCompletionRequest({
            tabType,
            completions,
            attrHead,
        });
        this._lastTabTime = time;
    }

    _entryKeyPressEvent(actor, event) {
        let cursorPos = this._entry.clutter_text.get_cursor_position();
        let text = this._entry.get_text();
        if (cursorPos !== -1)
            text = text.slice(0, cursorPos);

        if (event.get_key_symbol() === Clutter.KEY_Tab)
            this._handleCompletions(text, event.get_time()).catch(logError);
        return Clutter.EVENT_PROPAGATE;
    }

    // Insert characters of text not already included in head at cursor position.  i.e., if text="abc" and head="a",
    // the string "bc" will be appended to this._entry
    additionalCompletionText(text, head) {
        let additionalCompletionText = text.slice(head.length);
        let cursorPos = this._entry.clutter_text.get_cursor_position();

        this._entry.clutter_text.insert_text(additionalCompletionText, cursorPos);
    }
}

const Notebook = GObject.registerClass({
    Signals: {'selection': {param_types: [Clutter.Actor.$gtype]}},
}, class Notebook extends St.BoxLayout {
    _init() {
        super._init({
            orientation: Clutter.Orientation.VERTICAL,
            y_expand: true,
        });

        this.tabControls = new St.BoxLayout({style_class: 'labels'});

        this._selectedIndex = -1;
        this._tabs = [];
    }

    appendPage(name, child) {
        const labelBox = new St.BoxLayout({
            style_class: 'notebook-tab',
            reactive: true,
            track_hover: true,
        });
        let label = new St.Button({label: name});
        label.connect('clicked', () => {
            this.selectChild(child);
            return true;
        });
        labelBox.add_child(label);
        this.tabControls.add_child(labelBox);

        const scrollview = new St.ScrollView({
            child,
            y_expand: true,
        });

        const tabData = {
            child,
            labelBox,
            label,
            scrollView: scrollview,
            _scrollToBottom: false,
        };
        this._tabs.push(tabData);
        scrollview.hide();
        this.add_child(scrollview);

        const vAdjust = scrollview.vadjustment;
        vAdjust.connect('changed', () => this._onAdjustScopeChanged(tabData));
        vAdjust.connect('notify::value', () => this._onAdjustValueChanged(tabData));

        if (this._selectedIndex === -1)
            this.selectIndex(0);
    }

    _unselect() {
        if (this._selectedIndex < 0)
            return;
        let tabData = this._tabs[this._selectedIndex];
        tabData.labelBox.remove_style_pseudo_class('selected');
        tabData.scrollView.hide();
        this._selectedIndex = -1;
    }

    selectIndex(index) {
        if (index === this._selectedIndex)
            return;
        if (index < 0) {
            this._unselect();
            this.emit('selection', null);
            return;
        }

        // Focus the new tab before unmapping the old one
        let tabData = this._tabs[index];
        if (!tabData.scrollView.navigate_focus(null, St.DirectionType.TAB_FORWARD, false))
            this.grab_key_focus();

        this._unselect();

        tabData.labelBox.add_style_pseudo_class('selected');
        tabData.scrollView.show();
        this._selectedIndex = index;
        this.emit('selection', tabData.child);
    }

    selectChild(child) {
        if (child == null) {
            this.selectIndex(-1);
        } else {
            for (let i = 0; i < this._tabs.length; i++) {
                let tabData = this._tabs[i];
                if (tabData.child === child) {
                    this.selectIndex(i);
                    return;
                }
            }
        }
    }

    scrollToBottom(index) {
        let tabData = this._tabs[index];
        tabData._scrollToBottom = true;
    }

    _onAdjustValueChanged(tabData) {
        const vAdjust = tabData.scrollView.vadjustment;
        if (vAdjust.value < (vAdjust.upper - vAdjust.lower - 0.5))
            tabData._scrolltoBottom = false;
    }

    _onAdjustScopeChanged(tabData) {
        if (!tabData._scrollToBottom)
            return;
        const vAdjust = tabData.scrollView.vadjustment;
        vAdjust.value = vAdjust.upper - vAdjust.page_size;
    }

    nextTab() {
        let nextIndex = this._selectedIndex;
        if (nextIndex < this._tabs.length - 1)
            ++nextIndex;

        this.selectIndex(nextIndex);
    }

    prevTab() {
        let prevIndex = this._selectedIndex;
        if (prevIndex > 0)
            --prevIndex;

        this.selectIndex(prevIndex);
    }
});

function objectToString(o) {
    if (typeof o === typeof objectToString) {
        // special case this since the default is way, way too verbose
        return '<js function>';
    } else if (o && o.toString === undefined) {
        // eeks, something unprintable. we'll have to guess, probably a module
        return typeof o === 'object' && !(o instanceof Object)
            ? '<module>'
            : '<unknown>';
    } else {
        return `${o}`;
    }
}

const ObjLink = GObject.registerClass(
class ObjLink extends St.Button {
    _init(lookingGlass, o, title) {
        let text;
        if (title)
            text = title;
        else
            text = objectToString(o);

        super._init({
            reactive: true,
            track_hover: true,
            style_class: 'shell-link',
            label: text,
            x_align: Clutter.ActorAlign.START,
        });
        this.get_child().single_line_mode = true;

        this._obj = o;
        this._lookingGlass = lookingGlass;
    }

    vfunc_clicked() {
        this._lookingGlass.inspectObject(this._obj, this);
    }
});

const Result = GObject.registerClass(
class Result extends St.BoxLayout {
    _init(lookingGlass, command, o, index) {
        super._init({orientation: Clutter.Orientation.VERTICAL});

        this.index = index;
        this.o = o;

        this._lookingGlass = lookingGlass;

        let cmdTxt = new St.Label({text: command});
        cmdTxt.clutter_text.ellipsize = Pango.EllipsizeMode.END;
        this.add_child(cmdTxt);
        let box = new St.BoxLayout({});
        this.add_child(box);
        let resultTxt = new St.Label({text: `r(${index}) = `});
        resultTxt.clutter_text.ellipsize = Pango.EllipsizeMode.END;
        box.add_child(resultTxt);
        let objLink = new ObjLink(this._lookingGlass, o);
        box.add_child(objLink);
    }
});

const WindowList = GObject.registerClass({
}, class WindowList extends St.BoxLayout {
    _init(lookingGlass) {
        super._init({
            name: 'LookingGlassWindows',
            orientation: Clutter.Orientation.VERTICAL,
        });
        let tracker = Shell.WindowTracker.get_default();
        this._updateId = Main.initializeDeferredWork(this, this._updateWindowList.bind(this));
        global.display.connect('window-created', this._updateWindowList.bind(this));
        tracker.connect('tracked-windows-changed', this._updateWindowList.bind(this));

        this._lookingGlass = lookingGlass;
    }

    _updateWindowList() {
        if (!this._lookingGlass.isOpen)
            return;

        this.destroy_all_children();
        let windows = global.get_window_actors();
        let tracker = Shell.WindowTracker.get_default();
        for (let i = 0; i < windows.length; i++) {
            let metaWindow = windows[i].metaWindow;
            // Avoid multiple connections
            if (!metaWindow._lookingGlassManaged) {
                metaWindow.connect('unmanaged', this._updateWindowList.bind(this));
                metaWindow._lookingGlassManaged = true;
            }
            const box = new St.BoxLayout({
                orientation: Clutter.Orientation.VERTICAL,
                style_class: 'lg-window',
            });
            this.add_child(box);

            const header = new St.BoxLayout({
                orientation: Clutter.Orientation.VERTICAL,
                style_class: 'lg-window-name',
            });
            box.add_child(header);

            let windowLink = new ObjLink(this._lookingGlass, metaWindow, metaWindow.title);
            header.add_child(windowLink);

            const propsBox = new St.BoxLayout({
                orientation: Clutter.Orientation.VERTICAL,
                style_class: 'lg-window-props-box',
            });
            box.add_child(propsBox);
            propsBox.add_child(new St.Label({text: `wmclass: ${metaWindow.get_wm_class()}`}));
            let app = tracker.get_window_app(metaWindow);
            if (app != null && !app.is_window_backed()) {
                let icon = app.create_icon_texture(22);
                let propBox = new St.BoxLayout({style_class: 'lg-window-props'});
                propsBox.add_child(propBox);
                propBox.add_child(new St.Label({text: 'app: '}));
                let appLink = new ObjLink(this._lookingGlass, app, app.get_id());
                propBox.add_child(appLink);
                propBox.add_child(icon);
            } else {
                propsBox.add_child(new St.Label({text: '<untracked>'}));
            }
        }
    }

    update() {
        this._updateWindowList();
    }
});

const ObjInspector = GObject.registerClass(
class ObjInspector extends St.ScrollView {
    _init(lookingGlass) {
        super._init({
            name: 'LookingGlassPropertyInspector',
            style_class: 'lg-dialog',
            pivot_point: new Graphene.Point({x: 0.5, y: 0.5}),
        });

        this._obj = null;
        this._previousObj = null;

        this._parentList = [];

        this._container = new St.BoxLayout({
            orientation: Clutter.Orientation.VERTICAL,
            x_expand: true,
            y_expand: true,
        });
        this.child = this._container;

        this._lookingGlass = lookingGlass;
    }

    selectObject(obj, skipPrevious) {
        if (!skipPrevious)
            this._previousObj = this._obj;
        else
            this._previousObj = null;
        this._obj = obj;

        this._container.destroy_all_children();

        let hbox = new St.BoxLayout({style_class: 'lg-obj-inspector-title'});
        this._container.add_child(hbox);
        let label = new St.Label({
            text: `Inspecting: ${typeof obj}: ${objectToString(obj)}`,
            x_expand: true,
        });
        label.single_line_mode = true;
        hbox.add_child(label);
        let button = new St.Button({label: 'Insert', style_class: 'lg-obj-inspector-button'});
        button.connect('clicked', this._onInsert.bind(this));
        hbox.add_child(button);

        if (this._previousObj != null) {
            button = new St.Button({label: 'Back', style_class: 'lg-obj-inspector-button'});
            button.connect('clicked', this._onBack.bind(this));
            hbox.add_child(button);
        }

        button = new St.Button({
            style_class: 'lg-obj-inspector-close-button',
            icon_name: 'window-close-symbolic',
        });
        button.connect('clicked', this.close.bind(this));
        hbox.add_child(button);
        if (typeof obj === typeof {}) {
            let properties = [];
            for (let propName in obj)
                properties.push(propName);
            properties.sort();

            for (let i = 0; i < properties.length; i++) {
                let propName = properties[i];
                let link;
                try {
                    let prop = obj[propName];
                    link = new ObjLink(this._lookingGlass, prop);
                } catch {
                    link = new St.Label({text: '<error>'});
                }
                let box = new St.BoxLayout();
                box.add_child(new St.Label({text: `${propName}: `}));
                box.add_child(link);
                this._container.add_child(box);
            }
        }
    }

    open(sourceActor) {
        if (this._open)
            return;

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

        this._grab = grab;
        this._previousObj = null;
        this._open = true;
        this.show();
        if (sourceActor) {
            this.set_scale(0, 0);
            this.ease({
                scale_x: 1,
                scale_y: 1,
                mode: Clutter.AnimationMode.EASE_OUT_QUAD,
                duration: 200,
            });
        } else {
            this.set_scale(1, 1);
        }
    }

    close() {
        if (!this._open)
            return;
        Main.popModal(this._grab);
        this._grab = null;
        this._open = false;
        this.hide();
        this._previousObj = null;
        this._obj = null;
    }

    vfunc_key_press_event(event) {
        const symbol = event.get_key_symbol();
        if (symbol === Clutter.KEY_Escape) {
            this.close();
            return Clutter.EVENT_STOP;
        }
        return super.vfunc_key_press_event(event);
    }

    _onInsert() {
        let obj = this._obj;
        this.close();
        this._lookingGlass.insertObject(obj);
    }

    _onBack() {
        this.selectObject(this._previousObj, true);
    }
});

const RedBorderEffect = GObject.registerClass(
class RedBorderEffect extends Clutter.Effect {
    _init() {
        super._init();
        this._pipeline = null;
    }

    vfunc_paint_node(node, paintContext) {
        let actor = this.get_actor();

        const actorNode = new Clutter.ActorNode(actor, -1);
        node.add_child(actorNode);

        if (!this._pipeline) {
            const framebuffer = paintContext.get_framebuffer();
            const coglContext = framebuffer.get_context();

            let color = new Cogl.Color();
            color.init_from_4f(1.0, 0.0, 0.0, 196.0 / 255.0);

            this._pipeline = Cogl.Pipeline.new(coglContext);
            this._pipeline.set_color(color);
        }

        let alloc = actor.get_allocation_box();
        let width = 2;

        const pipelineNode = new Clutter.PipelineNode(this._pipeline);
        pipelineNode.set_name('Red Border');
        node.add_child(pipelineNode);

        const box = new Clutter.ActorBox();

        // clockwise order
        box.set_origin(0, 0);
        box.set_size(alloc.get_width(), width);
        pipelineNode.add_rectangle(box);

        box.set_origin(alloc.get_width() - width, width);
        box.set_size(width, alloc.get_height() - width);
        pipelineNode.add_rectangle(box);

        box.set_origin(0, alloc.get_height() - width);
        box.set_size(alloc.get_width() - width, width);
        pipelineNode.add_rectangle(box);

        box.set_origin(0, width);
        box.set_size(width, alloc.get_height() - width * 2);
        pipelineNode.add_rectangle(box);
    }
});

export const Inspector = GObject.registerClass({
    Signals: {
        'closed': {},
        'target': {param_types: [Clutter.Actor.$gtype, GObject.TYPE_DOUBLE, GObject.TYPE_DOUBLE]},
    },
}, class Inspector extends Clutter.Actor {
    _init(lookingGlass) {
        super._init({width: 0, height: 0});

        Main.uiGroup.add_child(this);

        const eventHandler = new St.BoxLayout({
            name: 'LookingGlassDialog',
            orientation: Clutter.Orientation.HORIZONTAL,
            reactive: true,
        });
        this._eventHandler = eventHandler;
        this.add_child(eventHandler);
        this._displayText = new St.Label({x_expand: true});
        eventHandler.add_child(this._displayText);

        eventHandler.connect('key-press-event', this._onKeyPressEvent.bind(this));
        eventHandler.connect('button-press-event', this._onButtonPressEvent.bind(this));
        eventHandler.connect('scroll-event', this._onScrollEvent.bind(this));
        eventHandler.connect('motion-event', this._onMotionEvent.bind(this));

        this._grab = global.stage.grab(eventHandler);

        // this._target is the actor currently shown by the inspector.
        // this._pointerTarget is the actor directly under the pointer.
        // Normally these are the same, but if you use the scroll wheel
        // to drill down, they'll diverge until you either scroll back
        // out, or move the pointer outside of _pointerTarget.
        this._target = null;
        this._pointerTarget = null;

        this._lookingGlass = lookingGlass;
    }

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

        if (!this._eventHandler)
            return;

        let primary = Main.layoutManager.primaryMonitor;

        let [, , natWidth, natHeight] =
            this._eventHandler.get_preferred_size();

        let childBox = new Clutter.ActorBox();
        childBox.x1 = primary.x + Math.floor((primary.width - natWidth) / 2);
        childBox.x2 = childBox.x1 + natWidth;
        childBox.y1 = primary.y + Math.floor((primary.height - natHeight) / 2);
        childBox.y2 = childBox.y1 + natHeight;
        this._eventHandler.allocate(childBox);
    }

    _close() {
        if (this._grab) {
            this._grab.dismiss();
            this._grab = null;
        }
        this._eventHandler.destroy();
        this._eventHandler = null;
        this.emit('closed');
    }

    _onKeyPressEvent(actor, event) {
        if (event.get_key_symbol() === Clutter.KEY_Escape)
            this._close();
        return Clutter.EVENT_STOP;
    }

    _onButtonPressEvent(actor, event) {
        if (this._target) {
            let [stageX, stageY] = event.get_coords();
            this.emit('target', this._target, stageX, stageY);
        }
        this._close();
        return Clutter.EVENT_STOP;
    }

    _onScrollEvent(actor, event) {
        switch (event.get_scroll_direction()) {
        case Clutter.ScrollDirection.UP: {
            // select parent
            let parent = this._target.get_parent();
            if (parent != null) {
                this._target = parent;
                this._update(event);
            }
            break;
        }

        case Clutter.ScrollDirection.DOWN:
            // select child
            if (this._target !== this._pointerTarget) {
                let child = this._pointerTarget;
                while (child) {
                    let parent = child.get_parent();
                    if (parent === this._target)
                        break;
                    child = parent;
                }
                if (child) {
                    this._target = child;
                    this._update(event);
                }
            }
            break;

        default:
            break;
        }
        return Clutter.EVENT_STOP;
    }

    _onMotionEvent(actor, event) {
        this._update(event);
        return Clutter.EVENT_STOP;
    }

    _update(event) {
        let [stageX, stageY] = event.get_coords();
        let target = global.stage.get_actor_at_pos(
            Clutter.PickMode.ALL, stageX, stageY);

        if (target !== this._pointerTarget)
            this._target = target;
        this._pointerTarget = target;

        let position = `[inspect x: ${stageX} y: ${stageY}]`;
        this._displayText.text = '';
        this._displayText.text = `${position} ${this._target}`;

        this._lookingGlass.setBorderPaintTarget(this._target);
    }
});

const Extensions = GObject.registerClass({
}, class Extensions extends St.BoxLayout {
    _init(lookingGlass) {
        super._init({
            orientation: Clutter.Orientation.VERTICAL,
            name: 'LookingGlassExtensions',
        });

        this._lookingGlass = lookingGlass;
        this._noExtensions = new St.Label({
            style_class: 'lg-extensions-none',
            text: _('No extensions installed'),
        });
        this._numExtensions = 0;
        this._extensionsList = new St.BoxLayout({
            orientation: Clutter.Orientation.VERTICAL,
            style_class: 'lg-extensions-list',
        });
        this._extensionsList.add_child(this._noExtensions);
        this.add_child(this._extensionsList);

        Main.extensionManager.getUuids().forEach(uuid => {
            this._loadExtension(null, uuid);
        });

        Main.extensionManager.connect('extension-loaded',
            this._loadExtension.bind(this));
    }

    _loadExtension(o, uuid) {
        let extension = Main.extensionManager.lookup(uuid);
        // There can be cases where we create dummy extension metadata
        // that's not really a proper extension. Don't bother with these.
        if (!extension.metadata.name)
            return;

        let extensionDisplay = this._createExtensionDisplay(extension);
        if (this._numExtensions === 0)
            this._extensionsList.remove_child(this._noExtensions);

        this._numExtensions++;
        const {name} = extension.metadata;
        const pos = [...this._extensionsList].findIndex(
            dsp => dsp._extension.metadata.name.localeCompare(name) > 0);
        this._extensionsList.insert_child_at_index(extensionDisplay, pos);
    }

    _onViewSource(actor) {
        let extension = actor._extension;
        let uri = extension.dir.get_uri();
        Gio.app_info_launch_default_for_uri(uri, global.create_app_launch_context(0, -1));
        this._lookingGlass.close();
    }

    _onWebPage(actor) {
        let extension = actor._extension;
        Gio.app_info_launch_default_for_uri(extension.metadata.url, global.create_app_launch_context(0, -1));
        this._lookingGlass.close();
    }

    _onViewErrors(actor) {
        let extension = actor._extension;
        let shouldShow = !actor._isShowing;

        if (shouldShow) {
            let errors = extension.errors;
            const errorDisplay = new St.BoxLayout({
                orientation: Clutter.Orientation.VERTICAL,
            });
            if (errors && errors.length) {
                for (let i = 0; i < errors.length; i++)
                    errorDisplay.add_child(new St.Label({text: errors[i]}));
            } else {
                /* Translators: argument is an extension UUID. */
                let message = _('%s has not emitted any errors').format(extension.uuid);
                errorDisplay.add_child(new St.Label({text: message}));
            }

            actor._errorDisplay = errorDisplay;
            actor._parentBox.add_child(errorDisplay);
            actor.label = _('Hide Errors');
        } else {
            actor._errorDisplay.destroy();
            actor._errorDisplay = null;
            actor.label = _('Show Errors');
        }

        actor._isShowing = shouldShow;
    }

    _stateToString(extensionState) {
        switch (extensionState) {
        case ExtensionState.ACTIVE:
            return _('Active');
        case ExtensionState.INACTIVE:
        case ExtensionState.INITIALIZED:
            return _('Inactive');
        case ExtensionState.ERROR:
            return _('Error');
        case ExtensionState.OUT_OF_DATE:
            return _('Out of date');
        case ExtensionState.DOWNLOADING:
            return _('Downloading');
        case ExtensionState.DEACTIVATING:
            return _('Deactivating');
        case ExtensionState.ACTIVATING:
            return _('Activating');
        }
        return 'Unknown'; // Not translated, shouldn't appear
    }

    _createExtensionDisplay(extension) {
        const box = new St.BoxLayout({
            style_class: 'lg-extension',
            orientation: Clutter.Orientation.VERTICAL,
        });
        box._extension = extension;
        let name = new St.Label({
            style_class: 'lg-extension-name',
            text: extension.metadata.name,
            x_expand: true,
        });
        box.add_child(name);
        let description = new St.Label({
            style_class: 'lg-extension-description',
            text: extension.metadata.description || 'No description',
            x_expand: true,
        });
        box.add_child(description);

        let metaBox = new St.BoxLayout({style_class: 'lg-extension-meta'});
        box.add_child(metaBox);
        const state = new St.Label({
            style_class: 'lg-extension-state',
            text: this._stateToString(extension.state),
        });
        metaBox.add_child(state);

        const viewsource = new St.Button({
            reactive: true,
            track_hover: true,
            style_class: 'shell-link',
            label: _('View Source'),
        });
        viewsource._extension = extension;
        viewsource.connect('clicked', this._onViewSource.bind(this));
        metaBox.add_child(viewsource);

        if (extension.metadata.url) {
            const webpage = new St.Button({
                reactive: true,
                track_hover: true,
                style_class: 'shell-link',
                label: _('Web Page'),
            });
            webpage._extension = extension;
            webpage.connect('clicked', this._onWebPage.bind(this));
            metaBox.add_child(webpage);
        }

        const viewerrors = new St.Button({
            reactive: true,
            track_hover: true,
            style_class: 'shell-link',
            label: _('Show Errors'),
        });
        viewerrors._extension = extension;
        viewerrors._parentBox = box;
        viewerrors._isShowing = false;
        viewerrors.connect('clicked', this._onViewErrors.bind(this));
        metaBox.add_child(viewerrors);

        return box;
    }
});


const ActorLink = GObject.registerClass({
    Signals: {
        'inspect-actor': {},
    },
}, class ActorLink extends St.Button {
    _init(actor) {
        this._arrow = new St.Icon({
            icon_name: 'pan-end-symbolic',
            icon_size: 12,
            x_align: Clutter.ActorAlign.CENTER,
            y_align: Clutter.ActorAlign.CENTER,
            pivot_point: new Graphene.Point({x: 0.5, y: 0.5}),
        });

        const label = new St.Label({
            text: actor.toString(),
            x_align: Clutter.ActorAlign.START,
        });

        const inspectButton = new St.Button({
            icon_name: 'insert-object-symbolic',
            reactive: true,
            x_expand: true,
            x_align: Clutter.ActorAlign.START,
            y_align: Clutter.ActorAlign.CENTER,
        });
        inspectButton.connect('clicked', () => this.emit('inspect-actor'));

        const box = new St.BoxLayout();
        box.add_child(this._arrow);
        box.add_child(label);
        box.add_child(inspectButton);

        super._init({
            reactive: true,
            track_hover: true,
            toggle_mode: true,
            style_class: 'actor-link',
            child: box,
            x_align: Clutter.ActorAlign.START,
        });

        this._actor = actor;
    }

    vfunc_clicked() {
        this._arrow.ease({
            rotation_angle_z: this.checked ? 90 : 0,
            duration: 250,
        });
    }
});

const ActorTreeViewer = GObject.registerClass(
class ActorTreeViewer extends St.BoxLayout {
    _init(lookingGlass) {
        super._init({name: 'LookingGlassActors'});

        this._lookingGlass = lookingGlass;
        this._actorData = new Map();
    }

    _showActorChildren(actor) {
        const data = this._actorData.get(actor);
        if (!data || data.visible)
            return;

        data.visible = true;
        data.actorAddedId = actor.connect('child-added', (container, child) => {
            this._addActor(data.children, child);
        });
        data.actorRemovedId = actor.connect('child-removed', (container, child) => {
            this._removeActor(child);
        });

        for (let child of actor)
            this._addActor(data.children, child);
    }

    _hideActorChildren(actor) {
        const data = this._actorData.get(actor);
        if (!data || !data.visible)
            return;

        for (let child of actor)
            this._removeActor(child);

        data.visible = false;
        if (data.actorAddedId > 0) {
            actor.disconnect(data.actorAddedId);
            data.actorAddedId = 0;
        }
        if (data.actorRemovedId > 0) {
            actor.disconnect(data.actorRemovedId);
            data.actorRemovedId = 0;
        }
        data.children.remove_all_children();
    }

    _addActor(container, actor) {
        if (this._actorData.has(actor))
            return;

        if (actor === this._lookingGlass)
            return;

        const button = new ActorLink(actor);
        button.connect('notify::checked', () => {
            this._lookingGlass.setBorderPaintTarget(actor);
            if (button.checked)
                this._showActorChildren(actor);
            else
                this._hideActorChildren(actor);
        });
        button.connect('inspect-actor', () => {
            this._lookingGlass.inspectObject(actor, button);
        });

        const mainContainer = new St.BoxLayout({
            orientation: Clutter.Orientation.VERTICAL,
        });
        const childrenContainer = new St.BoxLayout({
            orientation: Clutter.Orientation.VERTICAL,
            style: 'padding: 0 0 0 18px',
        });

        mainContainer.add_child(button);
        mainContainer.add_child(childrenContainer);

        this._actorData.set(actor, {
            button,
            container: mainContainer,
            children: childrenContainer,
            visible: false,
            actorAddedId: 0,
            actorRemovedId: 0,
            actorDestroyedId: actor.connect('destroy', () => this._removeActor(actor)),
        });

        let belowChild = null;
        const nextSibling = actor.get_next_sibling();
        if (nextSibling && this._actorData.has(nextSibling))
            belowChild = this._actorData.get(nextSibling).container;

        container.insert_child_above(mainContainer, belowChild);
    }

    _removeActor(actor) {
        const data = this._actorData.get(actor);
        if (!data)
            return;

        for (let child of actor)
            this._removeActor(child);

        if (data.actorAddedId > 0) {
            actor.disconnect(data.actorAddedId);
            data.actorAddedId = 0;
        }
        if (data.actorRemovedId > 0) {
            actor.disconnect(data.actorRemovedId);
            data.actorRemovedId = 0;
        }
        if (data.actorDestroyedId > 0) {
            actor.disconnect(data.actorDestroyedId);
            data.actorDestroyedId = 0;
        }
        data.container.destroy();
        this._actorData.delete(actor);
    }

    vfunc_map() {
        super.vfunc_map();
        this._addActor(this, global.stage);
    }

    vfunc_unmap() {
        super.vfunc_unmap();
        this._removeActor(global.stage);
    }
});

const DebugFlag = GObject.registerClass({
    GTypeFlags: GObject.TypeFlags.ABSTRACT,
}, class DebugFlag extends St.Button {
    _init(label) {
        const box = new St.BoxLayout({x_expand: true});

        const flagLabel = new St.Label({
            text: label,
            x_expand: true,
            x_align: Clutter.ActorAlign.START,
            y_align: Clutter.ActorAlign.CENTER,
        });
        box.add_child(flagLabel);

        this._flagSwitch = new PopupMenu.Switch(false);
        this._stateHandler = this._flagSwitch.connect('notify::state', () => {
            if (this._flagSwitch.state)
                this._enable();
            else
                this._disable();
        });

        // Update state whenever the switch is mapped, because most debug flags
        // don't have a way of notifying us of changes.
        this._flagSwitch.connect('notify::mapped', () => {
            if (!this._flagSwitch.is_mapped())
                return;

            const state = this._isEnabled();
            if (state === this._flagSwitch.state)
                return;

            this._flagSwitch.block_signal_handler(this._stateHandler);
            this._flagSwitch.state = state;
            this._flagSwitch.unblock_signal_handler(this._stateHandler);
        });

        box.add_child(this._flagSwitch);

        super._init({
            style_class: 'lg-debug-flag-button',
            can_focus: true,
            toggleMode: true,
            child: box,
            label_actor: flagLabel,
            y_align: Clutter.ActorAlign.CENTER,
        });

        this.connect('clicked', () => this._flagSwitch.toggle());
    }

    _isEnabled() {
        throw new Error('Method not implemented');
    }

    _enable() {
        throw new Error('Method not implemented');
    }

    _disable() {
        throw new Error('Method not implemented');
    }
});


const ClutterDebugFlag = GObject.registerClass(
class ClutterDebugFlag extends DebugFlag {
    _init(categoryName, flagName) {
        super._init(flagName);

        this._argPos = CLUTTER_DEBUG_FLAG_CATEGORIES.get(categoryName).argPos;
        this._enumValue = Clutter[categoryName][flagName];
    }

    _isEnabled() {
        const enabledFlags = Clutter.get_debug_flags();
        return !!(enabledFlags[this._argPos] & this._enumValue);
    }

    _getArgs() {
        const args = [0, 0, 0];
        args[this._argPos] = this._enumValue;
        return args;
    }

    _enable() {
        Clutter.add_debug_flags(...this._getArgs());
    }

    _disable() {
        Clutter.remove_debug_flags(...this._getArgs());
    }
});

const MutterPaintDebugFlag = GObject.registerClass(
class MutterPaintDebugFlag extends DebugFlag {
    _init(flagName) {
        super._init(flagName);

        this._enumValue = Meta.DebugPaintFlag[flagName];
    }

    _isEnabled() {
        return !!(Meta.get_debug_paint_flags() & this._enumValue);
    }

    _enable() {
        Meta.add_debug_paint_flag(this._enumValue);
    }

    _disable() {
        Meta.remove_debug_paint_flag(this._enumValue);
    }
});

const MutterTopicDebugFlag = GObject.registerClass(
class MutterTopicDebugFlag extends DebugFlag {
    _init(flagName) {
        super._init(flagName);

        this._enumValue = Meta.DebugTopic[flagName];
    }

    _isEnabled() {
        return Meta.is_topic_enabled(this._enumValue);
    }

    _enable() {
        Meta.add_verbose_topic(this._enumValue);
    }

    _disable() {
        Meta.remove_verbose_topic(this._enumValue);
    }
});

const UnsafeModeDebugFlag = GObject.registerClass(
class UnsafeModeDebugFlag extends DebugFlag {
    _init() {
        super._init('unsafe-mode');
    }

    _isEnabled() {
        return global.context.unsafe_mode;
    }

    _enable() {
        global.context.unsafe_mode = true;
    }

    _disable() {
        global.context.unsafe_mode = false;
    }
});

const DebugControlExportedDebugFlag = GObject.registerClass(
class DebugControlExportedDebugFlag extends DebugFlag {
    _init() {
        super._init('debug-control');
    }

    _isEnabled() {
        return global.context.get_debug_control().exported;
    }

    _enable() {
        global.context.get_debug_control().exported = true;
    }

    _disable() {
        global.context.get_debug_control().exported = false;
    }
});

const DebugFlags = GObject.registerClass(
class DebugFlags extends St.BoxLayout {
    _init() {
        super._init({
            name: 'LookingGlassDebugFlags',
            orientation: Clutter.Orientation.VERTICAL,
            x_align: Clutter.ActorAlign.CENTER,
        });

        // Clutter debug flags
        for (const [categoryName, props] of CLUTTER_DEBUG_FLAG_CATEGORIES.entries()) {
            this._addHeader(`Clutter${categoryName}`);
            for (const flagName of this._getFlagNames(Clutter[categoryName])) {
                if (props.exclude.includes(flagName))
                    continue;
                this.add_child(new ClutterDebugFlag(categoryName, flagName));
            }
        }

        // Meta paint flags
        this._addHeader('MetaDebugPaintFlag');
        for (const flagName of this._getFlagNames(Meta.DebugPaintFlag))
            this.add_child(new MutterPaintDebugFlag(flagName));

        // Meta debug topics
        this._addHeader('MetaDebugTopic');
        for (const flagName of this._getFlagNames(Meta.DebugTopic))
            this.add_child(new MutterTopicDebugFlag(flagName));

        // General / Context
        this._addHeader('General');
        // MetaContext::unsafe-mode
        this.add_child(new UnsafeModeDebugFlag());
        // DebugControl::exported
        this.add_child(new DebugControlExportedDebugFlag());
    }

    _addHeader(title) {
        const header = new St.Label({
            text: title,
            style_class: 'lg-debug-flags-header',
            x_align: Clutter.ActorAlign.START,
        });
        this.add_child(header);
    }

    *_getFlagNames(enumObject) {
        for (const flagName of Object.getOwnPropertyNames(enumObject)) {
            if (typeof enumObject[flagName] !== 'number')
                continue;

            if (enumObject[flagName] <= 0)
                continue;

            yield flagName;
        }
    }
});


export const LookingGlass = GObject.registerClass(
class LookingGlass extends St.BoxLayout {
    _init() {
        super._init({
            name: 'LookingGlassDialog',
            style_class: 'lg-dialog',
            orientation: Clutter.Orientation.VERTICAL,
            visible: false,
            reactive: true,
        });

        this._borderPaintTarget = null;
        this._redBorderEffect = new RedBorderEffect();

        this._open = false;

        this._it = null;
        this._offset = 0;

        // Sort of magic, but...eh.
        this._maxItems = 150;

        // We want it to appear to slide out from underneath the panel
        Main.uiGroup.add_child(this);
        Main.uiGroup.set_child_below_sibling(this,
            Main.layoutManager.panelBox);
        Main.layoutManager.panelBox.connect('notify::allocation',
            this._queueResize.bind(this));
        Main.layoutManager.keyboardBox.connect('notify::allocation',
            this._queueResize.bind(this));

        this._objInspector = new ObjInspector(this);
        Main.uiGroup.add_child(this._objInspector);
        this._objInspector.hide();

        let toolbar = new St.BoxLayout({name: 'Toolbar'});
        this.add_child(toolbar);
        const inspectButton = new St.Button({
            style_class: 'lg-toolbar-button',
            icon_name: 'find-location-symbolic',
        });
        toolbar.add_child(inspectButton);
        inspectButton.connect('clicked', () => {
            let inspector = new Inspector(this);
            inspector.connect('target', (i, target, stageX, stageY) => {
                this._pushResult(`inspect(${Math.round(stageX)}, ${Math.round(stageY)})`, target);
            });
            inspector.connect('closed', () => {
                this.show();
                global.stage.set_key_focus(this._entry);
            });
            this.hide();
            return Clutter.EVENT_STOP;
        });

        const gcButton = new St.Button({
            style_class: 'lg-toolbar-button',
            icon_name: 'user-trash-full-symbolic',
        });
        toolbar.add_child(gcButton);
        gcButton.connect('clicked', () => {
            gcButton.child.icon_name = 'user-trash-symbolic';
            System.gc();
            this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, () => {
                gcButton.child.icon_name = 'user-trash-full-symbolic';
                this._timeoutId = 0;
                return GLib.SOURCE_REMOVE;
            });
            GLib.Source.set_name_by_id(
                this._timeoutId,
                '[gnome-shell] gcButton.child.icon_name = \'user-trash-full-symbolic\''
            );
            return Clutter.EVENT_PROPAGATE;
        });

        let notebook = new Notebook();
        this._notebook = notebook;
        this.add_child(notebook);

        let emptyBox = new St.Bin({x_expand: true});
        toolbar.add_child(emptyBox);
        toolbar.add_child(notebook.tabControls);

        this._evalBox = new St.BoxLayout({
            name: 'LookingGlassEvaluator',
            orientation: Clutter.Orientation.VERTICAL,
        });
        notebook.appendPage('Evaluator', this._evalBox);

        this._resultsArea = new St.BoxLayout({
            style_class: 'evaluator-results',
            orientation: Clutter.Orientation.VERTICAL,
            y_expand: true,
        });
        this._evalBox.add_child(this._resultsArea);

        this._entryArea = new St.BoxLayout({
            name: 'EntryArea',
            y_align: Clutter.ActorAlign.END,
        });
        this._evalBox.add_child(this._entryArea);

        let label = new St.Label({text: CHEVRON});
        this._entryArea.add_child(label);

        this._entry = new St.Entry({
            can_focus: true,
            x_expand: true,
        });
        ShellEntry.addContextMenu(this._entry);
        this._entryArea.add_child(this._entry);

        this._windowList = new WindowList(this);
        notebook.appendPage('Windows', this._windowList);

        this._extensions = new Extensions(this);
        notebook.appendPage('Extensions', this._extensions);

        this._actorTreeViewer = new ActorTreeViewer(this);
        notebook.appendPage('Actors', this._actorTreeViewer);

        this._debugFlags = new DebugFlags();
        notebook.appendPage('Flags', this._debugFlags);

        this._entry.clutter_text.connect('activate', (o, _e) => {
            // Hide any completions we are currently showing
            this._hideCompletions();

            let text = o.get_text();
            // Ensure we don't get newlines in the command; the history file is
            // newline-separated.
            text = text.replaceAll('\n', ' ');
            this._evaluate(text).catch(logError);
            return true;
        });

        this._history = new History.HistoryManager({
            gsettingsKey: HISTORY_KEY,
            entry: this._entry.clutter_text,
        });

        this._autoComplete = new AutoComplete(this._entry);
        this._autoComplete.connect('suggest', (a, e) => {
            this._showCompletions(e.completions);
        });
        // If a completion is completed unambiguously, the currently-displayed completion
        // suggestions become irrelevant.
        this._autoComplete.connect('completion', (a, e) => {
            if (e.type === 'whole-word')
                this._hideCompletions();
        });

        this._resize();
    }

    vfunc_captured_event(event) {
        if (Main.keyboard.maybeHandleEvent(event))
            return Clutter.EVENT_STOP;

        return Clutter.EVENT_PROPAGATE;
    }

    setBorderPaintTarget(obj) {
        if (this._borderPaintTarget != null)
            this._borderPaintTarget.remove_effect(this._redBorderEffect);
        this._borderPaintTarget = obj;
        if (this._borderPaintTarget != null)
            this._borderPaintTarget.add_effect(this._redBorderEffect);
    }

    _pushResult(command, obj) {
        let index = this._resultsArea.get_n_children() + this._offset;
        let result = new Result(this, CHEVRON + command, obj, index);
        this._resultsArea.add_child(result);
        if (obj instanceof Clutter.Actor)
            this.setBorderPaintTarget(obj);

        if (this._resultsArea.get_n_children() > this._maxItems) {
            this._resultsArea.get_first_child().destroy();
            this._offset++;
        }
        this._it = obj;

        // Scroll to bottom
        this._notebook.scrollToBottom(0);
    }

    _showCompletions(completions) {
        if (!this._completionActor) {
            this._completionActor = new St.Label({name: 'LookingGlassAutoCompletionText', style_class: 'lg-completions-text'});
            this._completionActor.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
            this._completionActor.clutter_text.line_wrap = true;
            this._evalBox.insert_child_below(this._completionActor, this._entryArea);
        }

        this._completionActor.set_text(completions.join(', '));

        // Setting the height to -1 allows us to get its actual preferred height rather than
        // whatever was last set when animating
        this._completionActor.set_height(-1);
        let [, naturalHeight] = this._completionActor.get_preferred_height(this._resultsArea.get_width());

        // Don't reanimate if we are already visible
        if (this._completionActor.visible) {
            this._completionActor.height = naturalHeight;
        } else {
            let settings = St.Settings.get();
            let duration = AUTO_COMPLETE_SHOW_COMPLETION_ANIMATION_DURATION / settings.slow_down_factor;
            this._completionActor.show();
            this._completionActor.remove_all_transitions();
            this._completionActor.ease({
                height: naturalHeight,
                opacity: 255,
                duration,
                mode: Clutter.AnimationMode.EASE_OUT_QUAD,
            });
        }
    }

    _hideCompletions() {
        if (this._completionActor) {
            let settings = St.Settings.get();
            let duration = AUTO_COMPLETE_SHOW_COMPLETION_ANIMATION_DURATION / settings.slow_down_factor;
            this._completionActor.remove_all_transitions();
            this._completionActor.ease({
                height: 0,
                opacity: 0,
                duration,
                mode: Clutter.AnimationMode.EASE_OUT_QUAD,
                onComplete: () => {
                    this._completionActor.hide();
                },
            });
        }
    }

    async _evaluate(command) {
        command = this._history.addItem(command); // trims command
        if (!command)
            return;

        let lines = command.split(';');
        lines.push(`return ${lines.pop()}`);

        let fullCmd = commandHeader + lines.join(';');

        let resultObj;
        try {
            resultObj = await AsyncFunction(fullCmd)();
        } catch (e) {
            resultObj = `<exception ${e}>`;
        }

        this._pushResult(command, resultObj);
        this._entry.text = '';
    }

    inspect(x, y) {
        return global.stage.get_actor_at_pos(Clutter.PickMode.REACTIVE, x, y);
    }

    getIt() {
        return this._it;
    }

    getResult(idx) {
        try {
            return this._resultsArea.get_child_at_index(idx - this._offset).o;
        } catch {
            throw new Error(`Unknown result at index ${idx}`);
        }
    }

    toggle() {
        if (this._open)
            this.close();
        else
            this.open();
    }

    _queueResize() {
        const laters = global.compositor.get_laters();
        laters.add(Meta.LaterType.BEFORE_REDRAW, () => {
            this._resize();
            return GLib.SOURCE_REMOVE;
        });
    }

    _resize() {
        let primary = Main.layoutManager.primaryMonitor;
        let myWidth = primary.width * 0.7;
        let availableHeight = primary.height - Main.layoutManager.keyboardBox.height;
        let myHeight = Math.min(primary.height * 0.7, availableHeight * 0.9);
        this.x = primary.x + (primary.width - myWidth) / 2;
        this._hiddenY = primary.y + Main.layoutManager.panelBox.height - myHeight;
        this._targetY = this._hiddenY + myHeight;
        this.y = this._hiddenY;
        this.width = myWidth;
        this.height = myHeight;
        this._objInspector.set_size(Math.floor(myWidth * 0.8), Math.floor(myHeight * 0.8));
        this._objInspector.set_position(
            this.x + Math.floor(myWidth * 0.1),
            this._targetY + Math.floor(myHeight * 0.1));
    }

    insertObject(obj) {
        this._pushResult('<insert>', obj);
    }

    inspectObject(obj, sourceActor) {
        this._objInspector.open(sourceActor);
        this._objInspector.selectObject(obj);
    }

    // Handle key events which are relevant for all tabs of the LookingGlass
    vfunc_key_press_event(event) {
        let symbol = event.get_key_symbol();
        if (symbol === Clutter.KEY_Escape) {
            this.close();
            return Clutter.EVENT_STOP;
        }
        // Ctrl+PgUp and Ctrl+PgDown switches tabs in the notebook view
        if (event.get_state() & Clutter.ModifierType.CONTROL_MASK) {
            if (symbol === Clutter.KEY_Page_Up)
                this._notebook.prevTab();
            else if (symbol === Clutter.KEY_Page_Down)
                this._notebook.nextTab();
        }
        return super.vfunc_key_press_event(event);
    }

    open() {
        if (this._open)
            return;

        let grab = Main.pushModal(this, {actionMode: Shell.ActionMode.LOOKING_GLASS});
        if (grab.get_seat_state() !== Clutter.GrabState.ALL) {
            Main.popModal(grab);
            return;
        }

        this._grab = grab;
        this.show();
        this._open = true;
        this._history.lastItem();

        this.remove_all_transitions();

        // We inverse compensate for the slow-down so you can change the factor
        // through LookingGlass without long waits.
        let duration = LG_ANIMATION_TIME / St.Settings.get().slow_down_factor;
        this.ease({
            y: this._targetY,
            duration,
            mode: Clutter.AnimationMode.EASE_OUT_QUAD,
        });

        this._windowList.update();
        this._entry.grab_key_focus();
    }

    close() {
        if (!this._open)
            return;

        this._objInspector.hide();

        this._open = false;
        this.remove_all_transitions();

        this.setBorderPaintTarget(null);

        let settings = St.Settings.get();
        let duration = Math.min(
            LG_ANIMATION_TIME / settings.slow_down_factor,
            LG_ANIMATION_TIME);
        this.ease({
            y: this._hiddenY,
            duration,
            mode: Clutter.AnimationMode.EASE_OUT_QUAD,
            onComplete: () => {
                Main.popModal(this._grab);
                this._grab = null;
                this.hide();
            },
        });
    }

    get isOpen() {
        return this._open;
    }
});
