/* eslint-disable jsdoc/require-jsdoc */
import Clutter from 'gi://Clutter';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import Pango from 'gi://Pango';
import Shell from 'gi://Shell';
import St from 'gi://St';

import * as Config from 'resource:///org/gnome/shell/misc/config.js';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import {getLoginManager} from 'resource:///org/gnome/shell/misc/loginManager.js';

import * as Constants from './constants.js';
import {ArcMenuManager} from './arcmenuManager.js';

import {Extension, gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js';

const [ShellVersion] = Config.PACKAGE_VERSION.split('.').map(s => Number(s));

const InterfaceXml = `<node>
  <interface name="com.Extensions.ArcMenu">
    <method name="ToggleArcMenu"/>
    <method name="ToggleStandaloneRunner"/>
  </interface>
</node>`;

export const DBusService = class {
    constructor() {
        this._exported = false;
        this.ToggleArcMenu = () => {};
        this.ToggleStandaloneRunner = () => {};

        this._dbusExportedObject = Gio.DBusExportedObject.wrapJSObject(InterfaceXml, this);

        const onBusAcquired = (connection, _name) => {
            try {
                this._dbusExportedObject.export(connection, '/com/Extensions/ArcMenu');
                this._exported = true;
            } catch (error) {
                console.log(`ArcMenu Error - onBusAcquired export failed: ${error}`);
                this._exported = false;
            }
        };

        function onNameAcquired() { }

        function onNameLost() { }

        this._ownerId = Gio.bus_own_name(Gio.BusType.SESSION, 'com.Extensions.ArcMenu', Gio.BusNameOwnerFlags.NONE,
            onBusAcquired, onNameAcquired, onNameLost);
    }

    destroy() {
        if (this._ownerId) {
            Gio.bus_unown_name(this._ownerId);
            this._ownerId = null;
        }

        if (this._dbusExportedObject && this._exported)
            this._dbusExportedObject.unexport();
        this._dbusExportedObject = null;
        this._exported = null;
        this.ToggleArcMenu = null;
        this.ToggleStandaloneRunner  = null;
    }
};

export function activateHibernateOrSleep(powerType) {
    const loginManager = getLoginManager();
    let callName, activateCall;

    if (powerType === Constants.PowerType.HIBERNATE) {
        callName = 'CanHibernate';
        activateCall = 'Hibernate';
    } else if (powerType === Constants.PowerType.HYBRID_SLEEP) {
        callName = 'CanHybridSleep';
        activateCall = 'HybridSleep';
    }

    if (!loginManager._proxy) {
        Main.notifyError(`ArcMenu - ${activateCall} Error!`, `System unable to ${activateCall}.`);
        return;
    }

    canHibernateOrSleep(callName, result => {
        if (!result) {
            Main.notifyError(`ArcMenu - ${activateCall} Error!`, `System unable to ${activateCall}.`);
            return;
        }

        loginManager._proxy.call(activateCall,
            GLib.Variant.new('(b)', [true]),
            Gio.DBusCallFlags.NONE,
            -1, null, null);
    });
}

export function canHibernateOrSleep(callName, asyncCallback) {
    const loginManager = getLoginManager();

    if (!loginManager._proxy)
        asyncCallback(false);

    loginManager._proxy.call(callName, null, Gio.DBusCallFlags.NONE, -1, null, (proxy, asyncResult) => {
        let result, error;

        try {
            result = proxy.call_finish(asyncResult).deep_unpack();
        } catch (e) {
            error = e;
        }

        if (error)
            asyncCallback(false);
        else
            asyncCallback(result[0] === 'yes');
    });
}

/**
 * Helper function to monitor 'changed' events on a variable number of GSettings keys using
 * the same callback and binding object. It connects each key's `changed::${key}` signal to
 * the provided callback via `connectObject`.
 *
 * @param {string[]} settingNamesArray Array of GSettings key names to monitor for changes.
 * @param {Function} callback The callback function to invoke on any monitored setting change.
 * @param {object} object The object to bind as `this` context for the callback connections.
 */
export function connectSettings(settingNamesArray, callback, object) {
    ArcMenuManager.settings.connectObject(
        ...settingNamesArray.flatMap(setting => [`changed::${setting}`, callback]),
        object
    );
}

/**
 * A utility for debouncing callbacks using GLib timeouts.
 * Useful for rapid GSetting key changes to prevent excessive callbacks.
 * Schedules executions after a delay, canceling and rescheduling on repeated calls per ID.
 * Supports multiple independent debouncers keyed by unique IDs.
 */
export class Debouncer {
    constructor(delay = 300) {
        this._delay = delay;
        this._timers = new Map();
        this._idles = new Map();
    }

    debounce(id, callback) {
        this._cancel(id);

        const timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, this._delay, () => {
            const idleId = GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
                callback();
                this._idles.delete(id);
                return GLib.SOURCE_REMOVE;
            });
            this._idles.set(id, idleId);
            this._timers.delete(id);
            return GLib.SOURCE_REMOVE;
        });

        this._timers.set(id, timeoutId);
    }

    _cancel(id) {
        if (this._timers.has(id)) {
            const timeoutId = this._timers.get(id);
            GLib.source_remove(timeoutId);
            this._timers.delete(id);
        }
        if (this._idles.has(id)) {
            const timeoutId = this._idles.get(id);
            GLib.source_remove(timeoutId);
            this._idles.delete(id);
        }
    }

    destroy() {
        for (const id of this._timers.values())
            GLib.source_remove(id);
        for (const id of this._idles.values())
            GLib.source_remove(id);

        this._idles.clear();
        this._idles = null;
        this._timers.clear();
        this._timers = null;
    }
}

