import type { Disposable, TextDocument } from 'vscode';
import { Uri, ViewColumn, window, workspace } from 'vscode';
import type { GitCommit } from '@gitlens/git/models/commit.js';
import type { ProcessedRebaseTodo, RebaseTodoAction } from '@gitlens/git/models/rebase.js';
import { getConflictIncomingRef, resolveConflictFilePaths } from '@gitlens/git/utils/pausedOperationStatus.utils.js';
import { createReference } from '@gitlens/git/utils/reference.utils.js';
import type { Deferrable } from '@gitlens/utils/debounce.js';
import { debounce } from '@gitlens/utils/debounce.js';
import { debug } from '@gitlens/utils/decorators/log.js';
import { concat, filterMap, find, first, join, last, map } from '@gitlens/utils/iterable.js';
import { Logger } from '@gitlens/utils/logger.js';
import { extname, normalizePath } from '@gitlens/utils/path.js';
import { getSettledValue } from '@gitlens/utils/promise.js';
import { getAvatarUri, getAvatarUriFromGravatarEmail } from '../../avatars.js';
import type { DiffWithCommandArgs } from '../../commands/diffWith.js';
import type { GlWebviewCommandsOrCommandsWithSuffix } from '../../constants.commands.js';
import type { RebaseEditorTelemetryContext } from '../../constants.telemetry.js';
import type { Container } from '../../container.js';
import { emojify } from '../../emojis.js';
import {
	abortPausedOperation,
	continuePausedOperation,
	showPausedOperationStatus,
	skipPausedOperation,
} from '../../git/actions/pausedOperation.js';
import { GitUri } from '../../git/gitUri.js';
import {
	formatCommitDate,
	formatIdentityDisplayName,
	getCommitFormattedDate,
} from '../../git/utils/-webview/commit.utils.js';
import { processRebaseEntries, readAndParseRebaseDoneFile } from '../../git/utils/-webview/rebase.parsing.utils.js';
import { reopenRebaseTodoEditor } from '../../git/utils/-webview/rebase.utils.js';
import type { Subscription } from '../../plus/gk/models/subscription.js';
import { isSubscriptionTrialOrPaidFromState } from '../../plus/gk/utils/subscription.utils.js';
import { executeCommand, executeCoreCommand } from '../../system/-webview/command.js';
import { configuration } from '../../system/-webview/configuration.js';
import { closeTab } from '../../system/-webview/vscode/tabs.js';
import { exists } from '../../system/-webview/vscode/uris.js';
import { createCommandDecorator, getWebviewCommand } from '../../system/decorators/command.js';
import type { IpcParams, IpcResponse } from '../ipc/handlerRegistry.js';
import { ipcCommand, ipcRequest } from '../ipc/handlerRegistry.js';
import type { ComposerWebviewShowingArgs } from '../plus/composer/registration.js';
import type { ShowInCommitGraphCommandArgs } from '../plus/graph/registration.js';
import type { WebviewHost } from '../webviewProvider.js';
import type { WebviewPanelShowCommandArgs } from '../webviewsController.js';
import type {
	Author,
	Commit,
	ConflictFileInfo,
	RebaseActiveStatus,
	RebaseEntry,
	RebasePauseReason,
	State,
	UpdateSelectionParams,
} from './protocol.js';
import {
	AbortCommand,
	ChangeEntriesCommand,
	ChangeEntryCommand,
	ContinueCommand,
	DidChangeAvatarsNotification,
	DidChangeCommitsNotification,
	DidChangeNotification,
	DidChangeSubscriptionNotification,
	DismissCloseWarningCommand,
	GetConflictsRequest,
	GetMissingAvatarsCommand,
	GetMissingCommitsCommand,
	MoveEntriesCommand,
	MoveEntryCommand,
	OpenConflictChangesCommand,
	OpenConflictFileCommand,
	RecomposeCommand,
	ReorderCommand,
	RevealRefCommand,
	SearchCommand,
	ShiftEntriesCommand,
	SkipCommand,
	StartCommand,
	SwitchCommand,
	UpdateSelectionCommand,
} from './protocol.js';
import { RebaseTodoDocument } from './rebaseTodoDocument.js';

export const maxSmallIntegerV8 = 2 ** 30 - 1;

const { command, getCommands } = createCommandDecorator<GlWebviewCommandsOrCommandsWithSuffix<'rebase'>>();

interface Enrichment {
	commits: Map<string, GitCommit>;
	authors: Map<string, Author>;
	onto: { sha: string; commit?: GitCommit | undefined } | undefined;
}

interface RebaseEditorContext {
	sessionStart: string;
	todoCount?: number;
	doneCount?: number;
	isRebasing?: boolean;
	isPaused?: boolean;
	preservesMerges?: boolean;
	hasConflicts?: boolean;
}

export class RebaseWebviewProvider implements Disposable {
	private _branchName?: string | null;
	private _closing: boolean = false;
	private _conflictMarkerCache = new Map<string, { mtime: number; count: number }>();
	private readonly _disposables: Disposable[] = [];
	private _enrichment: Enrichment;
	private readonly _todoDocument: RebaseTodoDocument;

	// Telemetry context - tracks composer-specific data for getTelemetryContext
	private _context: RebaseEditorContext = { sessionStart: new Date().toISOString() };

	private get ascending() {
		return configuration.get('rebaseEditor.ordering') === 'asc';
	}

