<commit_before>/*---------------------------------------------------------------------------------------------
 *  Copyright (c) Microsoft Corporation. All rights reserved.
 *  Licensed under the MIT License. See License.txt in the project root for license information.
 *--------------------------------------------------------------------------------------------*/

import { URI } from 'vs/base/common/uri';
import { Event, Emitter } from 'vs/base/common/event';
import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation';
import { Disposable } from 'vs/base/common/lifecycle';
import { ETAG_DISABLED, FileChangesEvent, FileChangeType, FileOperationError, FileOperationResult, FileSystemProviderCapabilities, IFileService, IFileStatWithMetadata, IFileStreamContent } from 'vs/platform/files/common/files';
import { ISaveOptions, IRevertOptions, SaveReason } from 'vs/workbench/common/editor';
import { IWorkingCopy, IWorkingCopyBackup, IWorkingCopyService, WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopyService';
import { TaskSequentializer, timeout } from 'vs/base/common/async';
import { ILogService } from 'vs/platform/log/common/log';
import { DefaultEndOfLine, ITextBufferFactory, ITextSnapshot } from 'vs/editor/common/model';
import { assertIsDefined } from 'vs/base/common/types';
import { ITextFileEditorModel, ITextFileService, snapshotToString } from 'vs/workbench/services/textfile/common/textfiles';
import { newWriteableBufferStream, VSBuffer, VSBufferReadableStream } from 'vs/base/common/buffer';
import { IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService';
import { IBackupFileService, IResolvedBackup } from 'vs/workbench/services/backup/common/backup';
import { isWindows } from 'vs/base/common/platform';

export interface IFileWorkingCopyModelFactory<T extends IFileWorkingCopyModel> {

	/**
	 * Asks the file working copy delegate to create a model from the given
	 * content.
	 *
	 * @param resource the `URI` of the model
	 * @param contents the content of the model to create it
	 * @param token support for cancellation
	 */
	createModel(resource: URI, contents: VSBufferReadableStream, token: CancellationToken): Promise<T>;
}

/**
 * The underlying model of a file working copy provides some
 * methods for the working copy to function. The model is
 * typically only available after the working copy has been
 * resolved via it's `resolve` method.
 */
export interface IFileWorkingCopyModel {

	/**
	 * An even that fires whenever the content of the file working
	 * copy model changes.
	 */
	readonly onDidChangeContent: Event<IFileWorkingCopyModelContentChangedEvent>;

	/**
	 * An event that fires whenever the model is disposed.
	 */
	readonly onDispose: Event<void>;

	/**
	 * Snapshots the model's current content for writing.
	 *
	 * @param token support for cancellation
	 */
	snapshot(token: CancellationToken): Promise<VSBufferReadableStream>;

	/**
	 * Updates the model with the provided contents.
	 *
	 * @param the contents to use for the model
	 * @param token support for cancellation
	 */
	update(contents: VSBufferReadableStream, token: CancellationToken): Promise<void>;

	/**
	 * TODO should find a better name here
	 */
	getAlternativeVersionId(): number;

	/**
	 * Close the current undo-redo element.
	 * This offers a way to create an undo/redo stop point.
	 */
	pushStackElement(): void;

	/**
	 * @deprecated
	 */
	_backupSnapshot(): ITextSnapshot | undefined;
}

export interface IFileWorkingCopyModelContentChangedEvent {

	/**
	 * Flag that indicates that this event was generated while undoing.
	 */
	readonly isUndoing: boolean;

	/**
	 * Flag that indicates that this event was generated while redoing.
	 */
	readonly isRedoing: boolean;
}

/**
 * A file based `IWorkingCopy` is backed by a `URI` from a
 * known file system provider. Given this assumption, a lot
 * of functionality can be built on top, such as saving in
 * a secure way to prevent data loss.
 */
export interface IFileWorkingCopy<T extends IFileWorkingCopyModel> extends IWorkingCopy, Disposable {

	/**
	 * An event for when a file working copy was resolved.
	 */
	readonly onDidResolve: Event<void>;

	/**
	 * An event for when a file working copy was saved successfully.
	 */
	readonly onDidSave: Event<SaveReason>;

	/**
	 * An event indicating that a file working copy save operation failed.
	 */
	readonly onDidSaveError: Event<void>;

	/**
	 * An event for when the file working copy was reverted.
	 */
	readonly onDidRevert: Event<void>;

	/**
	 * An event for when the orphaned state of the file working copy changes.
	 */
	readonly onDidChangeOrphaned: Event<void>;

	/**
	 * An event for when the file working copy has been disposed.
	 */
	readonly onDispose: Event<void>;

	/**
	 * Provides access to the underlying model of this file
	 * based working copy. As long as the file working copy
	 * has not been resolved, the model is `undefined`.
	 */
	readonly model: T | undefined;

	/**
	 * Whether we have a resolved model or not.
	 */
	isResolved(): this is IResolvedFileWorkingCopy<T>;

	/**
	 * Whether the file working copy has been disposed or not.
	 */
	isDisposed(): boolean;

	/**
	 * Resolves a file working copy.
	 */
	resolve(options?: IFileWorkingCopyResolveOptions): Promise<IFileWorkingCopy<T>>;
}

export interface IResolvedFileWorkingCopy<T extends IFileWorkingCopyModel> extends IFileWorkingCopy<T> {

	/**
	 * A resolved file working copy has a resolved model `T`.
	 */
	readonly model: T;
}

/**
 * States the file working copy can be in.
 */
export const enum FileWorkingCopyState {

	/**
	 * A file working copy is saved.
	 */
	SAVED,

	/**
	 * A file working copy is dirty.
	 */
	DIRTY,

	/**
	 * A file working copy is currently being saved but
	 * this operation has not completed yet.
	 */
	PENDING_SAVE,

	/**
	 * A file working copy is in conflict mode when changes
	 * cannot be saved because the underlying file has changed.
	 * File working copies in conflict mode are always dirty.
	 */
	CONFLICT,

	/**
	 * A file working copy is in orphan state when the underlying
	 * file has been deleted.
	 */
	ORPHAN,

	/**
	 * Any error that happens during a save that is not causing
	 * the `FileWorkingCopyState.CONFLICT` state.
	 * File working copies in error mode are always dirty.
	 */
	ERROR
}

/**
 * Metadata associated with a file working copy backup.
 */
interface IFileWorkingCopyBackupMetaData {
	mtime: number;
	ctime: number;
	size: number;
	etag: string;
	orphaned: boolean;
}

export interface IFileWorkingCopySaveOptions extends ISaveOptions {

	/**
	 * Save the file working copy with an attempt to unlock it.
	 */
	writeUnlock?: boolean;

	/**
	 * Save the file working copy with elevated privileges.
	 *
	 * Note: This may not be supported in all environments.
	 */
	writeElevated?: boolean;

	/**
	 * Allows to write to a file working copy even if it has been
	 * modified on disk. This should only be triggered from an
	 * explicit user action.
	 */
	ignoreModifiedSince?: boolean;

	/**
	 * If set, will bubble up the file working copy save error to
	 * the caller instead of handling it.
	 */
	ignoreErrorHandler?: boolean;
}

export interface IFileWorkingCopyResolveOptions {

	/**
	 * The contents to use for the file working copy if known. If not
	 * provided, the contents will be retrieved from the underlying
	 * resource or backup if present.
	 *
	 * If contents are provided, the file working copy will be marked
	 * as dirty right from the beginning.
	 */
	contents?: VSBufferReadableStream;

	/**
	 * Go to disk bypassing any cache of the file working copy if any.
	 */
	forceReadFromDisk?: boolean;
}

export class FileWorkingCopy<T extends IFileWorkingCopyModel> extends Disposable implements IFileWorkingCopy<T>  {

	readonly capabilities: WorkingCopyCapabilities = WorkingCopyCapabilities.None;

	private _model: T | undefined = undefined;
	get model(): T | undefined { return this._model; }

	//#region events

	private readonly _onDidChangeContent = this._register(new Emitter<void>());
	readonly onDidChangeContent = this._onDidChangeContent.event;

	private readonly _onDidResolve = this._register(new Emitter<void>());
	readonly onDidResolve = this._onDidResolve.event;

	private readonly _onDidChangeDirty = this._register(new Emitter<void>());
	readonly onDidChangeDirty = this._onDidChangeDirty.event;

	private readonly _onDidSaveError = this._register(new Emitter<void>());
	readonly onDidSaveError = this._onDidSaveError.event;

	private readonly _onDidSave = this._register(new Emitter<SaveReason>());
	readonly onDidSave = this._onDidSave.event;

	private readonly _onDidRevert = this._register(new Emitter<void>());
	readonly onDidRevert = this._onDidRevert.event;

	private readonly _onDidChangeOrphaned = this._register(new Emitter<void>());
	readonly onDidChangeOrphaned = this._onDidChangeOrphaned.event;

	private readonly _onDispose = this._register(new Emitter<void>());
	readonly onDispose = this._onDispose.event;

	//#endregion

	constructor(
		readonly resource: URI,
		readonly name: string,
		private readonly modelFactory: IFileWorkingCopyModelFactory<T>,
		@IFileService private readonly fileService: IFileService,
		@ILogService private readonly logService: ILogService,
		@ITextFileService private readonly textFileService: ITextFileService,
		@IFilesConfigurationService private readonly filesConfigurationService: IFilesConfigurationService,
		@IBackupFileService private readonly backupFileService: IBackupFileService,
		@IWorkingCopyService workingCopyService: IWorkingCopyService
	) {
		super();

		if (!fileService.canHandleResource(this.resource)) {
			throw new Error(`The file working copy resource ${this.resource.toString(true)} does not have an associated file system provider.`);
		}

		// Make known to working copy service
		this._register(workingCopyService.registerWorkingCopy(this));

		this.registerListeners();
	}

	//#region Orphaned Tracking

	private inOrphanMode = false;

	private registerListeners(): void {
		this._register(this.fileService.onDidFilesChange(e => this.onDidFilesChange(e)));
	}

	private async onDidFilesChange(e: FileChangesEvent): Promise<void> {
		let fileEventImpactsUs = false;
		let newInOrphanModeGuess: boolean | undefined;

		// If we are currently orphaned, we check if the file was added back
		if (this.inOrphanMode) {
			const fileWorkingCopyResourceAdded = e.contains(this.resource, FileChangeType.ADDED);
			if (fileWorkingCopyResourceAdded) {
				newInOrphanModeGuess = false;
				fileEventImpactsUs = true;
			}
		}

		// Otherwise we check if the file was deleted
		else {
			const fileWorkingCopyResourceDeleted = e.contains(this.resource, FileChangeType.DELETED);
			if (fileWorkingCopyResourceDeleted) {
				newInOrphanModeGuess = true;
				fileEventImpactsUs = true;
			}
		}

		if (fileEventImpactsUs && this.inOrphanMode !== newInOrphanModeGuess) {
			let newInOrphanModeValidated: boolean = false;
			if (newInOrphanModeGuess) {
				// We have received reports of users seeing delete events even though the file still
				// exists (network shares issue: https://github.com/microsoft/vscode/issues/13665).
				// Since we do not want to mark the working copy as orphaned, we have to check if the
				// file is really gone and not just a faulty file event.
				await timeout(100);

				if (this.isDisposed()) {
					newInOrphanModeValidated = true;
				} else {
					const exists = await this.fileService.exists(this.resource);
					newInOrphanModeValidated = !exists;
				}
			}

			if (this.inOrphanMode !== newInOrphanModeValidated && !this.isDisposed()) {
				this.setOrphaned(newInOrphanModeValidated);
			}
		}
	}

	private setOrphaned(orphaned: boolean): void {
		if (this.inOrphanMode !== orphaned) {
			this.inOrphanMode = orphaned;
			this._onDidChangeOrphaned.fire();
		}
	}

	//#endregion

	//#region Dirty

	private dirty = false;
	private bufferSavedVersionId: number | undefined;

	isDirty(): this is IResolvedFileWorkingCopy<T> {
		return this.dirty;
	}

	setDirty(dirty: boolean): void {
		if (!this.isResolved()) {
			return; // only resolved working copies can be marked dirty
		}

		// Track dirty state and version id
		const wasDirty = this.dirty;
		this.doSetDirty(dirty);

		// Emit as Event if dirty changed
		if (dirty !== wasDirty) {
			this._onDidChangeDirty.fire();
		}
	}

	private doSetDirty(dirty: boolean): () => void {
		const wasDirty = this.dirty;
		const wasInConflictMode = this.inConflictMode;
		const wasInErrorMode = this.inErrorMode;
		const oldBufferSavedVersionId = this.bufferSavedVersionId;

		if (!dirty) {
			this.dirty = false;
			this.inConflictMode = false;
			this.inErrorMode = false;
			this.updateSavedVersionId();
		} else {
			this.dirty = true;
		}

		// Return function to revert this call
		return () => {
			this.dirty = wasDirty;
			this.inConflictMode = wasInConflictMode;
			this.inErrorMode = wasInErrorMode;
			this.bufferSavedVersionId = oldBufferSavedVersionId;
		};
	}

	//#endregion

	//#region Resolve

	private lastResolvedFileStat: IFileStatWithMetadata | undefined;

	async resolve(options?: IFileWorkingCopyResolveOptions): Promise<IFileWorkingCopy<T>> {
		this.logService.trace('[file working copy] resolve() - enter', this.resource.toString(true));

		// Return early if we are disposed
		if (this.isDisposed()) {
			this.logService.trace('[file working copy] resolve() - exit - without resolving because file working copy is disposed', this.resource.toString(true));

			return this;
		}

		// Unless there are explicit contents provided, it is important that we do not
		// resolve a working copy that is dirty or is in the process of saving to prevent
		// data loss.
		if (!options?.contents && (this.dirty || this.saveSequentializer.hasPending())) {
			this.logService.trace('[file working copy] resolve() - exit - without resolving because file working copy is dirty or being saved', this.resource.toString(true));

			return this;
		}

		return this.doResolve(options);
	}

	private async doResolve(options?: IFileWorkingCopyResolveOptions): Promise<IFileWorkingCopy<T>> {

		// First check if we have contents to use for the working copy
		if (options?.contents) {
			return this.resolveFromBuffer(options.contents, options);
		}

		// Second, check if we have a backup to resolve from (only for new working copies)
		const isNew = !this.isResolved();
		if (isNew) {
			const resolvedFromBackup = await this.resolveFromBackup(options);
			if (resolvedFromBackup) {
				return resolvedFromBackup;
			}
		}

		// Finally, resolve from file resource
		return this.resolveFromFile(options);
	}

	private async resolveFromBuffer(buffer: VSBufferReadableStream, options?: IFileWorkingCopyResolveOptions): Promise<IFileWorkingCopy<T>> {
		this.logService.trace('[file working copy] resolveFromBuffer()', this.resource.toString(true));

		// Try to resolve metdata from disk
		let mtime: number;
		let ctime: number;
		let size: number;
		let etag: string;
		try {
			const metadata = await this.fileService.resolve(this.resource, { resolveMetadata: true });
			mtime = metadata.mtime;
			ctime = metadata.ctime;
			size = metadata.size;
			etag = metadata.etag;

			// Clear orphaned state when resolving was successful
			this.setOrphaned(false);
		} catch (error) {

			// Put some fallback values in error case
			mtime = Date.now();
			ctime = Date.now();
			size = 0;
			etag = ETAG_DISABLED;

			// Apply orphaned state based on error code
			this.setOrphaned(error.fileOperationResult === FileOperationResult.FILE_NOT_FOUND);
		}

		// Resolve with buffer
		await this.resolveFromContent({
			resource: this.resource,
			name: this.name,
			mtime,
			ctime,
			size,
			etag,
			value: buffer
		}, true /* dirty (resolved from buffer) */);

		return this;
	}

	private async resolveFromBackup(options?: IFileWorkingCopyResolveOptions): Promise<IFileWorkingCopy<T> | undefined> {

		// Resolve backup if any
		const backup = await this.backupFileService.resolve<IFileWorkingCopyBackupMetaData>(this.resource);

		// Abort if someone else managed to resolve the working copy by now
		let isNew = !this.isResolved();
		if (!isNew) {
			this.logService.trace('[file working copy] resolveFromBackup() - exit - withoutresolving because previously new file working copy got created meanwhile', this.resource.toString(true));

			return this; // imply that resolving has happened in another operation
		}

		// Try to resolve from backup if we have any
		if (backup) {
			return this.doResolveFromBackup(backup, options);
		}

		// Otherwise signal back that resolving did not happen
		return undefined;
	}

	private async doResolveFromBackup(backup: IResolvedBackup<IFileWorkingCopyBackupMetaData>, options?: IFileWorkingCopyResolveOptions): Promise<IFileWorkingCopy<T>> {
		this.logService.trace('[file working copy] doResolveFromBackup()', this.resource.toString(true));

		// Resolve with backup
		await this.resolveFromContent({
			resource: this.resource,
			name: this.name,
			mtime: backup.meta ? backup.meta.mtime : Date.now(),
			ctime: backup.meta ? backup.meta.ctime : Date.now(),
			size: backup.meta ? backup.meta.size : 0,
			etag: backup.meta ? backup.meta.etag : ETAG_DISABLED, // etag disabled if unknown!
			value: this.textBufferFactoryToStream(backup.value)
		}, true /* dirty (resolved from backup) */);

		// Restore orphaned flag based on state
		if (backup.meta && backup.meta.orphaned) {
			this.setOrphaned(true);
		}

		return this;
	}

	private async resolveFromFile(options?: IFileWorkingCopyResolveOptions): Promise<IFileWorkingCopy<T>> {
		this.logService.trace('[file working copy] resolveFromFile()', this.resource.toString(true));

		const forceReadFromDisk = options?.forceReadFromDisk;

		// Decide on etag
		let etag: string | undefined;
		if (forceReadFromDisk) {
			etag = ETAG_DISABLED; // disable ETag if we enforce to read from disk
		} else if (this.lastResolvedFileStat) {
			etag = this.lastResolvedFileStat.etag; // otherwise respect etag to support caching
		}

		// Remember current version before doing any long running operation
		// to ensure we are not changing a working copy that was changed
		// meanwhile
		const currentVersionId = this.versionId;

		// Resolve Content
		try {
			const content = await this.fileService.readFileStream(this.resource, { etag });

			// Clear orphaned state when resolving was successful
			this.setOrphaned(false);

			// Return early if the working copy content has changed
			// meanwhile to prevent loosing any changes
			if (currentVersionId !== this.versionId) {
				this.logService.trace('[file working copy] resolveFromFile() - exit - without resolving because file working copy content changed', this.resource.toString(true));

				return this;
			}

			return await this.resolveFromContent(content, false /* not dirty (resolved from file) */);
		} catch (error) {
			const result = error.fileOperationResult;

			// Apply orphaned state based on error code
			this.setOrphaned(result === FileOperationResult.FILE_NOT_FOUND);

			// NotModified status is expected and can be handled gracefully
			if (result === FileOperationResult.FILE_NOT_MODIFIED_SINCE) {
				return this;
			}

			// Ignore when a working copy has been resolved once and the file was deleted
			// meanwhile. Since we already have the working copy resolved, we can return to
			// this state and update the orphaned flag to indicate that this working copy
			// has no version on disk anymore.
			if (this.isResolved() && result === FileOperationResult.FILE_NOT_FOUND) {
				return this;
			}

			// Otherwise bubble up the error
			throw error;
		}
	}

	private async resolveFromContent(content: IFileStreamContent, dirty: boolean): Promise<IFileWorkingCopy<T>> {
		this.logService.trace('[file working copy] resolveFromContent() - enter', this.resource.toString(true));

		// Return early if we are disposed
		if (this.isDisposed()) {
			this.logService.trace('[file working copy] resolveFromContent() - exit - because working copy is disposed', this.resource.toString(true));

			return this;
		}

		// Update our resolved disk stat
		this.updateLastResolvedFileStat({
			resource: this.resource,
			name: content.name,
			mtime: content.mtime,
			ctime: content.ctime,
			size: content.size,
			etag: content.etag,
			isFile: true,
			isDirectory: false,
			isSymbolicLink: false
		});

		// Update existing model if we had been resolved
		if (this.isResolved()) {
			await this.doUpdateModel(content.value);
		}

		// Create new model otherwise
		else {
			await this.doCreateModel(content.value);
		}

		// Update working copy dirty flag. This is very important to call
		// in both cases of dirty or not because it conditionally updates
		// the `bufferSavedVersionId` to determine the version when to consider
		// the working copy as saved again (e.g. when undoing back to the
		// saved state)
		this.setDirty(!!dirty);

		// Emit as event
		this._onDidResolve.fire();

		return this;
	}

	private async doCreateModel(contents: VSBufferReadableStream): Promise<void> {
		this.logService.trace('[file working copy] doCreateModel()', this.resource.toString(true));

		// Create model
		this._model = await this.modelFactory.createModel(this.resource, contents, CancellationToken.None);

		// Model listeners
		this.installModelListeners(this._model);
	}

	private ignoreDirtyOnModelContentChange = false;

	private async doUpdateModel(contents: VSBufferReadableStream): Promise<void> {
		this.logService.trace('[file working copy] doUpdateModel()', this.resource.toString(true));

		// Update model value in a block that ignores content change events for dirty tracking
		this.ignoreDirtyOnModelContentChange = true;
		try {
			await this.model?.update(contents, CancellationToken.None);
		} finally {
			this.ignoreDirtyOnModelContentChange = false;
		}
	}

	private installModelListeners(model: IFileWorkingCopyModel): void {

		// See https://github.com/microsoft/vscode/issues/30189
		// This code has been extracted to a different method because it caused a memory leak
		// where `value` was captured in the content change listener closure scope.

		// Content Change
		this._register(model.onDidChangeContent(e => this.onModelContentChanged(model, e.isUndoing || e.isRedoing)));
	}

	private onModelContentChanged(model: IFileWorkingCopyModel, isUndoingOrRedoing: boolean): void {
		this.logService.trace(`[file working copy] onModelContentChanged() - enter`, this.resource.toString(true));

		// In any case increment the version id because it tracks the textual content state of the model at all times
		this.versionId++;
		this.logService.trace(`[file working copy] onModelContentChanged() - new versionId ${this.versionId}`, this.resource.toString(true));

		// Remember when the user changed the model through a undo/redo operation.
		// We need this information to throttle save participants to fix
		// https://github.com/microsoft/vscode/issues/102542
		if (isUndoingOrRedoing) {
			this.lastContentChangeFromUndoRedo = Date.now();
		}

		// We mark check for a dirty-state change upon model content change, unless:
		// - explicitly instructed to ignore it (e.g. from model.resolve())
		// - the model is readonly (in that case we never assume the change was done by the user)
		if (!this.ignoreDirtyOnModelContentChange && !this.isReadonly()) {

			// The contents changed as a matter of Undo and the version reached matches the saved one
			// In this case we clear the dirty flag and emit a SAVED event to indicate this state.
			if (model.getAlternativeVersionId() === this.bufferSavedVersionId) {
				this.logService.trace('[file working copy] onModelContentChanged() - model content changed back to last saved version', this.resource.toString(true));

				// Clear flags<commit_msg>file working copy - dispose working copy when model disposes<commit_after>/*---------------------------------------------------------------------------------------------
 *  Copyright (c) Microsoft Corporation. All rights reserved.
 *  Licensed under the MIT License. See License.txt in the project root for license information.
 *--------------------------------------------------------------------------------------------*/

import { URI } from 'vs/base/common/uri';
import { Event, Emitter } from 'vs/base/common/event';
import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation';
import { Disposable } from 'vs/base/common/lifecycle';
import { ETAG_DISABLED, FileChangesEvent, FileChangeType, FileOperationError, FileOperationResult, FileSystemProviderCapabilities, IFileService, IFileStatWithMetadata, IFileStreamContent } from 'vs/platform/files/common/files';
import { ISaveOptions, IRevertOptions, SaveReason } from 'vs/workbench/common/editor';
import { IWorkingCopy, IWorkingCopyBackup, IWorkingCopyService, WorkingCopyCapabilities } from 'vs/workbench/services/workingCopy/common/workingCopyService';
import { TaskSequentializer, timeout } from 'vs/base/common/async';
import { ILogService } from 'vs/platform/log/common/log';
import { DefaultEndOfLine, ITextBufferFactory, ITextSnapshot } from 'vs/editor/common/model';
import { assertIsDefined } from 'vs/base/common/types';
import { ITextFileEditorModel, ITextFileService, snapshotToString } from 'vs/workbench/services/textfile/common/textfiles';
import { newWriteableBufferStream, VSBuffer, VSBufferReadableStream } from 'vs/base/common/buffer';
import { IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService';
import { IBackupFileService, IResolvedBackup } from 'vs/workbench/services/backup/common/backup';
import { isWindows } from 'vs/base/common/platform';

export interface IFileWorkingCopyModelFactory<T extends IFileWorkingCopyModel> {

	/**
	 * Asks the file working copy delegate to create a model from the given
	 * content under the provided resource. The content may originate from
	 * different sources depending on context:
	 * - from a backup if that exists
	 * - from the underlying file resource
	 * - passed in from the caller
	 *
	 * @param resource the `URI` of the model
	 * @param contents the content of the model to create it
	 * @param token support for cancellation
	 */
	createModel(resource: URI, contents: VSBufferReadableStream, token: CancellationToken): Promise<T>;
}

/**
 * The underlying model of a file working copy provides some
 * methods for the file working copy to function. The model is
 * typically only available after the working copy has been
 * resolved via it's `resolve()` method.
 */
export interface IFileWorkingCopyModel {

	/**
	 * An even that fires whenever the content of the file working
	 * copy model changes. The file working copy will listen to these
	 * changes and mark the working copy as dirty whenever this event
	 * fires.
	 *
	 * Note: ONLY report changes to the model but not the underlying
	 * file. The file working copy is tracking changes to the file
	 * automatically.
	 */
	readonly onDidChangeContent: Event<IFileWorkingCopyModelContentChangedEvent>;

	/**
	 * An event emitted right before disposing the model.
	 */
	readonly onWillDispose: Event<void>;

	/**
	 * Snapshots the model's current content for writing. This must include
	 * any changes that were made to the model that are in memory.
	 *
	 * @param token support for cancellation
	 */
	snapshot(token: CancellationToken): Promise<VSBufferReadableStream>;

	/**
	 * Updates the model with the provided contents. The implementation should
	 * behave in a similar fashion as `IFileWorkingCopyModelFactory#createModel`
	 * except that here the model already exists and just needs to update to
	 * the provided contents.
	 *
	 * @param the contents to use for the model
	 * @param token support for cancellation
	 */
	update(contents: VSBufferReadableStream, token: CancellationToken): Promise<void>;

	/**
	 * TODO@bpasero should find a better name here maybe together
	 * with the pushStackElement concept since this is around
	 * undo/redo?
	 */
	getAlternativeVersionId(): number;

	/**
	 * Close the current undo-redo element. This offers a way
	 * to create an undo/redo stop point.
	 *
	 * This method may for example be called right before the
	 * save is triggered so that the user can always undo back
	 * to the state before saving.
	 *
	 * TODO@bpasero rename to beforeSave()?
	 */
	pushStackElement(): void;

	/**
	 * @deprecated do not invest to implement, will try to get this to
	 * work through the other `snapshot` method.
	 */
	_backupSnapshot(): ITextSnapshot | undefined;
}

export interface IFileWorkingCopyModelContentChangedEvent {

	/**
	 * Flag that indicates that this event was generated while undoing.
	 */
	readonly isUndoing: boolean;

	/**
	 * Flag that indicates that this event was generated while redoing.
	 */
	readonly isRedoing: boolean;
}

/**
 * A file based `IWorkingCopy` is backed by a `URI` from a
 * known file system provider. Given this assumption, a lot
 * of functionality can be built on top, such as saving in
 * a secure way to prevent data loss.
 */
export interface IFileWorkingCopy<T extends IFileWorkingCopyModel> extends IWorkingCopy, Disposable {

	/**
	 * An event for when a file working copy was resolved.
	 */
	readonly onDidResolve: Event<void>;

	/**
	 * An event for when a file working copy was saved successfully.
	 */
	readonly onDidSave: Event<SaveReason>;

	/**
	 * An event indicating that a file working copy save operation failed.
	 */
	readonly onDidSaveError: Event<void>;

	/**
	 * An event for when the file working copy was reverted.
	 */
	readonly onDidRevert: Event<void>;

	/**
	 * An event for when the orphaned state of the file working copy changes.
	 */
	readonly onDidChangeOrphaned: Event<void>;

	/**
	 * An event for when the file working copy has been disposed.
	 */
	readonly onDispose: Event<void>;

	/**
	 * Provides access to the underlying model of this file
	 * based working copy. As long as the file working copy
	 * has not been resolved, the model is `undefined`.
	 */
	readonly model: T | undefined;

	/**
	 * Whether we have a resolved model or not.
	 */
	isResolved(): this is IResolvedFileWorkingCopy<T>;

	/**
	 * Whether the file working copy has been disposed or not.
	 */
	isDisposed(): boolean;

	/**
	 * Resolves a file working copy.
	 */
	resolve(options?: IFileWorkingCopyResolveOptions): Promise<IFileWorkingCopy<T>>;
}

export interface IResolvedFileWorkingCopy<T extends IFileWorkingCopyModel> extends IFileWorkingCopy<T> {

	/**
	 * A resolved file working copy has a resolved model `T`.
	 */
	readonly model: T;
}

/**
 * States the file working copy can be in.
 */
export const enum FileWorkingCopyState {

	/**
	 * A file working copy is saved.
	 */
	SAVED,

	/**
	 * A file working copy is dirty.
	 */
	DIRTY,

	/**
	 * A file working copy is currently being saved but
	 * this operation has not completed yet.
	 */
	PENDING_SAVE,

	/**
	 * A file working copy is in conflict mode when changes
	 * cannot be saved because the underlying file has changed.
	 * File working copies in conflict mode are always dirty.
	 */
	CONFLICT,

	/**
	 * A file working copy is in orphan state when the underlying
	 * file has been deleted.
	 */
	ORPHAN,

	/**
	 * Any error that happens during a save that is not causing
	 * the `FileWorkingCopyState.CONFLICT` state.
	 * File working copies in error mode are always dirty.
	 */
	ERROR
}

/**
 * Metadata associated with a file working copy backup.
 */
interface IFileWorkingCopyBackupMetaData {
	mtime: number;
	ctime: number;
	size: number;
	etag: string;
	orphaned: boolean;
}

export interface IFileWorkingCopySaveOptions extends ISaveOptions {

	/**
	 * Save the file working copy with an attempt to unlock it.
	 */
	writeUnlock?: boolean;

	/**
	 * Save the file working copy with elevated privileges.
	 *
	 * Note: This may not be supported in all environments.
	 */
	writeElevated?: boolean;

	/**
	 * Allows to write to a file working copy even if it has been
	 * modified on disk. This should only be triggered from an
	 * explicit user action.
	 */
	ignoreModifiedSince?: boolean;

	/**
	 * If set, will bubble up the file working copy save error to
	 * the caller instead of handling it.
	 */
	ignoreErrorHandler?: boolean;
}

export interface IFileWorkingCopyResolveOptions {

	/**
	 * The contents to use for the file working copy if known. If not
	 * provided, the contents will be retrieved from the underlying
	 * resource or backup if present.
	 *
	 * If contents are provided, the file working copy will be marked
	 * as dirty right from the beginning.
	 */
	contents?: VSBufferReadableStream;

	/**
	 * Go to disk bypassing any cache of the file working copy if any.
	 */
	forceReadFromDisk?: boolean;
}

export class FileWorkingCopy<T extends IFileWorkingCopyModel> extends Disposable implements IFileWorkingCopy<T>  {

	readonly capabilities: WorkingCopyCapabilities = WorkingCopyCapabilities.None;

	private _model: T | undefined = undefined;
	get model(): T | undefined { return this._model; }

	//#region events

	private readonly _onDidChangeContent = this._register(new Emitter<void>());
	readonly onDidChangeContent = this._onDidChangeContent.event;

	private readonly _onDidResolve = this._register(new Emitter<void>());
	readonly onDidResolve = this._onDidResolve.event;

	private readonly _onDidChangeDirty = this._register(new Emitter<void>());
	readonly onDidChangeDirty = this._onDidChangeDirty.event;

	private readonly _onDidSaveError = this._register(new Emitter<void>());
	readonly onDidSaveError = this._onDidSaveError.event;

	private readonly _onDidSave = this._register(new Emitter<SaveReason>());
	readonly onDidSave = this._onDidSave.event;

	private readonly _onDidRevert = this._register(new Emitter<void>());
	readonly onDidRevert = this._onDidRevert.event;

	private readonly _onDidChangeOrphaned = this._register(new Emitter<void>());
	readonly onDidChangeOrphaned = this._onDidChangeOrphaned.event;

	private readonly _onDispose = this._register(new Emitter<void>());
	readonly onDispose = this._onDispose.event;

	//#endregion

	constructor(
		readonly resource: URI,
		readonly name: string,
		private readonly modelFactory: IFileWorkingCopyModelFactory<T>,
		@IFileService private readonly fileService: IFileService,
		@ILogService private readonly logService: ILogService,
		@ITextFileService private readonly textFileService: ITextFileService,
		@IFilesConfigurationService private readonly filesConfigurationService: IFilesConfigurationService,
		@IBackupFileService private readonly backupFileService: IBackupFileService,
		@IWorkingCopyService workingCopyService: IWorkingCopyService
	) {
		super();

		if (!fileService.canHandleResource(this.resource)) {
			throw new Error(`The file working copy resource ${this.resource.toString(true)} does not have an associated file system provider.`);
		}

		// Make known to working copy service
		this._register(workingCopyService.registerWorkingCopy(this));

		this.registerListeners();
	}

	//#region Orphaned Tracking

	private inOrphanMode = false;

	private registerListeners(): void {
		this._register(this.fileService.onDidFilesChange(e => this.onDidFilesChange(e)));
	}

	private async onDidFilesChange(e: FileChangesEvent): Promise<void> {
		let fileEventImpactsUs = false;
		let newInOrphanModeGuess: boolean | undefined;

		// If we are currently orphaned, we check if the file was added back
		if (this.inOrphanMode) {
			const fileWorkingCopyResourceAdded = e.contains(this.resource, FileChangeType.ADDED);
			if (fileWorkingCopyResourceAdded) {
				newInOrphanModeGuess = false;
				fileEventImpactsUs = true;
			}
		}

		// Otherwise we check if the file was deleted
		else {
			const fileWorkingCopyResourceDeleted = e.contains(this.resource, FileChangeType.DELETED);
			if (fileWorkingCopyResourceDeleted) {
				newInOrphanModeGuess = true;
				fileEventImpactsUs = true;
			}
		}

		if (fileEventImpactsUs && this.inOrphanMode !== newInOrphanModeGuess) {
			let newInOrphanModeValidated: boolean = false;
			if (newInOrphanModeGuess) {
				// We have received reports of users seeing delete events even though the file still
				// exists (network shares issue: https://github.com/microsoft/vscode/issues/13665).
				// Since we do not want to mark the working copy as orphaned, we have to check if the
				// file is really gone and not just a faulty file event.
				await timeout(100);

				if (this.isDisposed()) {
					newInOrphanModeValidated = true;
				} else {
					const exists = await this.fileService.exists(this.resource);
					newInOrphanModeValidated = !exists;
				}
			}

			if (this.inOrphanMode !== newInOrphanModeValidated && !this.isDisposed()) {
				this.setOrphaned(newInOrphanModeValidated);
			}
		}
	}

	private setOrphaned(orphaned: boolean): void {
		if (this.inOrphanMode !== orphaned) {
			this.inOrphanMode = orphaned;
			this._onDidChangeOrphaned.fire();
		}
	}

	//#endregion

	//#region Dirty

	private dirty = false;
	private bufferSavedVersionId: number | undefined;

	isDirty(): this is IResolvedFileWorkingCopy<T> {
		return this.dirty;
	}

	setDirty(dirty: boolean): void {
		if (!this.isResolved()) {
			return; // only resolved working copies can be marked dirty
		}

		// Track dirty state and version id
		const wasDirty = this.dirty;
		this.doSetDirty(dirty);

		// Emit as Event if dirty changed
		if (dirty !== wasDirty) {
			this._onDidChangeDirty.fire();
		}
	}

	private doSetDirty(dirty: boolean): () => void {
		const wasDirty = this.dirty;
		const wasInConflictMode = this.inConflictMode;
		const wasInErrorMode = this.inErrorMode;
		const oldBufferSavedVersionId = this.bufferSavedVersionId;

		if (!dirty) {
			this.dirty = false;
			this.inConflictMode = false;
			this.inErrorMode = false;
			this.updateSavedVersionId();
		} else {
			this.dirty = true;
		}

		// Return function to revert this call
		return () => {
			this.dirty = wasDirty;
			this.inConflictMode = wasInConflictMode;
			this.inErrorMode = wasInErrorMode;
			this.bufferSavedVersionId = oldBufferSavedVersionId;
		};
	}

	//#endregion

	//#region Resolve

	private lastResolvedFileStat: IFileStatWithMetadata | undefined;

	async resolve(options?: IFileWorkingCopyResolveOptions): Promise<IFileWorkingCopy<T>> {
		this.logService.trace('[file working copy] resolve() - enter', this.resource.toString(true));

		// Return early if we are disposed
		if (this.isDisposed()) {
			this.logService.trace('[file working copy] resolve() - exit - without resolving because file working copy is disposed', this.resource.toString(true));

			return this;
		}

		// Unless there are explicit contents provided, it is important that we do not
		// resolve a working copy that is dirty or is in the process of saving to prevent
		// data loss.
		if (!options?.contents && (this.dirty || this.saveSequentializer.hasPending())) {
			this.logService.trace('[file working copy] resolve() - exit - without resolving because file working copy is dirty or being saved', this.resource.toString(true));

			return this;
		}

		return this.doResolve(options);
	}

	private async doResolve(options?: IFileWorkingCopyResolveOptions): Promise<IFileWorkingCopy<T>> {

		// First check if we have contents to use for the working copy
		if (options?.contents) {
			return this.resolveFromBuffer(options.contents, options);
		}

		// Second, check if we have a backup to resolve from (only for new working copies)
		const isNew = !this.isResolved();
		if (isNew) {
			const resolvedFromBackup = await this.resolveFromBackup(options);
			if (resolvedFromBackup) {
				return resolvedFromBackup;
			}
		}

		// Finally, resolve from file resource
		return this.resolveFromFile(options);
	}

	private async resolveFromBuffer(buffer: VSBufferReadableStream, options?: IFileWorkingCopyResolveOptions): Promise<IFileWorkingCopy<T>> {
		this.logService.trace('[file working copy] resolveFromBuffer()', this.resource.toString(true));

		// Try to resolve metdata from disk
		let mtime: number;
		let ctime: number;
		let size: number;
		let etag: string;
		try {
			const metadata = await this.fileService.resolve(this.resource, { resolveMetadata: true });
			mtime = metadata.mtime;
			ctime = metadata.ctime;
			size = metadata.size;
			etag = metadata.etag;

			// Clear orphaned state when resolving was successful
			this.setOrphaned(false);
		} catch (error) {

			// Put some fallback values in error case
			mtime = Date.now();
			ctime = Date.now();
			size = 0;
			etag = ETAG_DISABLED;

			// Apply orphaned state based on error code
			this.setOrphaned(error.fileOperationResult === FileOperationResult.FILE_NOT_FOUND);
		}

		// Resolve with buffer
		await this.resolveFromContent({
			resource: this.resource,
			name: this.name,
			mtime,
			ctime,
			size,
			etag,
			value: buffer
		}, true /* dirty (resolved from buffer) */);

		return this;
	}

	private async resolveFromBackup(options?: IFileWorkingCopyResolveOptions): Promise<IFileWorkingCopy<T> | undefined> {

		// Resolve backup if any
		const backup = await this.backupFileService.resolve<IFileWorkingCopyBackupMetaData>(this.resource);

		// Abort if someone else managed to resolve the working copy by now
		let isNew = !this.isResolved();
		if (!isNew) {
			this.logService.trace('[file working copy] resolveFromBackup() - exit - withoutresolving because previously new file working copy got created meanwhile', this.resource.toString(true));

			return this; // imply that resolving has happened in another operation
		}

		// Try to resolve from backup if we have any
		if (backup) {
			return this.doResolveFromBackup(backup, options);
		}

		// Otherwise signal back that resolving did not happen
		return undefined;
	}

	private async doResolveFromBackup(backup: IResolvedBackup<IFileWorkingCopyBackupMetaData>, options?: IFileWorkingCopyResolveOptions): Promise<IFileWorkingCopy<T>> {
		this.logService.trace('[file working copy] doResolveFromBackup()', this.resource.toString(true));

		// Resolve with backup
		await this.resolveFromContent({
			resource: this.resource,
			name: this.name,
			mtime: backup.meta ? backup.meta.mtime : Date.now(),
			ctime: backup.meta ? backup.meta.ctime : Date.now(),
			size: backup.meta ? backup.meta.size : 0,
			etag: backup.meta ? backup.meta.etag : ETAG_DISABLED, // etag disabled if unknown!
			value: this.textBufferFactoryToStream(backup.value)
		}, true /* dirty (resolved from backup) */);

		// Restore orphaned flag based on state
		if (backup.meta && backup.meta.orphaned) {
			this.setOrphaned(true);
		}

		return this;
	}

	private async resolveFromFile(options?: IFileWorkingCopyResolveOptions): Promise<IFileWorkingCopy<T>> {
		this.logService.trace('[file working copy] resolveFromFile()', this.resource.toString(true));

		const forceReadFromDisk = options?.forceReadFromDisk;

		// Decide on etag
		let etag: string | undefined;
		if (forceReadFromDisk) {
			etag = ETAG_DISABLED; // disable ETag if we enforce to read from disk
		} else if (this.lastResolvedFileStat) {
			etag = this.lastResolvedFileStat.etag; // otherwise respect etag to support caching
		}

		// Remember current version before doing any long running operation
		// to ensure we are not changing a working copy that was changed
		// meanwhile
		const currentVersionId = this.versionId;

		// Resolve Content
		try {
			const content = await this.fileService.readFileStream(this.resource, { etag });

			// Clear orphaned state when resolving was successful
			this.setOrphaned(false);

			// Return early if the working copy content has changed
			// meanwhile to prevent loosing any changes
			if (currentVersionId !== this.versionId) {
				this.logService.trace('[file working copy] resolveFromFile() - exit - without resolving because file working copy content changed', this.resource.toString(true));

				return this;
			}

			return await this.resolveFromContent(content, false /* not dirty (resolved from file) */);
		} catch (error) {
			const result = error.fileOperationResult;

			// Apply orphaned state based on error code
			this.setOrphaned(result === FileOperationResult.FILE_NOT_FOUND);

			// NotModified status is expected and can be handled gracefully
			if (result === FileOperationResult.FILE_NOT_MODIFIED_SINCE) {
				return this;
			}

			// Ignore when a working copy has been resolved once and the file was deleted
			// meanwhile. Since we already have the working copy resolved, we can return to
			// this state and update the orphaned flag to indicate that this working copy
			// has no version on disk anymore.
			if (this.isResolved() && result === FileOperationResult.FILE_NOT_FOUND) {
				return this;
			}

			// Otherwise bubble up the error
			throw error;
		}
	}

	private async resolveFromContent(content: IFileStreamContent, dirty: boolean): Promise<IFileWorkingCopy<T>> {
		this.logService.trace('[file working copy] resolveFromContent() - enter', this.resource.toString(true));

		// Return early if we are disposed
		if (this.isDisposed()) {
			this.logService.trace('[file working copy] resolveFromContent() - exit - because working copy is disposed', this.resource.toString(true));

			return this;
		}

		// Update our resolved disk stat
		this.updateLastResolvedFileStat({
			resource: this.resource,
			name: content.name,
			mtime: content.mtime,
			ctime: content.ctime,
			size: content.size,
			etag: content.etag,
			isFile: true,
			isDirectory: false,
			isSymbolicLink: false
		});

		// Update existing model if we had been resolved
		if (this.isResolved()) {
			await this.doUpdateModel(content.value);
		}

		// Create new model otherwise
		else {
			await this.doCreateModel(content.value);
		}

		// Update working copy dirty flag. This is very important to call
		// in both cases of dirty or not because it conditionally updates
		// the `bufferSavedVersionId` to determine the version when to consider
		// the working copy as saved again (e.g. when undoing back to the
		// saved state)
		this.setDirty(!!dirty);

		// Emit as event
		this._onDidResolve.fire();

		return this;
	}

	private async doCreateModel(contents: VSBufferReadableStream): Promise<void> {
		this.logService.trace('[file working copy] doCreateModel()', this.resource.toString(true));

		// Create model
		this._model = await this.modelFactory.createModel(this.resource, contents, CancellationToken.None);

		// Model listeners
		this.installModelListeners(this._model);
	}

	private ignoreDirtyOnModelContentChange = false;

	private async doUpdateModel(contents: VSBufferReadableStream): Promise<void> {
		this.logService.trace('[file working copy] doUpdateModel()', this.resource.toString(true));

		// Update model value in a block that ignores content change events for dirty tracking
		this.ignoreDirtyOnModelContentChange = true;
		try {
			await this.model?.update(contents, CancellationToken.None);
		} finally {
			this.ignoreDirtyOnModelContentChange = false;
		}
	}

	private installModelListeners(model: IFileWorkingCopyModel): void {

		// See https://github.com/microsoft/vscode/issues/30189
		// This code has been extracted to a different method because it caused a memory leak
		// where `value` was captured in the content change listener closure scope.

		// Content Change
		this._register(model.onDidChangeContent(e => this.onModelContentChanged(model, e.isUndoing || e.isRedoing)));

		// Lifecycle
		this._register(model.onWillDispose(() => this.dispose()));
	}

	private onModelContentChanged(model: IFileWorkingCopyModel, isUndoingOrRedoing: boolean): void {
		this.logService.trace(`[file working copy] onModelContentChanged() - enter`, this.resource.toString(true));

		// In any case increment the version id because it tracks the textual content state of the model at all times
		this.versionId++;
		this.logService.trace(`[file working copy] onModelContentChanged() - new versionId ${this.versionId}`, this.resource.toString(true));

		// Remember when the user changed the model through a undo/redo operation.
		// We need this information to throttle save participants to fix
		// https://github.com/microsoft/vscode/issues/102542
		if (isUndoingOrRedoing) {
			this.lastContentChangeFromUndoRedo = Date.now();
		}

		// We mark check for a dirty-state change upon model content change, unless:
		// - explicitly instructed to ignore it (e.g. from model.resolve())
		// - the model is readonly (in that case we never assume the change was done by the user)
		if (!this.ignoreDirtyOnModelContentChange && !this.isReadonly()) {

			// The contents changed as a matter of Undo and the version reached matches the saved one
			// In this case we clear the dirty flag and emit a SAVED event to indicate this state.
			if (model.getAlternativeVersionId() === this.bufferSavedVersionId) {
				this.logService.trace('[file working copy] onModelContentChanged() - model content changed back to last saved version', this.resource.toString(true));

				// Clear flags
{'fraction_non_alphanumeric': 0.10099425451285604, 'fraction_numerical': 0.0008557108512285563, 'mean_word_length': 3.9344525987734995, 'pattern_counts': {'":': 0, '<': 101, '<?xml version=': 0, '>': 110, 'https://': 6, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '2768479', 'n_tokens_mistral': 15268, 'n_tokens_neox': 13689, 'n_words': 4604}