export function convertToButton(item) {
    item.tooltipLocation = Constants.TooltipLocation.BOTTOM_CENTERED;
    item.remove_child(item.label);
    item.set({
        x_expand: false,
        x_align: Clutter.ActorAlign.CENTER,
        y_expand: false,
        y_align: Clutter.ActorAlign.CENTER,
        style_class: 'popup-menu-item arcmenu-button',
    });
}

export function convertToGridLayout(item) {
    const menuLayout = item._menuLayout;
    const icon = item._iconBin;

    const {settings} = ArcMenuManager;

    const iconSizeEnum = settings.get_enum('menu-item-grid-icon-size');
    const defaultIconSize = menuLayout.icon_grid_size;
    const {width, height, iconSize_} = getGridIconSize(iconSizeEnum, defaultIconSize);

    item.add_style_class_name('ArcMenuIconGrid');
    item.set({
        ...getOrientationProp(true),
        x_align: Clutter.ActorAlign.CENTER,
        tooltipLocation: Constants.TooltipLocation.BOTTOM_CENTERED,
        style: `width: ${width}px; height: ${height}px;`,
    });

    icon?.set({
        y_align: Clutter.ActorAlign.CENTER,
        y_expand: true,
    });

    if (item._indicator) {
        item.remove_child(item._indicator);
        item.insert_child_at_index(item._indicator, 0);
        item._indicator.set({
            x_align: Clutter.ActorAlign.CENTER,
            y_align: Clutter.ActorAlign.START,
            y_expand: false,
        });
    }

    if (!settings.get_boolean('multi-lined-labels'))
        return;

    icon?.set({
        y_align: Clutter.ActorAlign.TOP,
        y_expand: false,
    });

    const clutterText = item.label.get_clutter_text();
    clutterText.set({
        line_wrap: true,
        line_wrap_mode: Pango.WrapMode.WORD_CHAR,
    });
}

export function getIconSize(iconSizeEnum, defaultIconSize) {
    switch (iconSizeEnum) {
    case Constants.IconSize.DEFAULT:
        return defaultIconSize;
    case Constants.IconSize.EXTRA_SMALL:
        return Constants.EXTRA_SMALL_ICON_SIZE;
    case Constants.IconSize.SMALL:
        return Constants.SMALL_ICON_SIZE;
    case Constants.IconSize.MEDIUM:
        return Constants.MEDIUM_ICON_SIZE;
    case Constants.IconSize.LARGE:
        return Constants.LARGE_ICON_SIZE;
    case Constants.IconSize.EXTRA_LARGE:
        return Constants.EXTRA_LARGE_ICON_SIZE;
    case Constants.IconSize.HIDDEN:
        return Constants.ICON_HIDDEN;
    default:
        return defaultIconSize;
    }
}

export function getGridIconSize(iconSizeEnum, defaultIconSize) {
    const {settings} = ArcMenuManager;

    if (iconSizeEnum === Constants.GridIconSize.CUSTOM) {
        const {width, height, iconSize} = settings.get_value('custom-grid-icon-size').deep_unpack();
        return {width, height, iconSize};
    }

    if (iconSizeEnum === Constants.GridIconSize.DEFAULT)
        iconSizeEnum = defaultIconSize;

    let width, height, iconSize;
    Constants.GridIconInfo.forEach(info => {
        if (iconSizeEnum === info.ENUM) {
            width = info.WIDTH;
            height = info.HEIGHT;
            iconSize = info.ICON_SIZE;
        }
    });
    return {width, height, iconSize};
}

