import type { TextDocumentShowOptions } from 'vscode';
import { Disposable, EventEmitter, window } from 'vscode';
import { GitCommit } from '@gitlens/git/models/commit.js';
import type { GitDiffFileStats } from '@gitlens/git/models/diff.js';
import type { GitFileChange } from '@gitlens/git/models/fileChange.js';
import type { GitRevisionReference } from '@gitlens/git/models/reference.js';
import { createReference } from '@gitlens/git/utils/reference.utils.js';
import { isUncommitted } from '@gitlens/git/utils/revision.utils.js';
import { uniqueBy } from '@gitlens/utils/iterable.js';
import { getSettledValue } from '@gitlens/utils/promise.js';
import type { CopyMessageToClipboardCommandArgs } from '../../commands/copyMessageToClipboard.js';
import type { CopyShaToClipboardCommandArgs } from '../../commands/copyShaToClipboard.js';
import type { ExplainCommitCommandArgs } from '../../commands/explainCommit.js';
import type { ExplainStashCommandArgs } from '../../commands/explainStash.js';
import type { InspectTelemetryContext, InspectWebviewTelemetryContext, Sources } from '../../constants.telemetry.js';
import type { Container } from '../../container.js';
import type { CommitSelectedEvent } from '../../eventBus.js';
import { executeGitCommand } from '../../git/actions.js';
import { showDetailsQuickPick } from '../../git/actions/commit.js';
import { CommitFormatter } from '../../git/formatters/commitFormatter.js';
import {
	getCommitAndFileByPath,
	getCommitAuthorAvatarUri,
	getCommitCommitterAvatarUri,
} from '../../git/utils/-webview/commit.utils.js';
import { getReferenceFromRevision } from '../../git/utils/-webview/reference.utils.js';
import { getReachableWorktrees } from '../../git/utils/-webview/worktree.utils.js';
import { executeCommand, executeCoreCommand, registerWebviewCommand } from '../../system/-webview/command.js';
import { getWebviewCommand } from '../../system/decorators/command.js';
import type { LinesChangeEvent } from '../../trackers/lineTracker.js';
import type { ShowInCommitGraphCommandArgs } from '../plus/graph/registration.js';
import type { EventVisibilityBuffer, SubscriptionTracker } from '../rpc/eventVisibilityBuffer.js';
import { bufferEventHandler } from '../rpc/eventVisibilityBuffer.js';
import { createSharedServices, proxyServices } from '../rpc/services/common.js';
import type { WebviewHost, WebviewProvider, WebviewShowingArgs } from '../webviewProvider.js';
import type { WebviewShowOptions } from '../webviewsController.js';
import { isSerializedState } from '../webviewsController.js';
import type { CommitDetailsServices, CommitSelectionEvent, ExplainResult } from './commitDetailsService.js';
import type { ComparisonContext } from './commitDetailsWebview.utils.js';
import {
	getFileCommitFromContext,
	isDetailsFileContext,
	isDetailsFolderContext,
	isDetailsItemContext,
	resolveMultiFileContext,
} from './commitDetailsWebview.utils.js';
import { DetailsFileCommands, getDetailsFileCommands, getDetailsFileMultiCommands } from './detailsFileCommands.js';
import {
	DetailsFolderCommands,
	getDetailsFolderCommands,
	sharedDetailsFolderCommandRoutes,
} from './detailsFolderCommands.js';
import type { CommitDetails, DetailsItemContext, ExecuteFileActionParams, State } from './protocol.js';
import { messageHeadlineSplitterToken } from './protocol.js';
import type { CommitDetailsWebviewShowingArgs } from './registration.js';

// Commands that open or modify editor content and need the line tracker suspended
const lineTrackerCommands = new Set([
	'gitlens.views.openChanges:',
	'gitlens.views.openChangesWithWorking:',
	'gitlens.openChangesWithWorktreeFile:',
	'gitlens.views.openPreviousChangesWithWorking:',
	'gitlens.views.openFile:',
	'gitlens.openWorktreeFile:',
	'gitlens.views.openFileRevision:',
	'gitlens.externalDiff:',
	'gitlens.views.highlightChanges:',
	'gitlens.views.highlightRevisionChanges:',
]);

