// *****************************************************************************
// Copyright (C) 2018 TypeFox 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 debounce = require('@theia/core/shared/lodash.debounce');

import { MenuPath } from '@theia/core';
import { Key } from '@theia/core/lib/browser';
import { SourceTreeWidget } from '@theia/core/lib/browser/source-tree';
import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable';
import { Message } from '@theia/core/shared/@lumino/messaging';
import { Widget } from '@theia/core/shared/@lumino/widgets';
import { Container, inject, injectable, interfaces, postConstruct } from '@theia/core/shared/inversify';
import { URI as CodeUri } from '@theia/core/shared/vscode-uri';
import * as monaco from '@theia/monaco-editor-core';
import { IEditorHoverOptions } from '@theia/monaco-editor-core/esm/vs/editor/common/config/editorOptions';
import { StandaloneServices } from '@theia/monaco-editor-core/esm/vs/editor/standalone/browser/standaloneServices';
import { IConfigurationService } from '@theia/monaco-editor-core/esm/vs/platform/configuration/common/configuration';
import { DebugVariable } from '../console/debug-console-items';
import { DebugSessionManager } from '../debug-session-manager';
import { DebugEditor } from './debug-editor';
import { DebugExpressionProvider } from './debug-expression-provider';
import { DebugHoverSource } from './debug-hover-source';

export interface ShowDebugHoverOptions {
    selection: monaco.Range
    /** default: false */
    focus?: boolean
    /** default: true */
    immediate?: boolean
}

export interface HideDebugHoverOptions {
    /** default: true */
    immediate?: boolean
}

export function createDebugHoverWidgetContainer(parent: interfaces.Container, editor: DebugEditor): Container {
    const child = SourceTreeWidget.createContainer(parent, {
        contextMenuPath: DebugHoverWidget.CONTEXT_MENU,
        virtualized: false
    });
    child.bind(DebugEditor).toConstantValue(editor);
    child.bind(DebugHoverSource).toSelf();
    child.unbind(SourceTreeWidget);
    child.bind(DebugHoverWidget).toSelf();
    return child;
}

@injectable()
export class DebugHoverWidget extends SourceTreeWidget implements monaco.editor.IContentWidget {

    static CONTEXT_MENU: MenuPath = ['debug-hover-context-menu'];
    static EDIT_MENU: MenuPath = [...DebugHoverWidget.CONTEXT_MENU, 'a_edit'];
    static WATCH_MENU: MenuPath = [...DebugHoverWidget.CONTEXT_MENU, 'b_watch'];

    @inject(DebugEditor)
    protected readonly editor: DebugEditor;

    @inject(DebugSessionManager)
    protected readonly sessions: DebugSessionManager;

    @inject(DebugHoverSource)
    protected readonly hoverSource: DebugHoverSource;

    @inject(DebugExpressionProvider)
    protected readonly expressionProvider: DebugExpressionProvider;

    allowEditorOverflow = true;

    protected suppressEditorHoverToDispose = new DisposableCollection();

    static ID = 'debug.editor.hover';
    getId(): string {
        return DebugHoverWidget.ID;
    }

    protected readonly domNode = document.createElement('div');
    protected readonly titleNode = document.createElement('div');
    protected readonly contentNode = document.createElement('div');
    getDomNode(): HTMLElement {
        return this.domNode;
    }

    @postConstruct()
    protected override init(): void {
        super.init();
        this.domNode.className = 'theia-debug-hover';
        this.titleNode.className = 'theia-debug-hover-title';
        this.domNode.appendChild(this.titleNode);
        this.contentNode.className = 'theia-debug-hover-content';
        this.domNode.appendChild(this.contentNode);

        // for stopping scroll events from contentNode going to the editor
        this.contentNode.addEventListener('wheel', e => e.stopPropagation());

        this.editor.getControl().addContentWidget(this);
        this.source = this.hoverSource;
        this.toDispose.pushAll([
            this.hoverSource,
            Disposable.create(() => this.editor.getControl().removeContentWidget(this)),
            Disposable.create(() => this.hide()),
            this.sessions.onDidChange(() => {
                if (!this.isEditorFrame()) {
                    this.hide();
                }
            })
        ]);
    }

    override dispose(): void {
        this.suppressEditorHoverToDispose.dispose();
        this.toDispose.dispose();
    }

    override show(options?: ShowDebugHoverOptions): void {
        this.schedule(() => this.doShow(options), options && options.immediate);
    }

    override hide(options?: HideDebugHoverOptions): void {
        this.schedule(() => this.doHide(), options && options.immediate);
    }