export function getCategoryDetails(iconTheme, currentCategory) {
    let name = null, gicon = null, fallbackIcon = null;

    for (const entry of Constants.Categories) {
        if (entry.CATEGORY === currentCategory) {
            name = entry.NAME;
            gicon = Gio.Icon.new_for_string(entry.IMAGE);
            return [name, gicon, fallbackIcon];
        }
    }

    if (currentCategory === Constants.CategoryType.HOME_SCREEN) {
        name = _('Home');
        gicon = Gio.Icon.new_for_string('computer-symbolic');
        return [name, gicon, fallbackIcon];
    } else {
        name = currentCategory.get_name();
        const categoryIcon = currentCategory.get_icon();

        if (!categoryIcon)
            return [name, gicon, fallbackIcon];

        const categoryIconName = categoryIcon.to_string();
        const symbolicName = `${categoryIconName}-symbolic`;
        const symbolicIconFile = `${symbolicName}.svg`;

        gicon = categoryIcon;

        const categoryIconType = ArcMenuManager.settings.get_enum('category-icon-type');
        if (categoryIconType === Constants.CategoryIconType.SYMBOLIC) {
            const icon = iconTheme.lookup_icon(symbolicName, 26, St.IconLookupFlags.FORCE_SYMBOLIC);
            if (icon) {
                gicon = Gio.Icon.new_for_string(symbolicName);
            } else {
                const filePath = `${Constants.RESOURCE_PATH}/categories/${symbolicIconFile}`;
                const file = Gio.File.new_for_uri(filePath);
                if (file.query_exists(null))
                    gicon = Gio.Icon.new_for_string(`${Constants.RESOURCE_PATH}/categories/${symbolicIconFile}`);
            }
        }

        fallbackIcon = Gio.Icon.new_for_string(`${Constants.RESOURCE_PATH}/categories/${symbolicIconFile}`);
        return [name, gicon, fallbackIcon];
    }
}

export function getPowerTypeFromShortcutCommand(command) {
    switch (command) {
    case Constants.ShortcutCommands.LOG_OUT:
        return Constants.PowerType.LOGOUT;
    case Constants.ShortcutCommands.LOCK:
        return Constants.PowerType.LOCK;
    case Constants.ShortcutCommands.POWER_OFF:
        return Constants.PowerType.POWER_OFF;
    case Constants.ShortcutCommands.RESTART:
        return Constants.PowerType.RESTART;
    case Constants.ShortcutCommands.SUSPEND:
        return Constants.PowerType.SUSPEND;
    case Constants.ShortcutCommands.HYBRID_SLEEP:
        return Constants.PowerType.HYBRID_SLEEP;
    case Constants.ShortcutCommands.HIBERNATE:
        return Constants.PowerType.HIBERNATE;
    case Constants.ShortcutCommands.SWITCH_USER:
        return Constants.PowerType.SWITCH_USER;
    default:
        return Constants.PowerType.POWER_OFF;
    }
}

export function findSoftwareManager() {
    const appSys = Shell.AppSystem.get_default();

    for (const softwareManagerID of Constants.SoftwareManagerIDs) {
        if (appSys.lookup_app(softwareManagerID))
            return softwareManagerID;
    }

    return 'ArcMenu_InvalidShortcut.desktop';
}

export function areaOfTriangle(p1, p2, p3) {
    return Math.abs((p1[0] * (p2[1] - p3[1]) + p2[0] * (p3[1] - p1[1]) + p3[0] * (p1[1] - p2[1])) / 2.0);
}

// modified from GNOME shell's ensureActorVisibleInScrollView()
export function ensureActorVisibleInScrollView(actor, axis = Clutter.Orientation.VERTICAL, dummyBox = null) {
    let box = dummyBox ? dummyBox : actor.get_allocation_box();
    let {y1} = box, {y2} = box;
    let {x1} = box, {x2} = box;

    let parent = actor.get_parent();
    while (!(parent instanceof St.ScrollView)) {
        if (!parent)
            return;

        box = parent.get_allocation_box();
        y1 += box.y1;
        y2 += box.y1;
        x1 += box.x1;
        x2 += box.x1;
        parent = parent.get_parent();
    }

    const isVertical = axis === Clutter.Orientation.VERTICAL;
    const {hadjustment, vadjustment} = getScrollViewAdjustments(parent);
    const adjustment = isVertical ? vadjustment : hadjustment;
    const [startPoint, endPoint] = isVertical ? [y1, y2] : [x1, x2];
    const [value, lower_, upper, stepIncrement_, pageIncrement_, pageSize] = adjustment.get_values();

    let offset = 0;
    let newValue;

    const fade = parent.get_effect('fade');
    if (fade)
        offset = isVertical ? fade.fade_margins.top : fade.fade_margins.left;

    if (startPoint < value + offset)
        newValue = Math.max(0, startPoint - offset);
    else if (endPoint > value + pageSize - offset)
        newValue = Math.min(upper, endPoint + offset - pageSize);
    else
        return;

    adjustment.ease(newValue, {
        mode: Clutter.AnimationMode.EASE_OUT_QUAD,
        duration: 100,
    });
}