	/** Computes whether the commits are already in place (parent of first commit is the onto commit) */
	private computeIsInPlace(processed?: ProcessedRebaseTodo): boolean {
		processed ??= this._todoDocument.parsed.processed;
		const { commits, onto: ontoCommit } = this._enrichment;

		const firstShortSha = first(processed.commits.keys());
		if (firstShortSha == null || ontoCommit?.sha == null) return false;

		const firstCommit = commits.get(firstShortSha);
		if (firstCommit == null) return false;

		// Use short SHA comparison - onto.sha is already a short SHA
		return firstCommit.parents.some(parent => parent.startsWith(ontoCommit.sha));
	}

	constructor(
		private readonly container: Container,
		private readonly host: WebviewHost<'gitlens.rebase'>,
		document: TextDocument,
		private readonly repoPath: string,
	) {
		this._enrichment = { commits: new Map(), authors: new Map(), onto: undefined };
		this._todoDocument = new RebaseTodoDocument(document);

		this._disposables.push(
			workspace.onDidChangeTextDocument(e => {
				if (!this._closing && e.document === document && e.contentChanges.length) {
					// Don't refresh (which reloads the webview), just update state
					this.updateState();
				}
			}),
			workspace.onDidSaveTextDocument(e => {
				if (!this._closing && e === document) {
					// Don't refresh (which reloads the webview), just update state
					this.updateState();
				}
			}),
			// Close the editor if the document is closed (e.g., file deleted)
			workspace.onDidCloseTextDocument(e => {
				if (e === document) {
					this._closing = true;
					void closeTab(document.uri);
				}
			}),
			this.container.subscription.onDidChange(e => {
				this.onSubscriptionChanged(e.current);
			}),
			this.container.onboarding.onDidChange(e => {
				if (e.key === 'rebaseEditor:closeWarning') {
					this.updateState();
				}
			}),
		);

		// Subscribe to repository changes
		const repo = this.container.git.getRepository(this.repoPath);
		if (repo != null) {
			this._disposables.push(
				repo.onDidChange(async e => {
					if (e.changed('rebase')) {
						// Check if the rebase todo file still exists, if not close the editor
						if (await exists(this._todoDocument.uri)) {
							this.updateState();
						} else if (!this._closing) {
							this._closing = true;
							void closeTab(this._todoDocument.uri);
						}
					} else if (e.changed('index')) {
						// Refresh when index changes to update conflict state during paused rebase
						this.updateState();
					}
				}),
			);
		}
	}

	dispose(): void {
		this._disposables.forEach(d => void d.dispose());
	}

	private getSessionDuration(): number {
		return Date.now() - new Date(this._context.sessionStart).getTime();
	}

	getTelemetryContext(): RebaseEditorTelemetryContext {
		return {
			...this.host.getTelemetryContext(),
			'context.ascending': this.ascending,
			'context.todo.count': this._context.todoCount,
			'context.done.count': this._context.doneCount,
			'context.isRebasing': this._context.isRebasing,
			'context.isPaused': this._context.isPaused,
			'context.preservesMerges': this._context.preservesMerges,
			'context.hasConflicts': this._context.hasConflicts,
			'context.session.start': this._context.sessionStart,
		};
	}

	async includeBootstrap(deferrable?: boolean): Promise<State> {
		if (deferrable) {
			return Promise.resolve({
				webviewId: this.host.id,
				webviewInstanceId: this.host.instanceId,
				timestamp: Date.now(),
			} as State);
		}

		return this.parseState();
	}

	registerCommands(): Disposable[] {
		const commands: Disposable[] = [];
		for (const c of getCommands()) {
			commands.push(
				this.host.registerWebviewCommand(getWebviewCommand(c.command, 'rebase'), c.handler.bind(this)),
			);
		}
		return commands;
	}

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

	onVisibilityChanged(visible: boolean): void {
		if (visible) {
			// If there was a pending change while hidden, update now
			this.updateState();
		}
	}

	private onSubscriptionChanged(subscription: Subscription): void {
		if (!this.host.visible) return;

		void this.host.notify(DidChangeSubscriptionNotification, { subscription: subscription });
	}

	@ipcCommand(OpenConflictFileCommand)
	@debug()
	private async onOpenConflictFile(params: IpcParams<typeof OpenConflictFileCommand>): Promise<void> {
		const normalizedPath = normalizePath(params.path);

		this.host.sendTelemetryEvent('rebaseEditor/action/openConflictFile', {
			'conflict.fileExtension': extname(normalizedPath),
		});

		const uri = Uri.joinPath(Uri.file(this.repoPath), normalizedPath);
		await executeCoreCommand('vscode.open', uri, { viewColumn: this.getConflictFileViewColumn() });
	}

