// *****************************************************************************
// 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('p-debounce');
import { injectable, inject, postConstruct, interfaces, Container } from '@theia/core/shared/inversify';
import * as monaco from '@theia/monaco-editor-core';
import { StandaloneCodeEditor } from '@theia/monaco-editor-core/esm/vs/editor/standalone/browser/standaloneCodeEditor';
import { IDecorationOptions } from '@theia/monaco-editor-core/esm/vs/editor/common/editorCommon';
import URI from '@theia/core/lib/common/uri';
import { Disposable, DisposableCollection, MenuPath, isOSX } from '@theia/core';
import { ContextMenuRenderer, isContextMenuEvent } from '@theia/core/lib/browser';
import { BreakpointManager, SourceBreakpointsChangeEvent } from '../breakpoint/breakpoint-manager';
import { DebugSourceBreakpoint } from '../model/debug-source-breakpoint';
import { DebugSessionManager } from '../debug-session-manager';
import { SourceBreakpoint } from '../breakpoint/breakpoint-marker';
import { DebugEditor } from './debug-editor';
import { DebugHoverWidget, createDebugHoverWidgetContainer } from './debug-hover-widget';
import { DebugBreakpointWidget } from './debug-breakpoint-widget';
import { DebugExceptionWidget } from './debug-exception-widget';
import { DebugProtocol } from '@vscode/debugprotocol';
import { DebugInlineValueDecorator, INLINE_VALUE_DECORATION_KEY } from './debug-inline-value-decorator';

export const DebugEditorModelFactory = Symbol('DebugEditorModelFactory');
export type DebugEditorModelFactory = (editor: DebugEditor) => DebugEditorModel;

@injectable()
export class DebugEditorModel implements Disposable {

    static createContainer(parent: interfaces.Container, editor: DebugEditor): Container {
        const child = createDebugHoverWidgetContainer(parent, editor);
        child.bind(DebugEditorModel).toSelf();
        child.bind(DebugBreakpointWidget).toSelf();
        child.bind(DebugExceptionWidget).toSelf();
        return child;
    }
    static createModel(parent: interfaces.Container, editor: DebugEditor): DebugEditorModel {
        return DebugEditorModel.createContainer(parent, editor).get(DebugEditorModel);
    }

    static CONTEXT_MENU: MenuPath = ['debug-editor-context-menu'];

    protected readonly toDispose = new DisposableCollection();
    protected readonly toDisposeOnUpdate = new DisposableCollection();

    protected uri: URI;

    protected breakpointDecorations: string[] = [];
    protected breakpointRanges = new Map<string, [monaco.Range, DebugSourceBreakpoint]>();

    protected currentBreakpointDecorations: string[] = [];

    protected editorDecorations: string[] = [];

    /**
     * Set during `render()` to prevent `onDidChangeDecorations` → `updateBreakpoints()`
     * from reading back the decoration positions we just wrote, which would produce
     * a spurious or circular `setBreakpoints` call.
     */
    protected ignoreDecorationsChangedEvent = false;

    /**
     * Set during `updateBreakpoints()` to prevent the resulting
     * `onDidChangeBreakpoints` from re-entering `render()`.
     */
    protected ignoreBreakpointsChangeEvent = false;
    protected toDisposeOnModelChange = new DisposableCollection();

    @inject(DebugHoverWidget)
    readonly hover: DebugHoverWidget;

    @inject(DebugEditor)
    readonly editor: DebugEditor;

    @inject(BreakpointManager)
    readonly breakpoints: BreakpointManager;

    @inject(DebugSessionManager)
    readonly sessions: DebugSessionManager;

    @inject(ContextMenuRenderer)
    readonly contextMenu: ContextMenuRenderer;

    @inject(DebugBreakpointWidget)
    readonly breakpointWidget: DebugBreakpointWidget;

    @inject(DebugExceptionWidget)
    readonly exceptionWidget: DebugExceptionWidget;

    @inject(DebugInlineValueDecorator)
    readonly inlineValueDecorator: DebugInlineValueDecorator;

    @inject(DebugSessionManager)
    protected readonly sessionManager: DebugSessionManager;