export function getCategories(info) {
    const categoriesStr = info.get_categories();
    if (!categoriesStr)
        return [];
    return categoriesStr.split(';');
}

export function findBestFolderName(apps) {
    const appInfos = apps.map(app => app.get_app_info());

    const categoryCounter = {};
    const commonCategories = [];

    appInfos.reduce((categories, appInfo) => {
        for (const category of getCategories(appInfo)) {
            if (!(category in categoryCounter))
                categoryCounter[category] = 0;

            categoryCounter[category] += 1;

            // If a category is present in all apps, its counter will
            // reach appInfos.length
            if (category.length > 0 &&
                categoryCounter[category] === appInfos.length)
                categories.push(category);
        }
        return categories;
    }, commonCategories);

    for (const category of commonCategories) {
        const directory = `${category}.directory`;
        const translated = Shell.util_get_translated_folder_name(directory);
        if (translated !== null)
            return translated;
    }

    return null;
}

export function openPrefs(uuid) {
    const extension = Extension.lookupByUUID(uuid);
    if (extension !== null)
        extension.openPreferences();
}

export function createPanActionScrollView(menuButton, params) {
    const {settings} = ArcMenuManager;
    const showScrollbars = settings.get_boolean('scrollbars-visible');
    const overlayScrollbars = settings.get_boolean('scrollbars-overlay');

    const scrollView = new St.ScrollView({
        ...params,
        clip_to_allocation: true,
        hscrollbar_policy: St.PolicyType.NEVER,
        vscrollbar_policy: showScrollbars ? St.PolicyType.AUTOMATIC : St.PolicyType.EXTERNAL,
        overlay_scrollbars: overlayScrollbars,
    });

    // With overlay_scrollbars = true, the scrollbar appears behind the menu items
    // Maybe a bug in GNOME? Fix it with this.
    if (overlayScrollbars) {
        scrollView.get_children().forEach(child => {
            if (child instanceof St.ScrollBar)
                child.z_position = 1;
        });
    }

    const onPanFunc = (a, mb, sv) => {
        mb.clearTooltipShowingId();
        mb.hideTooltip();

        let delta;
        if (a.get_delta)
            delta = a.get_delta().get_y();
        else
            [, , delta] = a.get_motion_delta(0);

        const {vadjustment} = getScrollViewAdjustments(sv);
        vadjustment.value -= delta;
        return false;
    };

    createAction({
        actor: scrollView,
        actionType: Constants.ClutterAction.PAN,
        actionParams: {interpolate: true},
        actionArgs: {onPan: action => onPanFunc(action, menuButton, scrollView)},
    });
    return scrollView;
}

/**
 *
 * @param {Clutter.Actor} parent
 * @param {Clutter.Actor} child
 * @description GNOME 46 no longer supports add_actor() method.\
 *              Check which method to use to maintain compatibility with GNOME 45 and 46.
 */
export function addChildToParent(parent, child) {
    if (parent.add_actor)
        parent.add_actor(child);
    else if (parent instanceof St.Button || parent instanceof St.ScrollView)
        parent.set_child(child);
    else
        parent.add_child(child);
}

/**
 * GNOME 46 renamed the extension states. Use this const instead.
 */
export const ExtensionState = {
    ACTIVE: 1,
    INACTIVE: 2,
};

/**
 *
 * @param {St.ScrollView} scrollView
 * @description ScrollView.(hv)scroll was deprecated in GNOME 46.\
 *              Check which ScrollView property to use to maintain compatibility with GNOME 45 and 46.
 */
export function getScrollViewAdjustments(scrollView) {
    const hadjustment = scrollView.hadjustment ?? scrollView.hscroll.adjustment;
    const vadjustment = scrollView.vadjustment ?? scrollView.vscroll.adjustment;

    return {
        hadjustment,
        vadjustment,
    };
}

