// *****************************************************************************
// Copyright (C) 2017 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 { Position, Range, TextDocumentSaveReason } from '@theia/core/shared/vscode-languageserver-protocol';
import { TextEditorDocument, EncodingMode, FindMatchesOptions, FindMatch } from '@theia/editor/lib/browser';
import { DisposableCollection, Disposable } from '@theia/core/lib/common/disposable';
import { Emitter, Event } from '@theia/core/lib/common/event';
import { CancellationTokenSource, CancellationToken } from '@theia/core/lib/common/cancellation';
import { Resource, ResourceError, ResourceVersion } from '@theia/core/lib/common/resource';
import { Saveable, SaveOptions, SaveReason } from '@theia/core/lib/browser/saveable';
import { MonacoToProtocolConverter } from './monaco-to-protocol-converter';
import { ProtocolToMonacoConverter } from './protocol-to-monaco-converter';
import { ILogger, Loggable, Log } from '@theia/core/lib/common/logger';
import { ITextBufferFactory, ITextModel, ITextSnapshot } from '@theia/monaco-editor-core/esm/vs/editor/common/model';
import { IResolvedTextEditorModel } from '@theia/monaco-editor-core/esm/vs/editor/common/services/resolverService';
import * as monaco from '@theia/monaco-editor-core';
import { StandaloneServices } from '@theia/monaco-editor-core/esm/vs/editor/standalone/browser/standaloneServices';
import { ILanguageService } from '@theia/monaco-editor-core/esm/vs/editor/common/languages/language';
import { IModelService } from '@theia/monaco-editor-core/esm/vs/editor/common/services/model';
import { createTextBufferFactoryFromStream } from '@theia/monaco-editor-core/esm/vs/editor/common/model/textModel';
import { editorGeneratedPreferenceProperties } from '@theia/editor/lib/common/editor-generated-preference-schema';
import { MarkdownString } from '@theia/core/lib/common/markdown-rendering';
import { BinaryBuffer } from '@theia/core/lib/common/buffer';
import { Listener, ListenerList } from '@theia/core';
import { EditorPreferences } from '@theia/editor/lib/common/editor-preferences';

export {
    TextDocumentSaveReason
};

export interface WillSaveMonacoModelEvent {
    model: MonacoEditorModel,
    token: CancellationToken,
    options?: SaveOptions
}

export interface MonacoModelContentChangedEvent {
    readonly model: MonacoEditorModel;
    readonly contentChanges: MonacoTextDocumentContentChange[];
}

export interface MonacoTextDocumentContentChange {
    readonly range: Range;
    readonly rangeOffset: number;
    readonly rangeLength: number;
    readonly text: string;
}

export class MonacoEditorModel implements IResolvedTextEditorModel, TextEditorDocument {

    suppressOpenEditorWhenDirty = false;
    lineNumbersMinChars = 3;

    /* @deprecated there is no general save timeout, each participant should introduce a sensible timeout  */
    readonly onWillSaveLoopTimeOut = 1500;
    protected bufferSavedVersionId: number;

    protected model: ITextModel;
    protected readonly resolveModel: Promise<void>;

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

    protected readonly onDidChangeContentEmitter = new Emitter<MonacoModelContentChangedEvent>();
    readonly onDidChangeContent = this.onDidChangeContentEmitter.event;

    get onContentChanged(): Event<void> {
        return (listener, thisArgs, disposables) => this.onDidChangeContent(() => listener(), thisArgs, disposables);
    }

    protected readonly onDidSaveModelEmitter = new Emitter<ITextModel>();
    readonly onDidSaveModel = this.onDidSaveModelEmitter.event;

    protected readonly onDidChangeValidEmitter = new Emitter<void>();
    readonly onDidChangeValid = this.onDidChangeValidEmitter.event;

    protected readonly onDidChangeEncodingEmitter = new Emitter<string>();
    readonly onDidChangeEncoding = this.onDidChangeEncodingEmitter.event;

    readonly onDidChangeReadOnly: Event<boolean | MarkdownString> = this.resource.onDidChangeReadOnly ?? Event.None;

    private preferredEncoding: string | undefined;
    private contentEncoding: string | undefined;

    protected resourceVersion: ResourceVersion | undefined;

    protected readonly onWillSaveModelListeners: ListenerList<WillSaveMonacoModelEvent, Promise<void>> = new ListenerList;
    readonly onModelWillSaveModel = this.onWillSaveModelListeners.registration;