    @postConstruct()
    protected init(): void {
        this.uri = new URI(this.editor.getResourceUri().toString());
        this.toDispose.pushAll([
            this.hover,
            this.breakpointWidget,
            this.exceptionWidget,
            this.editor.getControl().onMouseDown(event => this.handleMouseDown(event)),
            this.editor.getControl().onMouseMove(event => this.handleMouseMove(event)),
            this.editor.getControl().onMouseLeave(event => this.handleMouseLeave(event)),
            this.editor.getControl().onKeyDown(() => this.hover.hide({ immediate: false })),
            this.editor.getControl().onDidChangeModelContent(() => this.update()),
            this.editor.getControl().onDidChangeModel(e => this.updateModel()),
            this.editor.onDidResize(e => this.breakpointWidget.inputSize = e),
            this.sessions.onDidChange(() => this.update()),
            this.toDisposeOnUpdate,
            Disposable.create(() => this.toDisposeOnModelChange.dispose()),
            this.breakpoints.onDidChangeBreakpoints(event => {
                if (!this.ignoreBreakpointsChangeEvent) {
                    this.render();
                }
                this.closeBreakpointIfAffected(event);
            }),
        ]);
        this.updateModel();
    }

    protected updateModel(): void {
        this.toDisposeOnModelChange.dispose();
        this.toDisposeOnModelChange = new DisposableCollection();
        const model = this.editor.getControl().getModel();
        if (model) {
            this.toDisposeOnModelChange.push(model.onDidChangeDecorations(() => this.updateBreakpoints()));
        }
        this.update();
        this.render();
    }

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

    protected readonly update = debounce(async () => {
        if (this.toDispose.disposed) {
            return;
        }
        this.toDisposeOnUpdate.dispose();
        this.toggleExceptionWidget();
        await this.updateEditorDecorations();
    }, 100);

    protected async updateEditorDecorations(): Promise<void> {
        const [newFrameDecorations, inlineValueDecorations] = await Promise.all([
            this.createFrameDecorations(),
            this.createInlineValueDecorations()
        ]);
        const codeEditor = this.editor.getControl() as unknown as StandaloneCodeEditor;
        codeEditor.removeDecorations([INLINE_VALUE_DECORATION_KEY]);
        codeEditor.setDecorationsByType('Inline debug decorations', INLINE_VALUE_DECORATION_KEY, inlineValueDecorations);
        this.editorDecorations = this.deltaDecorations(this.editorDecorations, newFrameDecorations);
    }

    protected async createInlineValueDecorations(): Promise<IDecorationOptions[]> {
        if (!this.sessions.isCurrentEditorFrame(this.uri)) {
            return [];
        }
        const { currentFrame } = this.sessions;
        return this.inlineValueDecorator.calculateDecorations(this, currentFrame);
    }

    protected createFrameDecorations(): monaco.editor.IModelDeltaDecoration[] {
        const { currentFrame, topFrame } = this.sessions;
        if (!currentFrame || !topFrame) {
            return [];
        }

        if (!currentFrame.thread.stopped) {
            return [];
        }

        const decorations: monaco.editor.IModelDeltaDecoration[] = [];
        const isTopFrameInEditor = topFrame.source && new URI(topFrame.source.uri.toString()).isEqual(this.uri);
        const isCurrentFrameInEditor = this.sessionManager.isCurrentEditorFrame(this.uri);

        if (isTopFrameInEditor) {
            const columnUntilEOLRange = new monaco.Range(topFrame.raw.line, topFrame.raw.column, topFrame.raw.line, 1 << 30);
            const range = new monaco.Range(topFrame.raw.line, topFrame.raw.column, topFrame.raw.line, topFrame.raw.column + 1);

            decorations.push({
                options: DebugEditorModel.TOP_STACK_FRAME_MARGIN,
                range
            });
            decorations.push({
                options: DebugEditorModel.TOP_STACK_FRAME_DECORATION,
                range: columnUntilEOLRange
            });
            const firstNonWhitespaceColumn = this.editor.document.textEditorModel.getLineFirstNonWhitespaceColumn(topFrame.raw.line);
            if (firstNonWhitespaceColumn !== 0 && topFrame.raw.column > firstNonWhitespaceColumn) {
                decorations.push({
                    options: DebugEditorModel.TOP_STACK_FRAME_INLINE_DECORATION,
                    range: columnUntilEOLRange
                });
            }
        }

        if (isCurrentFrameInEditor && topFrame !== currentFrame) {
            const columnUntilEOLRange = new monaco.Range(currentFrame.raw.line, currentFrame.raw.column, currentFrame.raw.line, 1 << 30);
            const range = new monaco.Range(currentFrame.raw.line, currentFrame.raw.column, currentFrame.raw.line, currentFrame.raw.column + 1);

            decorations.push({
                options: DebugEditorModel.FOCUSED_STACK_FRAME_MARGIN,
                range
            });
            decorations.push({
                options: DebugEditorModel.FOCUSED_STACK_FRAME_DECORATION,
                range: columnUntilEOLRange
            });
        }
        return decorations;
    }

