// *****************************************************************************
// Copyright (C) 2021 Ericsson and others.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************

import { injectable, inject, postConstruct } from '@theia/core/shared/inversify';
import { FileNode, FileStatNode, FileTreeModel } from '@theia/filesystem/lib/browser';
import {
    ApplicationShell,
    CompositeTreeNode,
    open,
    NavigatableWidget,
    OpenerService,
    SelectableTreeNode,
    TreeNode,
    Widget,
    ExpandableTreeNode,
    TabBar
} from '@theia/core/lib/browser';
import { WorkspaceService } from '@theia/workspace/lib/browser';
import debounce = require('@theia/core/shared/lodash.debounce');
import { DisposableCollection, nls } from '@theia/core/lib/common';
import { FileStat } from '@theia/filesystem/lib/common/files';

export interface OpenEditorNode extends FileStatNode {
    widget: Widget;
};

export namespace OpenEditorNode {
    export function is(node: unknown): node is OpenEditorNode {
        return FileStatNode.is(node) && 'widget' in node;
    }
}

@injectable()
export class OpenEditorsModel extends FileTreeModel {
    static GROUP_NODE_ID_PREFIX = 'group-node';
    static AREA_NODE_ID_PREFIX = 'area-node';

    @inject(ApplicationShell) protected readonly applicationShell: ApplicationShell;
    @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService;
    @inject(OpenerService) protected readonly openerService: OpenerService;

    protected toDisposeOnPreviewWidgetReplaced = new DisposableCollection();
    // Returns the collection of editors belonging to a tabbar group in the main area
    protected _editorWidgetsByGroup = new Map<number, { widgets: NavigatableWidget[], tabbar: TabBar<Widget> }>();

    // Returns the collection of editors belonging to an area grouping (main, left, right bottom)
    protected _editorWidgetsByArea = new Map<ApplicationShell.Area, NavigatableWidget[]>();

    // Last collection of editors before a layout modification, used to detect changes in widget ordering
    protected _lastEditorWidgetsByArea = new Map<ApplicationShell.Area, NavigatableWidget[]>();

    protected cachedFileStats = new Map<string, FileStat>();

    get editorWidgets(): NavigatableWidget[] {
        const editorWidgets: NavigatableWidget[] = [];
        this._editorWidgetsByArea.forEach(widgets => editorWidgets.push(...widgets));
        return editorWidgets;
    }

    getTabBarForGroup(id: number): TabBar<Widget> | undefined {
        return this._editorWidgetsByGroup.get(id)?.tabbar;
    }

    @postConstruct()
    protected override init(): void {
        super.init();
        this.setupHandlers();
        this.initializeRoot();
    }

    protected setupHandlers(): void {
        this.toDispose.push(this.applicationShell.onDidChangeCurrentWidget(({ newValue }) => {
            const nodeToSelect = this.tree.getNode(newValue?.id);
            if (nodeToSelect && SelectableTreeNode.is(nodeToSelect)) {
                this.selectNode(nodeToSelect);
            }
        }));
        this.toDispose.push(this.applicationShell.onDidAddWidget(async () => {
            await this.updateOpenWidgets();
            const existingWidgetIds = new Set(this.editorWidgets.map(widget => widget.id));
            this.cachedFileStats.forEach((_fileStat, id) => {
                if (!existingWidgetIds.has(id)) {
                    this.cachedFileStats.delete(id);
                }
            });
        }));
        this.toDispose.push(this.applicationShell.onDidRemoveWidget(() => this.updateOpenWidgets()));
        // Check for tabs rearranged in main and bottom
        this.applicationShell.mainPanel.layoutModified.connect(() => this.doUpdateOpenWidgets('main'));
        this.applicationShell.bottomPanel.layoutModified.connect(() => this.doUpdateOpenWidgets('bottom'));
    }

    protected async initializeRoot(): Promise<void> {
        await this.updateOpenWidgets();
        this.fireChanged();
    }

    protected updateOpenWidgets = debounce(this.doUpdateOpenWidgets, 250);

    protected async doUpdateOpenWidgets(layoutModifiedArea?: ApplicationShell.Area): Promise<void> {
        this._lastEditorWidgetsByArea = this._editorWidgetsByArea;
        this._editorWidgetsByArea = new Map<ApplicationShell.Area, NavigatableWidget[]>();
        let doRebuild = true;
        const areas: ApplicationShell.Area[] = ['main', 'bottom', 'left', 'right', 'top', 'secondaryWindow'];
        areas.forEach(area => {
            const editorWidgetsForArea = this.applicationShell.getWidgets(area).filter((widget): widget is NavigatableWidget => NavigatableWidget.is(widget));
            if (editorWidgetsForArea.length) {
                this._editorWidgetsByArea.set(area, editorWidgetsForArea);
            }
        });
        if (this._lastEditorWidgetsByArea.size === 0) {
            this._lastEditorWidgetsByArea = this._editorWidgetsByArea;
        }
        // `layoutModified` can be triggered when tabs are clicked, even if they are not rearranged.
        // This will check for those instances and prevent a rebuild if it is unnecessary. Rebuilding
        // the tree if there is no change can cause the tree's selection to flicker.
        if (layoutModifiedArea) {
            doRebuild = this.shouldRebuildTreeOnLayoutModified(layoutModifiedArea);
        }
        if (doRebuild) {
            this.root = await this.buildRootFromOpenedWidgets(this._editorWidgetsByArea);
        }
    }