	@ipcCommand(OpenConflictChangesCommand)
	@debug()
	private async onOpenConflictChanges(params: IpcParams<typeof OpenConflictChangesCommand>): Promise<void> {
		const normalizedPath = normalizePath(params.path);

		this.host.sendTelemetryEvent('rebaseEditor/action/openConflictChanges', {
			side: params.side,
		});

		const svc = this.container.git.getRepositoryService(this.repoPath);
		const pausedStatus = await svc.pausedOps?.getPausedOperationStatus?.();
		if (pausedStatus?.type !== 'rebase' || pausedStatus.mergeBase == null) {
			Logger.warn('onOpenConflictChanges: unable to open conflict changes — missing rebase status or merge base');
			void window.showWarningMessage('Unable to open conflict changes — rebase status is no longer available');
			return;
		}

		const incomingRef = getConflictIncomingRef(pausedStatus) ?? pausedStatus.HEAD.ref;
		const mergeBase = pausedStatus.mergeBase;

		// Resolve rename-aware paths (mirrors mergeConflictFileNode.ts pattern)
		const [currentFilesResult, incomingFilesResult] = await Promise.allSettled([
			svc.diff.getDiffStatus(mergeBase, 'HEAD', { renameLimit: 0 }),
			svc.diff.getDiffStatus(mergeBase, incomingRef, { renameLimit: 0 }),
		]);
		const currentFiles = getSettledValue(currentFilesResult);
		const incomingFiles = getSettledValue(incomingFilesResult);

		let lhsPath: string;
		let rhsPath: string;
		if (params.side === 'current') {
			({ lhsPath, rhsPath } = resolveConflictFilePaths(currentFiles, incomingFiles, normalizedPath));
		} else {
			// Swap: when viewing incoming changes, "my side" is the incoming ref
			({ lhsPath, rhsPath } = resolveConflictFilePaths(incomingFiles, currentFiles, normalizedPath));
		}

		const ref = params.side === 'current' ? 'HEAD' : incomingRef;

		await executeCommand<DiffWithCommandArgs>('gitlens.diffWith', {
			lhs: {
				sha: mergeBase,
				uri: GitUri.fromFile(lhsPath, this.repoPath, mergeBase),
				title: `${lhsPath} (merge-base)`,
			},
			rhs: {
				sha: ref,
				uri: GitUri.fromFile(rhsPath, this.repoPath, ref),
				title: `${rhsPath} (${params.side === 'current' ? 'current' : 'incoming'})`,
			},
			repoPath: this.repoPath,
			showOptions: {
				preserveFocus: false,
				preview: true,
				viewColumn: this.getConflictFileViewColumn(),
			},
		});
	}

	// Opens conflict files in a different pane than the rebase editor.
	// If only one pane exists, falls back to ViewColumn.Beside to open a new pane to the side.
	private getConflictFileViewColumn(): ViewColumn {
		const rebaseColumn = this.host.viewColumn;
		return window.tabGroups.all.find(g => g.viewColumn !== rebaseColumn)?.viewColumn ?? ViewColumn.Beside;
	}

	@ipcCommand(AbortCommand)
	@debug()
	private async onAbort(): Promise<void> {
		this.host.sendTelemetryEvent('rebaseEditor/action/abort', {
			'context.session.duration': this.getSessionDuration(),
		});

		this._closing = true;

		// Delete the contents to abort the rebase
		await this._todoDocument.clear();
		await this._todoDocument.save();

		const svc = this.container.git.getRepositoryService(this.repoPath);
		await abortPausedOperation(svc);
		await closeTab(this._todoDocument.uri);
	}

	@ipcCommand(ContinueCommand)
	@debug()
	private async onContinue(): Promise<void> {
		this.host.sendTelemetryEvent('rebaseEditor/action/continue');

		// Save the document first to ensure any changes are persisted
		await this._todoDocument.save();

		const svc = this.container.git.getRepositoryService(this.repoPath);
		await continuePausedOperation(svc);
	}

	@ipcCommand(RecomposeCommand)
	@debug()
	private async onRecompose(): Promise<void> {
		this.host.sendTelemetryEvent('rebaseEditor/action/recompose', {
			'context.session.duration': this.getSessionDuration(),
		});

		// Get commit SHAs from the rebase entries
		const { processed } = this._todoDocument.parsed;

		const firstShortSha = first(processed.commits.keys())!;
		const ontoShortSha = this._enrichment.onto!.sha;

		// Get the base commit SHA from the onto commit (the commit we're rebasing onto)
		const headShortSha = last(processed.commits.keys())!;
		const { commits } = await this.getAndUpdateCommits([headShortSha, ontoShortSha, firstShortSha]);

		const ontoCommit = commits.get(ontoShortSha)!;
		const firstCommit = commits.get(firstShortSha)!;

		let baseCommitSha = ontoCommit.sha;
		if (!firstCommit.parents.includes(baseCommitSha)) {
			baseCommitSha = firstCommit.parents[0];
		}

		const headCommitSha = commits.get(headShortSha)!.sha;

		// Open the Commit Composer with the commits
		void executeCommand<WebviewPanelShowCommandArgs<ComposerWebviewShowingArgs>>(
			'gitlens.showComposerPage',
			undefined,
			{
				repoPath: this.repoPath,
				source: 'rebaseEditor',
				mode: 'preview',
				branchName: this._branchName ?? undefined,
				range: { base: baseCommitSha, head: headCommitSha },
			},
		);

		await this.onAbort();
	}

	@command('gitlens.pausedOperation.showConflicts:')
	@debug()
	private async onShowConflicts(): Promise<void> {
		this.host.sendTelemetryEvent('rebaseEditor/action/showConflicts');
		await showPausedOperationStatus(this.container, this.repoPath);
	}

	@ipcCommand(SearchCommand)
	private onSearch() {
		void executeCoreCommand('editor.action.webvieweditor.showFind');
	}

	@ipcCommand(SkipCommand)
	@debug()
	private async onSkip(): Promise<void> {
		this.host.sendTelemetryEvent('rebaseEditor/action/skip');
		const svc = this.container.git.getRepositoryService(this.repoPath);
		await skipPausedOperation(svc);
	}

	@ipcCommand(StartCommand)
	@debug()
	private async onStart(): Promise<void> {
		this.host.sendTelemetryEvent('rebaseEditor/action/start', {
			'context.session.duration': this.getSessionDuration(),
		});

		this._closing = true;

		await this._todoDocument.save();
		await closeTab(this._todoDocument.uri);
	}