    protected async toggleExceptionWidget(): Promise<void> {
        const { currentFrame } = this.sessions;
        if (!currentFrame) {
            return;
        }
        if (!this.sessions.isCurrentEditorFrame(this.uri)) {
            this.exceptionWidget.hide();
            return;
        }
        const info = await currentFrame.thread.getExceptionInfo();
        if (!info) {
            this.exceptionWidget.hide();
            return;
        }
        this.exceptionWidget.show({
            info,
            lineNumber: currentFrame.raw.line,
            column: currentFrame.raw.column
        });
    }

    render(): void {
        this.ignoreDecorationsChangedEvent = true;
        try {
            this.renderBreakpoints();
            this.renderCurrentBreakpoints();
        } finally {
            this.ignoreDecorationsChangedEvent = false;
        }
    }
    protected renderBreakpoints(): void {
        const breakpoints = this.breakpoints.getBreakpoints(this.uri);
        const decorations = this.createBreakpointDecorations(breakpoints);
        this.breakpointDecorations = this.deltaDecorations(this.breakpointDecorations, decorations);
        this.updateBreakpointRanges(breakpoints);
    }
    protected createBreakpointDecorations(breakpoints: readonly DebugSourceBreakpoint[]): monaco.editor.IModelDeltaDecoration[] {
        return breakpoints.map(breakpoint => this.createBreakpointDecoration(breakpoint));
    }
    protected createBreakpointDecoration(breakpoint: DebugSourceBreakpoint): monaco.editor.IModelDeltaDecoration {
        const lineNumber = breakpoint.line;
        const column = breakpoint.column || this.editor.getControl().getModel()?.getLineFirstNonWhitespaceColumn(lineNumber) || 1;
        const range = new monaco.Range(lineNumber, column, lineNumber, column + 1);
        return {
            range,
            options: {
                stickiness: DebugEditorModel.STICKINESS
            }
        };
    }

    protected updateBreakpointRanges(breakpoints: readonly DebugSourceBreakpoint[]): void {
        this.breakpointRanges.clear();
        for (let i = 0; i < this.breakpointDecorations.length; i++) {
            const decoration = this.breakpointDecorations[i];
            const breakpoint = breakpoints[i];
            const range = this.editor.getControl().getModel()?.getDecorationRange(decoration);
            if (range) {
                this.breakpointRanges.set(decoration, [range, breakpoint]);
            }
        }
    }

    protected renderCurrentBreakpoints(): void {
        const decorations = this.createCurrentBreakpointDecorations();
        this.currentBreakpointDecorations = this.deltaDecorations(this.currentBreakpointDecorations, decorations);
    }
    protected createCurrentBreakpointDecorations(): monaco.editor.IModelDeltaDecoration[] {
        const breakpoints = this.breakpoints.getBreakpoints(this.uri);
        // Deduplicate by rendered position: when multiple breakpoints resolve
        // to the same (line, column) — e.g. via source-map collapsing — only
        // create one decoration to avoid double dots in the editor.
        const seen = new Set<string>();
        const result: monaco.editor.IModelDeltaDecoration[] = [];
        for (const bp of breakpoints) {
            const key = `${bp.line}:${bp.column ?? 0}`;
            if (seen.has(key)) { continue; }
            seen.add(key);
            result.push(this.createCurrentBreakpointDecoration(bp));
        }
        return result;
    }
    protected createCurrentBreakpointDecoration(breakpoint: DebugSourceBreakpoint): monaco.editor.IModelDeltaDecoration {
        const lineNumber = breakpoint.line;
        const column = breakpoint.column;
        const range = typeof column === 'number' ? new monaco.Range(lineNumber, column, lineNumber, column + 1) : new monaco.Range(lineNumber, 1, lineNumber, 1);
        const { className, message } = breakpoint.getDecoration();
        const renderInline = typeof column === 'number' && (column > (this.editor.getControl().getModel()?.getLineFirstNonWhitespaceColumn(lineNumber) || 0));
        return {
            range,
            options: {
                glyphMarginClassName: className,
                glyphMarginHoverMessage: message.map(value => ({ value })),
                stickiness: DebugEditorModel.STICKINESS,
                beforeContentClassName: renderInline ? `theia-debug-breakpoint-column codicon ${className}` : undefined
            }
        };
    }