    constructor(
        protected readonly resource: Resource,
        protected readonly m2p: MonacoToProtocolConverter,
        protected readonly p2m: ProtocolToMonacoConverter,
        protected readonly logger?: ILogger,
        protected readonly editorPreferences?: EditorPreferences
    ) {
        this.toDispose.push(resource);
        this.toDispose.push(this.toDisposeOnAutoSave);
        this.toDispose.push(this.onDidChangeContentEmitter);
        this.toDispose.push(this.onDidSaveModelEmitter);
        this.toDispose.push(this.onDirtyChangedEmitter);
        this.toDispose.push(this.onDidChangeEncodingEmitter);
        this.toDispose.push(this.onDidChangeValidEmitter);
        this.toDispose.push(Disposable.create(() => this.cancelSave()));
        this.toDispose.push(Disposable.create(() => this.cancelSync()));
        this.resolveModel = this.readContents().then(
            content => this.initialize(content || '')
        );
    }

    undo(): void {
        this.model.undo();
    }

    redo(): void {
        this.model.redo();
    }

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

    isDisposed(): boolean {
        return this.toDispose.disposed;
    }

    resolve(): Promise<void> {
        return this.resolveModel;
    }

    isResolved(): boolean {
        return Boolean(this.model);
    }

    setEncoding(encoding: string, mode: EncodingMode): Promise<void> {
        if (mode === EncodingMode.Decode && this.dirty) {
            return Promise.resolve();
        }
        if (!this.setPreferredEncoding(encoding)) {
            return Promise.resolve();
        }
        if (mode === EncodingMode.Decode) {
            return this.sync();
        }
        return this.scheduleSave(this.cancelSave(), true, { saveReason: SaveReason.Manual });
    }

    getEncoding(): string | undefined {
        return this.preferredEncoding || this.contentEncoding;
    }

    protected setPreferredEncoding(encoding: string): boolean {
        if (encoding === this.preferredEncoding || (!this.preferredEncoding && encoding === this.contentEncoding)) {
            return false;
        }
        this.preferredEncoding = encoding;
        this.onDidChangeEncodingEmitter.fire(encoding);
        return true;
    }

    protected updateContentEncoding(): void {
        const contentEncoding = this.resource.encoding;
        if (!contentEncoding || this.contentEncoding === contentEncoding) {
            return;
        }
        this.contentEncoding = contentEncoding;
        if (!this.preferredEncoding) {
            this.onDidChangeEncodingEmitter.fire(contentEncoding);
        }
    }

    /**
     * #### Important
     * Only this method can create an instance of `monaco.editor.IModel`,
     * there should not be other calls to `monaco.editor.createModel`.
     */
    protected initialize(value: string | ITextBufferFactory): void {
        if (!this.toDispose.disposed) {
            const uri = monaco.Uri.parse(this.resource.uri.toString());
            let firstLine;
            if (typeof value === 'string') {
                firstLine = value;
                const firstLF = value.indexOf('\n');
                if (firstLF !== -1) {
                    firstLine = value.substring(0, firstLF);
                }
            } else {
                firstLine = value.getFirstLineText(1000);
            }
            const languageSelection = StandaloneServices.get(ILanguageService).createByFilepathOrFirstLine(uri, firstLine);
            this.model = StandaloneServices.get(IModelService).createModel(value, languageSelection, uri);
            this.resourceVersion = this.resource.version;
            this.setDirty(this._dirty || (!!this.resource.initiallyDirty));
            this.updateSavedVersionId();
            this.toDispose.push(this.model);
            this.toDispose.push(this.model.onDidChangeContent(event => this.fireDidChangeContent(event)));
            if (this.resource.onDidChangeContents) {
                this.toDispose.push(this.resource.onDidChangeContents(() => this.sync()));
            }
        }
    }

    /**
     * Use `valid` to access it.
     * Use `setValid` to mutate it.
     */
    protected _valid = false;
    /**
     * Whether it is possible to load content from the underlying resource.
     */
    get valid(): boolean {
        return this._valid;
    }
    protected setValid(valid: boolean): void {
        if (valid === this._valid) {
            return;
        }
        this._valid = valid;
        this.onDidChangeValidEmitter.fire(undefined);
    }

    protected _dirty = false;
    get dirty(): boolean {
        return this._dirty;
    }
    protected setDirty(dirty: boolean): void {
        if (dirty === this._dirty) {
            return;
        }
        this._dirty = dirty;
        if (dirty === false) {
            this.updateSavedVersionId();
        }
        this.onDirtyChangedEmitter.fire(undefined);
    }

    private updateSavedVersionId(): void {
        this.bufferSavedVersionId = this.model.getAlternativeVersionId();
    }

