"use strict";

import GObject from "gi://GObject";
import St from "gi://St";
import type Gio from "gi://Gio";

import * as Main from "resource:///org/gnome/shell/ui/main.js";

import type { CustomPanel } from "../extension.js"

export type Box = "left" | "center" | "right";
type Hide = "hide" | "show" | "default";
/**
 * A resolved box order item containing the items role, settings identifier and
 * additional information.
 */
interface ResolvedBoxOrderItem {
    settingsId: string // The settings identifier of the item.
    role: string // The role of the item.
    hide: Hide // Whether the item should be (forcefully) hidden, (forcefully) shown or just be left as is.
}

/**
 * This class provides an interfaces to the box orders stored in settings.
 * It takes care of handling AppIndicator and Task Up UltraLite items and
 * resolving from the internal item settings identifiers to roles.
 * In the end this results in convenient functions, which are directly useful in
 * other extension code.
 */
export default class BoxOrderManager extends GObject.Object {
    static {
        GObject.registerClass({
            Signals: {
                "appIndicatorReady": {},
            },
        }, this);
    }

    // Can't have type guarantees here, since this is working with types from
    // the KStatusNotifier/AppIndicator extension.
    #appIndicatorReadyHandlerIdMap: Map<any, any>;
    #appIndicatorItemSettingsIdToRolesMap: Map<string, string[]>;
    #taskUpUltraLiteItemRoles: string[];
    #settings: Gio.Settings;

    constructor(params = {}, settings: Gio.Settings) {
        // @ts-ignore Params should be passed, see: https://gjs.guide/guides/gobject/subclassing.html#subclassing-gobject
        super(params);

        this.#appIndicatorReadyHandlerIdMap = new Map();
        this.#appIndicatorItemSettingsIdToRolesMap = new Map();
        this.#taskUpUltraLiteItemRoles = [];

        this.#settings = settings;
    }

