/********************************************************************************
 * Copyright (c) 2017-2018 TypeFox and others.
 * Modifications: (c) 2019-2024 EclipseSource 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 WITH Classpath-exception-2.0
 ********************************************************************************/
// based on: https://github.com/eclipse-sprotty/sprotty-theia/blob/v0.12.0/src/theia/diagram-widget.ts
import {
    AnyObject,
    Args,
    Bounds,
    DiagramLoader,
    EditorContextService,
    FocusStateChangedAction,
    FocusTracker,
    GLSPActionDispatcher,
    GLSPModelSource,
    hasStringProp,
    ICopyPasteHandler,
    InitializeCanvasBoundsAction,
    isViewport,
    RequestModelAction,
    SelectAction,
    SetViewportAction,
    TYPES,
    ViewerOptions,
    Viewport
} from '@eclipse-glsp/client';
import {
    ApplicationShell,
    BaseWidget,
    Message,
    Navigatable,
    NavigatableWidgetOptions,
    SaveableSource,
    StatefulWidget,
    StorageService,
    Widget
} from '@theia/core/lib/browser';
import { SelectionService } from '@theia/core/lib/common/selection-service';
import URI from '@theia/core/lib/common/uri';
import { Container, inject } from '@theia/core/shared/inversify';
import { EditorPreferences } from '@theia/editor/lib/browser';
import { pickBy } from 'lodash';
import { GLSPSaveable } from './glsp-saveable';

export interface GLSPDiagramWidgetOptions extends NavigatableWidgetOptions {
    uri: string;
    diagramType: string;
    label: string;
    iconClass: string;
    editMode: string;
}

export namespace GLSPDiagramWidgetOptions {
    export function is(object: unknown): object is GLSPDiagramWidgetOptions {
        return (
            NavigatableWidgetOptions.is(object) &&
            hasStringProp(object, 'uri') &&
            hasStringProp(object, 'diagramType') &&
            hasStringProp(object, 'label') &&
            hasStringProp(object, 'iconClass') &&
            hasStringProp(object, 'editMode')
        );
    }
}

export interface GLSPDiagramWidgetContainer {
    diagramWidget: GLSPDiagramWidget;
}

export function isDiagramWidgetContainer(widget?: Widget): widget is Widget & GLSPDiagramWidgetContainer {
    return !!widget && 'diagramWidget' in widget && widget.diagramWidget instanceof GLSPDiagramWidget;
}

export class GLSPDiagramWidget extends BaseWidget implements SaveableSource, StatefulWidget, Navigatable {
    @inject(EditorPreferences)
    readonly editorPreferences: EditorPreferences;

    @inject(StorageService)
    readonly storage: StorageService;

    @inject(SelectionService)
    readonly theiaSelectionService: SelectionService;

    protected diagramContainer?: HTMLDivElement;
    protected copyPasteHandler?: ICopyPasteHandler;
    protected requestModelOptions: Args;
    protected storeViewportStateOnClose = true;
    protected _options: GLSPDiagramWidgetOptions;
    protected _diContainer: Container;

    saveable: GLSPSaveable;

    configure(options: GLSPDiagramWidgetOptions, diContainer: Container): void {
        this._options = options;
        this._diContainer = diContainer;
        this.title.closable = true;
        this.title.label = options.label;
        this.title.iconClass = options.iconClass;
        this.id = this.createWidgetId();
        this.saveable = new GLSPSaveable(this.actionDispatcher, this.diContainer.get(EditorContextService));
        this.updateSaveable();
        this.title.caption = this.uri.path.fsPath();
        this.toDispose.push(this.editorPreferences.onPreferenceChanged(() => this.updateSaveable()));
        this.toDispose.push(this.saveable);
    }

    protected createWidgetId(): string {
        return `${this.diagramType}:${this.options.uri}`;
    }

