// *****************************************************************************
// Copyright (C) 2018 Red Hat, Inc. 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 {
    TextEditorsMain,
    MAIN_RPC_CONTEXT,
    TextEditorsExt,
    TextEditorConfigurationUpdate,
    Selection,
    TextEditorRevealType,
    SingleEditOperation,
    ApplyEditsOptions,
    DecorationRenderOptions,
    ThemeDecorationInstanceRenderOptions,
    DecorationOptions,
    WorkspaceEditDto,
    WorkspaceNotebookCellEditDto,
    DocumentsMain,
    WorkspaceEditMetadataDto,
    SnippetEditOptions,
} from '../../common/plugin-api-rpc';
import { Range, TextDocumentShowOptions } from '../../common/plugin-api-rpc-model';
import { EditorsAndDocumentsMain } from './editors-and-documents-main';
import { RPCProtocol } from '../../common/rpc-protocol';
import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable';
import { TextEditorMain } from './text-editor-main';
import { disposed } from '../../common/errors';
import { toMonacoWorkspaceEdit } from './languages-main';
import { MonacoBulkEditService } from '@theia/monaco/lib/browser/monaco-bulk-edit-service';
import { UriComponents } from '../../common/uri-components';
import { Endpoint } from '@theia/core/lib/browser/endpoint';
import * as monaco from '@theia/monaco-editor-core';
import { ResourceEdit } from '@theia/monaco-editor-core/esm/vs/editor/browser/services/bulkEditService';
import { IDecorationRenderOptions } from '@theia/monaco-editor-core/esm/vs/editor/common/editorCommon';
import { StandaloneServices } from '@theia/monaco-editor-core/esm/vs/editor/standalone/browser/standaloneServices';
import { ICodeEditorService } from '@theia/monaco-editor-core/esm/vs/editor/browser/services/codeEditorService';
import { type ILineChange } from '@theia/monaco-editor-core/esm/vs/editor/common/diff/legacyLinesDiffComputer';
import { ArrayUtils, URI } from '@theia/core';
import { DiffUris } from '@theia/core/lib/browser/diff-uris';
import { TextEditorChangeKind } from '../../plugin/types-impl';
import { Change } from '@theia/scm/lib/browser/dirty-diff/diff-computer';
import { DirtyDiffUpdate } from '@theia/scm/lib/browser/dirty-diff/dirty-diff-decorator';
import { ScmDecorationsService } from '@theia/scm/lib/browser/decorations/scm-decorations-service';
import { toNotebookWorspaceEdit } from './notebooks/notebooks-main';
import { interfaces } from '@theia/core/shared/inversify';
import { NotebookService } from '@theia/notebook/lib/browser';

export class TextEditorsMainImpl implements TextEditorsMain, Disposable {

    private readonly proxy: TextEditorsExt;
    private readonly toDispose = new DisposableCollection();
    private readonly editorsToDispose = new Map<string, DisposableCollection>();
    private readonly fileEndpoint = new Endpoint({ path: 'file' }).getRestUrl();

    private readonly bulkEditService: MonacoBulkEditService;
    private readonly notebookService: NotebookService;
    private readonly scmDecorationsService: ScmDecorationsService;

    constructor(
        private readonly editorsAndDocuments: EditorsAndDocumentsMain,
        private readonly documents: DocumentsMain,
        rpc: RPCProtocol,
        container: interfaces.Container
    ) {
        this.proxy = rpc.getProxy(MAIN_RPC_CONTEXT.TEXT_EDITORS_EXT);

        this.bulkEditService = container.get(MonacoBulkEditService);
        this.notebookService = container.get(NotebookService);
        this.scmDecorationsService = container.get(ScmDecorationsService);

        this.toDispose.push(editorsAndDocuments);
        this.toDispose.push(editorsAndDocuments.onTextEditorAdd(editors => editors.forEach(this.onTextEditorAdd, this)));
        this.toDispose.push(editorsAndDocuments.onTextEditorRemove(editors => editors.forEach(this.onTextEditorRemove, this)));
        this.toDispose.push(this.scmDecorationsService.onDirtyDiffUpdate(update => this.onDirtyDiffUpdate(update)));
    }

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

    private onTextEditorAdd(editor: TextEditorMain): void {
        const id = editor.getId();
        const toDispose = new DisposableCollection(
            editor.onPropertiesChangedEvent(e => {
                this.proxy.$acceptEditorPropertiesChanged(id, e);
            }),
            Disposable.create(() => this.editorsToDispose.delete(id))
        );
        this.editorsToDispose.set(id, toDispose);
        this.toDispose.push(toDispose);

        const diffEditor = editor.getDiffEditor();
        if (diffEditor) {
            const pushDiffEditorInfo = () => this.pushDiffEditorDiffInformation(id, editor);
            toDispose.push(diffEditor.diffEditor.onDidUpdateDiff(pushDiffEditorInfo));
            pushDiffEditorInfo();
        }
    }