    protected shouldRebuildTreeOnLayoutModified(area: ApplicationShell.Area): boolean {
        const currentOrdering = this._editorWidgetsByArea.get(area);
        const previousOrdering = this._lastEditorWidgetsByArea.get(area);
        if (currentOrdering?.length === 1) {
            return true;
        }
        if (currentOrdering?.length !== previousOrdering?.length) {
            return true;
        }
        if (!!currentOrdering && !!previousOrdering) {
            return !currentOrdering.every((widget, index) => widget === previousOrdering[index]);
        }
        return true;
    }

    protected tryCreateWidgetGroupMap(): Map<Widget, CompositeTreeNode> {
        const mainTabBars = this.applicationShell.mainAreaTabBars;
        this._editorWidgetsByGroup = new Map();
        const widgetGroupMap = new Map<Widget, CompositeTreeNode>();
        if (mainTabBars.length > 1) {
            mainTabBars.forEach((tabbar, index) => {
                const groupNumber = index + 1;
                const newCaption = nls.localizeByDefault('Group {0}', groupNumber);
                const groupNode = {
                    parent: undefined,
                    id: `${OpenEditorsModel.GROUP_NODE_ID_PREFIX}:${groupNumber}`,
                    name: newCaption,
                    children: []
                };
                const widgets: NavigatableWidget[] = [];
                tabbar.titles.map(title => {
                    const { owner } = title;
                    widgetGroupMap.set(owner, groupNode);
                    if (NavigatableWidget.is(owner)) {
                        widgets.push(owner);
                    }
                });
                this._editorWidgetsByGroup.set(groupNumber, { widgets, tabbar });
            });
        }
        return widgetGroupMap;
    }

    protected async buildRootFromOpenedWidgets(widgetsByArea: Map<ApplicationShell.Area, NavigatableWidget[]>): Promise<CompositeTreeNode> {
        const rootNode: CompositeTreeNode = {
            id: 'open-editors:root',
            parent: undefined,
            visible: false,
            children: [],
        };

        const mainAreaWidgetGroups = this.tryCreateWidgetGroupMap();

        for (const [area, widgetsInArea] of widgetsByArea.entries()) {
            const areaNode: CompositeTreeNode & ExpandableTreeNode = {
                id: `${OpenEditorsModel.AREA_NODE_ID_PREFIX}:${area}`,
                parent: rootNode,
                name: ApplicationShell.areaLabels[area],
                expanded: true,
                children: []
            };
            for (const widget of widgetsInArea) {
                const uri = widget.getResourceUri();
                if (uri) {
                    let fileStat: FileStat;
                    try {
                        fileStat = await this.fileService.resolve(uri);
                        this.cachedFileStats.set(widget.id, fileStat);
                    } catch {
                        const cachedStat = this.cachedFileStats.get(widget.id);
                        if (cachedStat) {
                            fileStat = cachedStat;
                        } else {
                            continue;
                        }
                    }

                    const openEditorNode: OpenEditorNode = {
                        id: widget.id,
                        fileStat,
                        uri,
                        selected: false,
                        parent: undefined,
                        name: widget.title.label,
                        icon: widget.title.iconClass,
                        widget
                    };
                    // only show groupings for main area widgets if more than one tabbar
                    if ((area === 'main') && (mainAreaWidgetGroups.size > 1)) {
                        const groupNode = mainAreaWidgetGroups.get(widget);
                        if (groupNode) {
                            CompositeTreeNode.addChild(groupNode, openEditorNode);
                            CompositeTreeNode.addChild(areaNode, groupNode);
                        }
                    } else {
                        CompositeTreeNode.addChild(areaNode, openEditorNode);
                    }
                }
            }
            // If widgets are only in the main area and in a single tabbar, then don't show area node
            if (widgetsByArea.size === 1 && widgetsByArea.has('main') && area === 'main') {
                areaNode.children.forEach(child => CompositeTreeNode.addChild(rootNode, child));
            } else {
                CompositeTreeNode.addChild(rootNode, areaNode);
            }

        }
        return rootNode;
    }

    protected override doOpenNode(node: TreeNode): void {
        if (node.visible === false) {
            return;
        } else if (FileNode.is(node)) {
            open(this.openerService, node.uri);
        } else {
            super.doOpenNode(node);
        }
    }
}