    protected override onAfterAttach(msg: Message): void {
        if (!this.diagramContainer) {
            // Create the container and initialize its content upon first attachment
            this.createContainer();
            this.initializeDiagram();
        }
        super.onAfterAttach(msg);

        this.restoreViewportDataFromStorageService();

        this.disposed.connect(() => {
            this.diContainer.unbindAll();
        });

        this.node.dataset['uri'] = this.uri.toString();
        if (this.diContainer.isBound(TYPES.ICopyPasteHandler)) {
            this.copyPasteHandler = this.diContainer.get<ICopyPasteHandler>(TYPES.ICopyPasteHandler);
            this.addClipboardListener(this.node, 'copy', e => this.handleCopy(e));
            this.addClipboardListener(this.node, 'paste', e => this.handlePaste(e));
            this.addClipboardListener(this.node, 'cut', e => this.handleCut(e));
        }
        this.addEventListener(this.node, 'mouseenter', e => this.handleMouseEnter(e));
        this.addEventListener(this.node, 'mouseleave', e => this.handleMouseLeave(e));
    }

    protected createContainer(): void {
        this.diagramContainer = document.createElement('div');
        this.diagramContainer.id = this.viewerOptions.baseDiv;
        this.node.appendChild(this.diagramContainer);

        const hiddenContainer = document.createElement('div');
        hiddenContainer.id = this.viewerOptions.hiddenDiv;
        document.body.appendChild(hiddenContainer);
    }

    /**
     * Note: Manually updating the autosave settings is only necessary when using Theia < 1.50.0.
     * Since Theia 1.50.0, the autosave settings are generically handled and no longer the responsibility
     * of the `Saveable` implementation. See https://github.com/eclipse-theia/theia/pull/13683
     */
    protected updateSaveable(): void {
        this.saveable.autoSave = this.editorPreferences['files.autoSave'];
        this.saveable.autoSaveDelay = this.editorPreferences['files.autoSaveDelay'];
    }

    protected async initializeDiagram(): Promise<void> {
        // Filter options to only contain defined primitive values
        this.requestModelOptions = this.getRequestModelOptions();

        const loader = this.diContainer.get(DiagramLoader);
        return loader.load({ requestModelOptions: this.requestModelOptions });
    }

    protected getRequestModelOptions(): Args {
        const definedOptions: any = pickBy(this.options, v => v !== undefined && typeof v !== 'object');

        return {
            sourceUri: this.uri.path.fsPath(),
            ...definedOptions
        };
    }

    protected getBoundsInPage(element: Element): Bounds {
        const bounds = element.getBoundingClientRect();
        return {
            x: bounds.left,
            y: bounds.top,
            width: bounds.width,
            height: bounds.height
        };
    }

    protected override onResize(msg: Widget.ResizeMessage): void {
        super.onResize(msg);
        const newBounds = this.getBoundsInPage(this.node as Element);
        this.actionDispatcher.dispatch(InitializeCanvasBoundsAction.create(newBounds));
    }

    protected override onActivateRequest(msg: Message): void {
        this.makeFocusable(this.node.querySelector(`#${this.viewerOptions.baseDiv} svg`));
        super.onActivateRequest(msg);
        this.focusDiagram();
        this.updateGlobalSelection();
    }

    protected makeFocusable(element: Element | null): void {
        // eslint-disable-next-line no-null/no-null
        if (element !== null) {
            const tabindex = element.getAttribute('tabindex');
            // eslint-disable-next-line no-null/no-null
            if (tabindex === null) {
                // use -1 to make focusable but not reachable via keyboard navigation
                element.setAttribute('tabindex', '-1');
            }
        }
    }

    protected focusDiagram(): void {
        const svgElement = this.node.querySelector(`#${this.viewerOptions.baseDiv} svg`) as HTMLElement;
        if (svgElement) {
            svgElement.focus();
        } else {
            const tabindex = this.node.getAttribute('tabindex');
            if (tabindex) {
                this.node.setAttribute('tabindex', '-1');
            }
            this.node.focus();
        }
    }

    protected async updateGlobalSelection(): Promise<void> {
        this.getSelectedElementIds().then((selectedElementsIDs: string[]) =>
            this.actionDispatcher.dispatch(SelectAction.create({ selectedElementsIDs }))
        );
    }