/**
 * Backend provider for Commit Details webview.
 *
 * Architecture: The webview is the source of truth for ALL domain state
 * (commit details, WIP data, etc.). The backend:
 * - Tracks "showing context" (what was requested when opened) so it can
 *   tell the webview what to load on initialization
 * - Provides data via RPC methods (all accept params from webview)
 * - Forwards events to the webview
 * - Controls line tracker based on pinned state
 * - Maintains navigation history for back/forward
 *
 * The backend does NOT cache domain data (commit objects, WIP details, etc.).
 */
export class CommitDetailsWebviewProvider implements WebviewProvider<State, State, CommitDetailsWebviewShowingArgs> {
	private readonly _disposable: Disposable;
	private _focused = false;

	/** Controls line tracker - set via setPin() RPC */
	private _pinned = false;

	// --- Showing context ---
	// These track what was requested when the webview was shown,
	// so getInitialContext() can tell the webview what to load.
	// This is NOT cached domain data - just the request parameters.
	private _showingCommitRef: { repoPath: string; sha: string; refType?: GitRevisionReference['refType'] } | undefined;

	// --- Telemetry context pushed from the webview via RPC ---
	private _telemetryContext: InspectWebviewTelemetryContext | undefined;

	// View-specific event emitters — support multiple subscribers
	private readonly _onCommitSelected = new EventEmitter<CommitSelectionEvent>();

	constructor(
		private readonly container: Container,
		private readonly host: WebviewHost<'gitlens.views.commitDetails'>,
	) {
		this._disposable = Disposable.from();
	}

	dispose(): void {
		this._disposable.dispose();
		this._lineTrackerDisposable?.dispose();
		this._selectionTrackerDisposable?.dispose();
		this._onCommitSelected.dispose();
	}

	getTelemetrySource(): Sources {
		return 'inspect';
	}

	getTelemetryContext(): InspectTelemetryContext {
		const context: InspectTelemetryContext = {
			...this.host.getTelemetryContext(),
			'context.mode': 'commit',
			'context.autolinks': 0,
			'context.pinned': this._pinned,
			'context.type': undefined,
			'context.uncommitted': false,
			...this._telemetryContext,
		};
		return context;
	}

	private _skipNextRefreshOnVisibilityChange = false;

	onShowing(
		loading: boolean,
		options?: WebviewShowOptions,
		...args: WebviewShowingArgs<CommitDetailsWebviewShowingArgs, State>
	): [boolean, InspectTelemetryContext] {
		const [arg] = args;
		return [
			this.onShowingCommit(arg as Partial<CommitSelectedEvent['data']> | undefined, loading, options),
			this.getTelemetryContext(),
		];
	}

	onShowingCommit(
		arg: Partial<CommitSelectedEvent['data']> | undefined,
		loading: boolean,
		options?: WebviewShowOptions,
	): boolean {
		let data: Partial<CommitSelectedEvent['data']> | undefined;

		if (isSerializedState<State>(arg)) {
			const { commit: selected } = arg.state;
			if (selected?.repoPath != null && selected?.sha != null) {
				if (selected.stashNumber != null) {
					data = {
						commit: createReference(selected.sha, selected.repoPath, {
							refType: 'stash',
							name: selected.message,
							number: selected.stashNumber,
						}),
					};
				} else {
					data = {
						commit: createReference(selected.sha, selected.repoPath, {
							refType: 'revision',
							message: selected.message,
						}),
					};
				}
			}
		} else if (arg != null && typeof arg === 'object') {
			data = arg;
		}

		// Capture showing context for getInitialContext()
		if (data?.commit != null) {
			const ref = getReferenceFromRevision(data.commit);
			this._showingCommitRef = { repoPath: ref.repoPath, sha: ref.ref, refType: ref.refType };
		} else {
			// No explicit commit - try to resolve from event cache / line tracker
			this._showingCommitRef = this.resolveCurrentCommitRef();
		}

		if (data?.preserveVisibility && !this.host.visible) return false;
		if (options?.preserveVisibility && !this.host.visible) return false;

		// If the webview is already live (reused panel), fire commit selection event
		// so it navigates to the new commit. When loading=true, the webview will call
		// fetchInitialState() which reads _showingCommitRef.
		// Note: For event-bus-driven selections (graph clicks), onCommitSelected() fires
		// the event before show() is called, so this is a no-op (same commit).
		if (!loading && this._showingCommitRef != null) {
			this._onCommitSelected.fire({
				repoPath: this._showingCommitRef.repoPath,
				sha: this._showingCommitRef.sha,
				passive: false,
			});
		}

		this._skipNextRefreshOnVisibilityChange = true;
		return true;
	}