    protected updateBreakpoints(): void {
        if (this.areBreakpointsAffected()) {
            const breakpoints = this.createBreakpoints();
            this.ignoreBreakpointsChangeEvent = true;
            try {
                this.breakpoints.setBreakpoints(this.uri, breakpoints);
            } finally {
                this.ignoreBreakpointsChangeEvent = false;
            }
        }
    }
    protected areBreakpointsAffected(): boolean {
        if (this.ignoreDecorationsChangedEvent || !this.editor.getControl().getModel()) {
            return false;
        }
        for (const decoration of this.breakpointDecorations) {
            const range = this.editor.getControl().getModel()?.getDecorationRange(decoration);
            const oldRange = this.breakpointRanges.get(decoration)![0];
            if (!range || !range.equalsRange(oldRange)) {
                return true;
            }
        }
        return false;
    }
    protected createBreakpoints(): SourceBreakpoint[] {
        const { uri } = this;
        const positions = new Set<string>();
        const breakpoints: SourceBreakpoint[] = [];
        for (const decoration of this.breakpointDecorations) {
            const range = this.editor.getControl().getModel()?.getDecorationRange(decoration);
            if (range) {
                const line = range.startLineNumber;
                const column = range.startColumn;
                const oldBreakpoint = this.breakpointRanges.get(decoration)?.[1];
                if (oldBreakpoint) {
                    const isLineBreakpoint = oldBreakpoint.origin.raw.line !== undefined && oldBreakpoint.origin.raw.column === undefined;
                    const position = isLineBreakpoint ? `${line}` : `${line}:${column}`;
                    if (!positions.has(position)) {
                        const change = isLineBreakpoint ? { line } : { line, column };
                        const breakpoint = SourceBreakpoint.create(uri, change, oldBreakpoint.origin);
                        breakpoints.push(breakpoint);
                        positions.add(position);
                    }
                }
            }
        }
        return breakpoints;
    }

    get position(): monaco.Position {
        return this.editor.getControl().getPosition()!;
    }

    getBreakpoint(position: monaco.Position = this.position): DebugSourceBreakpoint | undefined {
        return this.getInlineBreakpoint(position) || this.getLineBreakpoints(position)[0];
    }

    getInlineBreakpoint(position: monaco.Position = this.position): DebugSourceBreakpoint | undefined {
        return this.breakpoints.getBreakpoints(this.uri).find(candidate => candidate.line === position.lineNumber && candidate.column === position.column);
    }

    protected getLineBreakpoints(position: monaco.Position = this.position): DebugSourceBreakpoint[] {
        return this.breakpoints.getBreakpoints(this.uri).filter(candidate => candidate.line === position.lineNumber);
    }

    protected addBreakpoint(raw: DebugProtocol.SourceBreakpoint): void {
        this.breakpoints.addBreakpoint(SourceBreakpoint.create(this.uri, raw));
    }

    toggleBreakpoint(position: monaco.Position = this.position): void {
        const { lineNumber } = position;
        const breakpoints = this.getLineBreakpoints(position);
        if (breakpoints.length) {
            for (const breakpoint of breakpoints) {
                breakpoint.remove();
            }
        } else {
            this.addBreakpoint({ line: lineNumber });
        }
    }

    addInlineBreakpoint(): void {
        const { position: { lineNumber: line, column } } = this;
        this.addBreakpoint({ line, column });
    }

    acceptBreakpoint(): void {
        const { position, values } = this.breakpointWidget;
        if (position && values) {
            const breakpoint = position.column > 0 ? this.getInlineBreakpoint(position) : this.getLineBreakpoints(position)[0];
            if (breakpoint) {
                this.breakpoints.updateBreakpoint(breakpoint, values);
            } else {
                const { lineNumber } = position;
                const column = position.column > 0 ? position.column : undefined;
                this.addBreakpoint({ line: lineNumber, column, ...values });
            }
            this.breakpointWidget.hide();
        }
    }

    protected handleMouseDown(event: monaco.editor.IEditorMouseEvent): void {
        if (event.target && event.target.type === monaco.editor.MouseTargetType.GUTTER_GLYPH_MARGIN) {
            if (!isContextMenuEvent(event.event.browserEvent)) {
                this.toggleBreakpoint(event.target.position!);
            }
        }
        this.hintBreakpoint(event);
    }
    protected handleMouseMove(event: monaco.editor.IEditorMouseEvent): void {
        this.showHover(event);
        this.hintBreakpoint(event);
    }
    protected handleMouseLeave(event: monaco.editor.IPartialEditorMouseEvent): void {
        this.hideHover(event);
        this.deltaHintDecorations([]);
    }