    /**
     * We cannot activate the widget before the SVG element is there, as it takes the focus.
     * This should happen within two animation frames, as the action dispatcher issues
     * a SetModelCommand in the constructor. OTOH, shell.activateWidget() is synchronous. So
     * after creating the widget and before activating it, we use this method to wait for the
     * SVG to be appended to the DOM.
     */
    async getSvgElement(): Promise<HTMLElement | undefined> {
        return new Promise<HTMLElement | undefined>(resolve => {
            let frames = 0;
            const waitForSvg = (): void => {
                requestAnimationFrame(() => {
                    const svgElement = this.node.querySelector(`#${this.viewerOptions.baseDiv} svg`) as HTMLElement | null;
                    if (svgElement) {
                        resolve(svgElement);
                    } else if (++frames < 5) {
                        waitForSvg();
                    } else {
                        resolve(undefined);
                    }
                });
            };
            waitForSvg();
        });
    }

    protected override onBeforeDetach(msg: Message): void {
        this.storeViewportDataInStorageService();
        document.querySelector(`#${this.viewerOptions.hiddenDiv}`)?.remove();
        super.onBeforeDetach(msg);
    }

    protected override onCloseRequest(msg: Message): void {
        super.onCloseRequest(msg);
        this.clearGlobalSelection();
    }

    reloadModel(): Promise<void> {
        return this.actionDispatcher.dispatch(RequestModelAction.create({ options: this.requestModelOptions }));
    }

    handleMouseEnter(e: MouseEvent): void {
        this.node.classList.add('mouse-enter');
        this.node.classList.remove('mouse-leave');
    }

    handleMouseLeave(e: MouseEvent): void {
        this.node.classList.add('mouse-leave');
        this.node.classList.remove('mouse-enter');
    }

    handleCopy(e: ClipboardEvent): void {
        if (this.copyPasteHandler) {
            this.copyPasteHandler.handleCopy(e);
        }
    }

    handleCut(e: ClipboardEvent): void {
        if (this.copyPasteHandler) {
            this.copyPasteHandler.handleCut(e);
        }
    }

    handlePaste(e: ClipboardEvent): void {
        if (this.copyPasteHandler) {
            this.copyPasteHandler.handlePaste(e);
        }
    }

    listenToFocusState(shell: ApplicationShell): void {
        this.toDispose.push(
            shell.onDidChangeActiveWidget(event => {
                const focusedWidget = event.newValue;
                if (this.hasFocus && focusedWidget && !this.isThisWidget(focusedWidget)) {
                    this.actionDispatcher.dispatch(FocusStateChangedAction.create(false));
                } else if (!this.hasFocus && this.isThisWidget(focusedWidget)) {
                    this.actionDispatcher.dispatch(FocusStateChangedAction.create(true));
                }
            })
        );
    }

    protected isThisWidget(widget?: Widget | null): boolean {
        if (!widget) {
            return false;
        }
        const diagramWidget = getDiagramWidget(widget);
        return diagramWidget !== undefined && diagramWidget.id === this.id;
    }

    get hasFocus(): boolean | undefined {
        let focusTracker: FocusTracker | undefined;
        if (this.diContainer.isBound(FocusTracker)) {
            focusTracker = this.diContainer.get(FocusTracker);
        }
        if (focusTracker) {
            return focusTracker.hasFocus;
        }
        return undefined;
    }

    protected async getSelectedElementIds(): Promise<string[]> {
        const editorContextService = this.diContainer.get(EditorContextService);
        return editorContextService.selectedElements.map(element => element.id);
    }

    protected async clearGlobalSelection(): Promise<void> {
        this.theiaSelectionService.selection = undefined;
    }

    storeState(): AnyObject {
        // the viewport is stored in the application layout
        // so there is no need to keep it in the storage
        this.removeViewportDataFromStorageService();
        return { ...this.options, ...this.getViewportData() };
    }

    restoreState(oldState: AnyObject): void {
        if (GLSPDiagramWidgetOptions.is(oldState)) {
            this._options = oldState;
        }
        if (isViewportDataContainer(oldState)) {
            this.setViewportData(oldState);
        }
    }