	/**
	 * Resolve the current commit ref from the event cache or line tracker.
	 * Used when no explicit commit is provided in onShowingCommit.
	 */
	private resolveCurrentCommitRef():
		| { repoPath: string; sha: string; refType?: GitRevisionReference['refType'] }
		| undefined {
		if (this._pinned) return undefined;

		// Check line tracker first
		if (window.activeTextEditor != null) {
			const { lineTracker } = this.container;
			const line = lineTracker.selections?.[0]?.active;
			if (line != null) {
				const commit = lineTracker.getState(line)?.commit;
				if (commit != null) {
					return { repoPath: commit.repoPath, sha: commit.sha };
				}
			}
		}

		// Check event cache
		const args = this.container.events.getCachedEventArgs('commit:selected');
		if (args?.commit != null) {
			return { repoPath: args.commit.repoPath, sha: args.commit.ref, refType: args.commit.refType };
		}

		return undefined;
	}

	includeBootstrap(_deferrable?: boolean): Promise<State> {
		// Webview fetches all data via RPC — bootstrap only provides metadata
		return Promise.resolve({
			webviewId: this.host.id,
			webviewInstanceId: this.host.instanceId,
			timestamp: Date.now(),
		} as State);
	}

	registerCommands(): Disposable[] {
		const subscriptions: Disposable[] = [
			registerWebviewCommand(`${this.host.id}.refresh`, () => this.host.refresh(true)),
		];

		// Shared file commands. `gitlens.views.copy:` and `gitlens.copyRelativePathToClipboard:` are
		// also wired to folder context — when the menu fires them on a folder row, route to the
		// folder commands instance instead of running the file lookup (which would no-op).
		const fileCommands = new DetailsFileCommands(this.container, this.host.id);
		const folderCommands = new DetailsFolderCommands(this.container);
		for (const { command: cmd, handler } of getDetailsFileCommands()) {
			const suspendsLineTracker = lineTrackerCommands.has(cmd);
			const folderRoute = sharedDetailsFolderCommandRoutes[cmd];
			subscriptions.push(
				registerWebviewCommand(
					getWebviewCommand(cmd, this.host.type),
					async (item?: DetailsItemContext | ExecuteFileActionParams) => {
						if (suspendsLineTracker) {
							this.suspendLineTracker();
						}

						if (folderRoute != null && isDetailsFolderContext(item)) {
							folderCommands[folderRoute](item.webviewItemValue);
							return;
						}

						const [commit, file, comparison] = await this.getFileCommitFromContextOrParams(item);
						if (commit == null) return;

						return void handler.call(fileCommands, commit, file, this.getShowOptions(item), comparison);
					},
					this,
				),
			);
		}

		// Multi-file commands. When a multi-selection is right-clicked, the row's data-vscode-context
		// carries `webviewItemsValues` (all selected files); resolve each to its commit+file and hand
		// the whole set to the multi handler (one clipboard write, one stage op, etc.).
		for (const { command: cmd, handler } of getDetailsFileMultiCommands()) {
			subscriptions.push(
				registerWebviewCommand(
					getWebviewCommand(cmd, this.host.type),
					async (item?: DetailsItemContext | ExecuteFileActionParams) => {
						const resolved = await resolveMultiFileContext(this.container, item);
						if (resolved.length) {
							await handler.call(fileCommands, resolved);
						}
					},
					this,
				),
			);
		}

		// Folder-only commands (Folder History submenu). `gitlens.views.copy:` and
		// `gitlens.copyRelativePathToClipboard:` are intentionally NOT registered here — they share
		// IDs with the file commands above.
		for (const { command: cmd, handler } of getDetailsFolderCommands()) {
			if (cmd in sharedDetailsFolderCommandRoutes) continue;

			subscriptions.push(
				registerWebviewCommand(getWebviewCommand(cmd, this.host.type), (item?: DetailsItemContext) => {
					if (!isDetailsFolderContext(item)) return;

					handler.call(folderCommands, item.webviewItemValue);
				}),
			);
		}

		return subscriptions;
	}