    protected readonly onDirtyChangedEmitter = new Emitter<void>();
    get onDirtyChanged(): Event<void> {
        return this.onDirtyChangedEmitter.event;
    }

    get uri(): string {
        return this.resource.uri.toString();
    }

    get autosaveable(): boolean | undefined {
        return this.resource.autosaveable;
    }

    protected _languageId: string | undefined;
    get languageId(): string {
        return this._languageId !== undefined ? this._languageId : this.model.getLanguageId();
    }

    getLanguageId(): string | undefined {
        return this.languageId;
    }

    /**
     * It's a hack to dispatch close notification with an old language id; don't use it.
     */
    setLanguageId(languageId: string | undefined): void {
        this._languageId = languageId;
    }

    get version(): number {
        return this.model.getVersionId();
    }

    /**
     * Return selected text by Range or all text by default
     */
    getText(range?: Range): string {
        if (!range) {
            return this.model.getValue();
        } else {
            return this.model.getValueInRange(this.p2m.asRange(range));
        }
    }

    positionAt(offset: number): Position {
        const { lineNumber, column } = this.model.getPositionAt(offset);
        return this.m2p.asPosition(lineNumber, column);
    }

    offsetAt(position: Position): number {
        return this.model.getOffsetAt(this.p2m.asPosition(position));
    }

    get lineCount(): number {
        return this.model.getLineCount();
    }

    /**
     * Retrieves a line in a text document expressed as a one-based position.
     */
    getLineContent(lineNumber: number): string {
        return this.model.getLineContent(lineNumber);
    }

    getLineMaxColumn(lineNumber: number): number {
        return this.model.getLineMaxColumn(lineNumber);
    }

    toValidPosition(position: Position): Position {
        const { lineNumber, column } = this.model.validatePosition(this.p2m.asPosition(position));
        return this.m2p.asPosition(lineNumber, column);
    }

    toValidRange(range: Range): Range {
        return this.m2p.asRange(this.model.validateRange(this.p2m.asRange(range)));
    }

    get readOnly(): boolean | MarkdownString {
        return this.resource.readOnly ?? false;
    }

    isReadonly(): boolean | MarkdownString {
        return this.readOnly;
    }

    get onDispose(): monaco.IEvent<void> {
        return this.toDispose.onDispose;
    }

    get onWillDispose(): Event<void> {
        return this.toDispose.onDispose;
    }

    // We have a TypeScript problem here. There is a const enum `DefaultEndOfLine` used for ITextModel and a non-const redeclaration of that enum in the public API in
    // Monaco.editor. The values will be the same, but TS won't accept that the two enums are equivalent, so it says these types are irreconcilable.
    get textEditorModel(): monaco.editor.ITextModel & ITextModel {
        // @ts-expect-error ts(2322)
        return this.model;
    }

    /**
     * Find all matches in an editor for the given options.
     * @param options the options for finding matches.
     *
     * @returns the list of matches.
     */
    findMatches(options: FindMatchesOptions): FindMatch[] {
        const wordSeparators = this.editorPreferences?.['editor.wordSeparators'] ?? editorGeneratedPreferenceProperties['editor.wordSeparators'].default as string;
        const results: monaco.editor.FindMatch[] = this.model.findMatches(
            options.searchString,
            false,
            options.isRegex,
            options.matchCase,
            // eslint-disable-next-line no-null/no-null
            options.matchWholeWord ? wordSeparators : null,
            true,
            options.limitResultCount
        );
        const extractedMatches: FindMatch[] = [];
        results.forEach(r => {
            if (r.matches) {
                extractedMatches.push({
                    matches: r.matches,
                    range: Range.create(r.range.startLineNumber, r.range.startColumn, r.range.endLineNumber, r.range.endColumn)
                });
            }
        });
        return extractedMatches;
    }

    async load(): Promise<MonacoEditorModel> {
        await this.resolveModel;
        return this;
    }

    save(options?: SaveOptions): Promise<void> {
        return this.scheduleSave(undefined, undefined, {
            saveReason: TextDocumentSaveReason.Manual,
            ...options
        });
    }

    protected pendingOperation = Promise.resolve();
    protected async run(operation: () => Promise<void>): Promise<void> {
        if (this.toDispose.disposed) {
            return;
        }
        return this.pendingOperation = this.pendingOperation.then(async () => {
            try {
                await operation();
            } catch (e) {
                console.error(e);
            }
        });
    }