    /**
     * Gets a box order for the given top bar box from settings.
     * @param {Box} box - The top bar box for which to get the box order.
     * @returns {string[]} - The box order consisting of an array of item
     * settings identifiers.
     */
    #getBoxOrder(box: Box): string[] {
        return this.#settings.get_strv(`${box}-box-order`);
    }

    /**
     * Save the given box order to settings, making sure to only save a changed
     * box order, to avoid loops when listening on settings changes.
     * @param {Box} box - The top bar box for which to save the box order.
     * @param {string[]} boxOrder - The box order to save. Must be an array of
     * item settings identifiers.
     */
    #saveBoxOrder(box: Box, boxOrder: string[]): void {
        const currentBoxOrder = this.#getBoxOrder(box);

        // Only save the given box order to settings, if it is different, to
        // avoid loops when listening on settings changes.
        if (JSON.stringify(boxOrder) !== JSON.stringify(currentBoxOrder)) {
            this.#settings.set_strv(`${box}-box-order`, boxOrder);
        }
    }

    /**
     * Handles an AppIndicator/KStatusNotifierItem item by deriving a settings
     * identifier and then associating the role of the given item to the items
     * settings identifier.
     * It then returns the derived settings identifier.
     * In the case, where the settings identifier can't be derived, because the
     * application can't be determined, this method throws an error. However it
     * then also makes sure that once the app indicators "ready" signal emits,
     * this classes "appIndicatorReady" signal emits as well, such that it and
     * other methods can be called again to properly handle the item.
     * @param {St.Bin} indicatorContainer - The container of the indicator of the
     * AppIndicator/KStatusNotifierItem item.
     * @param {string} role - The role of the AppIndicator/KStatusNotifierItem
     * item.
     * @returns {string} The derived items settings identifier.
     */
    #handleAppIndicatorItem(indicatorContainer: St.Bin, role: string): string {
        // Since this is working with types from the
        // AppIndicator/KStatusNotifierItem extension, we loose a bunch of type
        // safety here.
        // https://github.com/ubuntu/gnome-shell-extension-appindicator
        const appIndicator = (indicatorContainer.get_child() as any)._indicator;
        let application = appIndicator.id;

        if (!application && this.#appIndicatorReadyHandlerIdMap) {
            const handlerId = appIndicator.connect("ready", () => {
                this.emit("appIndicatorReady");
                appIndicator.disconnect(handlerId);
                this.#appIndicatorReadyHandlerIdMap.delete(handlerId);
            });
            this.#appIndicatorReadyHandlerIdMap.set(handlerId, appIndicator);
            throw new Error("Application can't be determined.");
        }

        // Since the Dropbox client appends its PID to the id, drop the PID and
        // the hyphen before it.
        if (application.startsWith("dropbox-client-")) {
            application = "dropbox-client";
        }

        // Derive the items settings identifier from the application name.
        const itemSettingsId = `appindicator-kstatusnotifieritem-${application}`;

        // Associate the role with the items settings identifier.
        let roles = this.#appIndicatorItemSettingsIdToRolesMap.get(itemSettingsId);
        if (roles) {
            // If the settings identifier already has an array of associated
            // roles, just add the role to it, if needed.
            if (!roles.includes(role)) {
                roles.push(role);
            }
        } else {
            // Otherwise create a new array.
            this.#appIndicatorItemSettingsIdToRolesMap.set(itemSettingsId, [role]);
        }

        // Return the item settings identifier.
        return itemSettingsId;
    }

    /**
     * Handles a Task Up UltraLite item by storing its role and returning the
     * Task Up UltraLite settings identifier.
     * This is needed since the Task Up UltraLite extension creates a bunch of
     * top bar items as part of its functionality, so we want to group them
     * under one identifier in the settings.
     * https://extensions.gnome.org/extension/7700/task-up-ultralite/
     * @param {string} role - The role of the Task Up UltraLite item.
     * @returns {string} The settings identifier to use.
     */
    #handleTaskUpUltraLiteItem(role: string): string {
        const roles = this.#taskUpUltraLiteItemRoles;

        if (!roles.includes(role)) {
            roles.push(role);
        }

        return "item-role-group-task-up-ultralite";
    }

    /**
     * Gets a resolved box order for the given top bar box, where all
     * AppIndicator and Task Up UltraLite items got resolved using their roles,
     * meaning they might be present multiple times or not at all depending on
     * the roles stored.
     * The items of the box order also have additional information stored.
     * @param {Box} box - The top bar box for which to get the resolved box order.
     * @returns {ResolvedBoxOrderItem[]} - The resolved box order.
     */
    #getResolvedBoxOrder(box: Box): ResolvedBoxOrderItem[] {
        let boxOrder = this.#getBoxOrder(box);

        const itemsToHide = this.#settings.get_strv("hide");
        const itemsToShow = this.#settings.get_strv("show");

        let resolvedBoxOrder = [];
        for (const itemSettingsId of boxOrder) {
            const resolvedBoxOrderItem = {
                settingsId: itemSettingsId,
                role: "",
                hide: "",
            };

            // Set the hide state of the item.
            if (itemsToHide.includes(resolvedBoxOrderItem.settingsId)) {
                resolvedBoxOrderItem.hide = "hide";
            } else if (itemsToShow.includes(resolvedBoxOrderItem.settingsId)) {
                resolvedBoxOrderItem.hide = "show";
            } else {
                resolvedBoxOrderItem.hide = "default";
            }

            // If the items settings identifier doesn't indicate that the item
            // is an AppIndicator/KStatusNotifierItem item or the Task Up
            // UltraLite item role group, then its identifier is the role and it
            // can just be added to the resolved box order.
            if (!itemSettingsId.startsWith("appindicator-kstatusnotifieritem-") &&
                itemSettingsId !== "item-role-group-task-up-ultralite") {
                resolvedBoxOrderItem.role = resolvedBoxOrderItem.settingsId;
                resolvedBoxOrder.push(resolvedBoxOrderItem);
                continue;
            }

            // If the items settings identifier indicates otherwise, then handle
            // the item specially.

            // Get the roles associated with the items settings id.
            let roles: string[] = [];
            if (itemSettingsId.startsWith("appindicator-kstatusnotifieritem-")) {
                roles = this.#appIndicatorItemSettingsIdToRolesMap.get(resolvedBoxOrderItem.settingsId) ?? [];
            } else if (itemSettingsId === "item-role-group-task-up-ultralite") {
                roles = this.#taskUpUltraLiteItemRoles;
            }

            // Create a new resolved box order item for each role and add it to
            // the resolved box order.
            for (const role of roles) {
                const newResolvedBoxOrderItem = JSON.parse(JSON.stringify(resolvedBoxOrderItem));
                newResolvedBoxOrderItem.role = role;
                resolvedBoxOrder.push(newResolvedBoxOrderItem);
            }
        }

        return resolvedBoxOrder;
    }

    /**
     * Disconnects all signals (and disables future signal connection).
     * This is typically used before nulling an instance of this class to make
     * sure all signals are disconnected.
     */
    disconnectSignals(): void {
        for (const [handlerId, appIndicator] of this.#appIndicatorReadyHandlerIdMap) {
            if (handlerId && appIndicator?.signalHandlerIsConnected(handlerId)) {
                appIndicator.disconnect(handlerId);
            }
        }
        // @ts-ignore
        this.#appIndicatorReadyHandlerIdMap = null;
    }

    /**
     * Gets a valid box order for the given top bar box, where all AppIndicator
     * and Task Up UltraLite items got resolved and where only items are
     * included, which are in some GNOME Shell top bar box.
     * The items of the box order also have additional information stored.
     * @param {Box} box - The top bar box to return the valid box order for.
     * @returns {ResolvedBoxOrderItem[]} - The valid box order.
     */
    getValidBoxOrder(box: Box): ResolvedBoxOrderItem[] {
        // Get a resolved box order.
        let resolvedBoxOrder = this.#getResolvedBoxOrder(box);

        // Get the indicator containers (of the items) currently present in the
        // GNOME Shell top bar.
        // They should be St.Bins (see link), so ensure that using a filter.
        // https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/48.2/js/ui/panelMenu.js?ref_type=tags#L21
        const indicatorContainers = new Set([
            (Main.panel as CustomPanel)._leftBox.get_children(),
            (Main.panel as CustomPanel)._centerBox.get_children(),
            (Main.panel as CustomPanel)._rightBox.get_children(),
        ].flat().filter(ic => ic instanceof St.Bin));

        // Go through the resolved box order and only add items to the valid box
        // order, where their indicator is currently present in the GNOME Shell
        // top bar.
        let validBoxOrder: ResolvedBoxOrderItem[] = [];
        for (const item of resolvedBoxOrder) {
            const associatedIndicatorContainer = (Main.panel.statusArea as any)[item.role]?.container;
            if (!(associatedIndicatorContainer instanceof St.Bin)) {
                // TODO: maybe add logging
                continue;
            }

            if (indicatorContainers.has(associatedIndicatorContainer)) {
                validBoxOrder.push(item);
            }
        }

        return validBoxOrder;
    }

    /**
     * This method saves all new items currently present in the GNOME Shell top
     * bar to the settings.
     */
    saveNewTopBarItems(): void {
        // Only run, when the session mode is "user" or the parent session mode
        // is "user".
        if (Main.sessionMode.currentMode !== "user" && Main.sessionMode.parentMode !== "user") {
            return;
        }

        // Get the box orders.
        const boxOrders = {
            left: this.#getBoxOrder("left"),
            center: this.#getBoxOrder("center"),
            right: this.#getBoxOrder("right"),
        };

        // Get roles (of items) currently present in the GNOME Shell top bar and
        // index them using their associated indicator container.
        let indicatorContainerRoleMap = new Map<St.Bin, string>();
        for (const role in (Main.panel.statusArea as any)) {
            const associatedIndicatorContainer = (Main.panel.statusArea as any)[role]?.container;
            if (!(associatedIndicatorContainer instanceof St.Bin)) {
                // TODO: maybe add logging
                continue;
            }
            indicatorContainerRoleMap.set(associatedIndicatorContainer, role);
        }

        // Get the indicator containers (of the items) currently present in the
        // GNOME Shell top bar boxes.
        // They should be St.Bins (see link), so ensure that using a filter.
        // https://gitlab.gnome.org/GNOME/gnome-shell/-/blob/48.2/js/ui/panelMenu.js?ref_type=tags#L21
        const boxIndicatorContainers = {
            left: (Main.panel as CustomPanel)._leftBox.get_children().filter(ic => ic instanceof St.Bin),
            center: (Main.panel as CustomPanel)._centerBox.get_children().filter(ic => ic instanceof St.Bin),
            // Reverse this array, since the items in the left and center box
            // are logically LTR, while the items in the right box are RTL.
            right: (Main.panel as CustomPanel)._rightBox.get_children().filter(ic => ic instanceof St.Bin).reverse(),
        };

        // This function goes through the indicator containers of the given box
        // and adds new item settings identifiers to the given box order.
        const addNewItemSettingsIdsToBoxOrder = (indicatorContainers: St.Bin[], boxOrder: string[], box: Box) => {
            for (const indicatorContainer of indicatorContainers) {
                // First get the role associated with the current indicator
                // container.
                let role = indicatorContainerRoleMap.get(indicatorContainer);
                if (!role) {
                    continue;
                }

                // Then get a settings identifier for the item.
                let itemSettingsId;
                if (role.startsWith("appindicator-")) {
                    // If the role indicates that the item is an
                    // AppIndicator/KStatusNotifierItem item, then handle it
                    // differently.
                    try {
                        itemSettingsId = this.#handleAppIndicatorItem(indicatorContainer, role);
                    } catch (e) {
                        if (!(e instanceof Error)) {
                            throw(e);
                        }
                        if (e.message !== "Application can't be determined.") {
                            throw(e);
                        }
                        continue;
                    }
                } else if (role.startsWith("task-button-")) {
                    // If the role indicates that the item is a Task Up
                    // UltraLite item, then handle it differently.
                    itemSettingsId = this.#handleTaskUpUltraLiteItem(role);
                } else { // Otherwise just use the role as the settings identifier.
                    itemSettingsId = role;
                }

                // Add the items settings identifier to the box order, if it
                // isn't in in one already.
                if (!boxOrders.left.includes(itemSettingsId)
                    && !boxOrders.center.includes(itemSettingsId)
                    && !boxOrders.right.includes(itemSettingsId)) {
                    if (box === "right") {
                        // Add the items to the beginning for this array, since
                        // its RTL.
                        boxOrder.unshift(itemSettingsId);
                    } else {
                        boxOrder.push(itemSettingsId);
                    }
                }
            }
        };

        addNewItemSettingsIdsToBoxOrder(boxIndicatorContainers.left, boxOrders.left, "left");
        addNewItemSettingsIdsToBoxOrder(boxIndicatorContainers.center, boxOrders.center, "center");
        addNewItemSettingsIdsToBoxOrder(boxIndicatorContainers.right, boxOrders.right, "right");

        this.#saveBoxOrder("left", boxOrders.left);
        this.#saveBoxOrder("center", boxOrders.center);
        this.#saveBoxOrder("right", boxOrders.right);
    }
}
