// *****************************************************************************
// 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
// *****************************************************************************
/*---------------------------------------------------------------------------------------------
 *  Copyright (c) Microsoft Corporation. All rights reserved.
 *  Licensed under the MIT License. See License.txt in the project root for license information.
 *--------------------------------------------------------------------------------------------*/
// copied and modified from https://github.com/Microsoft/vscode/blob/master/src/vs/workbench/services/workspace/node/workspaceEditingService.ts

import * as paths from 'path';
import * as theia from '@theia/plugin';
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
import { Event, Emitter } from '@theia/core/lib/common/event';
import { CancellationToken } from '@theia/core/lib/common/cancellation';
import {
    WorkspaceExt,
    WorkspaceFolderPickOptionsMain,
    WorkspaceMain,
    PLUGIN_RPC_CONTEXT as Ext,
    MainMessageType,
} from '../common/plugin-api-rpc';
import { Path } from '@theia/core/lib/common/path';
import { RPCProtocol } from '../common/rpc-protocol';
import { WorkspaceRootsChangeEvent, SearchInWorkspaceResult, Range } from '../common/plugin-api-rpc-model';
import { EditorsAndDocumentsExtImpl } from './editors-and-documents';
import { Disposable, URI } from './types-impl';
import { normalize } from '@theia/core/lib/common/paths';
import { relative } from '../common/paths-util';
import { Schemes, UriComponents } from '../common/uri-components';
import { MessageRegistryExt } from './message-registry';
import * as Converter from './type-converters';
import { FileStat } from '@theia/filesystem/lib/common/files';
import { isUndefinedOrNull, isUndefined } from '../common/types';
import { PluginLogger } from './logger';
import { consumeStream } from '@theia/core/lib/common/stream';
import { EncodingService } from '@theia/core/lib/common/encoding-service';
import { BinaryBuffer, BinaryBufferReadableStream } from '@theia/core/lib/common/buffer';

@injectable()
export class WorkspaceExtImpl implements WorkspaceExt {

    @inject(RPCProtocol)
    protected readonly rpc: RPCProtocol;

    @inject(EditorsAndDocumentsExtImpl)
    protected editorsAndDocuments: EditorsAndDocumentsExtImpl;

    @inject(MessageRegistryExt)
    protected messageService: MessageRegistryExt;

    private proxy: WorkspaceMain;
    private logger: PluginLogger;

    @inject(EncodingService)
    protected encodingService: EncodingService;

    private workspaceFoldersChangedEmitter = new Emitter<theia.WorkspaceFoldersChangeEvent>();
    public readonly onDidChangeWorkspaceFolders: Event<theia.WorkspaceFoldersChangeEvent> = this.workspaceFoldersChangedEmitter.event;

    private folders: theia.WorkspaceFolder[] | undefined;
    private workspaceFileUri: theia.Uri | undefined;
    private documentContentProviders = new Map<string, theia.TextDocumentContentProvider>();
    private searchInWorkspaceEmitter: Emitter<{ result?: theia.TextSearchResult, searchId: number }> = new Emitter<{ result?: theia.TextSearchResult, searchId: number }>();
    protected workspaceSearchSequence: number = 0;

    private _trusted?: boolean = undefined;
    private didGrantWorkspaceTrustEmitter = new Emitter<void>();
    public readonly onDidGrantWorkspaceTrust: Event<void> = this.didGrantWorkspaceTrustEmitter.event;
    private didChangeWorkspaceTrustEmitter = new Emitter<boolean>();
    public readonly onDidChangeWorkspaceTrust: Event<boolean> = this.didChangeWorkspaceTrustEmitter.event;

    private canonicalUriProviders = new Map<string, theia.CanonicalUriProvider>();

    @postConstruct()
    initialize(): void {
        this.proxy = this.rpc.getProxy(Ext.WORKSPACE_MAIN);
        this.logger = new PluginLogger(this.rpc, 'workspace');
    }

    get rootPath(): string | undefined {
        const folder = this.folders && this.folders[0];
        return folder && folder.uri.fsPath;
    }

    get workspaceFolders(): theia.WorkspaceFolder[] | undefined {
        if (this.folders && this.folders.length === 0) {
            return undefined;
        }
        return this.folders;
    }

    get workspaceFile(): theia.Uri | undefined {
        return this.workspaceFileUri;
    }