    protected hintDecorations: string[] = [];
    protected hintBreakpoint(event: monaco.editor.IEditorMouseEvent): void {
        const hintDecorations = this.createHintDecorations(event);
        this.deltaHintDecorations(hintDecorations);
    }
    protected deltaHintDecorations(hintDecorations: monaco.editor.IModelDeltaDecoration[]): void {
        this.hintDecorations = this.deltaDecorations(this.hintDecorations, hintDecorations);
    }
    protected createHintDecorations(event: monaco.editor.IEditorMouseEvent): monaco.editor.IModelDeltaDecoration[] {
        if (event.target && event.target.type === monaco.editor.MouseTargetType.GUTTER_GLYPH_MARGIN && event.target.position) {
            const lineNumber = event.target.position.lineNumber;
            if (this.getLineBreakpoints(event.target.position).length) {
                return [];
            }
            return [{
                range: new monaco.Range(lineNumber, 1, lineNumber, 1),
                options: DebugEditorModel.BREAKPOINT_HINT_DECORATION
            }];
        }
        return [];
    }

    protected closeBreakpointIfAffected({ uri, removed }: SourceBreakpointsChangeEvent): void {
        if (!uri.isEqual(this.uri)) {
            return;
        }
        const position = this.breakpointWidget.position;
        if (!position) {
            return;
        }
        for (const breakpoint of removed) {
            if (breakpoint.line === position.lineNumber) {
                this.breakpointWidget.hide();
                break;
            }
        }
    }

    protected showHover(mouseEvent: monaco.editor.IEditorMouseEvent): void {
        const targetType = mouseEvent.target.type;
        const stopKey = isOSX ? 'metaKey' : 'ctrlKey';

        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        if (targetType === monaco.editor.MouseTargetType.CONTENT_WIDGET && mouseEvent.target.detail === this.hover.getId() && !(<any>mouseEvent.event)[stopKey]) {
            // mouse moved on top of debug hover widget
            return;
        }
        if (targetType === monaco.editor.MouseTargetType.CONTENT_TEXT) {
            this.hover.show({
                selection: mouseEvent.target.range!,
                immediate: false
            });
        } else {
            this.hover.hide({ immediate: false });
        }
    }
    protected hideHover({ event }: monaco.editor.IPartialEditorMouseEvent): void {
        const rect = this.hover.getDomNode().getBoundingClientRect();
        if (event.posx < rect.left || event.posx > rect.right || event.posy < rect.top || event.posy > rect.bottom) {
            this.hover.hide({ immediate: false });
        }
    }

    protected deltaDecorations(oldDecorations: string[], newDecorations: monaco.editor.IModelDeltaDecoration[]): string[] {
        return this.editor.getControl().deltaDecorations(oldDecorations, newDecorations);
    }

    static STICKINESS = monaco.editor.TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges;

    static BREAKPOINT_HINT_DECORATION: monaco.editor.IModelDecorationOptions = {
        glyphMarginClassName: 'codicon-debug-hint',
        stickiness: DebugEditorModel.STICKINESS
    };

    static TOP_STACK_FRAME_MARGIN: monaco.editor.IModelDecorationOptions = {
        glyphMarginClassName: 'codicon-debug-stackframe',
        stickiness: DebugEditorModel.STICKINESS
    };
    static FOCUSED_STACK_FRAME_MARGIN: monaco.editor.IModelDecorationOptions = {
        glyphMarginClassName: 'codicon-debug-stackframe-focused',
        stickiness: DebugEditorModel.STICKINESS
    };
    static TOP_STACK_FRAME_DECORATION: monaco.editor.IModelDecorationOptions = {
        isWholeLine: true,
        className: 'theia-debug-top-stack-frame-line',
        stickiness: DebugEditorModel.STICKINESS
    };
    static TOP_STACK_FRAME_INLINE_DECORATION: monaco.editor.IModelDecorationOptions = {
        beforeContentClassName: 'theia-debug-top-stack-frame-column'
    };
    static FOCUSED_STACK_FRAME_DECORATION: monaco.editor.IModelDecorationOptions = {
        isWholeLine: true,
        className: 'theia-debug-focused-stack-frame-line',
        stickiness: DebugEditorModel.STICKINESS
    };

}