	private getShowOptions(
		item: DetailsItemContext | ExecuteFileActionParams | undefined,
	): TextDocumentShowOptions | undefined {
		return isDetailsItemContext(item) ? undefined : item?.showOptions;
	}

	onFocusChanged(focused: boolean): void {
		if (this._focused === focused) return;

		this._focused = focused;
		if (focused && this.isLineTrackerSuspended) {
			this.ensureTrackers();
		}
	}

	// Note: Git actions (fetch, push, pull, etc.) are now called directly via RPC with repoPath param

	onRefresh(_force?: boolean | undefined): void {
		// Backend is stateless - webview handles refresh via RPC
		// Just ensure trackers are set up
		this.ensureTrackers();
	}

	onReloaded(): void {
		// Webview will call fetchInitialState() on reload via onRpcReady()
	}

	onVisibilityChanged(visible: boolean): void {
		this.ensureTrackers();
		if (!visible) return;

		const skipRefresh = this._skipNextRefreshOnVisibilityChange;
		if (skipRefresh) {
			this._skipNextRefreshOnVisibilityChange = false;
			return;
		}

		// Notify webview about potential changes that happened while hidden
		if (!this._pinned) {
			// Check if there's a current/new commit to show
			const commitRef = this.resolveCurrentCommitRef();
			if (commitRef != null) {
				this._onCommitSelected.fire({
					repoPath: commitRef.repoPath,
					sha: commitRef.sha,
					passive: true,
				});
			}
		}
	}

	/** Computes hasAccount fresh (no caching) */
	async getHasAccount(): Promise<boolean> {
		return (await this.container.subscription.getSubscription())?.account != null;
	}

	private onCommitSelected(e: CommitSelectedEvent) {
		if (e.data == null) return;

		// Track what's being shown so file/stash actions know which commit to use
		const ref = getReferenceFromRevision(e.data.commit);
		this._showingCommitRef = { repoPath: ref.repoPath, sha: ref.ref, refType: ref.refType };

		// Forward event to webview - let webview decide what to do based on its state
		this._onCommitSelected.fire({
			repoPath: e.data.commit.repoPath,
			sha: e.data.commit.ref,
			searchContext: e.data.searchContext,
			passive: e.data.interaction === 'passive',
		});

		// Show webview if not passive and not pinned
		if (e.data.interaction !== 'passive' && !this._pinned) {
			void this.host.show(false, { preserveFocus: e.data.preserveFocus }, e.data);
		}
	}

	private _lineTrackerDisposable: Disposable | undefined;
	private _selectionTrackerDisposable: Disposable | undefined;
	private ensureTrackers(): void {
		this._selectionTrackerDisposable?.dispose();
		this._selectionTrackerDisposable = undefined;
		this._lineTrackerDisposable?.dispose();
		this._lineTrackerDisposable = undefined;

		if (!this.host.visible) return;

		this._selectionTrackerDisposable = this.container.events.on('commit:selected', this.onCommitSelected, this);

		if (this._pinned) return;

		const { lineTracker } = this.container;
		this._lineTrackerDisposable = lineTracker.subscribe(
			this,
			lineTracker.onDidChangeActiveLines(this.onActiveEditorLinesChanged, this),
		);
	}

	private get isLineTrackerSuspended() {
		return this._lineTrackerDisposable == null;
	}

	private suspendLineTracker() {
		// Defers the suspension of the line tracker, so that the focus change event can be handled first
		setTimeout(() => {
			this._lineTrackerDisposable?.dispose();
			this._lineTrackerDisposable = undefined;
		}, 100);
	}