    get name(): string | undefined {
        if (this.workspaceFolders && this.workspaceFolders.length > 0) {
            return new Path(this.workspaceFolders[0].uri.path).base;
        }

        return undefined;
    }

    resolveProxy(url: string): Promise<string | undefined> {
        return this.proxy.$resolveProxy(url);
    }

    $onWorkspaceFoldersChanged(event: WorkspaceRootsChangeEvent): void {
        const newRoots = event.roots || [];
        const oldFoldersByUri = new Map<string, theia.WorkspaceFolder>();
        if (this.folders) {
            for (const folder of this.folders) {
                oldFoldersByUri.set(folder.uri.toString(), folder);
            }
        }
        const newFolders = newRoots.map((root, index) => {
            const existing = oldFoldersByUri.get(root);
            if (existing) {
                // Preserve object identity even if the index changed,
                // since extensions may use folder objects as Map keys.
                Object.assign(existing, { index });
                return existing;
            }
            return this.toWorkspaceFolder(root, index);
        });
        const delta = this.deltaFolders(this.folders, newFolders);

        this.folders = newFolders;

        this.refreshWorkspaceFile();

        this.workspaceFoldersChangedEmitter.fire(delta);
    }

    $onWorkspaceLocationChanged(stat: FileStat | undefined): void {
        this.updateWorkSpace(stat);
    }

    $onTextSearchResult(searchRequestId: number, done: boolean, result?: SearchInWorkspaceResult): void {
        if (result) {
            result.matches.map(next => {
                const range: Range = {
                    endColumn: next.character + next.length,
                    endLineNumber: next.line + 1,
                    startColumn: next.character,
                    startLineNumber: next.line + 1
                };
                const tRange = <theia.Range>Converter.toRange(range);
                const searchResult: theia.TextSearchMatch = {
                    uri: URI.parse(result.fileUri),
                    preview: {
                        text: typeof next.lineText === 'string' ? next.lineText : next.lineText.text,
                        matches: tRange
                    },
                    ranges: tRange
                };
                return searchResult;
            }).forEach(next => this.searchInWorkspaceEmitter.fire({ result: next, searchId: searchRequestId }));
        } else if (done) {
            this.searchInWorkspaceEmitter.fire({ searchId: searchRequestId });
        }
    }

    private deltaFolders(currentFolders: theia.WorkspaceFolder[] = [], newFolders: theia.WorkspaceFolder[] = []): {
        added: theia.WorkspaceFolder[]
        removed: theia.WorkspaceFolder[]
    } {
        const added = this.foldersDiff(newFolders, currentFolders);
        const removed = this.foldersDiff(currentFolders, newFolders);
        return { added, removed };
    }

    private foldersDiff(folder1: theia.WorkspaceFolder[] = [], folder2: theia.WorkspaceFolder[] = []): theia.WorkspaceFolder[] {
        const map = new Map();
        folder1.forEach(folder => map.set(folder.uri.toString(), folder));
        folder2.forEach(folder => map.delete(folder.uri.toString()));

        return folder1.filter(folder => map.has(folder.uri.toString()));
    }

    private toWorkspaceFolder(root: string, index: number): theia.WorkspaceFolder {
        const uri = URI.parse(root);
        const path = new Path(uri.path);
        return {
            uri: uri,
            name: path.base,
            index: index
        };
    }

    pickWorkspaceFolder(options?: theia.WorkspaceFolderPickOptions): PromiseLike<theia.WorkspaceFolder | undefined> {
        return new Promise((resolve, reject) => {
            const optionsMain: WorkspaceFolderPickOptionsMain = {
                placeHolder: options && options.placeHolder ? options.placeHolder : undefined,
                ignoreFocusOut: options && options.ignoreFocusOut
            };

            this.proxy.$pickWorkspaceFolder(optionsMain).then(value => {
                resolve(value);
            });
        });
    }

    findFiles(include: theia.GlobPattern, exclude?: theia.GlobPattern | null, maxResults?: number, token: CancellationToken = CancellationToken.None): PromiseLike<URI[]> {
        let includePattern: string;
        let includeFolderUri: string | undefined;
        if (include) {
            if (typeof include === 'string') {
                includePattern = include;
            } else {
                includePattern = include.pattern;
                includeFolderUri = include.baseUri.toString();
            }
        } else {
            includePattern = '';
        }

        let excludeString: string = '';
        let useFileExcludes = true;
        if (exclude === null) {
            useFileExcludes = false;
        } else if (exclude !== undefined) {
            if (typeof exclude === 'string') {
                excludeString = exclude;
            } else {
                excludeString = exclude.pattern;
            }
        }

        if (token && token.isCancellationRequested) {
            return Promise.resolve([]);
        }

        return this.proxy.$startFileSearch(includePattern, includeFolderUri, {
            maxResults,
            exclude: excludeString,
            useDefaultExcludes: useFileExcludes,
            useDefaultSearchExcludes: false,
            useIgnoreFiles: false
        }, token).then(data => Array.isArray(data) ? data.map(uri => URI.revive(uri)) : []);
    }