	@ipcCommand(DismissCloseWarningCommand)
	@debug()
	private onDismissCloseWarning(): void {
		void this.container.onboarding.dismiss('rebaseEditor:closeWarning');
	}

	@ipcCommand(ReorderCommand)
	@debug()
	private async onSwapOrdering(params: IpcParams<typeof ReorderCommand>): Promise<void> {
		const oldOrdering = this.ascending ? 'asc' : 'desc';
		const newOrdering = (params.ascending ?? false) ? 'asc' : 'desc';

		this.host.sendTelemetryEvent('rebaseEditor/action/toggleOrdering', {
			'ordering.old': oldOrdering,
			'ordering.new': newOrdering,
		});

		await configuration.updateEffective('rebaseEditor.ordering', newOrdering);
		this.updateState(true);
	}

	@ipcCommand(SwitchCommand)
	@debug()
	private onSwitchToText(): Promise<void> {
		this.host.sendTelemetryEvent('rebaseEditor/action/switchToText', {
			'context.session.duration': this.getSessionDuration(),
		});
		return reopenRebaseTodoEditor('default');
	}

	/** Fetches enhanced avatars (from GitHub/GitLab/etc.) for the requested emails */
	@ipcCommand(GetMissingAvatarsCommand)
	private async onGetMissingAvatars(params: IpcParams<typeof GetMissingAvatarsCommand>): Promise<void> {
		if (!this._enrichment?.authors.size || !this.repoPath) return;

		const { authors } = this._enrichment;

		const promises: Promise<void>[] = [];
		let hasUpdates = false;

		for (const [email, sha] of Object.entries(params.emails)) {
			// Find the author by email to update their avatar
			const author = find(authors.values(), a => a.email === email);
			if (!author) continue;

			const avatarUrlOrPromise = author.avatarUrl ?? getAvatarUri(email, { ref: sha, repoPath: this.repoPath });
			if (avatarUrlOrPromise instanceof Promise) {
				promises.push(
					avatarUrlOrPromise.then(uri => {
						authors.set(author.author, { ...author, avatarUrl: uri.toString(true) });
					}),
				);
				continue;
			}

			authors.set(author.author, { ...author, avatarUrl: avatarUrlOrPromise.toString(true) });
			hasUpdates = true;
		}

		if (hasUpdates || promises.length) {
			await Promise.allSettled(promises);
			this.notifyDidChangeAvatars();
		}
	}

	/** Fetches commit data for the requested SHAs and sends enriched commit data to webview */
	@ipcCommand(GetMissingCommitsCommand)
	private async onGetMissingCommits(params: IpcParams<typeof GetMissingCommitsCommand>): Promise<void> {
		if (!params.shas.length || !this.repoPath) return;

		const { commits, authors } = await this.getAndUpdateCommits(params.shas);
		if (commits.size) {
			this.notifyDidChangeCommits(commits, authors);
		}
	}

	private async getAndUpdateCommits(
		shas: string[],
	): Promise<{ commits: Map<string, GitCommit>; authors: Map<string, Author> }> {
		if (!shas.length || !this.repoPath) return { commits: new Map(), authors: new Map() };

		const requestedCommits = new Map<string, GitCommit>();
		const requestedAuthors = new Map<string, Author>();

		const { authors, commits } = this._enrichment;

		const shasRequested = new Set(shas);
		const shasToLoad = new Set(shasRequested);
		for (const sha of shasToLoad) {
			const commit = commits.get(sha);
			if (commit == null) continue;

			shasToLoad.delete(sha);
			requestedCommits.set(sha, commit);
		}

		const results = shasToLoad.size ? await this.getCommitsByShas(shasToLoad) : [];
		for (const commit of concat(requestedCommits.values(), results)) {
			const requestedSha = find(shasRequested, s => commit.sha.startsWith(s));
			if (requestedSha == null) {
				debugger;
				continue;
			}

			commits.set(requestedSha, commit);
			requestedCommits.set(requestedSha, commit);
			if (requestedSha === this._enrichment.onto?.sha) {
				this._enrichment.onto.commit = commit;
			}

			const authorName = commit.author.name;
			let author = authors.get(authorName);
			if (author == null) {
				author = {
					author: authorName,
					email: commit.author.email ?? '',
					avatarUrl: undefined,
					avatarFallbackUrl: getAvatarUriFromGravatarEmail(commit.author.email ?? '', 16).toString(true),
				};
				authors.set(authorName, author);
			}
			requestedAuthors.set(authorName, author);

			const committerName = commit.committer.name;
			if (committerName === authorName) continue;

			let committer = authors.get(committerName);
			if (committer == null) {
				committer = {
					author: committerName,
					email: commit.committer.email ?? '',
					avatarUrl: undefined,
					avatarFallbackUrl: getAvatarUriFromGravatarEmail(commit.committer.email ?? '', 16).toString(true),
				};
				authors.set(committerName, committer);
			}
			requestedAuthors.set(committerName, committer);
		}

		return { commits: requestedCommits, authors: requestedAuthors };
	}