	private onActiveEditorLinesChanged(e: LinesChangeEvent) {
		if (e.pending || e.editor == null || e.suspended) return;

		// Get commit from line tracker
		const line = e.selections?.[0]?.active;
		const commit = line != null ? this.container.lineTracker.getState(line)?.commit : undefined;

		if (commit != null) {
			// Forward commit selection event to webview
			this._onCommitSelected.fire({
				repoPath: commit.repoPath,
				sha: commit.sha,
				passive: true, // Line tracker selections are passive
			});
		}
	}

	private async onExplainRequest(
		repoPath: string,
		sha: string,
		prompt?: string,
		signal?: AbortSignal,
	): Promise<ExplainResult> {
		try {
			signal?.throwIfAborted();
			if (this._showingCommitRef?.refType === 'stash') {
				await executeCommand<ExplainStashCommandArgs>('gitlens.ai.explainStash', {
					repoPath: repoPath,
					rev: sha,
					prompt: prompt || undefined,
					source: { source: this.getTelemetrySource(), context: { type: 'stash' } },
				});
			} else {
				await executeCommand<ExplainCommitCommandArgs>('gitlens.ai.explainCommit', {
					repoPath: repoPath,
					rev: sha,
					prompt: prompt || undefined,
					source: { source: this.getTelemetrySource(), context: { type: 'commit' } },
				});
			}
			signal?.throwIfAborted();

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

	private onUpdatePinned(params: { pin: boolean }) {
		if (params.pin === this._pinned) return;

		this._pinned = params.pin;
		this.ensureTrackers();
		// Webview already knows the new pin state - no notification needed
	}

	/**
	 * Get core commit details (fast path — no autolinks, no enriched data).
	 * The message is raw-formatted (headline split) but not linkified with autolink patterns.
	 */
	private async getCoreCommitDetails(commit: GitCommit, cancellation?: AbortSignal): Promise<CommitDetails> {
		const hasDistinctCommitter = commit.committer.email != null && commit.committer.email !== commit.author.email;
		const [commitResult, avatarUriResult, committerAvatarUriResult, worktreesResult] = await Promise.allSettled([
			!commit.hasFullDetails()
				? GitCommit.ensureFullDetails(commit, { include: { uncommittedFiles: true } }).then(() => commit)
				: commit,
			getCommitAuthorAvatarUri(commit, { size: 32 }),
			hasDistinctCommitter ? getCommitCommitterAvatarUri(commit, { size: 32 }) : Promise.resolve(undefined),
			commit.refType === 'stash' || commit.isUncommitted
				? Promise.resolve([])
				: getReachableWorktrees(this.container, commit.repoPath, commit.sha, cancellation),
		]);

		commit = getSettledValue(commitResult, commit);
		const avatarUri = getSettledValue(avatarUriResult);
		const committerAvatarUri = hasDistinctCommitter ? getSettledValue(committerAvatarUriResult) : undefined;

		// Raw message with headline split — no autolink linkification (that's deferred)
		let message = CommitFormatter.fromTemplate(`\${message}`, commit);
		const index = message.indexOf('\n');
		if (index !== -1) {
			message = `${message.substring(0, index)}${messageHeadlineSplitterToken}${message.substring(index + 1)}`;
		}

		// The working (uncommitted) commit's `anyFiles` carries a separate staged + unstaged entry for
		// each partially-staged ("mixed") file. The Inspect view shows working changes as a single
		// working-tree-vs-HEAD changeset, so collapse to one row per path (keeping the first = unstaged
		// twin). Row-click and right-click both diff HEAD↔working, so the dropped staged distinction is
		// invisible here — this only removes the duplicate row and de-inflates the file counts.
		const changedFiles = commit.isUncommitted
			? [
					...uniqueBy(
						commit.anyFiles ?? [],
						f => f.path,
						original => original,
					),
				]
			: commit.fileset?.files;

		return {
			repoPath: commit.repoPath,
			sha: commit.sha,
			shortSha: commit.shortSha,
			author: { ...commit.author, avatar: avatarUri?.toString(true) },
			committer: { ...commit.committer, avatar: committerAvatarUri?.toString(true) },
			message: message,
			parents: commit.parents,
			stashNumber: commit.refType === 'stash' ? commit.stashNumber : undefined,
			stashOnRef: commit.refType === 'stash' ? commit.stashOnRef : undefined,
			// Serialize files to plain objects - GitFileChange class instances contain
			// a Container reference which causes circular reference errors during JSON serialization
			files: changedFiles?.map(f => ({
				repoPath: f.repoPath,
				path: f.path,
				status: f.status,
				originalPath: f.originalPath,
				staged: f.staged,
				stats: f.stats,
			})),
			// Recompute the file counts from the deduped list so the working-changes header doesn't
			// double-count mixed files. Line totals (`additions`/`deletions`) come from `git diff --stat
			// HEAD` and are already counted once, so they're preserved as-is.
			stats:
				commit.isUncommitted && commit.stats != null
					? { ...commit.stats, files: countFileChanges(changedFiles) }
					: commit.stats,
			reachableFromOtherWorktrees: (getSettledValue(worktreesResult)?.length ?? 0) > 0,
		};
	}

	private async getFileCommitFromContextOrParams(
		item: DetailsItemContext | ExecuteFileActionParams | undefined,
	): Promise<
		| [commit: GitCommit, file: GitFileChange, comparison?: ComparisonContext]
		| [commit?: undefined, file?: undefined, comparison?: undefined]
	> {
		if (item == null) return [];

		if (isDetailsItemContext(item)) {
			if (!isDetailsFileContext(item)) return [];

			return getFileCommitFromContext(this.container, item.webviewItemValue);
		}

		return this.getFileCommitFromParams(item);
	}

	private async getFileCommitFromParams(
		params: ExecuteFileActionParams,
	): Promise<[commit: GitCommit, file: GitFileChange] | [commit?: undefined, file?: undefined]> {
		if (params.repoPath == null) return [];

		const ref = params.ref != null ? { ref: params.ref, stash: params.stash } : undefined;
		return getCommitAndFileByPath(params.repoPath, params.path, ref, params.staged);
	}

	private onShowCommitPicker() {
		// Open commit picker - let it determine best repo
		void executeGitCommand({
			command: 'log',
			state: { reference: 'HEAD', openPickInView: true },
		});
	}

	private onShowCommitSearch() {
		void executeGitCommand({ command: 'search', state: { openPickInView: true } });
	}

	private onExecuteCommitAction(params: {
		repoPath: string;
		sha: string;
		action: 'graph' | 'more' | 'scm' | 'sha';
		alt?: boolean;
	}) {
		switch (params.action) {
			case 'graph': {
				const ref = createReference(params.sha, params.repoPath, { refType: 'revision' });
				void executeCommand<ShowInCommitGraphCommandArgs>('gitlens.showInCommitGraph', {
					ref: ref,
					source: { source: this.getTelemetrySource() },
				});
				break;
			}
			case 'more':
				void this.showCommitActions(params.repoPath, params.sha);
				break;

			case 'scm':
				void executeCoreCommand('workbench.view.scm');
				break;

			case 'sha':
				if (params.alt) {
					// Copy message - need to fetch commit to get message
					void this.container.git
						.getRepositoryService(params.repoPath)
						.commits.getCommit(params.sha)
						.then(commit => {
							if (commit != null) {
								void executeCommand<CopyMessageToClipboardCommandArgs>(
									'gitlens.copyMessageToClipboard',
									{
										message: commit.message,
									},
								);
							}
						});
				} else {
					void executeCommand<CopyShaToClipboardCommandArgs>('gitlens.copyShaToClipboard', {
						sha: params.sha,
					});
				}
				break;
		}
	}

	private async showCommitActions(repoPath: string, sha: string) {
		if (isUncommitted(sha)) return;

		const commit = await this.container.git.getRepositoryService(repoPath).commits.getCommit(sha);
		if (commit == null) return;

		void showDetailsQuickPick(commit);
	}

	private async onShowFileActions(params: ExecuteFileActionParams) {
		const [commit, file] = await this.getFileCommitFromParams(params);
		if (commit == null) return;

		this.suspendLineTracker();
		void showDetailsQuickPick(commit, file);
	}

	// ============================================================
	// RPC Services (Supertalk)
	// ============================================================

	/**
	 * Returns services to expose via RPC (Supertalk).
	 *
	 * These are thin wrappers around existing functionality, providing a
	 * service-oriented interface for the webview to call.
	 */
	getRpcServices(buffer?: EventVisibilityBuffer, tracker?: SubscriptionTracker): CommitDetailsServices {
		const shared = createSharedServices(
			this.container,
			this.host,
			context => {
				this._telemetryContext = context as InspectWebviewTelemetryContext;
			},
			buffer,
			tracker,
		);

		return proxyServices({
			...shared,

			// ============================================================
			// Inspect: view-specific commit/WIP queries, navigation, actions, AI
			// ============================================================
			inspect: {
				// ── Events ──

				onCommitSelected: (callback: (event: CommitSelectionEvent) => void) => {
					this.ensureTrackers();
					const pendingKey = Symbol('commitSelected');
					const buffered = bufferEventHandler(buffer, pendingKey, callback, 'save-last');
					const disposable = this._onCommitSelected.event(buffered);

					// Replay cached selection so webview gets the current commit
					// even if the event fired before subscription was ready.
					const commitRef = this.resolveCurrentCommitRef();
					if (commitRef != null) {
						callback({
							repoPath: commitRef.repoPath,
							sha: commitRef.sha,
							passive: true,
						});
					}

					const unsubscribe = () => {
						buffer?.removePending(pendingKey);
						disposable.dispose();
					};
					return tracker != null ? tracker.track(unsubscribe) : unsubscribe;
				},

				// ── Initialization ──

				getInitialContext: () =>
					Promise.resolve({
						pinned: this._pinned,
						initialCommit: this._showingCommitRef,
					}),

				// ── Commit Queries ──

				getCommit: async (repoPath: string, sha: string, signal?: AbortSignal) => {
					signal?.throwIfAborted();
					const svc = this.container.git.getRepositoryService(repoPath);
					let commit: GitCommit | undefined;
					if (this._showingCommitRef?.refType === 'stash') {
						const stash = await svc.stash?.getStash();
						commit = stash?.stashes.get(sha);
					}
					commit ??= await svc.commits.getCommit(sha, signal);
					if (commit == null) return undefined;

					signal?.throwIfAborted();
					// Track what the webview is showing so file actions know which commit to use
					this._showingCommitRef = { repoPath: repoPath, sha: sha, refType: commit.refType };
					const details = await this.getCoreCommitDetails(commit, signal);
					signal?.throwIfAborted();
					return details;
				},

				setPin: pin => {
					this.onUpdatePinned({ pin: pin });
					return Promise.resolve();
				},

				// ── Commit Actions ──

				executeCommitAction: (repoPath, sha, action, alt?) => {
					this.onExecuteCommitAction({ repoPath: repoPath, sha: sha, action: action, alt: alt });
					return Promise.resolve();
				},

				pickCommit: () => {
					this.onShowCommitPicker();
					return Promise.resolve();
				},

				searchCommit: () => {
					this.onShowCommitSearch();
					return Promise.resolve();
				},

				openAutolinkSettings: async () => {
					await executeCommand('gitlens.showSettingsPage!autolinks');
				},

				// ── AI Operations ──

				explainCommit: (repoPath: string, sha: string, prompt?: string, signal?: AbortSignal) =>
					this.onExplainRequest(repoPath, sha, prompt, signal),
			},
		} satisfies CommitDetailsServices);
	}
}

/** Tallies added/deleted/changed counts from a file list — mirrors `GitCommit.computeFileStats` so the
 *  deduped working-changes list reports the same buckets, minus the mixed-file double-count. */
function countFileChanges(files: readonly GitFileChange[] | undefined): GitDiffFileStats {
	const counts = { added: 0, deleted: 0, changed: 0 };
	if (files != null) {
		for (const f of files) {
			if (f.status === 'A' || f.status === '?') {
				counts.added++;
			} else if (f.status === 'D') {
				counts.deleted++;
			} else {
				counts.changed++;
			}
		}
	}
	return counts;
}