    findTextInFiles(query: theia.TextSearchQuery, optionsOrCallback: theia.FindTextInFilesOptions | ((result: theia.TextSearchResult) => void),
        callbackOrToken?: CancellationToken | ((result: theia.TextSearchResult) => void), token?: CancellationToken): Promise<theia.TextSearchComplete> {
        let options: theia.FindTextInFilesOptions;
        let callback: (result: theia.TextSearchResult) => void;

        if (typeof optionsOrCallback === 'object') {
            options = optionsOrCallback;
            callback = callbackOrToken as (result: theia.TextSearchResult) => void;
        } else {
            options = {};
            callback = optionsOrCallback;
            token = callbackOrToken as CancellationToken;
        }
        const nextSearchID = this.workspaceSearchSequence + 1;
        this.workspaceSearchSequence = nextSearchID;
        const disposable = this.searchInWorkspaceEmitter.event(searchResult => {
            if (searchResult.searchId === nextSearchID) {
                if (searchResult.result) {
                    callback(searchResult.result);
                } else {
                    disposable.dispose();
                }
            }
        });
        if (token) {
            token.onCancellationRequested(() => {
                disposable.dispose();
            });
        }
        return this.proxy.$findTextInFiles(query, options || {}, nextSearchID, token);
    }

    registerTextDocumentContentProvider(scheme: string, provider: theia.TextDocumentContentProvider): theia.Disposable {
        // `file` and `untitled` schemas are reserved by `workspace.openTextDocument` API:
        // `file`-scheme for opening a file
        // `untitled`-scheme for opening a new file that should be saved
        if (scheme === Schemes.file || scheme === Schemes.untitled) {
            throw new Error(`Text Content Document Provider for scheme '${scheme}' is already registered`);
        } else if (this.documentContentProviders.has(scheme)) {
            // TODO: we should be able to handle multiple registrations, but for now we should ensure that it doesn't crash plugin activation.
            this.logger.warn(`Repeat registration of TextContentDocumentProvider for scheme '${scheme}'. This registration will be ignored.`);
            return { dispose: () => { } };
        }

        this.documentContentProviders.set(scheme, provider);
        this.proxy.$registerTextDocumentContentProvider(scheme);

        let onDidChangeSubscription: theia.Disposable;
        if (typeof provider.onDidChange === 'function') {
            onDidChangeSubscription = provider.onDidChange(async uri => {
                if (uri.scheme === scheme && this.editorsAndDocuments.getDocument(uri.toString())) {
                    const content = await this.$provideTextDocumentContent(uri.toString());
                    if (content) {
                        this.proxy.$onTextDocumentContentChange(uri.toString(), content);
                    }
                }
            });
        }

        const instance = this;
        return {
            dispose(): void {
                if (instance.documentContentProviders.delete(scheme)) {
                    instance.proxy.$unregisterTextDocumentContentProvider(scheme);
                }

                if (onDidChangeSubscription) {
                    onDidChangeSubscription.dispose();
                }
            }
        };
    }

    async $provideTextDocumentContent(documentURI: string): Promise<string | undefined | null> {
        const uri = URI.parse(documentURI);
        const provider = this.documentContentProviders.get(uri.scheme);
        if (provider) {
            return provider.provideTextDocumentContent(uri, CancellationToken.None);
        }

        return undefined;
    }