	/** Handles rebase conflict detection requests (Pro feature) — unified for initial and todo triggers */
	@ipcRequest(GetConflictsRequest)
	private async onGetConflicts(
		params: IpcParams<typeof GetConflictsRequest>,
	): Promise<IpcResponse<typeof GetConflictsRequest>> {
		const { trigger, onto, commits, base, stopOnFirstConflict } = params;
		const startTime = Date.now();
		const detection = trigger === 'initial' ? 'potential' : 'todo';

		const subscription = await this.container.subscription.getSubscription();
		if (!isSubscriptionTrialOrPaidFromState(subscription?.state)) {
			return { conflicts: undefined };
		}

		if (!commits?.length) {
			this.host.sendTelemetryEvent('rebaseEditor/conflicts/detected', {
				duration: Date.now() - startTime,
				status: 'clean',
				detection: detection,
				'commits.count': 0,
			});
			return { conflicts: { status: 'clean' } };
		}

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

		try {
			const result = await svc.branches.getPotentialApplyConflicts?.(base ?? onto, commits, {
				stopOnFirstConflict: trigger === 'initial' ? (stopOnFirstConflict ?? false) : false,
			});

			const duration = Date.now() - startTime;
			if (result?.status === 'conflicts') {
				this.host.sendTelemetryEvent('rebaseEditor/conflicts/detected', {
					duration: duration,
					status: 'conflicts',
					detection: detection,
					'commits.count': commits.length,
					'commits.conflicting': result.conflict.shas?.length ?? 0,
				});
			} else if (result?.status === 'clean') {
				this.host.sendTelemetryEvent('rebaseEditor/conflicts/detected', {
					duration: duration,
					status: 'clean',
					detection: detection,
					'commits.count': commits.length,
				});
			}

			return { conflicts: result };
		} catch (ex) {
			this.host.sendTelemetryEvent('rebaseEditor/conflicts/failed', {
				duration: Date.now() - startTime,
				'commits.count': commits.length,
				error: ex instanceof Error ? ex.message : String(ex),
			});
			Logger.error(ex, 'onGetConflicts');
			return { conflicts: undefined };
		}
	}

	@ipcCommand(RevealRefCommand)
	private async onRevealRef(params: IpcParams<typeof RevealRefCommand>): Promise<void> {
		const revealIn = configuration.get('rebaseEditor.revealLocation');

		// For branches, always use the graph since commit details doesn't support branches
		if (params.type === 'branch') {
			this.host.sendTelemetryEvent('rebaseEditor/action/revealRef', {
				'ref.type': 'branch',
				location: 'graph',
			});

			const ref = createReference(params.ref, this.repoPath, {
				refType: 'branch',
				name: params.ref,
				remote: false,
			});
			await executeCommand<ShowInCommitGraphCommandArgs>('gitlens.showInCommitGraph', {
				ref: ref,
				preserveFocus: true,
				viewColumn: ViewColumn.Beside,
				source: { source: 'rebaseEditor' },
			});
			return;
		}

		const location = revealIn === 'graph' ? 'graph' : 'commitDetails';
		this.host.sendTelemetryEvent('rebaseEditor/action/revealRef', {
			'ref.type': 'commit',
			location: location,
		});

		const ref = createReference(params.ref, this.repoPath, { refType: 'revision' });
		if (revealIn === 'graph') {
			await executeCommand<ShowInCommitGraphCommandArgs>('gitlens.showInCommitGraph', {
				ref: ref,
				preserveFocus: true,
				viewColumn: ViewColumn.Beside,
				source: { source: 'rebaseEditor' },
			});
		} else {
			await this.container.views.commitDetails.show(
				{ preserveFocus: true, source: { source: 'rebaseEditor' } },
				{ commit: ref },
			);
		}
	}

	private fireSelectionChangedDebounced?: Deferrable<RebaseWebviewProvider['fireSelectionChanged']>;
	@ipcCommand(UpdateSelectionCommand)
	private onSelectionChanged(params: IpcParams<typeof UpdateSelectionCommand>): void {
		this.fireSelectionChangedDebounced ??= debounce(this.fireSelectionChanged.bind(this), 250);
		void this.fireSelectionChangedDebounced(params);
	}

	private getRevealBehavior() {
		const revealBehavior = configuration.get('rebaseEditor.revealBehavior');
		// Handle deprecated 'never' and 'onOpen' behavior
		if ((revealBehavior as string) === 'onOpen' || (revealBehavior as string) === 'never') {
			return 'onDoubleClick';
		}

		return revealBehavior;
	}

	private async fireSelectionChanged(params: UpdateSelectionParams): Promise<void> {
		const revealBehavior = this.getRevealBehavior();
		// Only auto-reveal on selection if behavior is 'onSelection'
		if (revealBehavior !== 'onSelection') return;

		const { processed } = this._todoDocument.parsed;
		const commits = processed.commits.values();
		const entry = find(commits, e => e.sha === params.sha);
		if (entry == null) return;

		let commit = this._enrichment?.commits.get(entry.sha);
		if (commit == null) {
			commit = await this.container.git.getRepositoryService(this.repoPath).commits.getCommit(entry.sha);
			if (commit == null) return;

			this._enrichment.commits.set(entry.sha, commit);
		}

		// Reveal in the preferred location
		const revealLocation = configuration.get('rebaseEditor.revealLocation');
		if (revealLocation === 'graph') {
			const ref = createReference(commit.sha, this.repoPath, { refType: 'revision' });
			await executeCommand<ShowInCommitGraphCommandArgs>('gitlens.showInCommitGraph', {
				ref: ref,
				preserveFocus: true,
				viewColumn: ViewColumn.Beside,
				source: { source: 'rebaseEditor' },
			});
		} else {
			// Fire event for commit details view to pick up
			this.container.events.fire(
				'commit:selected',
				{ commit: commit, interaction: 'passive', preserveFocus: true, preserveVisibility: false },
				{ source: 'gitlens.rebase' },
			);
		}
	}