    getResourceUri(): URI | undefined {
        return this.uri;
    }

    createMoveToUri(resourceUri: URI): URI | undefined {
        return this.uri.withPath(resourceUri.path);
    }

    protected storeViewportDataInStorageService(): void {
        if (!this.storeViewportStateOnClose) {
            return;
        }
        const viewportData = this.getViewportData();
        if (viewportData) {
            this.storage.setData<ViewportDataContainer>(this.viewportStorageId, viewportData);
        }
    }

    async restoreViewportDataFromStorageService(): Promise<void> {
        if (!this.storeViewportStateOnClose) {
            return;
        }
        const viewportData = await this.storage.getData<ViewportDataContainer>(this.viewportStorageId);
        if (viewportData) {
            this.setViewportData(viewportData);
        }
    }

    protected async removeViewportDataFromStorageService(): Promise<void> {
        return this.storage.setData<ViewportDataContainer | undefined>(this.viewportStorageId, undefined);
    }

    protected getViewportData(): ViewportDataContainer | undefined {
        let viewportData = undefined;
        if (isViewport(this.editorContext.modelRoot)) {
            viewportData = <ViewportDataContainer>{
                elementId: this.editorContext.modelRoot.id,
                viewportData: {
                    scroll: this.editorContext.modelRoot.scroll,
                    zoom: this.editorContext.modelRoot.zoom
                }
            };
        }
        return viewportData;
    }

    protected async setViewportData(viewportData: ViewportDataContainer): Promise<void> {
        if (this.actionDispatcher instanceof GLSPActionDispatcher) {
            const restoreViewportAction = SetViewportAction.create(viewportData.elementId, viewportData.viewportData, { animate: true });
            return this.actionDispatcher.dispatchOnceModelInitialized(restoreViewportAction);
        }
    }

    protected get viewportStorageId(): string {
        return this.options.diagramType + ':' + this.options.uri;
    }

    get actionDispatcher(): GLSPActionDispatcher {
        return this.diContainer.get(TYPES.IActionDispatcher);
    }

    get editorContext(): EditorContextService {
        return this.diContainer.get(EditorContextService);
    }

    get viewerOptions(): ViewerOptions {
        return this.diContainer.get(TYPES.ViewerOptions);
    }

    get modelSource(): GLSPModelSource {
        return this.diContainer.get(GLSPModelSource);
    }

    get clientId(): string {
        return this.modelSource.clientId;
    }

    get uri(): URI {
        return new URI(this.options.uri);
    }

    get diagramType(): string {
        return this.options.diagramType;
    }

    get options(): GLSPDiagramWidgetOptions {
        return this._options;
    }

    get diContainer(): Container {
        return this._diContainer;
    }
}

interface ViewportDataContainer {
    readonly elementId: string;
    readonly viewportData: Viewport;
}

function isViewportDataContainer(obj: any | undefined): obj is ViewportDataContainer {
    return obj !== undefined && obj['elementId'] !== undefined && obj['viewportData'] !== undefined;
}

/**
 * Retrieve the active (or current) diagram widget from the given shell.
 * @param shell The application shell to retrieve the active widget from.
 * @returns The active diagram widget or `undefined` if no diagram widget is active/current.
 */
export function getDiagramWidget(shell?: ApplicationShell): GLSPDiagramWidget | undefined;
/**
 * Retrieve the corresponding diagram widget from the given widget.
 * @param widget The widget to retrieve the diagram widget from.
 * @returns The diagram widget or `undefined` if the given widget is not a diagram widget or a diagram widget container.
 */
export function getDiagramWidget(widget?: Widget): GLSPDiagramWidget | undefined;
export function getDiagramWidget(widgetOrShell?: Widget | ApplicationShell): GLSPDiagramWidget | undefined {
    const widget = widgetOrShell instanceof ApplicationShell ? (widgetOrShell.activeWidget ?? widgetOrShell.currentWidget) : widgetOrShell;

    if (widget instanceof GLSPDiagramWidget) {
        return widget;
    } else if (isDiagramWidgetContainer(widget)) {
        return widget.diagramWidget;
    }
    return undefined;
}