    getWorkspaceFolder(uri: theia.Uri, resolveParent?: boolean): theia.WorkspaceFolder | undefined {
        if (!this.folders || !this.folders.length) {
            return undefined;
        }

        function dirname(resource: URI): URI {
            if (resource.scheme === 'file') {
                return URI.file(paths.dirname(resource.fsPath));
            }
            return resource.with({
                path: paths.dirname(resource.path)
            });
        }

        if (resolveParent && this.hasFolder(uri)) {
            uri = dirname(uri);
        }

        const resourcePath = uri.toString();

        let workspaceFolder: theia.WorkspaceFolder | undefined;
        for (let i = 0; i < this.folders.length; i++) {
            const folder = this.folders[i];
            const folderPath = folder.uri.toString();

            if (resourcePath === folderPath) {
                return folder;
            }

            if (resourcePath.startsWith(folderPath)
                && resourcePath[folderPath.length] === '/'
                && (!workspaceFolder || folderPath.length > workspaceFolder.uri.toString().length)) {
                workspaceFolder = folder;
            }
        }
        return workspaceFolder;
    }

    private hasFolder(uri: URI): boolean {
        if (!this.folders) {
            return false;
        }
        return this.folders.some(folder => folder.uri.toString() === uri.toString());
    }

    getRelativePath(pathOrUri: string | theia.Uri, includeWorkspace?: boolean): string | undefined {
        let path: string | undefined;
        if (typeof pathOrUri === 'string') {
            path = pathOrUri;
        } else if (typeof pathOrUri !== 'undefined') {
            path = pathOrUri.fsPath;
        }

        if (!path) {
            return path;
        }

        const folder = this.getWorkspaceFolder(
            typeof pathOrUri === 'string' ? URI.file(pathOrUri) : pathOrUri,
            true
        ) as theia.WorkspaceFolder;

        if (!folder) {
            return path;
        }

        if (typeof includeWorkspace === 'undefined') {
            includeWorkspace = this.folders!.length > 1;
        }

        let result = relative(folder.uri.fsPath, path);
        if (includeWorkspace) {
            result = `${folder.name}/${result}`;
        }
        return normalize(result, true);
    }

    updateWorkspaceFolders(start: number, deleteCount: number, ...workspaceFoldersToAdd: { uri: theia.Uri, name?: string }[]): boolean {
        const rootsToAdd = new Set<string>();
        if (Array.isArray(workspaceFoldersToAdd)) {
            workspaceFoldersToAdd.forEach(folderToAdd => {
                const uri = URI.isUri(folderToAdd.uri) && folderToAdd.uri.toString();
                if (uri && !rootsToAdd.has(uri)) {
                    rootsToAdd.add(uri);
                }
            });
        }

        if ([start, deleteCount].some(i => typeof i !== 'number' || i < 0)) {
            return false; // validate numbers
        }

        if (deleteCount === 0 && rootsToAdd.size === 0) {
            return false; // nothing to delete or add
        }

        const currentWorkspaceFolders = this.workspaceFolders || [];
        if (start + deleteCount > currentWorkspaceFolders.length) {
            return false; // cannot delete more than we have
        }

        // Simulate the updateWorkspaceFolders method on our data to do more validation
        const newWorkspaceFolders = currentWorkspaceFolders.slice(0);
        newWorkspaceFolders.splice(start, deleteCount, ...[...rootsToAdd].map(uri => ({ uri: URI.parse(uri), name: undefined!, index: undefined! })));

        for (let i = 0; i < newWorkspaceFolders.length; i++) {
            const folder = newWorkspaceFolders[i];
            if (newWorkspaceFolders.some((otherFolder, index) => index !== i && folder.uri.toString() === otherFolder.uri.toString())) {
                return false; // cannot add the same folder multiple times
            }
        }

        const { added, removed } = this.deltaFolders(currentWorkspaceFolders, newWorkspaceFolders);
        if (added.length === 0 && removed.length === 0) {
            return false; // nothing actually changed
        }

        // Trigger on main side
        this.proxy.$updateWorkspaceFolders(start, deleteCount, ...rootsToAdd).then(undefined, error =>
            this.messageService.showMessage(MainMessageType.Error, `Failed to update workspace folders: ${error}`)
        );

        return true;
    }

    private async refreshWorkspaceFile(): Promise<void> {
        const workspace = await this.proxy.$getWorkspace();
        this.updateWorkSpace(workspace);
    }

    private updateWorkSpace(workspace: FileStat | undefined): void {
        // A workspace directory implies an undefined workspace file
        if (workspace && !workspace.isDirectory) {
            this.workspaceFileUri = URI.parse(workspace.resource.toString());
        }
    }

    get trusted(): boolean {
        if (this._trusted === undefined) {
            this.requestWorkspaceTrust();
        }
        return !!this._trusted;
    }