    protected syncCancellationTokenSource = new CancellationTokenSource();
    protected cancelSync(): CancellationToken {
        this.trace(log => log('MonacoEditorModel.cancelSync'));
        this.syncCancellationTokenSource.cancel();
        this.syncCancellationTokenSource = new CancellationTokenSource();
        return this.syncCancellationTokenSource.token;
    }

    async sync(): Promise<void> {
        const token = this.cancelSync();
        return this.run(() => this.doSync(token));
    }
    protected async doSync(token: CancellationToken): Promise<void> {
        this.trace(log => log('MonacoEditorModel.doSync - enter'));
        if (token.isCancellationRequested) {
            this.trace(log => log('MonacoEditorModel.doSync - exit - cancelled'));
            return;
        }

        const value = await this.readContents();
        if (value === undefined) {
            this.trace(log => log('MonacoEditorModel.doSync - exit - resource not found'));
            return;
        }
        if (token.isCancellationRequested) {
            this.trace(log => log('MonacoEditorModel.doSync - exit - cancelled while looking for a resource'));
            return;
        }
        if (this._dirty) {
            this.trace(log => log('MonacoEditorModel.doSync - exit - pending dirty changes'));
            return;
        }

        this.resourceVersion = this.resource.version;
        this.updateModel(() => StandaloneServices.get(IModelService).updateModel(this.model, value), {
            ignoreDirty: true,
            ignoreContentChanges: true
        });
        this.trace(log => log('MonacoEditorModel.doSync - exit'));
    }
    protected async readContents(): Promise<string | ITextBufferFactory | undefined> {
        try {
            const options = { encoding: this.getEncoding() };
            const content = await (this.resource.readStream ? this.resource.readStream(options) : this.resource.readContents(options));
            let value;
            if (typeof content === 'string') {
                value = content;
            } else {
                value = createTextBufferFactoryFromStream(content);
            }
            this.updateContentEncoding();
            this.setValid(true);
            return value;
        } catch (e) {
            this.setValid(false);
            if (ResourceError.NotFound.is(e)) {
                return undefined;
            }
            throw e;
        }
    }

    protected ignoreDirtyEdits = false;
    protected markAsDirty(): void {
        this.trace(log => log('MonacoEditorModel.markAsDirty - enter'));
        if (this.ignoreDirtyEdits) {
            this.trace(log => log('MonacoEditorModel.markAsDirty - exit - ignoring dirty changes enabled'));
            return;
        }
        this.cancelSync();
        this.setDirty(true);
        this.trace(log => log('MonacoEditorModel.markAsDirty - exit'));
    }

    protected saveCancellationTokenSource = new CancellationTokenSource();
    protected cancelSave(): CancellationToken {
        this.trace(log => log('MonacoEditorModel.cancelSave'));
        this.saveCancellationTokenSource.cancel();
        this.saveCancellationTokenSource = new CancellationTokenSource();
        return this.saveCancellationTokenSource.token;
    }

    protected scheduleSave(token: CancellationToken = this.cancelSave(), overwriteEncoding?: boolean, options?: SaveOptions): Promise<void> {
        return this.run(() => this.doSave(token, overwriteEncoding, options));
    }

    protected ignoreContentChanges = false;
    protected readonly contentChanges: MonacoTextDocumentContentChange[] = [];
    protected pushContentChanges(contentChanges: MonacoTextDocumentContentChange[]): void {
        if (!this.ignoreContentChanges) {
            this.contentChanges.push(...contentChanges);
        }
    }

    protected fireDidChangeContent(event: monaco.editor.IModelContentChangedEvent): void {
        this.trace(log => log(`MonacoEditorModel.fireDidChangeContent - enter - ${JSON.stringify(event, undefined, 2)}`));
        if (this.model.getAlternativeVersionId() === this.bufferSavedVersionId) {
            this.setDirty(false);
        } else {
            this.markAsDirty();
        }

        const changeContentEvent = this.asContentChangedEvent(event);
        this.onDidChangeContentEmitter.fire(changeContentEvent);
        this.pushContentChanges(changeContentEvent.contentChanges);
        this.trace(log => log('MonacoEditorModel.fireDidChangeContent - exit'));
    }
    protected asContentChangedEvent(event: monaco.editor.IModelContentChangedEvent): MonacoModelContentChangedEvent {
        const contentChanges = event.changes.map(change => this.asTextDocumentContentChangeEvent(change));
        return { model: this, contentChanges };
    }
    protected asTextDocumentContentChangeEvent(change: monaco.editor.IModelContentChange): MonacoTextDocumentContentChange {
        const range = this.m2p.asRange(change.range);
        const rangeOffset = change.rangeOffset;
        const rangeLength = change.rangeLength;
        const text = change.text;
        return { range, rangeOffset, rangeLength, text };
    }