    protected readonly doSchedule = debounce((fn: () => void) => fn(), 300);
    protected schedule(fn: () => void, immediate: boolean = true): void {
        if (immediate) {
            this.doSchedule.cancel();
            fn();
        } else {
            this.doSchedule(fn);
        }
    }

    protected options: ShowDebugHoverOptions | undefined;
    protected doHide(): void {
        if (!this.isVisible) {
            return;
        }
        this.suppressEditorHoverToDispose.dispose();

        if (this.domNode.contains(document.activeElement)) {
            this.editor.getControl().focus();
        }
        if (this.isAttached) {
            Widget.detach(this);
        }
        this.hoverSource.reset();
        super.hide();
        this.options = undefined;
        this.editor.getControl().layoutContentWidget(this);
    }

    protected async doShow(options: ShowDebugHoverOptions | undefined = this.options): Promise<void> {

        if (!this.isEditorFrame()) {
            this.hide();
            return;
        }
        if (!options) {
            this.hide();
            return;
        }
        if (this.options && this.options.selection.equalsRange(options.selection)) {
            return;
        }
        if (!this.isAttached) {
            Widget.attach(this, this.contentNode);
        }

        this.options = options;

        const result = await this.expressionProvider.getEvaluatableExpression(this.editor, options.selection);

        if (!result?.matchingExpression) {
            this.hide();
            return;
        }

        this.options.selection = monaco.Range.lift(result.range);

        const toFocus = new DisposableCollection();
        if (this.options.focus === true) {
            toFocus.push(this.model.onNodeRefreshed(() => {
                toFocus.dispose();
                this.activate();
            }));
        }
        const expression = await this.hoverSource.evaluate(result.matchingExpression);
        if (!expression) {
            toFocus.dispose();
            this.hide();
            return;
        }

        this.contentNode.hidden = false;
        ['number', 'boolean', 'string'].forEach(token => this.titleNode.classList.remove(token));
        this.domNode.classList.remove('complex-value');
        if (expression.hasElements) {
            this.domNode.classList.add('complex-value');
        } else {
            this.contentNode.hidden = true;
            if (expression.type === 'number' || expression.type === 'boolean' || expression.type === 'string') {
                this.titleNode.classList.add(expression.type);
            } else if (!isNaN(+expression.value)) {
                this.titleNode.classList.add('number');
            } else if (DebugVariable.booleanRegex.test(expression.value)) {
                this.titleNode.classList.add('boolean');
            } else if (DebugVariable.stringRegex.test(expression.value)) {
                this.titleNode.classList.add('string');
            }
        }

        this.suppressEditorHover();

        super.show();
        await new Promise<void>(resolve => {
            setTimeout(() => window.requestAnimationFrame(() => {
                this.editor.getControl().layoutContentWidget(this);
                resolve();
            }), 0);
        });
    }

    /**
     * Suppress the default editor-contribution hover from Code.
     * Otherwise, both `textdocument/hover` and the debug hovers are visible
     * at the same time when hovering over a symbol.
     * This will priorize the debug hover over the editor hover.
     */
    protected suppressEditorHover(): void {
        const codeEditor = this.editor.getControl();
        codeEditor.updateOptions({ hover: { enabled: 'off' } });
        this.suppressEditorHoverToDispose.push(Disposable.create(() => {
            const model = codeEditor.getModel();
            const overrides = {
                resource: CodeUri.parse(this.editor.getResourceUri().toString()),
                overrideIdentifier: model?.getLanguageId(),
            };
            const { enabled, delay, sticky } = StandaloneServices.get(IConfigurationService).getValue<IEditorHoverOptions>('editor.hover', overrides);
            codeEditor.updateOptions({
                hover: {
                    enabled,
                    delay,
                    sticky
                }
            });
        }));
    }

    protected isEditorFrame(): boolean {
        return this.sessions.isCurrentEditorFrame(this.editor.getResourceUri());
    }

    getPosition(): monaco.editor.IContentWidgetPosition {
        if (!this.isVisible) {
            return undefined!;
        }
        const position = this.options && this.options.selection.getStartPosition();
        return position
            ? {
                position: new monaco.Position(position.lineNumber, position.column),
                preference: [
                    monaco.editor.ContentWidgetPositionPreference.ABOVE,
                    monaco.editor.ContentWidgetPositionPreference.BELOW,
                ],
            }
            : undefined!;
    }

    protected override onUpdateRequest(msg: Message): void {
        super.onUpdateRequest(msg);
        const { expression } = this.hoverSource;
        const value = expression && expression.value || '';
        this.titleNode.textContent = value;
        this.titleNode.title = value;
    }

    protected override onAfterAttach(msg: Message): void {
        super.onAfterAttach(msg);
        this.addKeyListener(this.domNode, Key.ESCAPE, () => this.hide());
    }

}
