// *****************************************************************************
// Copyright (C) 2019-2021 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.
 *--------------------------------------------------------------------------------------------*/
// code copied and modified from https://github.com/microsoft/vscode/blob/1.52.1/src/vs/workbench/api/common/extHostSCM.ts

import * as theia from '@theia/plugin';
import { Emitter, Event } from '@theia/core/lib/common/event';
import {
    Plugin, PLUGIN_RPC_CONTEXT,
    ScmExt,
    ScmMain, ScmRawResource, ScmRawResourceGroup,
    ScmRawResourceSplice, ScmRawResourceSplices,
    SourceControlGroupFeatures,
    ScmActionButton,
    ScmHistoryItemRefDto,
    ScmHistoryItemDto,
    ScmHistoryItemChangeDto,
    ScmHistoryOptionsDto,
    ScmHistoryItemRefsChangeEventDto
} from '../common';
import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable';
import { CancellationToken } from '@theia/core/lib/common/cancellation';
import { CommandRegistryImpl } from '../plugin/command-registry';
import { Splice } from '../common/arrays';
import { UriComponents } from '../common/uri-components';
import { Command } from '../common/plugin-api-rpc-model';
import { RPCProtocol } from '../common/rpc-protocol';
import { URI, ThemeIcon } from './types-impl';
import { ScmCommandArg, ScmHistoryItemCommandArg } from '../common/plugin-api-rpc';
import { sep } from '@theia/core/lib/common/paths';
import { PluginIconPath } from './plugin-icon-path';
import { createAPIObject } from './plugin-context';

type ProviderHandle = number;
type GroupHandle = number;
type ResourceStateHandle = number;

function getIconResource(decorations?: theia.SourceControlResourceThemableDecorations): UriComponents | ThemeIcon | undefined {
    if (!decorations || !decorations.iconPath) {
        return undefined;
    } else if (typeof decorations.iconPath === 'string') {
        return URI.file(decorations.iconPath);
    } else if (URI.isUri(decorations.iconPath)) {
        return decorations.iconPath;
    } else if (ThemeIcon.is(decorations.iconPath)) {
        return decorations.iconPath;
    } else {
        console.warn(`Unexpected Value ${decorations.iconPath} in Source Control Resource Themable Decoration. URI, ThemeIcon or string expected.`);
        return undefined;
    }
}

function comparePaths(one: string, other: string, caseSensitive = false): number {
    const oneParts = one.split(sep);
    const otherParts = other.split(sep);

    const lastOne = oneParts.length - 1;
    const lastOther = otherParts.length - 1;
    let endOne: boolean;
    let endOther: boolean;

    for (let i = 0; ; i++) {
        endOne = lastOne === i;
        endOther = lastOther === i;

        if (endOne && endOther) {
            const onePart = caseSensitive ? oneParts[i].toLocaleLowerCase() : oneParts[i];
            const otherPart = caseSensitive ? otherParts[i].toLocaleLowerCase() : otherParts[i];
            return onePart > otherPart ? -1 : 1;
        } else if (endOne) {
            return -1;
        } else if (endOther) {
            return 1;
        }

        if (endOne) {
            return -1;
        } else if (endOther) {
            return 1;
        }

        const result = comparePathComponents(oneParts[i], otherParts[i], caseSensitive);

        if (result !== 0) {
            return result;
        }
    }
}

function comparePathComponents(one: string, other: string, caseSensitive = false): number {
    if (!caseSensitive) {
        one = one && one.toLowerCase();
        other = other && other.toLowerCase();
    }

    if (one === other) {
        return 0;
    }

    return one < other ? -1 : 1;
}

function compareResourceThemableDecorations(a: theia.SourceControlResourceThemableDecorations, b: theia.SourceControlResourceThemableDecorations): number {
    if (!a.iconPath && !b.iconPath) {
        return 0;
    } else if (!a.iconPath) {
        return -1;
    } else if (!b.iconPath) {
        return 1;
    }

    const aPath = typeof a.iconPath === 'string' ? a.iconPath : URI.isUri(a.iconPath) ? a.iconPath.fsPath : (a.iconPath as ThemeIcon).id;
    const bPath = typeof b.iconPath === 'string' ? b.iconPath : URI.isUri(b.iconPath) ? b.iconPath.fsPath : (b.iconPath as ThemeIcon).id;
    return comparePaths(aPath, bPath);
}

function compareResourceStatesDecorations(a: theia.SourceControlResourceDecorations, b: theia.SourceControlResourceDecorations): number {
    let result = 0;

    if (a.strikeThrough !== b.strikeThrough) {
        return a.strikeThrough ? 1 : -1;
    }

    if (a.faded !== b.faded) {
        return a.faded ? 1 : -1;
    }

    if (a.tooltip !== b.tooltip) {
        return (a.tooltip || '').localeCompare(b.tooltip || '');
    }

    result = compareResourceThemableDecorations(a, b);

    if (result !== 0) {
        return result;
    }

    if (a.light && b.light) {
        result = compareResourceThemableDecorations(a.light, b.light);
    } else if (a.light) {
        return 1;
    } else if (b.light) {
        return -1;
    }

    if (result !== 0) {
        return result;
    }

    if (a.dark && b.dark) {
        result = compareResourceThemableDecorations(a.dark, b.dark);
    } else if (a.dark) {
        return 1;
    } else if (b.dark) {
        return -1;
    }

    return result;
}