    protected applyEdits(
        operations: monaco.editor.IIdentifiedSingleEditOperation[],
        options?: Partial<MonacoEditorModel.ApplyEditsOptions>
    ): void {
        return this.updateModel(() => this.model.applyEdits(operations), options);
    }

    protected updateModel<T>(doUpdate: () => T, options?: Partial<MonacoEditorModel.ApplyEditsOptions>): T {
        const resolvedOptions: MonacoEditorModel.ApplyEditsOptions = {
            ignoreDirty: false,
            ignoreContentChanges: false,
            ...options
        };
        const { ignoreDirtyEdits, ignoreContentChanges } = this;
        this.ignoreDirtyEdits = resolvedOptions.ignoreDirty;
        this.ignoreContentChanges = resolvedOptions.ignoreContentChanges;
        try {
            return doUpdate();
        } finally {
            this.ignoreDirtyEdits = ignoreDirtyEdits;
            this.ignoreContentChanges = ignoreContentChanges;
        }
    }

    protected async doSave(token: CancellationToken, overwriteEncoding?: boolean, options?: SaveOptions): Promise<void> {
        if (token.isCancellationRequested || !this.resource.saveContents) {
            return;
        }

        await this.fireWillSaveModel(token, options);
        if (token.isCancellationRequested) {
            return;
        }

        const changes = [...this.contentChanges];
        if ((changes.length === 0 && !this.resource.initiallyDirty) && !overwriteEncoding && options?.saveReason !== TextDocumentSaveReason.Manual) {
            return;
        }

        const currentToSaveVersion = this.model.getAlternativeVersionId();
        const contentLength = this.model.getValueLength();
        const content = this.model.getValue();
        try {
            const encoding = this.getEncoding();
            const version = this.resourceVersion;
            await Resource.save(this.resource, { changes, content, contentLength, options: { encoding, overwriteEncoding, version } }, token);
            this.contentChanges.splice(0, changes.length);
            this.resourceVersion = this.resource.version;
            this.updateContentEncoding();
            this.setValid(true);

            if (token.isCancellationRequested && this.model.getAlternativeVersionId() !== currentToSaveVersion) {
                return;
            }

            this.setDirty(false);
            this.fireDidSaveModel();
        } catch (e) {
            if (!ResourceError.OutOfSync.is(e)) {
                throw e;
            }
        }
    }

    protected async fireWillSaveModel(token: CancellationToken, options?: SaveOptions): Promise<void> {
        await Listener.awaitAll({ model: this, token, options }, this.onWillSaveModelListeners);
    }

    protected fireDidSaveModel(): void {
        this.onDidSaveModelEmitter.fire(this.model);
    }

    async revert(options?: Saveable.RevertOptions): Promise<void> {
        this.trace(log => log('MonacoEditorModel.revert - enter'));
        this.cancelSave();
        const soft = options && options.soft;
        if (soft !== true) {
            const dirty = this._dirty;
            this._dirty = false;
            try {
                await this.sync();
            } finally {
                this._dirty = dirty;
            }
        }
        this.setDirty(false);
        this.trace(log => log('MonacoEditorModel.revert - exit'));
    }

    createSnapshot(preserveBOM?: boolean): ITextSnapshot {
        return { read: () => this.model.getValue(undefined, preserveBOM) };
    }

    applySnapshot(snapshot: Saveable.Snapshot): void {
        const value = Saveable.Snapshot.read(snapshot) ?? '';
        this.model.setValue(value);
    }

    async serialize(): Promise<BinaryBuffer> {
        return BinaryBuffer.fromString(this.model.getValue());
    }

    filters(): { [name: string]: string[] } {
        const language = monaco.languages.getLanguages().find(lang => lang.id === this.languageId);
        if (!language || !language.extensions) {
            return {};
        }
        const name = language.aliases?.[0] || this.languageId;
        const extensions = language.extensions.map(ext => ext.startsWith('.') ? ext.substring(1) : ext);
        return { [name]: extensions };
    }

    protected trace(loggable: Loggable): void {
        if (this.logger) {
            this.logger.debug((log: Log) =>
                loggable((message, ...params) => log(message, ...params, this.resource.uri.toString(true)))
            );
        }
    }

}
export namespace MonacoEditorModel {
    export interface ApplyEditsOptions {
        ignoreDirty: boolean
        ignoreContentChanges: boolean
    }
}