    requestWorkspaceTrust(options?: theia.WorkspaceTrustRequestOptions): Promise<boolean | undefined> {
        return this.proxy.$requestWorkspaceTrust(options);
    }

    $onWorkspaceTrustChanged(trust: boolean | undefined): void {
        const wasTrusted = this._trusted;
        this._trusted = trust;

        // Fire onDidChangeWorkspaceTrust if value actually changed
        if (wasTrusted !== trust && trust !== undefined) {
            this.didChangeWorkspaceTrustEmitter.fire(trust);
        }

        // Fire onDidGrantWorkspaceTrust when transitioning from untrusted to trusted
        if (!wasTrusted && trust) {
            this.didGrantWorkspaceTrustEmitter.fire();
        }
    }

    registerCanonicalUriProvider(scheme: string, provider: theia.CanonicalUriProvider): theia.Disposable {
        if (this.canonicalUriProviders.has(scheme)) {
            throw new Error(`Canonical URI provider for scheme: '${scheme}' already exists locally`);
        }

        this.canonicalUriProviders.set(scheme, provider);
        this.proxy.$registerCanonicalUriProvider(scheme).catch(e => {
            this.logger.error(`Canonical URI provider for scheme: '${scheme}' already exists globally`);
            this.canonicalUriProviders.delete(scheme);
        });
        const result = Disposable.create(() => { this.proxy.$unregisterCanonicalUriProvider(scheme); });
        return result;
    }

    $disposeCanonicalUriProvider(scheme: string): void {
        if (!this.canonicalUriProviders.delete(scheme)) {
            this.logger.warn(`diposeCanonicalUriProvider: No canonical uri provider registered for '${scheme}'`);
        }
    }

    async getCanonicalUri(uri: theia.Uri, options: theia.CanonicalUriRequestOptions, token: theia.CancellationToken): Promise<theia.Uri | undefined> {
        const canonicalUri = await this.proxy.$getCanonicalUri(uri.toString(), options.targetScheme, token);
        return isUndefined(canonicalUri) ? undefined : URI.parse(canonicalUri);
    }

    async $provideCanonicalUri(uri: string, targetScheme: string, token: CancellationToken): Promise<string | undefined> {
        const parsed = URI.parse(uri);
        const provider = this.canonicalUriProviders.get(parsed.scheme);
        if (!provider) {
            this.logger.warn(`provideCanonicalUri: No canonical uri provider registered for '${parsed.scheme}'`);
            return undefined;
        }
        const result = await provider.provideCanonicalUri(parsed, { targetScheme: targetScheme }, token);
        return isUndefinedOrNull(result) ? undefined : result.toString();
    }

    /** @stubbed */
    $registerEditSessionIdentityProvider(scheme: string, provider: theia.EditSessionIdentityProvider): theia.Disposable {
        return Disposable.NULL;
    }

    async decode(content: Uint8Array, args?: { uri?: theia.Uri; encoding?: string }): Promise<string> {
        const [uri, opts] = this.asEncodeDecodeParameters(args);
        const { preferredEncoding, guessEncoding } = await this.proxy.$resolveDecoding(uri, opts);
        const decodeStreamOptions = {
            guessEncoding,
            overwriteEncoding: (detectedEncoding: string | undefined) => {
                if (detectedEncoding === null || detectedEncoding === preferredEncoding) {
                    return Promise.resolve(preferredEncoding);
                }

                return this.proxy.$getValidEncoding(uri, detectedEncoding, opts);
            }
        };
        const stream = (await this.encodingService.decodeStream(BinaryBufferReadableStream.fromBuffer(BinaryBuffer.wrap(content)), decodeStreamOptions)).stream;
        return consumeStream(stream, s => s.join(''));

    }

    async encode(content: string, args?: { uri?: theia.Uri; encoding?: string }): Promise<Uint8Array> {
        const [uri, options] = this.asEncodeDecodeParameters(args);
        const { encoding, hasBOM } = await this.proxy.$resolveEncoding(uri, options);
        const buffer = this.encodingService.encode(content, { encoding, hasBOM });
        return buffer.buffer;
    }

    private asEncodeDecodeParameters(opts?: { uri?: theia.Uri; encoding?: string }): [UriComponents | undefined, { encoding: string } | undefined] {
        const uri = Converter.isUriComponents(opts?.uri) ? opts.uri : undefined;
        const encoding = typeof opts?.encoding === 'string' ? opts.encoding : undefined;

        return [uri, encoding ? { encoding } : undefined];
    }
}