	private async parseState(): Promise<State> {
		const svc = this.container.git.getRepositoryService(this.repoPath);

		const { parsed, processed } = this._todoDocument.parsed;

		// Fetch branch, rebase status, and subscription
		const [branchResult, rebaseStatusResult, subscriptionResult] = await Promise.allSettled([
			this._branchName === undefined ? svc.branches.getBranch() : undefined,
			this.getRebaseStatus(svc),
			this.container.subscription.getSubscription(),
		]);

		if (this._branchName === undefined) {
			this._branchName = getSettledValue(branchResult)?.name ?? null;
		}

		const {
			status: rebaseStatus,
			doneEntries,
			conflictFiles,
		} = getSettledValue(rebaseStatusResult, {
			status: undefined,
			doneEntries: undefined,
			conflictFiles: undefined,
		});

		const subscription = getSettledValue(subscriptionResult);

		// Get onto from parsed header or active rebase status
		const onto = parsed.info?.onto ?? rebaseStatus?.onto ?? '';
		this._enrichment.onto ??= { sha: onto };

		let { entries } = processed;
		const { authors, commits, onto: ontoCommit } = this._enrichment;

		// Filter out commits already in doneEntries to prevent duplicates caused by
		// the TextDocument being stale while the done file is read fresh from disk
		if (doneEntries?.length) {
			const doneShas = new Set<string>();
			for (const e of doneEntries) {
				if (e.type === 'commit') {
					doneShas.add(e.sha);
				}
			}
			if (doneShas.size) {
				entries = entries.filter(e => e.type !== 'commit' || !doneShas.has(e.sha));
			}
		}

		const defaultDateFormat = configuration.get('defaultDateFormat');

		// Only enrich entries if we we have commits already
		if (commits.size) {
			this.enrichEntries(entries, commits, defaultDateFormat);
			if (doneEntries) {
				this.enrichEntries(doneEntries, commits, defaultDateFormat);
			}
		}

		// Update telemetry tracking state
		this._context.todoCount = processed.commits.size;
		this._context.doneCount = doneEntries?.filter(e => e.type === 'commit').length ?? 0;
		this._context.isRebasing = rebaseStatus != null;
		this._context.isPaused = rebaseStatus?.isPaused;
		this._context.preservesMerges = processed.preservesMerges;
		this._context.hasConflicts = rebaseStatus?.hasConflicts ?? false;

		return {
			webviewId: 'gitlens.rebase',
			webviewInstanceId: this.host.instanceId,
			timestamp: Date.now(),
			branch: this._branchName ?? '',
			onto: onto
				? {
						sha: ontoCommit.sha ?? onto,
						commit:
							ontoCommit?.commit != null
								? convertCommit(ontoCommit.commit, defaultDateFormat)
								: undefined,
					}
				: undefined,
			isInPlace: this.computeIsInPlace(processed),
			entries: entries,
			doneEntries: doneEntries,
			authors: authors != null ? Object.fromEntries(authors) : {},
			ascending: this.ascending,
			preservesMerges: processed.preservesMerges,
			density: configuration.get('rebaseEditor.density'),
			revealLocation: configuration.get('rebaseEditor.revealLocation'),
			revealBehavior: this.getRevealBehavior(),
			rebaseStatus: rebaseStatus,
			repoPath: this.repoPath,
			subscription: subscription,
			conflictFiles: conflictFiles,
			closeWarningDismissed: this.container.onboarding.isDismissed('rebaseEditor:closeWarning'),
		};
	}

	/** Detects the reason the rebase is paused based on the last done entry's action */
	private detectPauseReason(lastAction: RebaseTodoAction | undefined): RebasePauseReason | undefined {
		switch (lastAction) {
			case 'edit':
				return 'edit';
			case 'reword':
				return 'reword';
			case 'break':
				return 'break';
			case 'exec':
				return 'exec';
			default:
				return undefined;
		}
	}

	/** Enriches entries with commit data */
	private enrichEntries(
		entries: RebaseEntry[],
		commits: Map<string, GitCommit>,
		defaultDateFormat: string | null,
	): void {
		for (const entry of entries) {
			if (entry.type !== 'commit' || entry.commit != null) continue;

			const commit = commits.get(entry.sha);
			if (commit == null) continue;

			entry.commit = convertCommit(commit, defaultDateFormat);
		}
	}

	private async getCommitsByShas(shas: Iterable<string>): Promise<Iterable<GitCommit>> {
		const query = join(
			map(shas, sha => `#:${sha}`),
			' ',
		);

		const result = await this.container.git
			.getRepositoryService(this.repoPath)
			.commits.searchCommits({ query: query }, { limit: 0, source: { source: 'rebaseEditor' } });
		return result.log?.commits.values() ?? [];
	}

	/**
	 * Parses the 'done' file and transforms entries for display
	 * @returns processed entries for display and the last action for pause detection
	 */
	private async getDoneEntries(): Promise<{ entries: RebaseEntry[]; lastAction?: RebaseTodoAction }> {
		const parsed = await readAndParseRebaseDoneFile(this._todoDocument.uri);
		if (!parsed?.entries.length) return { entries: [] };

		const lastAction = parsed.entries.at(-1)?.action;
		const processed = processRebaseEntries(parsed.entries, true);

		return { entries: processed.entries, lastAction: lastAction };
	}