    private onDirtyDiffUpdate(update: DirtyDiffUpdate): void {
        const editorUri = update.editor.uri.toString();
        const editorIds = this.findEditorIdsByUri(editorUri, true);
        if (editorIds.length === 0) {
            return;
        }

        const originalUri = update.previousRevisionUri?.toComponents();

        const changes = update.changes.map(change => {
            let kind: TextEditorChangeKind;
            if (Change.isAddition(change)) {
                kind = TextEditorChangeKind.Addition;
            } else if (Change.isRemoval(change)) {
                kind = TextEditorChangeKind.Deletion;
            } else {
                kind = TextEditorChangeKind.Modification;
            }
            return {
                original: {
                    startLineNumber: change.previousRange.start + 1,
                    endLineNumberExclusive: change.previousRange.end + 1
                },
                modified: {
                    startLineNumber: change.currentRange.start + 1,
                    endLineNumberExclusive: change.currentRange.end + 1
                },
                kind
            };
        });

        // Push diff information to all editors with this URI (regular editors and diff editor modified sides).
        for (const editorId of editorIds) {
            const editor = this.editorsAndDocuments.getEditor(editorId);
            if (!editor) {
                continue;
            }
            const model = editor.getModel();
            const modifiedUri = URI.fromComponents(model.uri).toComponents();
            this.proxy.$acceptEditorDiffInformation(editorId, [{
                documentVersion: model.getVersionId(),
                original: originalUri,
                modified: modifiedUri,
                changes,
                isStale: false
            }]);
        }
    }

    private pushDiffEditorDiffInformation(id: string, editor: TextEditorMain): void {
        const diffEditor = editor.getDiffEditor();
        if (!diffEditor) {
            return;
        }

        let originalUri: UriComponents | undefined;
        let modifiedUri: UriComponents;

        try {
            const [left, right] = DiffUris.decode(diffEditor.uri);
            originalUri = left.toComponents();
            modifiedUri = right.toComponents();
        } catch {
            // Not a valid DiffUri; fall back to model URIs
            originalUri = new URI(diffEditor.originalModel.uri).toComponents();
            modifiedUri = new URI(diffEditor.modifiedModel.uri).toComponents();
        }

        const lineChanges = diffEditor.diffInformation;

        const changes = lineChanges.map(change => {
            let kind: TextEditorChangeKind;
            if (change.originalEndLineNumber === 0) {
                kind = TextEditorChangeKind.Addition;
            } else if (change.modifiedEndLineNumber === 0) {
                kind = TextEditorChangeKind.Deletion;
            } else {
                kind = TextEditorChangeKind.Modification;
            }

            // ILineChange uses 1-based lines where 0 means empty range.
            // TextEditorLineRange uses 1-based startLineNumber and endLineNumberExclusive.
            const toLineRange = (start: number, end: number) => end === 0
                ? { startLineNumber: start + 1, endLineNumberExclusive: start + 1 }
                : { startLineNumber: start, endLineNumberExclusive: end + 1 };

            return {
                original: toLineRange(change.originalStartLineNumber, change.originalEndLineNumber),
                modified: toLineRange(change.modifiedStartLineNumber, change.modifiedEndLineNumber),
                kind
            };
        });

        const model = editor.getModel();
        this.proxy.$acceptEditorDiffInformation(id, [{
            documentVersion: model.getVersionId(),
            original: originalUri,
            modified: modifiedUri,
            changes,
            isStale: false
        }]);
    }

    private findEditorIdsByUri(uri: string, excludeDiffEditors = false): string[] {
        const ids: string[] = [];
        for (const id of this.editorsToDispose.keys()) {
            const editor = this.editorsAndDocuments.getEditor(id);
            if (editor && editor.getModel().uri.toString() === uri) {
                if (excludeDiffEditors && editor.getDiffEditor()) {
                    continue;
                }
                ids.push(id);
            }
        }
        return ids;
    }

    private onTextEditorRemove(id: string): void {
        const disposables = this.editorsToDispose.get(id);
        if (disposables) {
            disposables.dispose();
        }
    }

    $tryShowTextDocument(uri: UriComponents, options?: TextDocumentShowOptions): Promise<void> {
        return this.documents.$tryShowDocument(uri, options);
    }

    $trySetOptions(id: string, options: TextEditorConfigurationUpdate): Promise<void> {
        if (!this.editorsAndDocuments.getEditor(id)) {
            return Promise.reject(disposed(`TextEditor: ${id}`));
        }
        this.editorsAndDocuments.getEditor(id)!.setConfiguration(options);
        return Promise.resolve();
    }

    $trySetSelections(id: string, selections: Selection[]): Promise<void> {
        if (!this.editorsAndDocuments.getEditor(id)) {
            return Promise.reject(disposed(`TextEditor: ${id}`));
        }
        this.editorsAndDocuments.getEditor(id)!.setSelections(selections);
        return Promise.resolve();
    }

