import type { ConfigurationChangeEvent } from 'vscode';
import { Disposable, env, Uri, window } from 'vscode';
import { ApplyPatchCommitError } from '@gitlens/git/errors.js';
import type { GitCommit } from '@gitlens/git/models/commit.js';
import { GitFileChange } from '@gitlens/git/models/fileChange.js';
import type { PatchRevisionRange } from '@gitlens/git/models/patch.js';
import type { GkRepositoryId } from '@gitlens/git/models/repositoryIdentities.js';
import { uncommitted, uncommittedStaged } from '@gitlens/git/models/revision.js';
import { createReference } from '@gitlens/git/utils/reference.utils.js';
import { shortenRevision } from '@gitlens/git/utils/revision.utils.js';
import { CancellationError, isCancellationError } from '@gitlens/utils/cancellation.js';
import type { Deferrable } from '@gitlens/utils/debounce.js';
import { debounce } from '@gitlens/utils/debounce.js';
import { trace } from '@gitlens/utils/decorators/log.js';
import { find, some } from '@gitlens/utils/iterable.js';
import { basename } from '@gitlens/utils/path.js';
import { fileUri, joinUriPath } from '@gitlens/utils/uri.js';
import { getAvatarUri } from '../../../avatars.js';
import type { ContextKeys } from '../../../constants.context.js';
import { GlyphChars, previewBadge } from '../../../constants.js';
import type { Sources, WebviewTelemetryContext } from '../../../constants.telemetry.js';
import type { Container } from '../../../container.js';
import { openChanges, openChangesWithWorking, openFile } from '../../../git/actions/commit.js';
import type { RepositoriesChangeEvent } from '../../../git/gitProviderService.js';
import { GlRepository } from '../../../git/models/repository.js';
import {
	formatCurrentUserDisplayName,
	getCommitForFile,
	getCommitRepository,
} from '../../../git/utils/-webview/commit.utils.js';
import { showGitErrorMessage } from '../../../messages.js';
import { showPatchesView } from '../../../plus/drafts/actions.js';
import { getDraftEntityIdentifier } from '../../../plus/drafts/draftsService.js';
import type {
	CreateDraftChange,
	Draft,
	DraftPatch,
	DraftPatchFileChange,
	DraftPendingUser,
	DraftUser,
	DraftVisibility,
	LocalDraft,
} from '../../../plus/drafts/models/drafts.js';
import { confirmDraftStorage } from '../../../plus/drafts/utils/-webview/drafts.utils.js';
import type { OrganizationMember } from '../../../plus/gk/models/organization.js';
import { ensureAccount } from '../../../plus/gk/utils/-webview/acount.utils.js';
import { showNewOrSelectBranchPicker } from '../../../quickpicks/branchPicker.js';
import { showOrganizationMembersPicker } from '../../../quickpicks/organizationMembersPicker.js';
import { showReferencePicker2 } from '../../../quickpicks/referencePicker.js';
import { executeCommand, registerCommand } from '../../../system/-webview/command.js';
import { configuration } from '../../../system/-webview/configuration.js';
import { getContext, onDidChangeContext, setContext } from '../../../system/-webview/context.js';
import { gate } from '../../../system/decorators/gate.js';
import type { Serialized } from '../../../system/serialize.js';
import { serialize } from '../../../system/serialize.js';
import type { IpcParams, IpcResponse } from '../../ipc/handlerRegistry.js';
import { ipcCommand, ipcRequest } from '../../ipc/handlerRegistry.js';
import type { WebviewHost, WebviewProvider } from '../../webviewProvider.js';
import type { WebviewShowOptions } from '../../webviewsController.js';
import type { ShowInCommitGraphCommandArgs } from '../graph/registration.js';
import type {
	Change,
	CreateDraft,
	DraftUserSelection,
	ExecuteFileActionParams,
	Mode,
	Preferences,
	State,
} from './protocol.js';
import {
	ApplyPatchCommand,
	ArchiveDraftCommand,
	CopyCloudLinkCommand,
	CreatePatchCommand,
	DidChangeCreateNotification,
	DidChangeDraftNotification,
	DidChangeNotification,
	DidChangePatchRepositoryNotification,
	DidChangePreferencesNotification,
	DraftPatchCheckedCommand,
	ExplainRequest,
	GenerateRequest,
	OpenFileCommand,
	OpenFileComparePreviousCommand,
	OpenFileCompareWorkingCommand,
	OpenInCommitGraphCommand,
	SwitchModeCommand,
	UpdateCreatePatchMetadataCommand,
	UpdateCreatePatchRepositoryCheckedStateCommand,
	UpdatePatchDetailsMetadataCommand,
	UpdatePatchDetailsPermissionsCommand,
	UpdatePatchUsersCommand,
	UpdatePatchUserSelectionCommand,
	UpdatePreferencesCommand,
} from './protocol.js';
import type { PatchDetailsWebviewShowingArgs } from './registration.js';
import type { RepositoryChangeset } from './repositoryChangeset.js';
import { RepositoryRefChangeset, RepositoryWipChangeset } from './repositoryChangeset.js';

interface DraftUserState {
	users: DraftUser[];
	selections: DraftUserSelection[];
}
interface Context {
	mode: Mode;
	draft: LocalDraft | Draft | undefined;
	draftGkDevUrl: string | undefined;
	draftVisibiltyState: DraftVisibility | undefined;
	draftUserState: DraftUserState | undefined;
	create:
		| {
				title?: string;
				description?: string;
				changes: Map<string, RepositoryChangeset>;
				showingAllRepos: boolean;
				visibility: DraftVisibility;
				userSelections?: DraftUserSelection[];
		  }
		| undefined;
	preferences: Preferences;
	orgSettings: State['orgSettings'];
}

export class PatchDetailsWebviewProvider implements WebviewProvider<
	State,
	Serialized<State>,
	PatchDetailsWebviewShowingArgs