function compareCommands(a: theia.Command, b: theia.Command): number {
    if (a.command !== b.command) {
        return a.command! < b.command! ? -1 : 1;
    }

    if (a.title !== b.title) {
        return a.title! < b.title! ? -1 : 1;
    }

    if (a.tooltip !== b.tooltip) {
        if (a.tooltip !== undefined && b.tooltip !== undefined) {
            return a.tooltip < b.tooltip ? -1 : 1;
        } else if (a.tooltip !== undefined) {
            return 1;
        } else if (b.tooltip !== undefined) {
            return -1;
        }
    }

    if (a.arguments === b.arguments) {
        return 0;
    } else if (!a.arguments) {
        return -1;
    } else if (!b.arguments) {
        return 1;
    } else if (a.arguments.length !== b.arguments.length) {
        return a.arguments.length - b.arguments.length;
    }

    for (let i = 0; i < a.arguments.length; i++) {
        const aArg = a.arguments[i];
        const bArg = b.arguments[i];

        if (aArg === bArg) {
            continue;
        }

        return aArg < bArg ? -1 : 1;
    }

    return 0;
}

function compareResourceStates(a: theia.SourceControlResourceState, b: theia.SourceControlResourceState): number {
    let result = comparePaths(a.resourceUri.fsPath, b.resourceUri.fsPath, true);

    if (result !== 0) {
        return result;
    }

    if (a.command && b.command) {
        result = compareCommands(a.command, b.command);
    } else if (a.command) {
        return 1;
    } else if (b.command) {
        return -1;
    }

    if (result !== 0) {
        return result;
    }

    if (a.decorations && b.decorations) {
        result = compareResourceStatesDecorations(a.decorations, b.decorations);
    } else if (a.decorations) {
        return 1;
    } else if (b.decorations) {
        return -1;
    }

    return result;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function compareArgs(a: any[], b: any[]): boolean {
    for (let i = 0; i < a.length; i++) {
        if (a[i] !== b[i]) {
            return false;
        }
    }

    return true;
}

function commandEquals(a: theia.Command, b: theia.Command): boolean {
    return a.command === b.command
        && a.title === b.title
        && a.tooltip === b.tooltip
        && (a.arguments && b.arguments ? compareArgs(a.arguments, b.arguments) : a.arguments === b.arguments);
}

function commandListEquals(a: readonly theia.Command[], b: readonly theia.Command[]): boolean {
    return equals(a, b, commandEquals);
}

function equals<T>(one: ReadonlyArray<T> | undefined, other: ReadonlyArray<T> | undefined, itemEquals: (a: T, b: T) => boolean = (a, b) => a === b): boolean {
    if (one === other) {
        return true;
    }

    if (!one || !other) {
        return false;
    }

    if (one.length !== other.length) {
        return false;
    }

    for (let i = 0, len = one.length; i < len; i++) {
        if (!itemEquals(one[i], other[i])) {
            return false;
        }
    }

    return true;
}

interface ValidateInput {
    (value: string, cursorPosition: number): theia.ProviderResult<theia.SourceControlInputBoxValidation | undefined | null>;
}

export class ScmInputBoxImpl implements theia.SourceControlInputBox {

    private _value: string = '';
    apiObject: theia.SourceControlInputBox;

    get value(): string {
        return this._value;
    }

    set value(value: string) {
        this.proxy.$setInputBoxValue(this.sourceControlHandle, value);
        this.updateValue(value);
    }

    private readonly onDidChangeEmitter = new Emitter<string>();

    get onDidChange(): Event<string> {
        return this.onDidChangeEmitter.event;
    }

    private _placeholder: string = '';

    get placeholder(): string {
        return this._placeholder;
    }

    set placeholder(placeholder: string) {
        this.proxy.$setInputBoxPlaceholder(this.sourceControlHandle, placeholder);
        this._placeholder = placeholder;
    }

    private _visible: boolean = true;

    get visible(): boolean {
        return this._visible;
    }

    set visible(visible: boolean) {
        this.proxy.$setInputBoxVisible(this.sourceControlHandle, visible);
        this._visible = visible;
    }

    private _enabled: boolean;

    get enabled(): boolean {
        return this._enabled;
    }

    set enabled(enabled: boolean) {
        this.proxy.$setInputBoxEnabled(this.sourceControlHandle, enabled);
        this._enabled = enabled;
    }

    private _validateInput: ValidateInput | undefined;

    get validateInput(): ValidateInput | undefined {
        return this._validateInput;
    }

    set validateInput(fn: ValidateInput | undefined) {
        if (fn && typeof fn !== 'function') {
            throw new Error(`[${this.plugin.model.id}]: Invalid SCM input box validation function`);
        }

        this._validateInput = fn;
    }

    constructor(private plugin: Plugin, private proxy: ScmMain, private sourceControlHandle: number) {
        this.apiObject = createAPIObject(this);
    }

    onInputBoxValueChange(value: string): void {
        this.updateValue(value);
    }

    private updateValue(value: string): void {
        this._value = value;
        this.onDidChangeEmitter.fire(value);
    }
}

class ScmResourceGroupImpl implements theia.SourceControlResourceGroup {

    private static handlePool: number = 0;
    private resourceHandlePool: number = 0;
    private _resourceStates: theia.SourceControlResourceState[] = [];

    private resourceStatesMap = new Map<ResourceStateHandle, theia.SourceControlResourceState>();
    private resourceStatesCommandsMap = new Map<ResourceStateHandle, theia.Command>();
    private resourceStatesDisposablesMap = new Map<ResourceStateHandle, Disposable>();

    private readonly onDidUpdateResourceStatesEmitter = new Emitter<void>();
    readonly onDidUpdateResourceStates = this.onDidUpdateResourceStatesEmitter.event;

    private _disposed = false;
    get disposed(): boolean { return this._disposed; }
    private readonly onDidDisposeEmitter = new Emitter<void>();
    readonly onDidDispose = this.onDidDisposeEmitter.event;

    private handlesSnapshot: number[] = [];
    private resourceSnapshot: theia.SourceControlResourceState[] = [];

    get id(): string { return this._id; }

    get label(): string { return this._label; }
    set label(label: string) {
        this._label = label;
        this.proxy.$updateGroupLabel(this.sourceControlHandle, this.handle, label);
    }

    private _hideWhenEmpty: boolean | undefined = undefined;
    get hideWhenEmpty(): boolean | undefined { return this._hideWhenEmpty; }
    set hideWhenEmpty(hideWhenEmpty: boolean | undefined) {
        this._hideWhenEmpty = hideWhenEmpty;
        this.proxy.$updateGroup(this.sourceControlHandle, this.handle, this.features);
    }

    private _contextValue: string | undefined = undefined;
    get contextValue(): string | undefined { return this._contextValue; }
    set contextValue(contextValue: string | undefined) {
        this._contextValue = contextValue;
        this.proxy.$updateGroup(this.sourceControlHandle, this.handle, this.features);
    }

    get features(): SourceControlGroupFeatures {
        return {
            hideWhenEmpty: this.hideWhenEmpty,
            contextValue: this.contextValue
        };
    }

    get resourceStates(): theia.SourceControlResourceState[] { return [...this._resourceStates]; }
    set resourceStates(resources: theia.SourceControlResourceState[]) {
        this._resourceStates = [...resources];
        this.onDidUpdateResourceStatesEmitter.fire();
    }

    readonly handle = ScmResourceGroupImpl.handlePool++;

    constructor(
        private proxy: ScmMain,
        private commands: CommandRegistryImpl,
        private sourceControlHandle: number,
        private plugin: Plugin,
        private _id: string,
        private _label: string,
    ) { }

    getResourceState(handle: number): theia.SourceControlResourceState | undefined {
        return this.resourceStatesMap.get(handle);
    }

    executeResourceCommand(handle: number): Promise<void> {
        const command = this.resourceStatesCommandsMap.get(handle);

        if (!command) {
            return Promise.resolve(undefined);
        }

        return new Promise(() => this.commands.executeCommand(command.command!, ...(command.arguments || [])));
    }

    takeResourceStateSnapshot(): ScmRawResourceSplice[] {
        const snapshot = [...this._resourceStates];
        const diffs = sortedDiff(this.resourceSnapshot, snapshot, compareResourceStates);

        const splices = diffs.map<Splice<{ rawResource: ScmRawResource, handle: number }>>(diff => {
            const toInsert = diff.toInsert.map(r => {
                const handle = this.resourceHandlePool++;
                this.resourceStatesMap.set(handle, r);

                const sourceUri = r.resourceUri;

                const icon = getIconResource(r.decorations);
                const lightIcon = r.decorations && getIconResource(r.decorations.light) || icon;
                const darkIcon = r.decorations && getIconResource(r.decorations.dark) || icon;
                const icons = [this.getThemableIcon(lightIcon), this.getThemableIcon(darkIcon)];
                let command: Command | undefined;

                if (r.command) {
                    if (r.command.command === 'theia.open' || r.command.command === 'theia.diff') {
                        const disposables = new DisposableCollection();
                        command = this.commands.converter.toSafeCommand(r.command, disposables);
                        this.resourceStatesDisposablesMap.set(handle, disposables);
                    } else {
                        this.resourceStatesCommandsMap.set(handle, r.command);
                    }
                }

                const tooltip = (r.decorations && r.decorations.tooltip) || '';
                const strikeThrough = r.decorations && !!r.decorations.strikeThrough;
                const faded = r.decorations && !!r.decorations.faded;
                const contextValue = r.contextValue || '';

                // TODO remove the letter and colorId fields when the FileDecorationProvider is applied, see https://github.com/eclipse-theia/theia/pull/8911
                const rawResource = {
                    // eslint-disable-next-line @typescript-eslint/no-explicit-any
                    handle, sourceUri, letter: (r as any).letter, colorId: (r as any).color?.id, icons,
                    tooltip, strikeThrough, faded, contextValue, command
                } as ScmRawResource;

                return { rawResource, handle };
            });

            const { start, deleteCount } = diff;
            return { start, deleteCount, toInsert };
        });

        const rawResourceSplices = splices
            .map(({ start, deleteCount, toInsert }) => ({
                start: start,
                deleteCount: deleteCount,
                rawResources: toInsert.map(i => i.rawResource)
            } as ScmRawResourceSplice));

        const reverseSplices = splices.reverse();

        for (const { start, deleteCount, toInsert } of reverseSplices) {
            const handles = toInsert.map(i => i.handle);
            const handlesToDelete = this.handlesSnapshot.splice(start, deleteCount, ...handles);

            for (const handle of handlesToDelete) {
                this.resourceStatesMap.delete(handle);
                this.resourceStatesCommandsMap.delete(handle);
                this.resourceStatesDisposablesMap.get(handle)?.dispose();
                this.resourceStatesDisposablesMap.delete(handle);
            }
        }

        this.resourceSnapshot = snapshot;
        return rawResourceSplices;
    }

    private getThemableIcon(icon: UriComponents | ThemeIcon | undefined): string | ThemeIcon | undefined {
        if (!icon) {
            return undefined;
        } else if (ThemeIcon.is(icon)) {
            return icon;
        }
        return PluginIconPath.asString(URI.revive(icon), this.plugin);
    }

    dispose(): void {
        this._disposed = true;
        this.onDidDisposeEmitter.fire();
    }
}

function historyItemRefToDto(ref: theia.SourceControlHistoryItemRef): ScmHistoryItemRefDto {
    return {
        id: ref.id,
        name: ref.name,
        description: ref.description,
        revision: ref.revision,
        icon: ref.icon,
        category: ref.category,
    };
}

function historyItemToDto(item: theia.SourceControlHistoryItem): ScmHistoryItemDto {
    return {
        id: item.id,
        parentIds: item.parentIds ? [...item.parentIds] : undefined,
        subject: item.subject,
        message: item.message,
        author: item.author,
        authorEmail: item.authorEmail,
        authorIcon: item.authorIcon,
        displayId: item.displayId,
        timestamp: item.timestamp,
        tooltip: item.tooltip,
        statistics: item.statistics ? {
            files: item.statistics.files,
            insertions: item.statistics.insertions,
            deletions: item.statistics.deletions,
        } : undefined,
        references: item.references ? item.references.map(historyItemRefToDto) : undefined,
    };
}

function historyItemChangeToDto(change: theia.SourceControlHistoryItemChange): ScmHistoryItemChangeDto {
    return {
        uri: change.uri,
        originalUri: change.originalUri,
        modifiedUri: change.modifiedUri,
        renameUri: change.renameUri,
    };
}

class SourceControlImpl implements theia.SourceControl {

    private static handlePool: number = 0;
    private groups: Map<GroupHandle, ScmResourceGroupImpl> = new Map<GroupHandle, ScmResourceGroupImpl>();

    readonly apiObject: theia.SourceControl;

    get id(): string {
        return this._id;
    }

    get label(): string {
        return this._label;
    }

    get rootUri(): theia.Uri | undefined {
        return this._rootUri;
    }

    readonly inputBox: ScmInputBoxImpl;

    private _count: number | undefined = undefined;

    get count(): number | undefined {
        return this._count;
    }

    set count(count: number | undefined) {
        if (this._count === count) {
            return;
        }

        this._count = count;
        this.proxy.$updateSourceControl(this.handle, { count });
    }

    private _quickDiffProvider: theia.QuickDiffProvider | undefined = undefined;

    get quickDiffProvider(): theia.QuickDiffProvider | undefined {
        return this._quickDiffProvider;
    }

    set quickDiffProvider(quickDiffProvider: theia.QuickDiffProvider | undefined) {
        this._quickDiffProvider = quickDiffProvider;
        this.proxy.$updateSourceControl(this.handle, { hasQuickDiffProvider: !!quickDiffProvider });
    }

    private _commitTemplate: string | undefined = undefined;

    get commitTemplate(): string | undefined {
        return this._commitTemplate;
    }

    set commitTemplate(commitTemplate: string | undefined) {
        if (commitTemplate === this._commitTemplate) {
            return;
        }

        this._commitTemplate = commitTemplate;
        this.proxy.$updateSourceControl(this.handle, { commitTemplate });
    }

    private acceptInputDisposables = new DisposableCollection();
    private _acceptInputCommand: theia.Command | undefined = undefined;

    get acceptInputCommand(): theia.Command | undefined {
        return this._acceptInputCommand;
    }

    set acceptInputCommand(acceptInputCommand: theia.Command | undefined) {
        this.acceptInputDisposables = new DisposableCollection();

        this._acceptInputCommand = acceptInputCommand;

        const internal = this.commands.converter.toSafeCommand(acceptInputCommand, this.acceptInputDisposables);
        this.proxy.$updateSourceControl(this.handle, { acceptInputCommand: internal });
    }

    private _statusBarDisposables = new DisposableCollection();
    private _statusBarCommands: theia.Command[] | undefined = undefined;

    get statusBarCommands(): theia.Command[] | undefined {
        return this._statusBarCommands;
    }

    set statusBarCommands(statusBarCommands: theia.Command[] | undefined) {
        if (this._statusBarCommands && statusBarCommands && commandListEquals(this._statusBarCommands, statusBarCommands)) {
            return;
        }

        this._statusBarDisposables = new DisposableCollection();

        this._statusBarCommands = statusBarCommands;

        const internal = (statusBarCommands || []).map(c => this.commands.converter.toSafeCommand(c, this._statusBarDisposables)) as Command[];
        this.proxy.$updateSourceControl(this.handle, { statusBarCommands: internal });
    }

    private _actionButtonDisposables = new DisposableCollection();
    private _actionButton: theia.ScmActionButton | undefined = undefined;

    get actionButton(): theia.ScmActionButton | undefined {
        return this._actionButton;
    }

    set actionButton(actionButton: theia.ScmActionButton | undefined) {
        this._actionButtonDisposables.dispose();
        this._actionButtonDisposables = new DisposableCollection();
        this._actionButton = actionButton;

        if (actionButton) {
            const command: Command = this.commands.converter.toSafeCommand(actionButton.command, this._actionButtonDisposables);
            const secondaryCommands = actionButton.secondaryCommands?.map(row =>
                row.map(cmd => {
                    const safeCommand = this.commands.converter.toSafeCommand(cmd, this._actionButtonDisposables);
                    return safeCommand;
                })
            );

            const internal: ScmActionButton = {
                command,
                secondaryCommands: secondaryCommands,
                enabled: actionButton.enabled,
                description: actionButton.description
            };
            this.proxy.$setActionButton(this.handle, internal);
        } else {
            this.proxy.$setActionButton(this.handle, undefined);
        }
    }

    private _selected: boolean = false;

    get selected(): boolean {
        return this._selected;
    }

    private readonly onDidChangeSelectionEmitter = new Emitter<boolean>();
    readonly onDidChangeSelection = this.onDidChangeSelectionEmitter.event;

    private _contextValue: string | undefined = undefined;

    get contextValue(): string | undefined {
        return this._contextValue;
    }

    set contextValue(contextValue: string | undefined) {
        this._contextValue = contextValue;
        this.proxy.$updateSourceControl(this.handle, { contextValue });
    }

    private _historyProvider: theia.SourceControlHistoryProvider | undefined = undefined;
    private _historyProviderDisposables = new DisposableCollection();

    readonly historyItems = new Map<string, theia.SourceControlHistoryItem>();
    readonly historyItemRefs = new Map<string, theia.SourceControlHistoryItemRef>();

    get historyProvider(): theia.SourceControlHistoryProvider | undefined {
        return this._historyProvider;
    }

    set historyProvider(provider: theia.SourceControlHistoryProvider | undefined) {
        this._historyProviderDisposables.dispose();
        this._historyProviderDisposables = new DisposableCollection();
        this._historyProvider = provider;

        if (provider) {
            this._historyProviderDisposables.push(
                provider.onDidChangeCurrentHistoryItemRefs(() => {
                    this.proxy.$updateSourceControl(this.handle, {
                        hasHistoryProvider: true,
                        currentHistoryItemRef: provider.currentHistoryItemRef ? historyItemRefToDto(provider.currentHistoryItemRef) : undefined,
                        currentHistoryItemRemoteRef: provider.currentHistoryItemRemoteRef ? historyItemRefToDto(provider.currentHistoryItemRemoteRef) : undefined,
                        currentHistoryItemBaseRef: provider.currentHistoryItemBaseRef ? historyItemRefToDto(provider.currentHistoryItemBaseRef) : undefined,
                    });
                    this.proxy.$onDidChangeCurrentHistoryItemRefs(this.handle);
                })
            );
            this._historyProviderDisposables.push(
                provider.onDidChangeHistoryItemRefs(event => {
                    const dto: ScmHistoryItemRefsChangeEventDto = {
                        added: event.added.map(historyItemRefToDto),
                        removed: event.removed.map(historyItemRefToDto),
                        modified: event.modified.map(historyItemRefToDto),
                    };
                    this.proxy.$onDidChangeHistoryItemRefs(this.handle, dto);
                })
            );
        }

        this.proxy.$updateSourceControl(this.handle, {
            hasHistoryProvider: !!provider,
            currentHistoryItemRef: provider?.currentHistoryItemRef ? historyItemRefToDto(provider.currentHistoryItemRef) : undefined,
            currentHistoryItemRemoteRef: provider?.currentHistoryItemRemoteRef ? historyItemRefToDto(provider.currentHistoryItemRemoteRef) : undefined,
            currentHistoryItemBaseRef: provider?.currentHistoryItemBaseRef ? historyItemRefToDto(provider.currentHistoryItemBaseRef) : undefined,
        });
    }

    private readonly onDidDisposeEmitter = new Emitter<void>();
    readonly onDidDispose = this.onDidDisposeEmitter.event;

    readonly onDidDisposeParent: Event<void>;

    readonly handle: number = SourceControlImpl.handlePool++;

    constructor(
        private plugin: Plugin,
        private proxy: ScmMain,
        private commands: CommandRegistryImpl,
        private _id: string,
        private _label: string,
        private _rootUri?: theia.Uri,
        _iconPath?: theia.IconPath,
        _isHidden?: boolean,
        _parent?: SourceControlImpl
    ) {
        this.inputBox = new ScmInputBoxImpl(plugin, this.proxy, this.handle);
        this.proxy.$registerSourceControl(this.handle, _id, _label, _rootUri, _parent?.handle);
        this.onDidDisposeParent = _parent ? _parent.onDidDispose : Event.None;
        this.apiObject = createAPIObject(this);
    }

    private createdResourceGroups = new Map<ScmResourceGroupImpl, Disposable>();
    private updatedResourceGroups = new Set<ScmResourceGroupImpl>();

    createResourceGroup(id: string, label: string): ScmResourceGroupImpl {
        const group = new ScmResourceGroupImpl(this.proxy, this.commands, this.handle, this.plugin, id, label);
        const disposable = group.onDidDispose(() => this.createdResourceGroups.delete(group));
        this.createdResourceGroups.set(group, disposable);
        this.eventuallyAddResourceGroups();
        return group;
    }

    eventuallyAddResourceGroups(): void {
        const groups: ScmRawResourceGroup[] = [];
        const splices: ScmRawResourceSplices[] = [];

        for (const [group, disposable] of this.createdResourceGroups) {
            disposable.dispose();

            const updateListener = group.onDidUpdateResourceStates(() => {
                this.updatedResourceGroups.add(group);
                this.eventuallyUpdateResourceStates();
            });

            group.onDidDispose(() => {
                this.updatedResourceGroups.delete(group);
                updateListener.dispose();
                this.groups.delete(group.handle);
                this.proxy.$unregisterGroup(this.handle, group.handle);
            });

            const { handle, id, label, features } = group;
            groups.push({ handle, id, label, features });

            const snapshot = group.takeResourceStateSnapshot();

            if (snapshot.length > 0) {
                splices.push({ handle: group.handle, splices: snapshot });
            }

            this.groups.set(group.handle, group);
        }

        this.proxy.$registerGroups(this.handle, groups, splices);
        this.createdResourceGroups.clear();
    }

    eventuallyUpdateResourceStates(): void {
        const splices: ScmRawResourceSplices[] = [];

        this.updatedResourceGroups.forEach(group => {
            const snapshot = group.takeResourceStateSnapshot();

            if (snapshot.length === 0) {
                return;
            }

            splices.push({ handle: group.handle, splices: snapshot });
        });

        if (splices.length > 0) {
            this.proxy.$spliceResourceStates(this.handle, splices);
        }

        this.updatedResourceGroups.clear();
    }

    getResourceGroup(handle: GroupHandle): ScmResourceGroupImpl | undefined {
        return this.groups.get(handle);
    }

    getHistoryItem(id: string): theia.SourceControlHistoryItem | undefined {
        return this.historyItems.get(id);
    }

    getHistoryItemRef(id: string): theia.SourceControlHistoryItemRef | undefined {
        return this.historyItemRefs.get(id);
    }

    setSelectionState(selected: boolean): void {
        this._selected = selected;
        this.onDidChangeSelectionEmitter.fire(selected);
    }

    dispose(): void {
        this.acceptInputDisposables.dispose();
        this._statusBarDisposables.dispose();
        this._actionButtonDisposables.dispose();
        this._historyProviderDisposables.dispose();

        this.groups.forEach(group => group.dispose());
        this.proxy.$unregisterSourceControl(this.handle);

        this.onDidDisposeEmitter.fire();
        this.onDidDisposeEmitter.dispose();
    }
}

export class ScmExtImpl implements ScmExt {

    private static handlePool: number = 0;

    private proxy: ScmMain;
    private sourceControls: Map<ProviderHandle, SourceControlImpl> = new Map<ProviderHandle, SourceControlImpl>();
    private sourceControlsByExtension: Map<string, SourceControlImpl[]> = new Map<string, SourceControlImpl[]>();

    private readonly onDidChangeActiveProviderEmitter = new Emitter<theia.SourceControl>();
    get onDidChangeActiveProvider(): Event<theia.SourceControl> { return this.onDidChangeActiveProviderEmitter.event; }

    private selectedSourceControlHandle: number | undefined;

    constructor(rpc: RPCProtocol, private commands: CommandRegistryImpl) {
        this.proxy = rpc.getProxy(PLUGIN_RPC_CONTEXT.SCM_MAIN);

        // Register history item arg processor before the generic ScmCommandArg processor
        // so the more-specific guard matches first.
        commands.registerArgumentProcessor({
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
            processArgument: (arg: any) => {
                if (!ScmHistoryItemCommandArg.is(arg) || arg.type !== 'historyItem') {
                    return arg;
                }
                const sourceControl = this.sourceControls.get(arg.sourceControlHandle);
                const item = sourceControl?.getHistoryItem(arg.id);
                return item ?? { id: arg.id };
            }
        });

        commands.registerArgumentProcessor({
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
            processArgument: (arg: any) => {
                if (!ScmHistoryItemCommandArg.is(arg) || arg.type !== 'historyItemRef') {
                    return arg;
                }
                const sourceControl = this.sourceControls.get(arg.sourceControlHandle);
                const ref = sourceControl?.getHistoryItemRef(arg.id);
                return ref ?? { id: arg.id };
            }
        });

        commands.registerArgumentProcessor({
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
            processArgument: (arg: any) => {
                if (!ScmCommandArg.is(arg)) {
                    return arg;
                }
                const sourceControl = this.sourceControls.get(arg.sourceControlHandle);
                if (!sourceControl) {
                    return undefined;
                }
                if (typeof arg.resourceGroupHandle !== 'number') {
                    return sourceControl.apiObject;
                }
                const resourceGroup = sourceControl.getResourceGroup(arg.resourceGroupHandle);
                if (typeof arg.resourceStateHandle !== 'number') {
                    return resourceGroup;
                }
                return resourceGroup && resourceGroup.getResourceState(arg.resourceStateHandle);
            }
        });
    }

    createSourceControl(extension: Plugin, id: string, label: string, rootUri: theia.Uri | undefined,
        iconPath?: theia.IconPath, isHidden?: boolean, parent?: theia.SourceControl): theia.SourceControl {
        const handle = ScmExtImpl.handlePool++;
        const parentImpl = parent ? this.findSourceControlImpl(parent) : undefined;
        const sourceControl = new SourceControlImpl(extension, this.proxy, this.commands, id, label, rootUri, iconPath, isHidden, parentImpl);
        this.sourceControls.set(handle, sourceControl);

        const sourceControls = this.sourceControlsByExtension.get(extension.model.id) || [];
        sourceControls.push(sourceControl);
        this.sourceControlsByExtension.set(extension.model.id, sourceControls);

        // Clean up registries when the source control is disposed. Without this,
        // disposed entries leak and findSourceControlImpl() may return a stale
        // (disposed) instance as a parent for a later-created source control
        // with the same id + rootUri (e.g. a worktree recreated after removal).
        sourceControl.onDidDispose(() => {
            this.sourceControls.delete(handle);
            const list = this.sourceControlsByExtension.get(extension.model.id);
            if (list) {
                const index = list.indexOf(sourceControl);
                if (index >= 0) {
                    list.splice(index, 1);
                }
                if (list.length === 0) {
                    this.sourceControlsByExtension.delete(extension.model.id);
                }
            }
        });

        return sourceControl.apiObject;
    }

    private findSourceControlImpl(apiObject: theia.SourceControl): SourceControlImpl | undefined {
        for (const impl of this.sourceControls.values()) {
            if (impl.id === apiObject.id && impl.rootUri?.toString() === apiObject.rootUri?.toString()) {
                return impl;
            }
        }
        return undefined;
    }

    getLastInputBox(extension: Plugin): ScmInputBoxImpl | undefined {
        const sourceControls = this.sourceControlsByExtension.get(extension.model.id);
        const sourceControl = sourceControls && sourceControls[sourceControls.length - 1];
        return sourceControl && sourceControl.inputBox;
    }

    $provideOriginalResource(sourceControlHandle: number, uriComponents: string, token: theia.CancellationToken): Promise<UriComponents | undefined> {
        const sourceControl = this.sourceControls.get(sourceControlHandle);

        if (!sourceControl || !sourceControl.quickDiffProvider || !sourceControl.quickDiffProvider.provideOriginalResource) {
            return Promise.resolve(undefined);
        }

        return new Promise<UriComponents | undefined>(() => sourceControl.quickDiffProvider!.provideOriginalResource!(URI.file(uriComponents), token))
            .then<UriComponents | undefined>(r => r || undefined);
    }

    $onInputBoxValueChange(sourceControlHandle: number, value: string): Promise<void> {
        const sourceControl = this.sourceControls.get(sourceControlHandle);

        if (!sourceControl) {
            return Promise.resolve(undefined);
        }

        sourceControl.inputBox.onInputBoxValueChange(value);
        return Promise.resolve(undefined);
    }

    $executeResourceCommand(sourceControlHandle: number, groupHandle: number, handle: number): Promise<void> {
        const sourceControl = this.sourceControls.get(sourceControlHandle);

        if (!sourceControl) {
            return Promise.resolve(undefined);
        }

        const group = sourceControl.getResourceGroup(groupHandle);

        if (!group) {
            return Promise.resolve(undefined);
        }

        return group.executeResourceCommand(handle);
    }

    async $validateInput(sourceControlHandle: number, value: string, cursorPosition: number): Promise<[string, number] | undefined> {
        const sourceControl = this.sourceControls.get(sourceControlHandle);

        if (!sourceControl) {
            return Promise.resolve(undefined);
        }

        if (!sourceControl.inputBox.validateInput) {
            return Promise.resolve(undefined);
        }

        const result = await sourceControl.inputBox.validateInput!(value, cursorPosition);
        if (!result) {
            return Promise.resolve(undefined);
        }
        return [result.message, result.type];
    }

    async $provideHistoryItemRefs(sourceControlHandle: number, historyItemRefs: string[] | undefined, token: CancellationToken): Promise<ScmHistoryItemRefDto[] | undefined> {
        const sourceControl = this.sourceControls.get(sourceControlHandle);
        if (!sourceControl || !sourceControl.historyProvider) {
            return undefined;
        }
        const result = await sourceControl.historyProvider.provideHistoryItemRefs(historyItemRefs, token);
        if (!result) {
            return undefined;
        }
        sourceControl.historyItemRefs.clear();
        for (const ref of result) {
            sourceControl.historyItemRefs.set(ref.id, ref);
        }
        return result.map(historyItemRefToDto);
    }

    async $provideHistoryItems(sourceControlHandle: number, options: ScmHistoryOptionsDto, token: CancellationToken): Promise<ScmHistoryItemDto[] | undefined> {
        const sourceControl = this.sourceControls.get(sourceControlHandle);
        if (!sourceControl || !sourceControl.historyProvider) {
            return undefined;
        }
        const result = await sourceControl.historyProvider.provideHistoryItems(options, token);
        if (!result) {
            return undefined;
        }
        for (const item of result) {
            sourceControl.historyItems.set(item.id, item);
        }
        return result.map(historyItemToDto);
    }

    async $provideHistoryItemChanges(
        sourceControlHandle: number, historyItemId: string,
        historyItemParentId: string | undefined, token: CancellationToken
    ): Promise<ScmHistoryItemChangeDto[] | undefined> {
        const sourceControl = this.sourceControls.get(sourceControlHandle);
        if (!sourceControl || !sourceControl.historyProvider) {
            return undefined;
        }
        const result = await sourceControl.historyProvider.provideHistoryItemChanges(historyItemId, historyItemParentId, token);
        if (!result) {
            return undefined;
        }
        return result.map(historyItemChangeToDto);
    }

    async $resolveHistoryItem(sourceControlHandle: number, historyItemId: string, token: CancellationToken): Promise<ScmHistoryItemDto | undefined> {
        const sourceControl = this.sourceControls.get(sourceControlHandle);
        if (!sourceControl || !sourceControl.historyProvider) {
            return undefined;
        }
        const result = await sourceControl.historyProvider.resolveHistoryItem(historyItemId, token);
        if (!result) {
            return undefined;
        }
        return historyItemToDto(result);
    }

    async $resolveHistoryItemRefsCommonAncestor(sourceControlHandle: number, historyItemRefs: string[], token: CancellationToken): Promise<string | undefined> {
        const sourceControl = this.sourceControls.get(sourceControlHandle);
        if (!sourceControl || !sourceControl.historyProvider) {
            return undefined;
        }
        const result = await sourceControl.historyProvider.resolveHistoryItemRefsCommonAncestor(historyItemRefs, token);
        return result ?? undefined;
    }

    $setSelectedSourceControl(selectedSourceControlHandle: number | undefined): Promise<void> {
        if (selectedSourceControlHandle !== undefined) {
            this.sourceControls.get(selectedSourceControlHandle)?.setSelectionState(true);
        }

        if (this.selectedSourceControlHandle !== undefined) {
            this.sourceControls.get(this.selectedSourceControlHandle)?.setSelectionState(false);
        }

        this.selectedSourceControlHandle = selectedSourceControlHandle;
        return Promise.resolve(undefined);
    }
}

/**
 * Diffs two *sorted* arrays and computes the splices which apply the diff.
 */
function sortedDiff(before: ReadonlyArray<theia.SourceControlResourceState>,
    after: ReadonlyArray<theia.SourceControlResourceState>,
    compare: (a: theia.SourceControlResourceState,
        b: theia.SourceControlResourceState) => number): Splice<theia.SourceControlResourceState>[] {
    const result: MutableSplice<theia.SourceControlResourceState>[] = [];

    function pushSplice(start: number, deleteCount: number, toInsert: theia.SourceControlResourceState[]): void {
        if (deleteCount === 0 && toInsert.length === 0) {
            return;
        }

        const latest = result[result.length - 1];

        if (latest && latest.start + latest.deleteCount === start) {
            latest.deleteCount += deleteCount;
            latest.toInsert.push(...toInsert);
        } else {
            result.push({ start, deleteCount, toInsert });
        }
    }

    let beforeIdx = 0;
    let afterIdx = 0;

    while (true) {
        if (beforeIdx === before.length) {
            pushSplice(beforeIdx, 0, after.slice(afterIdx));
            break;
        }
        if (afterIdx === after.length) {
            pushSplice(beforeIdx, before.length - beforeIdx, []);
            break;
        }

        const beforeElement = before[beforeIdx];
        const afterElement = after[afterIdx];
        const n = compare(beforeElement, afterElement);
        if (n === 0) {
            // equal
            beforeIdx += 1;
            afterIdx += 1;
        } else if (n < 0) {
            // beforeElement is smaller -> before element removed
            pushSplice(beforeIdx, 1, []);
            beforeIdx += 1;
        } else if (n > 0) {
            // beforeElement is greater -> after element added
            pushSplice(beforeIdx, 0, [afterElement]);
            afterIdx += 1;
        }
    }

    return result;
}

interface MutableSplice<T> extends Splice<T> {
    deleteCount: number;
}