/**
 *
 * @param {boolean} vertical
 * @description GNOME 48 - St.BoxLayout uses 'orientation' instead of 'vertical'
 */
export function getOrientationProp(vertical) {
    if (ShellVersion >= 48)
        return {orientation: vertical ? Clutter.Orientation.VERTICAL : Clutter.Orientation.HORIZONTAL};
    else
        return {vertical};
}

/**
 * Creates and adds gestures or actions to a ClutterActor based on the provided action type.
 * Supports ClutterGestures (GNOME 49+) or ClutterActions (GNOME 48 and earlier), depending on the Shell version.
 *
 * @param {object} args - Configuration object for the action.
 * @param {Clutter.Actor} args.actor - The ClutterActor to attach the actions to.
 * @param {Constants.ClutterAction} args.actionType - The type of action to create.
 * @param {object} [args.actionParams] - Optional parameters for initializing the gesture/action.
 * @param {object} args.actionArgs - Callbacks for handling gesture/action events.
 * @param {Function} [args.actionArgs.onClick] - Callback for click events.
 * @param {Function} [args.actionArgs.onPressed] - Callback for press state changes.
 * @param {Function} [args.actionArgs.onLongPress] - Callback for long-press events.
 * @param {Function} [args.actionArgs.onRightClick] - Callback for right-click events.
 * @param {Function} [args.actionArgs.onPan] - Callback for pan events.
 * @returns {object} An object mapping action names to their instances. Possible keys include:
 *   - `clickAction`: `Clutter.ClickGesture` (GNOME 49+) or `Clutter.ClickAction`.
 *   - `longPressAction`: `Clutter.LongPressGesture` (GNOME 49+ only).
 *   - `rightClickAction`: `Clutter.ClickGesture` (GNOME 49+ only).
 *   - `panAction`: `Clutter.PanGesture` (GNOME 49+) or `Clutter.PanAction`.
 *   Returns an empty object if `actionType` is unsupported or `args` is invalid.
 */
export function createAction(args) {
    const actions = ShellVersion >= 49 ? getGestures(args) : getActions(args);

    Object.values(actions).forEach(action => {
        args.actor.add_action(action);
    });

    return actions;
}

// ClutterGestures (GNOME 49+)
function getGestures(args) {
    const actions = {};
    const {actionType, actionParams, actionArgs} = args;

    switch (actionType) {
    case Constants.ClutterAction.CLICK: {
        const clickGesture = new Clutter.ClickGesture(actionParams);
        actions.clickAction = clickGesture;

        if (actionArgs.onClick)
            clickGesture.connect('recognize', actionArgs.onClick);
        if (actionArgs.onPressed)
            clickGesture.connect('notify::pressed', actionArgs.onPressed);
        if (actionArgs.onLongPress) {
            const longPressGesture = new Clutter.LongPressGesture();
            actions.longPressAction = longPressGesture;
            longPressGesture.connect('recognize', actionArgs.onLongPress);
        }
        if (actionArgs.onRightClick) {
            const rightClickGesture = new Clutter.ClickGesture({
                required_button: Clutter.BUTTON_SECONDARY,
                recognize_on_press: true,
            });
            actions.rightClickAction = rightClickGesture;
            rightClickGesture.connect('recognize', actionArgs.onRightClick);
        }
        break;
    }
    case Constants.ClutterAction.PAN: {
        const panGesture = new Clutter.PanGesture();
        actions.panAction = panGesture;

        if (actionArgs.onPan)
            panGesture.connect('pan-update', actionArgs.onPan);
        break;
    }
    default:
        break;
    }

    return actions;
}

// ClutterActions (GNOME 48 and earlier)
function getActions(args) {
    const actions = {};
    const {actionType, actionParams, actionArgs} = args;

    switch (actionType) {
    case Constants.ClutterAction.CLICK: {
        const clickAction = new Clutter.ClickAction(actionParams);
        actions.clickAction = clickAction;

        if (actionArgs.onClick)
            clickAction.connect('clicked', actionArgs.onClick);
        if (actionArgs.onPressed)
            clickAction.connect('notify::pressed', actionArgs.onPressed);
        if (actionArgs.onLongPress)
            clickAction.connect('long-press', actionArgs.onLongPress);
        break;
    }
    case Constants.ClutterAction.PAN: {
        const panAction = new Clutter.PanAction(actionParams);
        actions.panAction = panAction;

        if (actionArgs.onPan)
            panAction.connect('pan', actionArgs.onPan);

        break;
    }
    default:
        break;
    }

    return actions;
}