	/** Gets the active rebase status and done entries separately */
	private async getRebaseStatus(svc: ReturnType<Container['git']['getRepositoryService']>): Promise<{
		status: (RebaseActiveStatus & { onto: string }) | undefined;
		doneEntries: RebaseEntry[] | undefined;
		conflictFiles: ConflictFileInfo[] | undefined;
	}> {
		// Get paused operation status to check if we're in an active rebase
		const pausedStatus = await svc.pausedOps?.getPausedOperationStatus?.();
		if (pausedStatus?.type !== 'rebase' || !pausedStatus.hasStarted) {
			return { status: undefined, doneEntries: undefined, conflictFiles: undefined };
		}

		const { entries, lastAction } = await this.getDoneEntries();
		if (!entries.length) return { status: undefined, doneEntries: undefined, conflictFiles: undefined };

		const files = await svc.status.getConflictingFiles();
		const hasConflicts = files.length > 0;
		if (!hasConflicts) {
			this._conflictMarkerCache.clear();
		}

		// Fetch conflict file details when there are conflicts
		let conflictFiles: ConflictFileInfo[] | undefined;
		if (hasConflicts) {
			const counts = await Promise.allSettled(
				files.map(f => this.countConflictMarkers(Uri.joinPath(Uri.file(this.repoPath), f.path))),
			);
			conflictFiles = files.map((f, i) => ({
				path: f.path,
				conflictStatus: f.conflictStatus,
				conflictCount: getSettledValue(counts[i]),
			}));
		}

		// Determine pause reason based on last done entry and conflict status
		const pauseReason: RebasePauseReason | undefined = hasConflicts
			? 'conflict'
			: this.detectPauseReason(lastAction);

		return {
			status: {
				currentStep: pausedStatus.steps.current.number,
				totalSteps: pausedStatus.steps.total,
				currentCommit: pausedStatus.steps.current.commit?.ref,
				hasConflicts: hasConflicts,
				isPaused: pausedStatus.isPaused,
				pauseReason: pauseReason,
				onto: pausedStatus.onto.ref,
			},
			doneEntries: entries,
			conflictFiles: conflictFiles,
		};
	}

	private static readonly conflictMarkerPattern = /^<{7}(?=[ \t\n\r])/gm;

	private async countConflictMarkers(uri: Uri): Promise<number> {
		try {
			const stat = await workspace.fs.stat(uri);

			const maxConflictFileSize = 5 * 1024 * 1024; // 5 MB
			if (stat.size > maxConflictFileSize) return 0;

			const key = uri.fsPath;
			const cached = this._conflictMarkerCache.get(key);
			if (cached?.mtime === stat.mtime) {
				return cached.count;
			}
			const content = await workspace.fs.readFile(uri);
			const text = new TextDecoder().decode(content);
			const count = text.match(RebaseWebviewProvider.conflictMarkerPattern)?.length ?? 0;
			this._conflictMarkerCache.set(key, { mtime: stat.mtime, count: count });
			return count;
		} catch (ex) {
			Logger.debug(ex, 'countConflictMarkers');
			return 0;
		}
	}

	private notifyDidChangeAvatars(): void {
		if (!this._enrichment?.authors.size || !this.host.visible) return;

		const avatars = Object.fromEntries(
			filterMap(this._enrichment.authors, ([k, v]) => (v.avatarUrl ? [k, v.avatarUrl] : undefined)),
		);

		void this.host.notify(DidChangeAvatarsNotification, { avatars: avatars });
	}

	private notifyDidChangeCommits(commits: Map<string, GitCommit>, authors: Map<string, Author>): void {
		if (!this.host.visible) return;

		const defaultDateFormat = configuration.get('defaultDateFormat');

		void this.host.notify(DidChangeCommitsNotification, {
			commits: Object.fromEntries(map(commits, ([k, v]) => [k, convertCommit(v, defaultDateFormat)])),
			authors: Object.fromEntries(authors),
			isInPlace: this.computeIsInPlace(),
		});
	}

	private async notifyDidChangeState(): Promise<void> {
		if (!this.host.visible) return;

		const state = await this.parseState();

		// Close the editor if rebase is complete (no entries, no done entries, and no active rebase)
		if (!state.entries.length && !state.doneEntries?.length && state.rebaseStatus == null) {
			this._closing = true;
			await closeTab(this._todoDocument.uri);
			return;
		}

		void this.host.notify(DidChangeNotification, { state: state });
	}

	private notifyDidChangeStateDebounced?: Deferrable<RebaseWebviewProvider['notifyDidChangeState']>;
	private updateState(immediate: boolean = false): void {
		if (immediate) {
			this.notifyDidChangeStateDebounced?.cancel();
			void this.notifyDidChangeState();
			return;
		}

		this.notifyDidChangeStateDebounced ??= debounce(this.notifyDidChangeState.bind(this), 250);
		void this.notifyDidChangeStateDebounced();
	}

	@ipcCommand(ChangeEntryCommand)
	@debug()
	private async onEntryChanged(params: IpcParams<typeof ChangeEntryCommand>): Promise<void> {
		return this.onEntriesChanged({ entries: [params] });
	}

	@ipcCommand(ChangeEntriesCommand)
	@debug()
	private async onEntriesChanged(params: IpcParams<typeof ChangeEntriesCommand>): Promise<void> {
		if (!params.entries.length) return;

		// Track action changes - use the first entry's action as representative
		this.host.sendTelemetryEvent('rebaseEditor/entries/changed', {
			action: params.entries[0].action,
			count: params.entries.length,
		});

		await this._todoDocument.changeActions(params.entries);
	}

