/*
 * Copyright 2024 New Vector Ltd.
 * Copyright 2021, 2022 The Matrix.org Foundation C.I.C.
 *
 * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
 * Please see LICENSE files in the repository root for full details.
 */

import { type Room, RoomStateEvent, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { MapWithDefault, recursiveMapToObject } from "matrix-js-sdk/src/utils";
import { type IWidget } from "matrix-widget-api";
import { clamp, defaultNumber, sum } from "@element-hq/web-shared-components";
import { type Container } from "@element-hq/element-web-module-api";

import SettingsStore from "../../settings/SettingsStore";
import WidgetStore, { type IApp } from "../WidgetStore";
import { WidgetType } from "../../widgets/WidgetType";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { ReadyWatchingStore } from "../ReadyWatchingStore";
import { SettingLevel } from "../../settings/SettingLevel";
import { arrayFastClone } from "../../utils/arrays";
import { UPDATE_EVENT } from "../AsyncStore";
import { type IStoredLayout, type ILayoutStateEvent, WIDGET_LAYOUT_EVENT_TYPE, type IWidgetLayouts } from "./types";

export type { IStoredLayout, ILayoutStateEvent };
export { type Container, WIDGET_LAYOUT_EVENT_TYPE };

export type ILayoutSettings = Partial<ILayoutStateEvent> & {
    overrides?: string; // event ID for layout state event, if present
};

// Dev note: "Pinned" widgets are ones in the top container.
export const MAX_PINNED = 3;

// These two are whole percentages and don't really mean anything. Later values will decide
// minimum, but these help determine proportions during our calculations here. In fact, these
// values should be *smaller* than the actual minimums imposed by later components.
const MIN_WIDGET_WIDTH_PCT = 10; // 10%
const MIN_WIDGET_HEIGHT_PCT = 2; // 2%

interface ContainerValue {
    ordered: IApp[];
    height?: number;
    distributions?: number[];
}

export class WidgetLayoutStore extends ReadyWatchingStore {
    private static internalInstance: WidgetLayoutStore;

    // Map: room Id → container → ContainerValue
    private byRoom: MapWithDefault<string, Map<Container, ContainerValue>> = new MapWithDefault(() => new Map());
    private pinnedRef: string | undefined;
    private layoutRef: string | undefined;
    private dynamicRef: string | undefined;

    private constructor() {
        super(defaultDispatcher);
    }

    public static get instance(): WidgetLayoutStore {
        if (!this.internalInstance) {
            this.internalInstance = new WidgetLayoutStore();
            this.internalInstance.start();
        }
        return this.internalInstance;
    }

    public static emissionForRoom(room: Room): string {
        return `update_${room.roomId}`;
    }

    private emitFor(room: Room): void {
        this.emit(WidgetLayoutStore.emissionForRoom(room));
    }

    protected async onReady(): Promise<void> {
        this.updateAllRooms();

        this.matrixClient?.on(RoomStateEvent.Events, this.updateRoomFromState);
        this.pinnedRef = SettingsStore.watchSetting("Widgets.pinned", null, this.updateFromSettings);
        this.layoutRef = SettingsStore.watchSetting("Widgets.layout", null, this.updateFromSettings);
        this.dynamicRef = SettingsStore.watchSetting(
            "feature_dynamic_room_predecessors",
            null,
            this.updateFromSettings,
        );
        WidgetStore.instance.on(UPDATE_EVENT, this.updateFromWidgetStore);
    }

    protected async onNotReady(): Promise<void> {
        this.byRoom = new MapWithDefault(() => new Map());

        this.matrixClient?.off(RoomStateEvent.Events, this.updateRoomFromState);
        SettingsStore.unwatchSetting(this.pinnedRef);
        SettingsStore.unwatchSetting(this.layoutRef);
        SettingsStore.unwatchSetting(this.dynamicRef);
        WidgetStore.instance.off(UPDATE_EVENT, this.updateFromWidgetStore);
    }

    private updateAllRooms = (): void => {
        const msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors");
        if (!this.matrixClient) return;
        this.byRoom = new MapWithDefault(() => new Map());
        for (const room of this.matrixClient.getVisibleRooms(msc3946ProcessDynamicPredecessor)) {
            this.recalculateRoom(room);
        }
    };

    private updateFromWidgetStore = (roomId?: string): void => {
        if (roomId) {
            const room = this.matrixClient?.getRoom(roomId);
            if (room) this.recalculateRoom(room);
        } else {
            this.updateAllRooms();
        }
    };

    private updateRoomFromState = (ev: MatrixEvent): void => {
        if (ev.getType() !== WIDGET_LAYOUT_EVENT_TYPE) return;
        const room = this.matrixClient?.getRoom(ev.getRoomId());
        if (room) this.recalculateRoom(room);
    };

    private updateFromSettings = (
        _settingName: string,
        roomId: string | null,
        _atLevel: SettingLevel,
        _newValAtLevel: any,
        _newVal: any,
    ): void => {
        if (roomId) {
            const room = this.matrixClient?.getRoom(roomId);
            if (room) this.recalculateRoom(room);
        } else {
            this.updateAllRooms();
        }
    };

    public recalculateRoom(room: Room): void {
        const widgets = WidgetStore.instance.getApps(room.roomId);
        if (!widgets?.length) {
            this.byRoom.set(room.roomId, new Map());
            this.emitFor(room);
            return;
        }

        const roomContainers = this.byRoom.getOrCreate(room.roomId);
        const beforeChanges = JSON.stringify(recursiveMapToObject(roomContainers));

        const layoutEv = room.currentState.getStateEvents(WIDGET_LAYOUT_EVENT_TYPE, "");
        const legacyPinned = SettingsStore.getValue("Widgets.pinned", room.roomId);
        let userLayout = SettingsStore.getValue("Widgets.layout", room.roomId);

        if (layoutEv && userLayout && userLayout.overrides !== layoutEv.getId()) {
            // For some other layout that we don't really care about. The user can reset this
            // by updating their personal layout.
            userLayout = null;
        }

        const roomLayout = layoutEv?.getContent<ILayoutStateEvent>() ?? null;
        // We filter for the center container first.
        // (An error is raised, if there are multiple widgets marked for the center container)
        // For the right and top container multiple widgets are allowed.
        const topWidgets: IApp[] = [];
        const rightWidgets: IApp[] = [];
        const centerWidgets: IApp[] = [];
        for (const widget of widgets) {
            const stateContainer = roomLayout?.widgets?.[widget.id]?.container;
            const manualContainer = userLayout?.widgets?.[widget.id]?.container;
            const isLegacyPinned = !!legacyPinned?.[widget.id];
            const defaultContainer = WidgetType.JITSI.matches(widget.type) ? "top" : "right";
            if (manualContainer ? manualContainer === "center" : stateContainer === "center") {
                if (centerWidgets.length) {
                    console.error("Tried to push a second widget into the center container");
                } else {
                    centerWidgets.push(widget);
                }
                // The widget won't need to be put in any other container.
                continue;
            }
            let targetContainer: Container = defaultContainer;
            if (!!manualContainer || !!stateContainer) {
                targetContainer = manualContainer ?? stateContainer!;
            } else if (isLegacyPinned && !stateContainer) {
                // Special legacy case
                targetContainer = "top";
            }
            (targetContainer === "top" ? topWidgets : rightWidgets).push(widget);
        }

        // Trim to MAX_PINNED
        const runoff = topWidgets.slice(MAX_PINNED);
        rightWidgets.push(...runoff);

        const collator = new Intl.Collator();

        // Order the widgets in the top container, putting autopinned Jitsi widgets first
        // unless they have a specific order in mind
        topWidgets.sort((a, b) => {
            const layoutA = roomLayout?.widgets?.[a.id];
            const layoutB = roomLayout?.widgets?.[b.id];

            const userLayoutA = userLayout?.widgets?.[a.id];
            const userLayoutB = userLayout?.widgets?.[b.id];

            // Jitsi widgets are defaulted to be the leftmost widget whereas other widgets
            // default to the right side.
            const defaultA = WidgetType.JITSI.matches(a.type) ? Number.MIN_SAFE_INTEGER : Number.MAX_SAFE_INTEGER;
            const defaultB = WidgetType.JITSI.matches(b.type) ? Number.MIN_SAFE_INTEGER : Number.MAX_SAFE_INTEGER;

            const orderA = defaultNumber(userLayoutA?.index, defaultNumber(layoutA?.index, defaultA));
            const orderB = defaultNumber(userLayoutB?.index, defaultNumber(layoutB?.index, defaultB));

            if (orderA === orderB) {
                // We just need a tiebreak
                return collator.compare(a.id, b.id);
            }

            return orderA - orderB;
        });

        // Determine width distribution and height of the top container now (the only relevant one)
        const widths: number[] = [];
        let maxHeight: number | null = null; // null == default
        let doAutobalance = true;
        for (let i = 0; i < topWidgets.length; i++) {
            const widget = topWidgets[i];
            const widgetLayout = roomLayout?.widgets?.[widget.id];
            const userWidgetLayout = userLayout?.widgets?.[widget.id];

            if (Number.isFinite(userWidgetLayout?.width) || Number.isFinite(widgetLayout?.width)) {
                const val = (userWidgetLayout?.width || widgetLayout?.width)!;
                const normalized = clamp(val, MIN_WIDGET_WIDTH_PCT, 100);
                widths.push(normalized);
                doAutobalance = false; // a manual width was specified
            } else {
                widths.push(100); // we'll figure this out later
            }

            if (widgetLayout?.height || userWidgetLayout?.height) {
                const defRoomHeight = defaultNumber(widgetLayout?.height, MIN_WIDGET_HEIGHT_PCT);
                const h = defaultNumber(userWidgetLayout?.height, defRoomHeight);
                maxHeight = Math.max(maxHeight ?? 0, clamp(h, MIN_WIDGET_HEIGHT_PCT, 100));
            }
        }
        if (doAutobalance) {
            for (let i = 0; i < widths.length; i++) {
                widths[i] = 100 / widths.length;
            }
        } else {
            // If we're not autobalancing then it means that we're trying to make
            // sure that widgets make up exactly 100% of space (not over, not under)
            const difference = sum(...widths) - 100; // positive = over, negative = under
            if (difference < 0) {
                // For a deficit we just fill everything in equally
                for (let i = 0; i < widths.length; i++) {
                    widths[i] += Math.abs(difference) / widths.length;
                }
            } else if (difference > 0) {
                // When we're over, we try to scale all the widgets within range first.
                // We clamp values to try and keep ourselves sane and within range.
                for (let i = 0; i < widths.length; i++) {
                    widths[i] = clamp(widths[i] - difference / widths.length, MIN_WIDGET_WIDTH_PCT, 100);
                }

                // If we're still over, find the widgets which have more width than the minimum
                // and balance them out until we're at 100%. This should keep us as close as possible
                // to the intended distributions.
                //
                // Note: if we ever decide to set a minimum which is larger than 100%/MAX_WIDGETS then
                // we probably have other issues - this code assumes we don't do that.
                const toReclaim = sum(...widths) - 100;
                if (toReclaim > 0) {
                    const largeIndices = widths
                        .map((v, i) => [i, v])
                        .filter((p) => p[1] > MIN_WIDGET_WIDTH_PCT)
                        .map((p) => p[0]);
                    for (const idx of largeIndices) {
                        widths[idx] -= toReclaim / largeIndices.length;
                    }
                }
            }
        }

        // Finally, fill in our cache and update
        const newRoomContainers = new Map();
        this.byRoom.set(room.roomId, newRoomContainers);
        if (topWidgets.length) {
            newRoomContainers.set("top", {
                ordered: topWidgets,
                distributions: widths,
                height: maxHeight,
            });
        }
        if (rightWidgets.length) {
            newRoomContainers.set("right", {
                ordered: rightWidgets,
            });
        }
        if (centerWidgets.length) {
            newRoomContainers.set("center", {
                ordered: centerWidgets,
            });
        }

        const afterChanges = JSON.stringify(recursiveMapToObject(newRoomContainers));

        if (afterChanges !== beforeChanges) {
            this.emitFor(room);
        }
    }

    public getContainerWidgets(room: Room | null, container: Container): IWidget[] {
        return (room && this.byRoom.get(room.roomId)?.get(container)?.ordered) || [];
    }

    public isInContainer(room: Room, widget: IWidget, container: Container): boolean {
        return this.getContainerWidgets(room, container).some((w) => w.id === widget.id);
    }

    public canAddToContainer(room: Room, container: Container): boolean {
        switch (container) {
            case "top":
                return this.getContainerWidgets(room, container).length < MAX_PINNED;
            case "right":
                return this.getContainerWidgets(room, container).length < MAX_PINNED;
            case "center":
                return this.getContainerWidgets(room, container).length < 1;
        }
    }

    public getResizerDistributions(room: Room, container: Container): string[] {
        // yes, string.
        let distributions = this.byRoom.get(room.roomId)?.get(container)?.distributions;
        if (!distributions || distributions.length < 2) return [];

        // The distributor actually expects to be fed N-1 sizes and expands the middle section
        // instead of the edges. Therefore, we need to return [0] when there's two widgets or
        // [0, 2] when there's three (skipping [1] because it's irrelevant).

        if (distributions.length === 2) distributions = [distributions[0]];
        if (distributions.length === 3) distributions = [distributions[0], distributions[2]];
        return distributions.map((d) => `${d.toFixed(1)}%`); // actual percents - these are decoded later
    }

    public setResizerDistributions(room: Room, container: Container, distributions: string[]): void {
        if (container !== "top") return; // ignore - not relevant

        const numbers = distributions.map((d) => Number(Number(d.substring(0, d.length - 1)).toFixed(1)));
        const widgets = this.getContainerWidgets(room, container);

        // From getResizerDistributions, we need to fill in the middle size if applicable.
        const remaining = 100 - sum(...numbers);
        if (numbers.length === 2) numbers.splice(1, 0, remaining);
        if (numbers.length === 1) numbers.push(remaining);

        const localLayout: Record<string, IStoredLayout> = {};
        widgets.forEach((w, i) => {
            localLayout[w.id] = {
                container: container,
                width: numbers[i],
                index: i,
                height: this.byRoom.get(room.roomId)?.get(container)?.height || MIN_WIDGET_HEIGHT_PCT,
            };
        });
        this.updateUserLayout(room, localLayout);
    }

    public getContainerHeight(room: Room, container: Container): number | null {
        return this.byRoom.get(room.roomId)?.get(container)?.height ?? null; // let the default get returned if needed
    }

    public setContainerHeight(room: Room, container: Container, height?: number | null): void {
        const widgets = this.getContainerWidgets(room, container);
        const widths = this.byRoom.get(room.roomId)?.get(container)?.distributions;
        const localLayout: Record<string, IStoredLayout> = {};
        widgets.forEach((w, i) => {
            localLayout[w.id] = {
                container: container,
                width: widths?.[i],
                index: i,
                height: height,
            };
        });
        this.updateUserLayout(room, localLayout);
    }

    public moveWithinContainer(room: Room, container: Container, widget: IWidget, delta: number): void {
        const widgets = arrayFastClone(this.getContainerWidgets(room, container));
        const currentIdx = widgets.findIndex((w) => w.id === widget.id);
        if (currentIdx < 0) return; // no change needed

        widgets.splice(currentIdx, 1); // remove existing widget
        const newIdx = clamp(currentIdx + delta, 0, widgets.length);
        widgets.splice(newIdx, 0, widget);

        const widths = this.byRoom.get(room.roomId)?.get(container)?.distributions;
        const height = this.byRoom.get(room.roomId)?.get(container)?.height;
        const localLayout: Record<string, IStoredLayout> = {};
        widgets.forEach((w, i) => {
            localLayout[w.id] = {
                container: container,
                width: widths?.[i],
                index: i,
                height,
            };
        });
        this.updateUserLayout(room, localLayout);
    }

    public moveToContainer(room: Room, widget: IWidget, toContainer: Container): void {
        const allWidgets = this.getAllWidgets(room);
        if (!allWidgets.some(([w]) => w.id === widget.id)) return; // invalid
        // Prepare other containers (potentially move widgets to obey the following rules)
        const newLayout: Record<string, IStoredLayout> = {};
        switch (toContainer) {
            case "right":
                // new "right" widget
                break;
            case "center":
                // new "center" widget => all other widgets go into "right"
                for (const w of this.getContainerWidgets(room, "top")) {
                    newLayout[w.id] = { container: "right" };
                }
                for (const w of this.getContainerWidgets(room, "center")) {
                    newLayout[w.id] = { container: "right" };
                }
                break;
            case "top":
                // new "top" widget => the center widget moves into "right"
                if (this.hasMaximisedWidget(room)) {
                    const centerWidget = this.getContainerWidgets(room, "center")[0];
                    newLayout[centerWidget.id] = { container: "right" };
                }
                break;
        }

        newLayout[widget.id] = { container: toContainer };

        // move widgets into requested containers.
        this.updateUserLayout(room, newLayout);
    }

    public hasMaximisedWidget(room: Room): boolean {
        return this.getContainerWidgets(room, "center").length > 0;
    }

    public hasPinnedWidgets(room: Room): boolean {
        return this.getContainerWidgets(room, "top").length > 0;
    }

    public canCopyLayoutToRoom(room: Room): boolean {
        if (!this.matrixClient) return false; // not ready yet
        return room.currentState.maySendStateEvent(WIDGET_LAYOUT_EVENT_TYPE, this.matrixClient.getUserId()!);
    }

    public copyLayoutToRoom(room: Room): void {
        const allWidgets = this.getAllWidgets(room);
        const evContent: ILayoutStateEvent = { widgets: {} };
        for (const [widget, container] of allWidgets) {
            evContent.widgets[widget.id] = { container };
            if (container === "top") {
                const containerWidgets = this.getContainerWidgets(room, container);
                const idx = containerWidgets.findIndex((w) => w.id === widget.id);
                const widths = this.byRoom.get(room.roomId)?.get(container)?.distributions;
                const height = this.byRoom.get(room.roomId)?.get(container)?.height;
                evContent.widgets[widget.id] = {
                    ...evContent.widgets[widget.id],
                    height: height ? Math.round(height) : undefined,
                    width: widths?.[idx] ? Math.round(widths[idx]) : undefined,
                    index: idx,
                };
            }
        }
        this.matrixClient?.sendStateEvent(room.roomId, WIDGET_LAYOUT_EVENT_TYPE, evContent, "");
    }

    private getAllWidgets(room: Room): [IApp, Container][] {
        const containers = this.byRoom.get(room.roomId);
        if (!containers) return [];

        const ret: [IApp, Container][] = [];
        for (const [container, containerValue] of containers) {
            const widgets = containerValue.ordered;
            for (const widget of widgets) {
                ret.push([widget, container as Container]);
            }
        }
        return ret;
    }

    private updateUserLayout(room: Room, newLayout: IWidgetLayouts): void {
        // Polyfill any missing widgets
        const allWidgets = this.getAllWidgets(room);
        for (const [widget, container] of allWidgets) {
            const containerWidgets = this.getContainerWidgets(room, container);
            const idx = containerWidgets.findIndex((w) => w.id === widget.id);
            const widths = this.byRoom.get(room.roomId)?.get(container)?.distributions;
            if (!newLayout[widget.id]) {
                newLayout[widget.id] = {
                    container: container,
                    index: idx,
                    height: this.byRoom.get(room.roomId)?.get(container)?.height,
                    width: widths?.[idx],
                };
            }
        }

        const layoutEv = room.currentState.getStateEvents(WIDGET_LAYOUT_EVENT_TYPE, "");
        SettingsStore.setValue("Widgets.layout", room.roomId, SettingLevel.ROOM_ACCOUNT, {
            overrides: layoutEv?.getId(),
            widgets: newLayout,
        }).catch(() => this.recalculateRoom(room));
        this.recalculateRoom(room); // call to try local echo on changes (the catch above undoes any errors)
    }
}