    $tryRevealRange(id: string, range: Range, revealType: TextEditorRevealType): Promise<void> {
        if (!this.editorsAndDocuments.getEditor(id)) {
            return Promise.reject(disposed(`TextEditor(${id})`));
        }

        this.editorsAndDocuments.getEditor(id)!.revealRange(new monaco.Range(range.startLineNumber, range.startColumn, range.endLineNumber, range.endColumn), revealType);
        return Promise.resolve();
    }

    $tryApplyEdits(id: string, modelVersionId: number, edits: SingleEditOperation[], opts: ApplyEditsOptions): Promise<boolean> {
        if (!this.editorsAndDocuments.getEditor(id)) {
            return Promise.reject(disposed(`TextEditor(${id})`));
        }

        return Promise.resolve(this.editorsAndDocuments.getEditor(id)!.applyEdits(modelVersionId, edits, opts));
    }

    async $tryApplyWorkspaceEdit(dto: WorkspaceEditDto, metadata?: WorkspaceEditMetadataDto): Promise<boolean> {
        const [notebookEdits, monacoEdits] = ArrayUtils.partition(dto.edits, edit => WorkspaceNotebookCellEditDto.is(edit));
        try {
            if (notebookEdits.length > 0) {
                const workspaceEdit = toNotebookWorspaceEdit({ edits: notebookEdits });
                return this.notebookService.applyWorkspaceEdit(workspaceEdit);
            }
            if (monacoEdits.length > 0) {
                const workspaceEdit = toMonacoWorkspaceEdit({ edits: monacoEdits });
                const edits = ResourceEdit.convert(workspaceEdit);
                const { isApplied } = await this.bulkEditService.apply(edits, { respectAutoSaveConfig: metadata?.isRefactoring });
                return isApplied;
            }
            return false;
        } catch {
            return false;
        }
    }

    $tryInsertSnippet(id: string, template: string, ranges: Range[], opts: SnippetEditOptions): Promise<boolean> {
        if (!this.editorsAndDocuments.getEditor(id)) {
            return Promise.reject(disposed(`TextEditor(${id})`));
        }
        return Promise.resolve(this.editorsAndDocuments.getEditor(id)!.insertSnippet(template, ranges, opts));
    }

    $registerTextEditorDecorationType(key: string, options: DecorationRenderOptions): void {
        this.injectRemoteUris(options);
        StandaloneServices.get(ICodeEditorService).registerDecorationType('Plugin decoration', key, options as IDecorationRenderOptions);
        this.toDispose.push(Disposable.create(() => this.$removeTextEditorDecorationType(key)));
    }

    protected injectRemoteUris(options: DecorationRenderOptions | ThemeDecorationInstanceRenderOptions): void {
        if (options.before) {
            options.before.contentIconPath = this.toRemoteUri(options.before.contentIconPath);
        }
        if (options.after) {
            options.after.contentIconPath = this.toRemoteUri(options.after.contentIconPath);
        }
        if ('gutterIconPath' in options) {
            options.gutterIconPath = this.toRemoteUri(options.gutterIconPath);
        }
        if ('dark' in options && options.dark) {
            this.injectRemoteUris(options.dark);
        }
        if ('light' in options && options.light) {
            this.injectRemoteUris(options.light);
        }
    }

    protected toRemoteUri(uri?: UriComponents): UriComponents | undefined {
        if (uri && uri.scheme === 'file') {
            return this.fileEndpoint.withQuery(URI.fromComponents(uri).toString()).toComponents();
        }
        return uri;
    }

    $removeTextEditorDecorationType(key: string): void {
        StandaloneServices.get(ICodeEditorService).removeDecorationType(key);
    }

    $tryHideEditor(id: string): Promise<void> {
        return this.editorsAndDocuments.hideEditor(id);
    }

    $trySetDecorations(id: string, key: string, ranges: DecorationOptions[]): Promise<void> {
        if (!this.editorsAndDocuments.getEditor(id)) {
            return Promise.reject(disposed(`TextEditor(${id})`));
        }
        this.editorsAndDocuments.getEditor(id)!.setDecorations(key, ranges);
        return Promise.resolve();
    }

    $trySetDecorationsFast(id: string, key: string, ranges: number[]): Promise<void> {
        if (!this.editorsAndDocuments.getEditor(id)) {
            return Promise.reject(disposed(`TextEditor(${id})`));
        }
        this.editorsAndDocuments.getEditor(id)!.setDecorationsFast(key, ranges);
        return Promise.resolve();
    }

    $save(uri: UriComponents): PromiseLike<UriComponents | undefined> {
        return this.editorsAndDocuments.save(URI.fromComponents(uri)).then(u => u?.toComponents());
    }

    $saveAs(uri: UriComponents): PromiseLike<UriComponents | undefined> {
        return this.editorsAndDocuments.saveAs(URI.fromComponents(uri)).then(u => u?.toComponents());
    }

    $saveAll(includeUntitled?: boolean): Promise<boolean> {
        return this.editorsAndDocuments.saveAll(includeUntitled);
    }

    $getDiffInformation(id: string): Promise<ILineChange[]> {
        return Promise.resolve(this.editorsAndDocuments.getDiffInformation(id));
    }

}