	@ipcCommand(MoveEntryCommand)
	@debug()
	private async onEntryMoved(params: IpcParams<typeof MoveEntryCommand>): Promise<void> {
		this.host.sendTelemetryEvent('rebaseEditor/entries/moved', { count: 1, method: 'drag' });

		const { entries } = this._todoDocument.parsed.processed;

		const index = entries.findIndex(e => e.id === params.id);
		if (index === -1) return;

		const entry = entries[index];

		// Calculate target index
		const targetIndex = this._todoDocument.calculateMoveTargetIndex(params, index, entries.length);
		if (targetIndex == null) return;

		// Handle "drop at end" case (targetIndex >= entries.length)
		const isDropAtEnd = targetIndex >= entries.length;
		const effectiveTargetIndex = isDropAtEnd ? entries.length - 1 : targetIndex;

		// Check if move would leave squash/fixup as oldest commit entry
		const needsSquashFix = this._todoDocument.wouldLeaveSquashAsOldest(entries, index, effectiveTargetIndex);

		// Apply the move edit first
		const targetEntry = entries[effectiveTargetIndex];
		await this._todoDocument.moveEntry(entry, targetEntry, isDropAtEnd, params.relative);

		// Fix squash/fixup as oldest commit entry AFTER move (re-read file for correct line numbers)
		if (needsSquashFix) {
			const processed = this._todoDocument.parsed.processed;
			const oldestCommit = first(processed.commits.values());
			if (oldestCommit) {
				await this._todoDocument.ensureValidOldestAction(oldestCommit);
			}
		}
	}

	@ipcCommand(MoveEntriesCommand)
	@debug()
	private async onEntriesMoved(params: IpcParams<typeof MoveEntriesCommand>): Promise<void> {
		if (!params.ids.length) return;

		this.host.sendTelemetryEvent('rebaseEditor/entries/moved', {
			count: params.ids.length,
			method: 'drag',
		});

		const { entries } = this._todoDocument.parsed.processed;
		const ids = new Set(params.ids);

		// Get selected entries in their current order
		const selectedEntries = entries.filter(e => ids.has(e.id));
		if (!selectedEntries.length) return;

		// Remove selected entries and build new order
		const remainingEntries = entries.filter(e => !ids.has(e.id));

		// Clamp target index to valid range
		const targetIndex = Math.max(0, Math.min(params.to, remainingEntries.length));

		// Insert selected entries at target position
		const newEntries = [
			...remainingEntries.slice(0, targetIndex),
			...selectedEntries,
			...remainingEntries.slice(targetIndex),
		];

		// Check if oldest commit would be squash/fixup
		const oldestCommit = newEntries.find(e => e.type === 'commit');
		const needsOldestFix = oldestCommit && (oldestCommit.action === 'squash' || oldestCommit.action === 'fixup');

		// Rewrite all entry lines in the new order
		await this._todoDocument.reorderEntries(newEntries, needsOldestFix ? oldestCommit : undefined);
	}

	/**
	 * Shifts entries up or down independently, preserving gaps between non-contiguous selections
	 * Each selected entry swaps with the adjacent non-selected entry in the shift direction
	 */
	@ipcCommand(ShiftEntriesCommand)
	@debug()
	private async onEntriesShifted(params: IpcParams<typeof ShiftEntriesCommand>): Promise<void> {
		if (!params.ids.length) return;

		this.host.sendTelemetryEvent('rebaseEditor/entries/moved', {
			count: params.ids.length,
			method: 'keyboard',
		});

		const { entries } = this._todoDocument.parsed.processed;
		const ids = new Set(params.ids);

		// Get indices of selected entries
		const selectedIndices = entries.map((e, i) => (ids.has(e.id) ? i : -1)).filter(i => i !== -1);
		if (!selectedIndices.length) return;

		// Create a mutable copy of entries
		const newEntries = [...entries];

		if (params.direction === 'up') {
			// Process from top to bottom to avoid conflicts
			for (const idx of selectedIndices) {
				if (idx === 0) continue; // Can't move up from top

				const aboveIdx = idx - 1;
				// Only swap if the entry above is not selected
				if (!ids.has(newEntries[aboveIdx].id)) {
					[newEntries[aboveIdx], newEntries[idx]] = [newEntries[idx], newEntries[aboveIdx]];
				}
			}
		} else {
			// Process from bottom to top to avoid conflicts
			for (let i = selectedIndices.length - 1; i >= 0; i--) {
				const idx = selectedIndices[i];
				if (idx === entries.length - 1) continue; // Can't move down from bottom

				const belowIdx = idx + 1;
				// Only swap if the entry below is not selected
				if (!ids.has(newEntries[belowIdx].id)) {
					[newEntries[belowIdx], newEntries[idx]] = [newEntries[idx], newEntries[belowIdx]];
				}
			}
		}

		// Check if oldest commit would be squash/fixup
		const oldestCommit = newEntries.find(e => e.type === 'commit');
		const needsOldestFix = oldestCommit && (oldestCommit.action === 'squash' || oldestCommit.action === 'fixup');

		// Rewrite entries in new order
		await this._todoDocument.reorderEntries(newEntries, needsOldestFix ? oldestCommit : undefined);
	}
}

function convertCommit(commit: GitCommit, defaultDateFormat: string | null): Commit {
	return {
		sha: commit.sha,
		author: formatIdentityDisplayName(commit.author),
		committer: formatIdentityDisplayName(commit.committer),
		date: formatCommitDate(commit, defaultDateFormat),
		formattedDate: getCommitFormattedDate(commit),
		message: emojify(commit.message ?? commit.summary),
	};
}