> {
	private _context: Context;
	private readonly _disposable: Disposable;

	constructor(
		private readonly container: Container,
		private readonly host: WebviewHost<'gitlens.views.patchDetails' | 'gitlens.patchDetails'>,
	) {
		this._context = {
			mode: 'create',
			draft: undefined,
			draftGkDevUrl: undefined,
			draftUserState: undefined,
			draftVisibiltyState: undefined,
			create: undefined,
			preferences: this.getPreferences(),
			orgSettings: this.getOrgSettings(),
		};

		this.setHostTitle();
		this.host.description = previewBadge;

		this._disposable = Disposable.from(
			configuration.onDidChangeAny(this.onAnyConfigurationChanged, this),
			container.git.onDidChangeRepositories(this.onRepositoriesChanged, this),
			onDidChangeContext(this.onContextChanged, this),
		);
	}

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

	canReuseInstance(...args: PatchDetailsWebviewShowingArgs): boolean | undefined {
		const [arg] = args;
		if (arg?.mode === 'view' && arg.draft != null) {
			switch (arg.draft.draftType) {
				case 'cloud':
					return (
						this._context.draft?.draftType === arg.draft.draftType &&
						this._context.draft.id === arg.draft.id
					);

				case 'local':
					return (
						this._context.draft?.draftType === arg.draft.draftType &&
						this._context.draft.patch.contents === arg.draft.patch?.contents
					);
			}
		}

		return false;
	}

	getTelemetryContext(): WebviewTelemetryContext {
		return {
			...this.host.getTelemetryContext(),
		};
	}

	async onShowing(
		_loading: boolean,
		options: WebviewShowOptions,
		...args: PatchDetailsWebviewShowingArgs
	): Promise<[boolean, Record<`context.${string}`, string | number | boolean> | undefined]> {
		const [arg] = args;
		if (arg?.mode === 'view' && arg.draft != null) {
			await this.updateViewDraftState(arg.draft);
			void this.trackViewDraft(this._context.draft, arg.source);
		} else {
			if (this.container.git.isDiscoveringRepositories) {
				await this.container.git.isDiscoveringRepositories;
			}

			const create = arg?.mode === 'create' && arg.create != null ? arg.create : { repositories: undefined };
			this.updateCreateDraftState(create);
		}

		if (options?.preserveVisibility && !this.host.visible) return [false, undefined];

		return [true, undefined];
	}

	includeBootstrap(_deferrable?: boolean): Promise<Serialized<State>> {
		return this.getState(this._context);
	}

	registerCommands(): Disposable[] {
		const commands: Disposable[] = [];

		if (this.host.is('view')) {
			commands.push(
				registerCommand(`${this.host.id}.refresh`, () => this.host.refresh(true)),
				registerCommand(`${this.host.id}.close`, () => this.closeView()),
			);
		}

		return commands;
	}

	@ipcCommand(OpenInCommitGraphCommand)
	private onOpenInCommitGraph(params: IpcParams<typeof OpenInCommitGraphCommand>) {
		void executeCommand<ShowInCommitGraphCommandArgs>('gitlens.showInCommitGraph', {
			ref: createReference(params.ref, params.repoPath, { refType: 'revision' }),
			source: { source: 'patchDetails' },
		});
	}

	onRefresh(): void {
		this.updateState(true);
	}

	onReloaded(): void {
		this.updateState(true);
	}

	onVisibilityChanged(visible: boolean): void {
		// TODO@eamodio ugly -- clean this up later
		this._context.create?.changes.forEach(c => (visible ? c.resume() : c.suspend()));

		if (visible) {
			this.host.sendPendingIpcNotifications();
		}
	}

	private onAnyConfigurationChanged(e: ConfigurationChangeEvent) {
		if (
			configuration.changed(e, [
				'defaultDateFormat',
				'views.patchDetails.files',
				'views.patchDetails.avatars',
				'ai.enabled',
			]) ||
			configuration.changedCore(e, 'workbench.tree.renderIndentGuides') ||
			configuration.changedCore(e, 'workbench.tree.indent')
		) {
			this._context.preferences = { ...this._context.preferences, ...this.getPreferences() };
			this.updateState();
		}
	}

	private getPreferences(): Preferences {
		return {
			avatars: configuration.get('views.patchDetails.avatars'),
			dateFormat: configuration.get('defaultDateFormat') ?? 'MMMM Do, YYYY h:mma',
			files: configuration.get('views.patchDetails.files'),
			indentGuides: configuration.getCore('workbench.tree.renderIndentGuides') ?? 'onHover',
			indent: configuration.getCore('workbench.tree.indent'),
			aiEnabled: this.container.ai.enabled,
		};
	}

	private onContextChanged(key: keyof ContextKeys) {
		if (['gitlens:gk:organization:ai:enabled', 'gitlens:gk:organization:drafts:enabled'].includes(key)) {
			this._context.orgSettings = this.getOrgSettings();
			this.updateState();
		}
	}

	private getOrgSettings(): State['orgSettings'] {
		return {
			ai: getContext('gitlens:gk:organization:ai:enabled', false),
			byob: getContext('gitlens:gk:organization:drafts:byob', false),
		};
	}

	private onRepositoriesChanged(e: RepositoriesChangeEvent) {
		if (this.mode === 'create' && this._context.create != null) {
			if (this._context.create?.showingAllRepos) {
				for (const repo of e.added) {
					this._context.create.changes.set(
						repo.uri.toString(),
						new RepositoryWipChangeset(
							this.container,
							repo,
							{ to: uncommitted, from: 'HEAD' },
							this.onRepositoryWipChanged.bind(this),
							false,
							true,
						),
					);
				}
			}

			for (const repo of e.removed) {
				this._context.create.changes.delete(repo.uri.toString());
			}

			void this.notifyDidChangeCreateDraftState();
		}
	}

	private onRepositoryWipChanged(_e: RepositoryWipChangeset) {
		void this.notifyDidChangeCreateDraftState();
	}

	private get mode(): Mode {
		return this._context.mode;
	}
	private setMode(mode: Mode, silent?: boolean) {
		this._context.mode = mode;
		this.setHostTitle(mode);
		void setContext(
			'gitlens:views:patchDetails:mode',
			configuration.get('cloudPatches.experimental.layout') === 'editor' ? undefined : mode,
		);
		if (!silent) {
			this.updateState(true);
		}
	}

	private setHostTitle(mode: Mode = this._context.mode) {
		if (mode === 'create') {
			this.host.title = 'Create Cloud Patch';
		} else if (this._context.draft?.draftType === 'cloud' && this._context.draft.type === 'suggested_pr_change') {
			this.host.title = 'Cloud Suggestion';
		} else {
			this.host.title = 'Cloud Patch Details';
		}
	}

	@ipcCommand(ApplyPatchCommand)
	private async onApplyPatch(params: IpcParams<typeof ApplyPatchCommand>) {
		// if (params.details.repoPath == null || params.details.commit == null) return;
		// void this.container.git.applyPatchCommit(params.details.repoPath, params.details.commit, {
		// 	branchName: params.targetRef,
		// });
		if (this._context.draft == null || this._context.draft.draftType === 'local' || !params.selected?.length) {
			return;
		}

		const changeset = this._context.draft.changesets?.[0];
		if (changeset == null) return;

		// TODO: should be overridable with targetRef
		const shouldPickBranch = params.target === 'branch';
		for (const patch of changeset.patches) {
			if (!params.selected.includes(patch.id)) continue;

			try {
				const commit = patch.commit ?? (await this.getOrCreateCommitForPatch(patch.gkRepositoryId));
				if (!commit) {
					// TODO: say we can't apply this patch
					continue;
				}

				let options:
					| {
							branchName?: string;
							createBranchIfNeeded?: boolean;
							createWorktreePath?: string;
					  }
					| undefined = undefined;

				if (shouldPickBranch) {
					const repo = getCommitRepository(commit.repoPath);
					const branch = await showNewOrSelectBranchPicker(
						`Select a Branch ${GlyphChars.Dot} ${repo?.name}`,
						// 'Choose a branch to apply the Cloud Patch to',
						repo,
					);

					if (branch == null) {
						void window.showErrorMessage(
							`Unable to apply patch to '${patch.repository!.name}': No branch selected`,
						);
						continue;
					}

					const isString = typeof branch === 'string';
					options = {
						branchName: isString ? branch : branch.ref,
						createBranchIfNeeded: isString,
					};
				}

				const svc = this.container.git.getRepositoryService(commit.repoPath);

				// Check for working changes and prompt before applying
				let shouldStash = false;
				if (await svc.status?.hasWorkingChanges()) {
					const confirm = { title: 'Stash Changes' };
					const cancel = { title: 'Cancel', isCloseAffordance: true };
					const result = await window.showWarningMessage(
						'You have changes in your working tree.\nDo you want to stash them before applying the patch?',
						{ modal: true },
						confirm,
						cancel,
					);
					if (result !== confirm) throw new CancellationError();

					shouldStash = true;
				}

				await svc.patch?.applyUnreachableCommitForPatch(commit.ref, {
					stash: shouldStash,
					...options,
				});
				void window.showInformationMessage(`Patch applied successfully`);
			} catch (ex) {
				if (isCancellationError(ex)) return;

				if (ApplyPatchCommitError.is(ex, 'appliedWithConflicts')) {
					void window.showWarningMessage('Patch applied with conflicts');
				} else if (ApplyPatchCommitError.is(ex)) {
					void showGitErrorMessage(ex);
				} else {
					void showGitErrorMessage(ex, `Unable to apply patch onto '${patch.baseRef}': ${ex.message}`);
				}
			}
		}
	}

	private closeView() {
		void setContext('gitlens:views:patchDetails:mode', undefined);

		if (this._context.mode === 'create') {
			void this.container.views.drafts.show();
		} else if (this._context.draft?.draftType === 'cloud') {
			void this.container.views.drafts.revealDraft(this._context.draft);
		}
	}

	@ipcCommand(CopyCloudLinkCommand)
	private onCopyCloudLink() {
		if (this._context.draft?.draftType !== 'cloud') return;

		void env.clipboard.writeText(this._context.draft.deepLinkUrl);
	}

	private async getOrganizationMembers() {
		return this.container.organizations.getMembers();
	}

	@ipcCommand(UpdatePatchUsersCommand)
	private async onInviteUsers() {
		let owner;
		let ownerSelection;
		let pickedMemberIds: string[] | undefined;
		if (this.mode === 'create') {
			pickedMemberIds = this._context.create?.userSelections?.map(u => u.member.id);
			ownerSelection = this._context.create?.userSelections?.find(u => u.member.role === 'owner');
			owner = ownerSelection?.user;
		} else {
			pickedMemberIds = this._context.draftUserState?.selections
				?.filter(s => s.change !== 'delete')
				?.map(u => u.member.id);
			owner = this._context.draftUserState?.users.find(u => u.role === 'owner');
		}

		const members = await showOrganizationMembersPicker(
			'Select Collaborators',
			'Choose collaborators to share this patch with',
			this.getOrganizationMembers(),
			{
				multiselect: true,
				filter: m => m.id !== owner?.userId,
				picked: m => pickedMemberIds?.includes(m.id) ?? false,
			},
		);
		if (members == null) return;

		if (this.mode === 'create') {
			const userSelections = members.map(member => toDraftUserSelection(member, undefined, 'editor', 'add'));
			if (ownerSelection != null) {
				userSelections.push(ownerSelection);
			}
			this._context.create!.userSelections = userSelections;
			void this.notifyDidChangeCreateDraftState();
			return;
		}

		const draftUserState = this._context.draftUserState!;

		const currentSelections = draftUserState.selections;
		const preserveSelections = new Map();
		const updatedMemberIds = new Set(members.map(member => member.id));
		const updatedSelections: DraftUserSelection[] = [];

		for (const selection of currentSelections) {
			if (updatedMemberIds.has(selection.member.id) || selection.member.role === 'owner') {
				preserveSelections.set(selection.member.id, selection);
				continue;
			}

			updatedSelections.push({ ...selection, change: 'delete' });
		}

		for (const member of members) {
			const selection = preserveSelections.get(member.id);
			// If we have an existing selection, and it's marked for deletion, we need to undo the deletion
			if (selection?.change === 'delete') {
				selection.change = undefined;
			}

			updatedSelections.push(selection ?? toDraftUserSelection(member, undefined, 'editor', 'add'));
		}

		if (updatedSelections.length) {
			draftUserState.selections = updatedSelections;
			void this.notifyDidChangeViewDraftState();
		}
	}

	@ipcCommand(UpdatePatchUserSelectionCommand)
	private onUpdatePatchUserSelection(params: IpcParams<typeof UpdatePatchUserSelectionCommand>) {
		if (this.mode === 'create') {
			const userSelections = this._context.create?.userSelections;
			if (userSelections == null) return;

			if (params.role === 'remove') {
				const selection = userSelections.findIndex(u => u.member.id === params.selection.member.id);
				if (selection === -1) return;

				userSelections.splice(selection, 1);
			} else {
				const selection = userSelections.find(u => u.member.id === params.selection.member.id);
				if (selection == null) return;

				selection.pendingRole = params.role;
			}

			void this.notifyDidChangeCreateDraftState();
			return;
		}

		const allSelections = this._context.draftUserState!.selections;
		const selection = allSelections.find(u => u.member.id === params.selection.member.id);
		if (selection == null) return;

		if (params.role === 'remove') {
			selection.change = 'delete';
		} else {
			selection.change = 'modify';
			selection.pendingRole = params.role;
		}

		void this.notifyDidChangeViewDraftState();
	}

	@ipcCommand(CreatePatchCommand)
	private async onCreateDraft({
		title,
		changesets,
		description,
		visibility,
		userSelections,
	}: IpcParams<typeof CreatePatchCommand>): Promise<void> {
		if (
			!(await ensureAccount(this.container, 'Cloud Patches are a Preview feature and require an account.', {
				source: 'cloud-patches',
				detail: 'create',
			})) ||
			!(await confirmDraftStorage(this.container))
		) {
			return;
		}

		const createChanges: CreateDraftChange[] = [];

		const changes = Object.entries(changesets);
		const ignoreChecked = changes.length === 1;

		for (const [id, change] of changes) {
			if (!ignoreChecked && change.checked === false) continue;

			const repoChangeset = this._context.create?.changes?.get(id);
			if (repoChangeset == null) continue;

			let { revision, repository } = repoChangeset;
			if (change.type === 'wip' && change.checked === 'staged') {
				revision = { ...revision, to: uncommittedStaged };
			}

			createChanges.push({
				repository: repository,
				revision: revision,
			});
		}
		if (createChanges == null) return;

		try {
			const options = {
				description: description,
				visibility: visibility,
			};
			const draft = await this.container.drafts.createDraft('patch', title, createChanges, options);

			if (userSelections != null && userSelections.length !== 0) {
				await this.container.drafts.addDraftUsers(
					draft.id,
					userSelections.map(u => ({
						userId: u.member.id,
						role: u.pendingRole!,
					})),
				);
			}

			async function showNotification() {
				const view = { title: 'View Patch' };
				const copy = { title: 'Copy Link' };
				let copied = false;
				while (true) {
					const result = await window.showInformationMessage(
						`Cloud Patch successfully created${copied ? '\u2014 link copied to the clipboard' : ''}`,
						view,
						copy,
					);

					if (result === copy) {
						void env.clipboard.writeText(draft.deepLinkUrl);
						copied = true;
						continue;
					}

					if (result === view) {
						void showPatchesView({ mode: 'view', draft: draft });
					}

					break;
				}
			}

			void showNotification();
			void this.container.views.drafts
				.refresh(true)
				.then(() => void this.container.views.drafts.revealDraft(draft));

			this.closeView();
		} catch (ex) {
			debugger;
			void this.notifyDidChangeCreateDraftState();
			void window.showErrorMessage(`Unable to create draft: ${ex.message}`);
		}
	}

	@ipcCommand(ArchiveDraftCommand)
	private async onArchiveDraft({ reason }: IpcParams<typeof ArchiveDraftCommand>) {
		if (this._context.draft?.draftType !== 'cloud') return;

		const isCodeSuggestion = this._context.draft.type === 'suggested_pr_change';
		let label = 'Cloud Patch';
		if (isCodeSuggestion) {
			label = 'Code Suggestion';
		}

		try {
			await this.container.drafts.archiveDraft(this._context.draft, { archiveReason: reason });
			this._context.draft = {
				...this._context.draft,
				isArchived: true,
				archivedReason: reason,
			};

			let action = 'archived';
			if (isCodeSuggestion) {
				switch (reason) {
					case 'accepted':
						action = 'accepted';
						break;
					case 'rejected':
						action = 'declined';
						break;
				}
			}

			void window.showInformationMessage(`${label} successfully ${action}`);
			void this.notifyDidChangeViewDraftState();
			if (isCodeSuggestion) {
				void this.trackArchiveDraft(this._context.draft);
			}
		} catch (ex) {
			let action = 'archive';
			if (isCodeSuggestion) {
				switch (reason) {
					case 'accepted':
						action = 'accept';
						break;
					case 'rejected':
						action = 'decline';
						break;
				}
			}

			void window.showErrorMessage(`Unable to ${action} ${label}: ${ex.message}`);
		}
	}

	private async trackArchiveDraft(draft: Draft) {
		draft = await this.ensureDraftContent(draft);
		const patch = draft.changesets?.[0].patches.find(p => isRepoLocated(p.repository));

		let repoPrivacy;
		if (GlRepository.is(patch?.repository)) {
			repoPrivacy = await this.container.git.visibility(patch.repository.uri);
		}

		const entity = getDraftEntityIdentifier(draft, patch);

		this.host.sendTelemetryEvent(
			'codeSuggestionArchived',
			{
				provider: entity?.provider,
				'repository.visibility': repoPrivacy,
				repoPrivacy: repoPrivacy,
				draftId: draft.id,
				reason: draft.archivedReason!,
			},
			{ source: 'patchDetails' },
		);
	}

	@ipcRequest(ExplainRequest)
	private async onExplain(): Promise<IpcResponse<typeof ExplainRequest>> {
		if (this._context.draft?.draftType !== 'cloud') {
			return { error: { message: 'Unable to find patch' } };
		}

		try {
			// TODO@eamodio HACK -- only works for the first patch
			const patch = await this.getDraftPatch(this._context.draft);
			if (patch == null) throw new Error('Unable to find patch');

			const commit = await this.getOrCreateCommitForPatch(patch.gkRepositoryId);
			if (commit == null) throw new Error('Unable to find commit');

			const deferredResult = await this.container.ai.actions.explainCommit(
				commit,
				{ source: 'patchDetails', context: { type: `draft-${this._context.draft.type}` } },
				{ progress: { location: { viewId: this.host.id } } },
			);
			if (deferredResult === 'cancelled') throw new Error('Operation was canceled');

			if (deferredResult == null) throw new Error('Error retrieving content');

			const { promise } = deferredResult;

			const result = await promise;
			if (result === 'cancelled') throw new Error('Operation was canceled');

			if (result == null) throw new Error('Error retrieving content');

			return { result: result.result };
		} catch (ex) {
			debugger;
			return { error: { message: ex.message } };
		}
	}

	@ipcRequest(GenerateRequest)
	private async onGenerateDetails(): Promise<IpcResponse<typeof GenerateRequest>> {
		let repo: GlRepository | undefined;
		if (this._context.create?.changes != null) {
			for (const change of this._context.create.changes.values()) {
				if (change.repository) {
					repo = change.repository;
					break;
				}
			}
		}

		if (!repo) {
			return { error: { message: 'Unable to find changes' } };
		}

		try {
			// TODO@eamodio HACK -- only works for the first patch
			// const patch = await this.getDraftPatch(this._context.draft);
			// if (patch == null) throw new Error('Unable to find patch');

			// const commit = await this.getOrCreateCommitForPatch(patch.gkRepositoryId);
			// if (commit == null) throw new Error('Unable to find commit');

			const result = await this.container.ai.actions.generateCreateDraft(
				repo,
				{ source: 'patchDetails', context: { type: 'patch' } },
				{ progress: { location: { viewId: this.host.id } } },
			);
			if (result === 'cancelled') throw new Error('Operation was canceled');

			if (result == null) throw new Error('Error retrieving content');

			return {
				title: result.result.summary,
				description: result.result.body,
			};
		} catch (ex) {
			debugger;
			return { error: { message: ex.message } };
		}
	}

	private async openPatchContents(_params: ExecuteFileActionParams) {
		// TODO@eamodio Open the patch contents for the selected repo in an untitled editor
	}

	@ipcCommand(DraftPatchCheckedCommand)
	private onPatchChecked(params: IpcParams<typeof DraftPatchCheckedCommand>) {
		if (params.patch.repository.located || params.checked === false) return;

		const patch = (this._context.draft as Draft)?.changesets?.[0].patches?.find(
			p => p.gkRepositoryId === params.patch.gkRepositoryId,
		);
		if (patch == null) return;

		void this.getOrLocatePatchRepository(patch, { prompt: true, notifyOnLocation: true });
	}

	private notifyPatchRepositoryUpdated(patch: DraftPatch) {
		return this.host.notify(DidChangePatchRepositoryNotification, {
			patch: serialize({
				...patch,
				contents: undefined,
				commit: undefined,
				repository: {
					id: patch.gkRepositoryId,
					name: patch.repository?.name ?? '',
					located: isRepoLocated(patch.repository),
				},
			}),
		});
	}

	@ipcCommand(UpdateCreatePatchRepositoryCheckedStateCommand)
	private onUpdateCreateCheckedState(params: IpcParams<typeof UpdateCreatePatchRepositoryCheckedStateCommand>) {
		const changeset = this._context.create?.changes.get(params.repoUri);
		if (changeset == null) return;

		changeset.checked = params.checked;
		void this.notifyDidChangeCreateDraftState();
	}

	@ipcCommand(UpdateCreatePatchMetadataCommand)
	private onUpdateCreateMetadata(params: IpcParams<typeof UpdateCreatePatchMetadataCommand>) {
		if (this._context.create == null) return;

		this._context.create.title = params.title;
		this._context.create.description = params.description;
		this._context.create.visibility = params.visibility;
		void this.notifyDidChangeCreateDraftState();
	}

	@ipcCommand(UpdatePatchDetailsMetadataCommand)
	private onUpdateDraftMetadata(params: IpcParams<typeof UpdatePatchDetailsMetadataCommand>) {
		if (this._context.draft == null) return;

		this._context.draftVisibiltyState = params.visibility;
		void this.notifyDidChangeViewDraftState();
	}

	@ipcCommand(UpdatePatchDetailsPermissionsCommand)
	private async onUpdateDraftPermissions() {
		const draft = this._context.draft as Draft;
		const draftId = draft.id;
		const changes = [];

		if (this._context.draftVisibiltyState != null && this._context.draftVisibiltyState !== draft.visibility) {
			changes.push(this.container.drafts.updateDraftVisibility(draftId, this._context.draftVisibiltyState));
		}

		const selections = this._context.draftUserState?.selections;
		const adds: DraftPendingUser[] = [];
		if (selections != null) {
			for (const selection of selections) {
				if (selection.change === undefined) continue;

				// modifying an existing user has to be done by deleting and adding back
				if (selection.change !== 'delete') {
					adds.push({
						userId: selection.member.id,
						role: selection.pendingRole!,
					});
				}

				if (selection.change !== 'add') {
					changes.push(this.container.drafts.removeDraftUser(draftId, selection.member.id));
				}
			}
		}

		if (changes.length === 0 && adds.length === 0) {
			return;
		}

		if (changes.length !== 0) {
			const results = await Promise.all(changes);
			console.log(results);
		}

		if (adds.length !== 0) {
			await this.container.drafts.addDraftUsers(draftId, adds);
		}
		await this.createDraftUserState(draft, { force: true });

		void window.showInformationMessage('Cloud Patch successfully updated');
		void this.notifyDidChangeViewDraftState();
	}

	// private shareLocalPatch() {
	// 	if (this._context.open?.draftType !== 'local') return;

	// 	this.updateCreateFromLocalPatch(this._context.open);
	// }

	@ipcCommand(SwitchModeCommand)
	private onSwitchMode(params: IpcParams<typeof SwitchModeCommand>) {
		this.setMode(params.mode);
	}

	private _notifyDidChangeStateDebounced: Deferrable<() => void> | undefined = undefined;

	private updateState(immediate: boolean = false) {
		this.host.clearPendingIpcNotifications();

		if (immediate) {
			void this.notifyDidChangeState();
			return;
		}

		this._notifyDidChangeStateDebounced ??= debounce(this.notifyDidChangeState.bind(this), 500);

		this._notifyDidChangeStateDebounced();
	}

	@trace({ args: false })
	protected async getState(current: Context): Promise<Serialized<State>> {
		let create;
		if (current.mode === 'create' && current.create != null) {
			create = await this.getCreateDraftState(current);
		}

		let draft;
		if (current.mode === 'view' && current.draft != null) {
			draft = await this.getViewDraftState(current);
		}

		const state = serialize<State>({
			...this.host.baseWebviewState,
			mode: current.mode,
			create: create,
			draft: draft,
			preferences: current.preferences,
			orgSettings: current.orgSettings,
		});
		return state;
	}

	private async notifyDidChangeState() {
		this._notifyDidChangeStateDebounced?.cancel();
		return this.host.notify(DidChangeNotification, { state: await this.getState(this._context) });
	}

	private updateCreateDraftState(create: CreateDraft) {
		let changesetByRepo: Map<string, RepositoryChangeset>;
		let allRepos = false;

		if (create.changes != null) {
			changesetByRepo = this._context.create?.changes ?? new Map<string, RepositoryChangeset>();

			const updated = new Set<string>();
			for (const change of create.changes) {
				const repo = this.container.git.getRepository(Uri.parse(change.repository.uri));
				if (repo == null) continue;

				let changeset: RepositoryChangeset;
				if (change.type === 'wip') {
					changeset = new RepositoryWipChangeset(
						this.container,
						repo,
						change.revision,
						this.onRepositoryWipChanged.bind(this),
						change.checked ?? true,
						change.expanded ?? true,
					);
				} else {
					changeset = new RepositoryRefChangeset(
						this.container,
						repo,
						change.revision,
						change.files,
						change.checked ?? true,
						change.expanded ?? true,
					);
				}

				updated.add(repo.uri.toString());
				changesetByRepo.set(repo.uri.toString(), changeset);
			}

			if (updated.size !== changesetByRepo.size) {
				for (const [uri, repoChange] of changesetByRepo) {
					if (updated.has(uri)) continue;

					repoChange.checked = false;
				}
			}
		} else {
			allRepos = create.repositories == null;
			const repos = create.repositories ?? this.container.git.openRepositories;
			changesetByRepo = new Map(
				repos.map(r => [
					r.uri.toString(),
					new RepositoryWipChangeset(
						this.container,
						r,
						{ to: uncommitted, from: 'HEAD' },
						this.onRepositoryWipChanged.bind(this),
						true,
						true, // TODO revisit
					),
				]),
			);
		}

		this._context.create = {
			title: create.title,
			description: create.description,
			changes: changesetByRepo,
			showingAllRepos: allRepos,
			visibility: 'public',
		};
		this.setMode('create', true);
		void this.notifyDidChangeCreateDraftState();
	}

	private async getCreateDraftState(current: Context): Promise<State['create'] | undefined> {
		const { create } = current;
		if (create == null) return undefined;

		const repoChanges: Record<string, Change> = {};

		if (create.changes.size !== 0) {
			for (const [id, repo] of create.changes) {
				const change = await repo.getChange();
				if (change?.files?.length === 0) continue; // TODO remove when we support dynamic expanded repos

				if (change.checked !== repo.checked) {
					change.checked = repo.checked;
				}
				repoChanges[id] = change;
			}
		}

		return {
			title: create.title,
			description: create.description,
			changes: repoChanges,
			visibility: create.visibility,
			userSelections: create.userSelections,
		};
	}

	private async notifyDidChangeCreateDraftState() {
		return this.host.notify(DidChangeCreateNotification, {
			mode: this._context.mode,
			create: await this.getCreateDraftState(this._context),
		});
	}

	private async trackViewDraft(draft: Draft | LocalDraft | undefined, source?: Sources | undefined) {
		if (draft?.draftType !== 'cloud' || draft.type !== 'suggested_pr_change') return;

		draft = await this.ensureDraftContent(draft);
		const patch = draft.changesets?.[0].patches.find(p => isRepoLocated(p.repository));

		let repoPrivacy;
		if (GlRepository.is(patch?.repository)) {
			repoPrivacy = await this.container.git.visibility(patch.repository.uri);
		}

		const entity = getDraftEntityIdentifier(draft, patch);

		this.host.sendTelemetryEvent(
			'codeSuggestionViewed',
			{
				provider: entity?.provider,
				'repository.visibility': repoPrivacy,
				repoPrivacy: repoPrivacy,
				draftId: draft.id,
				draftPrivacy: draft.visibility,
				source: source,
			},
			{ source: source ?? 'patchDetails' },
		);
	}

	private async updateViewDraftState(draft: LocalDraft | Draft | undefined) {
		this._context.draft = draft;
		if (draft?.draftType === 'cloud') {
			this._context.draftGkDevUrl = await this.container.drafts.generateWebUrl(draft);
			await this.createDraftUserState(draft, { force: true });
		}
		this.setMode('view', true);
		void this.notifyDidChangeViewDraftState();
	}

	// oxlint-disable-next-line typescript/require-await
	private async getViewDraftState(
		current: Context,
		deferredPatchLoading = true,
	): Promise<State['draft'] | undefined> {
		if (current.draft == null) return undefined;

		const draft = current.draft;

		// if (draft.draftType === 'local') {
		// 	const { patch } = draft;
		// 	if (patch.repository == null) {
		// 		const repo = this.container.git2.getBestRepository;
		// 		if (repo != null) {
		// 			patch.repository = repo;
		// 		}
		// 	}

		// 	return {
		// 		draftType: 'local',
		// 		files: patch.files ?? [],
		// 		repoPath: patch.repository?.path,
		// 		repoName: patch.repository?.name,
		// 		baseRef: patch.baseRef,
		// 	};
		// }

		if (draft.draftType === 'cloud') {
			if (deferredPatchLoading === true && isDraftMissingContent(draft)) {
				setTimeout(async () => {
					await this.ensureDraftContent(draft);

					void this.notifyDidChangeViewDraftState(false);
				}, 0);
			}

			const draftUserState = this._context.draftUserState!;
			return {
				draftType: 'cloud',
				id: draft.id,
				type: draft.type,
				createdAt: draft.createdAt.getTime(),
				updatedAt: draft.updatedAt.getTime(),
				author: {
					id: draft.author.id,
					name: draft.isMine ? formatCurrentUserDisplayName(draft.author.name) : draft.author.name,
					email: draft.author.email,
					avatar: draft.author.avatarUri?.toString(),
				},
				role: draft.role,
				title: draft.title,
				description: draft.description,
				isArchived: draft.isArchived,
				archivedReason: draft.archivedReason,
				visibility: draft.visibility,
				gkDevLink: this._context.draftGkDevUrl,
				patches: draft.changesets?.length
					? serialize(
							draft.changesets[0].patches.map(p => ({
								...p,
								contents: undefined,
								commit: undefined,
								repository: {
									id: p.gkRepositoryId,
									name: p.repository?.name ?? '',
									located: isRepoLocated(p.repository),
								},
							})),
						)
					: undefined,
				users: draftUserState.users,
				userSelections: draftUserState.selections,
			};
		}

		return undefined;
	}

	private async createDraftUserState(draft: Draft, options?: { force?: boolean }): Promise<void> {
		if (this._context.draftUserState != null && options?.force !== true) {
			return;
		}

		// try to create the state if it doesn't exist
		try {
			const draftUsers = await this.container.drafts.getDraftUsers(draft.id);
			if (draftUsers.length === 0) {
				return;
			}

			const users: DraftUser[] = [];
			const userSelections: DraftUserSelection[] = [];
			const members = await this.getOrganizationMembers();
			for (const user of draftUsers) {
				users.push(user);
				const member = members.find(m => m.id === user.userId)!;
				userSelections.push(toDraftUserSelection(member, user));
			}
			userSelections.sort(
				(a, b) =>
					((a.pendingRole ?? a.member.role) === 'owner' ? -1 : 1) -
						((b.pendingRole ?? b.member.role) === 'owner' ? -1 : 1) ||
					a.member.name.localeCompare(b.member.name),
			);

			this._context.draftUserState = { users: users, selections: userSelections };
		} catch (_ex) {
			debugger;
		}
	}

	private async notifyDidChangeViewDraftState(deferredPatchLoading = true) {
		return this.host.notify(DidChangeDraftNotification, {
			mode: this._context.mode,
			draft: serialize(await this.getViewDraftState(this._context, deferredPatchLoading)),
		});
	}

	@ipcCommand(UpdatePreferencesCommand)
	private onUpdatePreferences(params: IpcParams<typeof UpdatePreferencesCommand>) {
		if (
			this._context.preferences?.files?.compact === params.files?.compact &&
			this._context.preferences?.files?.icon === params.files?.icon &&
			this._context.preferences?.files?.layout === params.files?.layout &&
			this._context.preferences?.files?.threshold === params.files?.threshold
		) {
			return;
		}

		if (params.files != null) {
			if (this._context.preferences?.files?.compact !== params.files?.compact) {
				void configuration.updateEffective('views.patchDetails.files.compact', params.files?.compact);
			}
			if (this._context.preferences?.files?.icon !== params.files?.icon) {
				void configuration.updateEffective('views.patchDetails.files.icon', params.files?.icon);
			}
			if (this._context.preferences?.files?.layout !== params.files?.layout) {
				void configuration.updateEffective('views.patchDetails.files.layout', params.files?.layout);
			}
			if (this._context.preferences?.files?.threshold !== params.files?.threshold) {
				void configuration.updateEffective('views.patchDetails.files.threshold', params.files?.threshold);
			}

			this._context.preferences.files = params.files;
		}

		void this.notifyDidChangePreferences();
	}

	private async notifyDidChangePreferences() {
		return this.host.notify(DidChangePreferencesNotification, { preferences: this._context.preferences });
	}

	private async getDraftPatch(draft: Draft, gkRepositoryId?: GkRepositoryId): Promise<DraftPatch | undefined> {
		draft.changesets = await this.ensureChangesets(draft);

		const patch =
			gkRepositoryId == null
				? draft.changesets[0].patches?.[0]
				: draft.changesets[0].patches?.find(p => p.gkRepositoryId === gkRepositoryId);
		if (patch == null) return undefined;

		if (patch.contents == null || patch.files == null || patch.repository == null) {
			const details = await this.container.drafts.getPatchDetails(patch.id);
			patch.contents = details.contents;
			patch.files = details.files;
			patch.repository = details.repository;
		}

		return patch;
	}

	private async getFileCommitFromParams(
		params: ExecuteFileActionParams,
	): Promise<
		| [commit: GitCommit, file: GitFileChange, revision?: Required<Omit<PatchRevisionRange, 'branchName'>>]
		| undefined
	> {
		let [commit, revision] = await this.getOrCreateCommit(params);

		if (commit != null && revision != null) {
			return [
				commit,
				new GitFileChange(
					params.repoPath,
					params.path,
					params.status,
					joinUriPath(fileUri(params.repoPath), params.path),
					params.originalPath,
					params.originalPath != null
						? joinUriPath(fileUri(params.repoPath), params.originalPath)
						: undefined,
					undefined,
					undefined,
					params.staged,
					undefined,
					params.mode,
				),
				revision,
			];
		}

		commit = commit != null ? await getCommitForFile(commit, params.path, params.staged) : undefined;
		return commit != null ? [commit, commit.file!, revision] : undefined;
	}

	private async getOrCreateCommit(
		file: DraftPatchFileChange,
	): Promise<[commit: GitCommit | undefined, revision?: PatchRevisionRange]> {
		switch (this.mode) {
			case 'create':
				return this.getCommitForFile(file);
			case 'view':
				return [await this.getOrCreateCommitForPatch(file.gkRepositoryId)];
			default:
				return [undefined];
		}
	}

	async getCommitForFile(
		file: DraftPatchFileChange,
	): Promise<[commit: GitCommit | undefined, revision?: PatchRevisionRange]> {
		const changeset = find(this._context.create!.changes.values(), cs => cs.repository.path === file.repoPath);
		if (changeset == null) return [undefined];

		const change = await changeset.getChange();
		if (change == null) return [undefined];

		if (change.type === 'revision') {
			const commit = await this.container.git
				.getRepositoryService(file.repoPath)
				.commits.getCommit(change.revision.to ?? uncommitted);
			if (
				change.revision.to === change.revision.from ||
				(change.revision.from.length === change.revision.to.length + 1 &&
					change.revision.from.endsWith('^') &&
					change.revision.from.startsWith(change.revision.to))
			) {
				return [commit];
			}

			return [commit, change.revision];
		} else if (change.type === 'wip') {
			return [
				await this.container.git
					.getRepositoryService(file.repoPath)
					.commits.getCommit(change.revision.to ?? uncommitted),
			];
		}

		return [undefined];
	}

	async getOrCreateCommitForPatch(gkRepositoryId: GkRepositoryId): Promise<GitCommit | undefined> {
		const draft = this._context.draft!;
		if (draft.draftType === 'local') return undefined; // TODO

		const patch = await this.getDraftPatch(draft, gkRepositoryId);
		if (patch?.repository == null) return undefined;

		if (patch?.commit == null) {
			const repo = await this.getOrLocatePatchRepository(patch, { prompt: true });
			if (repo == null) return undefined;

			let baseRef = patch.baseRef ?? 'HEAD';

			do {
				try {
					const commit = await repo.git.patch?.createUnreachableCommitForPatch(
						baseRef,
						draft.title,
						patch.contents!,
					);
					patch.commit = commit;
				} catch (ex) {
					if (baseRef != null) {
						// const head = { title: 'HEAD' };
						const chooseBase = { title: 'Choose Base...' };
						const cancel = { title: 'Cancel', isCloseAffordance: true };

						const result = await window.showErrorMessage(
							`Unable to apply the patch onto ${
								baseRef === 'HEAD' ? 'HEAD' : `'${shortenRevision(baseRef)}'`
							}.\nDo you want to try again on a different base?`,
							{ modal: true },
							chooseBase,
							cancel,
							// ...(baseRef === 'HEAD' ? [chooseBase, cancel] : [head, chooseBase, cancel]),
						);

						if (result == null || result === cancel) break;
						// if (result === head) {
						// 	baseRef = 'HEAD';
						// 	continue;
						// }

						if (result === chooseBase) {
							const pick = await showReferencePicker2(
								repo.path,
								`Choose New Base for Patch`,
								`Choose a new base to apply the patch onto`,
								{
									allowedAdditionalInput: { rev: true },
									include: ['branches', 'tags', 'HEAD'],
								},
							);
							if (pick.value == null) break;

							baseRef = pick.value.ref;
							continue;
						}
					} else {
						void window.showErrorMessage(
							`Unable to apply the patch on base '${shortenRevision(baseRef)}': ${ex.message}`,
						);
					}
				}

				break;
			} while (true);
		}

		return patch?.commit;
	}

	@ipcCommand(OpenFileCommand)
	private async onOpenFile(params: IpcParams<typeof OpenFileCommand>) {
		const result = await this.getFileCommitFromParams(params);
		if (result == null) return;

		const [commit, file] = result;

		void openFile(file, commit, {
			preserveFocus: true,
			preview: true,
			...params.showOptions,
		});
	}

	private getChangesTitleNote() {
		if (
			this._context.mode === 'view' &&
			this._context.draft?.draftType === 'cloud' &&
			this._context.draft.type === 'suggested_pr_change'
		) {
			return 'Code Suggestion';
		}

		return 'Patch';
	}

	@ipcCommand(OpenFileComparePreviousCommand)
	private async onOpenFileComparisonWithPrevious(params: IpcParams<typeof OpenFileComparePreviousCommand>) {
		const result = await this.getFileCommitFromParams(params);
		if (result == null) return;

		const [commit, file, revision] = result;

		const titleNote = this.getChangesTitleNote();

		void openChanges(
			file,
			revision != null
				? { repoPath: commit.repoPath, rhs: revision.to ?? uncommitted, lhs: revision.from }
				: commit,
			{
				preserveFocus: true,
				preview: true,
				...params.showOptions,
				rhsTitle: this.mode === 'view' ? `${basename(file.path)} (${titleNote})` : undefined,
			},
		);
		this.container.events.fire('file:selected', { uri: file.uri }, { source: this.host.id });
	}

	@ipcCommand(OpenFileCompareWorkingCommand)
	private async onOpenFileComparisonWithWorking(params: IpcParams<typeof OpenFileCompareWorkingCommand>) {
		const result = await this.getFileCommitFromParams(params);
		if (result == null) return;

		const [commit, file, revision] = result;

		const titleNote = this.getChangesTitleNote();

		void openChangesWithWorking(file, revision != null ? { repoPath: commit.repoPath, ref: revision.to } : commit, {
			preserveFocus: true,
			preview: true,
			...params.showOptions,
			lhsTitle: this.mode === 'view' ? `${basename(file.path)} (${titleNote})` : undefined,
		});
	}

	private async getOrLocatePatchRepository(
		patch: DraftPatch,
		options?: { notifyOnLocation?: boolean; prompt?: boolean },
	): Promise<GlRepository | undefined> {
		if (patch.repository == null || GlRepository.is(patch.repository)) {
			return patch.repository;
		}

		const repo = await this.container.repositoryIdentity.getRepository(patch.repository, {
			openIfNeeded: true,
			prompt: options?.prompt ?? false,
		});
		if (repo == null) {
			void window.showErrorMessage(`Unable to locate repository '${patch.repository.name}'`);
		} else {
			patch.repository = repo;

			if (options?.notifyOnLocation) {
				void this.notifyPatchRepositoryUpdated(patch);
			}
		}

		return repo;
	}

	// Ensures that changesets arent mutated twice on the same draft
	@gate(d => d.id)
	private async ensureChangesets(draft: Draft) {
		draft.changesets ??= await this.container.drafts.getChangesets(draft.id);
		return draft.changesets;
	}

	private async ensureDraftContent(draft: Draft): Promise<Draft> {
		if (!isDraftMissingContent(draft)) {
			return draft;
		}

		draft.changesets = await this.ensureChangesets(draft);

		const patches = draft.changesets
			.flatMap(cs => cs.patches)
			.filter(p => p.contents == null || p.files == null || p.repository == null);

		if (patches.length === 0) {
			return draft;
		}

		const patchDetails = await Promise.allSettled(patches.map(p => this.container.drafts.getPatchDetails(p)));

		for (const d of patchDetails) {
			if (d.status === 'fulfilled') {
				const patch = patches.find(p => p.id === d.value.id);
				if (patch != null) {
					patch.contents = d.value.contents;
					patch.files = d.value.files;
					patch.repository = d.value.repository;
					await this.getOrLocatePatchRepository(patch);
				}
			}
		}

		return draft;
	}
}

function isDraftMissingContent(draft: Draft): boolean {
	if (draft.changesets == null) return true;

	return some(draft.changesets, cs =>
		cs.patches.some(p => p.contents == null || p.files == null || p.repository == null),
	);
}

function isRepoLocated(repo: DraftPatch['repository']): boolean {
	return repo != null && GlRepository.is(repo);
}

function toDraftUserSelection(
	member: OrganizationMember,
	user?: DraftUserSelection['user'],
	pendingRole?: DraftPendingUser['role'],
	change?: DraftUserSelection['change'],
): DraftUserSelection {
	return {
		change: change,
		member: member,
		user: user,
		pendingRole: pendingRole,
		avatarUrl: member?.email != null ? getAvatarUri(member.email, undefined).toString() : undefined,
	};
}
