import type { ConfigurationChangeEvent } from 'vscode';
import { CancellationTokenSource, commands, Disposable, window } from 'vscode';
import { AIConversation } from '@gitlens/ai/models/conversation.js';
import type { AIModel } from '@gitlens/ai/models/model.js';
import { rootSha } from '@gitlens/git/models/revision.js';
import { md5, sha256 } from '@gitlens/utils/crypto.js';
import { getSettledValue } from '@gitlens/utils/promise.js';
import { PromiseCache } from '@gitlens/utils/promiseCache.js';
import type { SwitchAIModelCommandArgs } from '../../../commands/ai.js';
import type { ContextKeys } from '../../../constants.context.js';
import type {
	ComposerTelemetryContext,
	Source,
	Sources,
	WebviewTelemetryEvents,
} from '../../../constants.telemetry.js';
import type { Container } from '../../../container.js';
import type {
	GlRepository,
	RepositoryChangeEvent,
	RepositoryWorkingTreeChangeEvent,
} from '../../../git/models/repository.js';
import { getBranchMergeTargetName } from '../../../git/utils/-webview/branch.utils.js';
import { sendFeedbackEvent, showUnhelpfulFeedbackPicker } from '../../../plus/ai/aiFeedbackUtils.js';
import type { AIModelChangeEvent } from '../../../plus/ai/aiProviderService.js';
import { getRepositoryPickerTitleAndPlaceholder, showRepositoryPicker } from '../../../quickpicks/repositoryPicker.js';
import { executeCoreCommand } from '../../../system/-webview/command.js';
import { configuration } from '../../../system/-webview/configuration.js';
import { onDidChangeContext } from '../../../system/-webview/context.js';
import type { IpcParams } from '../../ipc/handlerRegistry.js';
import { ipcCommand } from '../../ipc/handlerRegistry.js';
import type { WebviewHost, WebviewProvider } from '../../webviewProvider.js';
import type { ComposerComposeIntegration } from './compose/integration.js';
import type {
	ComposerBaseCommit,
	ComposerCommit,
	ComposerContext,
	ComposerHunk,
	ComposerSafetyState,
	State,
} from './protocol.js';
import {
	AdvanceOnboardingCommand,
	AIFeedbackHelpfulCommand,
	AIFeedbackUnhelpfulCommand,
	baseContext,
	CancelGenerateCommitMessageCommand,
	CancelGenerateCommitsCommand,
	ChooseRepositoryCommand,
	ClearAIOperationErrorCommand,
	CloseComposerCommand,
	DidCancelGenerateCommitMessageNotification,
	DidCancelGenerateCommitsNotification,
	DidChangeAiEnabledNotification,
	DidChangeAiModelNotification,
	DidClearAIOperationErrorNotification,
	DidErrorAIOperationNotification,
	DidFinishCommittingNotification,
	DidGenerateCommitMessageNotification,
	DidGenerateCommitsNotification,
	DidIndexChangeNotification,
	DidLoadingErrorNotification,
	DidProgressGeneratingCommitsNotification,
	DidReloadComposerNotification,
	DidSafetyErrorNotification,
	DidStartCommittingNotification,
	DidStartGeneratingCommitMessageNotification,
	DidStartGeneratingNotification,
	DidWorkingDirectoryChangeNotification,
	DismissOnboardingCommand,
	FinishAndCommitCommand,
	GenerateCommitMessageCommand,
	GenerateCommitsCommand,
	initialState,
	OnAddHunksToCommitCommand,
	OnRedoCommand,
	OnResetCommand,
	OnSelectAIModelCommand,
	OnUndoCommand,
	OpenOnboardingCommand,
	ReloadComposerCommand,
} from './protocol.js';
import type { ComposerWebviewShowingArgs } from './registration.js';
import type { ComposerDiffs } from './utils/composer.utils.js';
import {
	calculateCombinedDiffBetweenCommits,
	convertToComposerDiffInfo,
	createCombinedDiffForCommit,
	createComposerCommitsFromGitCommits,
	createHunksFromDiffs,
	createSafetyState,
	getAuthorAndCoAuthorsForCombinedDiffHunk,
	getBranchCommits,
	getComposerDiffs,
	validateResultingDiff,
	validateSafetyState,
} from './utils/composer.utils.js';

const useComposeToolsLibrary = false;

export class ComposerWebviewProvider implements WebviewProvider<State, State, ComposerWebviewShowingArgs> {
	private readonly _disposable: Disposable;
	private _args?: ComposerWebviewShowingArgs[0];
	private _cache = new PromiseCache<'bootstrap', State>({ accessTTL: 1000 * 60 * 5 });

	// Cancellation tokens for ongoing operations
	private _generateCommitsCancellation?: CancellationTokenSource;
	private _generateCommitMessageCancellation?: CancellationTokenSource;

	// Repository subscription for working directory changes
	private _repositorySubscription?: Disposable;
	private _currentRepository?: GlRepository;

	// Hunk map and safety state
	private _hunks: ComposerHunk[] = [];
	private _safetyState: ComposerSafetyState;

	// Branch mode state
	private _recompose: {
		enabled: boolean;
		branchName?: string;
		locked: boolean;
		commitShas?: string[];
		messages?: string[];
		range?: { base: string; head: string };
	} | null = null;

	// Telemetry context - tracks composer-specific data for getTelemetryContext
	private _context: ComposerContext;

	// Flag to ignore index change tracking for when we need to stage untracked files
	private _ignoreIndexChange = false;

	// AI conversation map for generate commits (keyed by hunks hash)
	private _generateCommitsConversations = new Map<string, AIConversation>();

	// Suppress the large prompt warning after the first successful AI action in this session
	private _suppressLargePromptWarning = false;

	// Compose-tools integration. Node-only — the webworker build resolves
	// `@env/coretools/composer.js` to a browser stub that returns undefined, which
	// causes onGenerateCommits to fall through to the legacy path. Lazily created by
	// `getOrCreateComposeTools` to keep the heavy library off the controller init path;
	// holds the two-phase cache between onGenerateCommits and onFinishAndCommit.
	private _composeTools: ComposerComposeIntegration | undefined;
	/** Cache key returned by integration.generatePlan — consumed by the library-backed onFinishAndCommit. */
	private _currentComposePlanCacheKey: string | undefined;

	constructor(
		protected readonly container: Container,
		protected readonly host: WebviewHost<'gitlens.composer'>,
	) {
		this._disposable = Disposable.from(
			configuration.onDidChangeAny(this.onAnyConfigurationChanged, this),
			onDidChangeContext(this.onContextChanged, this),
			this.container.ai.onDidChangeModel(this.onAIModelChanged, this),
		);
		this._context = { ...baseContext };
		this._safetyState = {
			repoPath: '',
			headSha: null,
			baseSha: null,
			hashes: {
				staged: null,
				unstaged: null,
				unified: null,
			},
		};
	}

	dispose(): void {
		this._cache.clear();
		this.resetContext();
		this._generateCommitsCancellation?.dispose();
		this._generateCommitMessageCancellation?.dispose();
		this._repositorySubscription?.dispose();
		this._disposable.dispose();
	}

	private async getOrCreateComposeTools(): Promise<ComposerComposeIntegration | undefined> {
		if (this._composeTools == null && useComposeToolsLibrary) {
			// Lazily import the node-only compose-tools library on demand, keeping it (and its eager zod
			// schema/JIT setup that trips VS Code's `navigator` deprecation warning) off the composer init path.
			const { createComposerComposeIntegration } = await import('@env/coretools/composer.js');
			this._composeTools ??= createComposerComposeIntegration(this.container);
		}
		return this._composeTools;
	}

	getTelemetryContext(): ComposerTelemetryContext {
		return {
			...this.host.getTelemetryContext(),
			'context.session.start': this._context.sessionStart,
			'context.session.duration': this._context.sessionDuration,
			'context.source':
				typeof this._context.source === 'object' ? this._context.source.source : this._context.source,
			'context.mode': this._context.mode,
			'context.diff.files.count': this._context.diff.files,
			'context.diff.hunks.count': this._context.diff.hunks,
			'context.diff.lines.count': this._context.diff.lines,
			'context.diff.hash': this._context.diff.hash,
			'context.diff.staged.exists': this._context.diff.staged,
			'context.diff.unstaged.exists': this._context.diff.unstaged,
			'context.diff.unstaged.included': this._context.diff.unstagedIncluded,
			'context.commits.initialCount': this._context.commits.initialCount,
			'context.commits.autoComposedCount': this._context.commits.autoComposedCount,
			'context.commits.composedCount': this._context.commits.composedCount,
			'context.commits.finalCount': this._context.commits.finalCount,
			'context.ai.enabled.config': this._context.ai.enabled.config,
			'context.ai.enabled.org': this._context.ai.enabled.org,
			'context.ai.model.id': this._context.ai.model?.id,
			'context.ai.model.name': this._context.ai.model?.name,
			'context.ai.model.provider.id': this._context.ai.model?.provider.id,
			'context.ai.model.temperature': this._context.ai.model?.temperature ?? undefined,
			'context.ai.model.maxTokens.input': this._context.ai.model?.maxTokens.input,
			'context.ai.model.maxTokens.output': this._context.ai.model?.maxTokens.output,
			'context.ai.model.default': this._context.ai.model?.default,
			'context.ai.model.hidden': this._context.ai.model?.hidden,
			'context.onboarding.stepReached': this._context.onboarding.stepReached,
			'context.onboarding.dismissed': this._context.onboarding.dismissed,
			'context.operations.generateCommits.count': this._context.operations.generateCommits.count,
			'context.operations.generateCommits.cancelled.count':
				this._context.operations.generateCommits.cancelledCount,
			'context.operations.generateCommits.error.count': this._context.operations.generateCommits.errorCount,
			'context.operations.generateCommits.feedback.upvote.count':
				this._context.operations.generateCommits.feedback.upvoteCount,
			'context.operations.generateCommits.feedback.downvote.count':
				this._context.operations.generateCommits.feedback.downvoteCount,
			'context.operations.generateCommitMessage.count': this._context.operations.generateCommitMessage.count,
			'context.operations.generateCommitMessage.cancelled.count':
				this._context.operations.generateCommitMessage.cancelledCount,
			'context.operations.generateCommitMessage.error.count':
				this._context.operations.generateCommitMessage.errorCount,
			'context.operations.finishAndCommit.error.count': this._context.operations.finishAndCommit.errorCount,
			'context.operations.undo.count': this._context.operations.undo.count,
			'context.operations.redo.count': this._context.operations.redo.count,
			'context.operations.reset.count': this._context.operations.reset.count,
			'context.warnings.workingDirectoryChanged': this._context.warnings.workingDirectoryChanged,
			'context.warnings.indexChanged': this._context.warnings.indexChanged,
			'context.errors.safety.count': this._context.errors.safety.count,
			'context.errors.operation.count': this._context.errors.operation.count,
		};
	}

	includeBootstrap(_deferrable?: boolean): Promise<State> {
		return this._cache.getOrCreate('bootstrap', () => this.getBootstrapState());
	}

	private async getBootstrapState(): Promise<State> {
		// Wait for repository discovery to complete if it's in progress
		if (this.container.git.isDiscoveringRepositories) {
			await this.container.git.isDiscoveringRepositories;
		}

		// Use real data if provided, otherwise initialize from best repository
		const args = this._args;

		// Get the repository from args or show picker
		let repo;
		if (args?.repoPath != null) {
			repo = this.container.git.getRepository(args.repoPath);
		} else {
			repo = this.container.git.getBestRepositoryOrFirst();
		}

		if (repo == null) {
			// return a base state with an error
			return {
				...this.initialState,
				loadingError: 'No repository found. Please open a Git repository to use the Commit Composer.',
			};
		}

		// If range is explicitly provided, use it directly (skips merge target resolution)
		if (args?.range) {
			return this.initializeStateAndContextFromExplicitRange(
				repo,
				args.branchName,
				args.range,
				args.mode,
				args.source,
				args.commitShas,
			);
		}

		// Check if this is branch mode (requires merge target resolution)
		if (args?.branchName) {
			return this.initializeStateAndContextFromBranch(
				repo,
				args.branchName,
				args.mode,
				args.source,
				args.commitShas,
			);
		}

		// Default to working directory mode
		return this.initializeStateAndContextFromWorkingDirectory(
			repo,
			args?.includedUnstagedChanges,
			args?.mode,
			args?.source,
		);
	}

	private get initialState(): State {
		return {
			...this.host.baseWebviewState,
			...initialState,
		};
	}

	private async initializeStateAndContext(
		repo: GlRepository,
		hunks: ComposerHunk[],
		commits: ComposerCommit[],
		diffs: ComposerDiffs,
		baseCommit?: ComposerBaseCommit,
		headCommitSha?: string,
		branchName?: string,
		mode: 'experimental' | 'preview' = 'preview',
		source?: Sources | Source,
		commitShas?: string[],
		isReload?: boolean,
	): Promise<State> {
		this._generateCommitsConversations.clear();
		this._suppressLargePromptWarning = false;
		this._currentRepository = repo;
		this._hunks = hunks;

		const safetyState = await createSafetyState(repo, diffs, baseCommit?.sha, headCommitSha, branchName);
		this._safetyState = safetyState;

		if (commitShas && commitShas.length > 0) {
			const recomposeSet = new Set(commitShas);
			for (const commit of commits) {
				if (commit.sha && !recomposeSet.has(commit.sha)) {
					commit.locked = true;
				}
			}
		}

		const messages = commitShas?.length
			? commits.filter(c => c.sha && commitShas.includes(c.sha)).map(c => c.message.content)
			: commits.map(c => c.message.content);

		if (branchName || (baseCommit && headCommitSha)) {
			this._recompose = {
				enabled: true,
				branchName: branchName,
				locked: true,
				commitShas: commitShas,
				messages: messages,
			};
		}

		const aiEnabled = this.getAiEnabled();
		const aiModel = await this.container.ai.getModel(
			{ silent: true, scope: 'compose' },
			{ source: 'composer', correlationId: this.host.instanceId },
		);

		const onboardingDismissed = this.isOnboardingDismissed();
		const onboardingStepReached = this.getOnboardingStepReached();

		// Update context
		this._context.diff.files = new Set(hunks.map(h => h.fileName)).size;
		this._context.diff.hunks = hunks.length;
		this._context.diff.lines = hunks.reduce((total, hunk) => total + hunk.content.split('\n').length - 1, 0);
		this._context.diff.hash = md5(hunks.map(h => h.content).join('\n'));
		this._context.commits.initialCount = 0;
		this._context.ai.enabled.org = aiEnabled.org;
		this._context.ai.enabled.config = aiEnabled.config;
		this._context.ai.model = aiModel;
		this._context.onboarding.dismissed = onboardingDismissed;
		this._context.onboarding.stepReached = onboardingStepReached;
		this._context.source = typeof source === 'string' ? { source: source } : source;
		this._context.mode = mode;
		this._context.warnings.workingDirectoryChanged = false;
		this._context.warnings.indexChanged = false;
		this._context.sessionStart = new Date().toISOString();
		this.host.sendTelemetryEvent(isReload ? 'composer/reloaded' : 'composer/loaded', {});

		return {
			...this.initialState,
			hunks: hunks,
			baseCommit: baseCommit ?? null,
			commits: commits,
			aiEnabled: aiEnabled,
			ai: {
				model: aiModel,
			},
			hasChanges: commits.length > 0,
			mode: mode,
			onboardingDismissed: onboardingDismissed,
			workingDirectoryHasChanged: false,
			indexHasChanged: false,
			repositoryState: this.getRepositoryState(),
			recompose: this._recompose ?? null,
			autoComposeInstructions: this._args?.autoComposeInstructions,
		};
	}

	private async initializeStateAndContextFromWorkingDirectory(
		repo: GlRepository,
		includedUnstagedChanges?: boolean,
		mode: 'experimental' | 'preview' = 'preview',
		source?: Sources | Source,
		isReload?: boolean,
	): Promise<State> {
		const [diffsResult, commitResult, branchResult] = await Promise.allSettled([
			getComposerDiffs(repo, undefined, { includeUntracked: true }),
			repo.git.commits.getCommit('HEAD'),
			repo.git.branches.getBranch(),
		]);

		const diffs = getSettledValue(diffsResult)!;

		this._context.diff.unstagedIncluded = false;
		if (includedUnstagedChanges) {
			this._context.diff.unstagedIncluded = true;
		}

		// Hack for now to make sure we don't try to "mix" staged and unstaged hunks together
		const staged = this._context.diff.unstagedIncluded ? diffs?.unified : diffs?.staged;
		const unstaged = this._context.diff.unstagedIncluded ? undefined : diffs?.unstaged;

		if (!diffs?.staged?.contents && diffs?.unstaged?.contents) {
			this._context.diff.unstagedIncluded = true;
		}

		// Allow composer to open with no changes - we'll handle this in the UI
		const hasChanges = Boolean(staged?.contents || unstaged?.contents);
		const hunks = createHunksFromDiffs(staged?.contents, unstaged?.contents);
		const baseCommit = getSettledValue(commitResult);
		const currentBranch = getSettledValue(branchResult);

		// Create initial commit with empty message (user will add message later)
		const hasStagedChanges = Boolean(staged?.contents);
		const hasUnstagedChanges = Boolean(unstaged?.contents);

		let initialHunkIndices: number[];
		if (hasStagedChanges && hasUnstagedChanges) {
			// Both staged and unstaged - assign only staged to initial commit
			initialHunkIndices = hunks.filter(h => h.source === 'staged').map(h => h.index);
		} else {
			// Only staged or only unstaged - assign all to initial commit
			initialHunkIndices = hunks.map(h => h.index);
		}

		const initialCommit = {
			id: 'draft-commit-1',
			message: { content: '', isGenerated: false },
			aiExplanation: '',
			hunkIndices: initialHunkIndices,
		};

		const commits = hasChanges ? [initialCommit] : [];

		// Update context
		this._context.diff.staged = hasStagedChanges;
		this._context.diff.unstaged = hasUnstagedChanges;
		this._context.diff.commits = false;

		// Subscribe to repository changes for working directory monitoring
		this.subscribeToRepository(repo);

		return this.initializeStateAndContext(
			repo,
			hunks,
			commits,
			diffs,
			baseCommit
				? {
						sha: baseCommit.sha,
						message: baseCommit.message ?? '',
						repoName: repo.name,
						branchName: currentBranch?.name ?? 'main',
					}
				: undefined,
			undefined,
			undefined,
			mode,
			source,
			undefined,
			isReload,
		);
	}

	private async initializeStateAndContextFromBranch(
		repo: GlRepository,
		branchName: string,
		mode: 'experimental' | 'preview' = 'preview',
		source?: Sources | Source,
		commitShas?: string[],
		isReload?: boolean,
	): Promise<State> {
		// Get the branch
		const branch = await repo.git.branches.getBranch(branchName);
		if (!branch) {
			return {
				...this.initialState,
				loadingError: `Branch '${branchName}' not found.`,
			};
		}

		// Get the merge target for the branch with recursive resolution
		let mergeTargetName: string | undefined;
		let currentMergeTargetBranchName = branchName;
		let currentMergeTargetBranch = branch;
		const visitedBranches = new Set<string>();
		let attempts = 0;
		const maxAttempts = 10;

		while (attempts < maxAttempts) {
			attempts++;

			// Prevent infinite loops by tracking visited branches
			if (visitedBranches.has(currentMergeTargetBranchName)) {
				break;
			}

			visitedBranches.add(currentMergeTargetBranchName);

			const mergeTargetNameResult = await getBranchMergeTargetName(this.container, currentMergeTargetBranch);
			if (!mergeTargetNameResult.paused && mergeTargetNameResult.value) {
				mergeTargetName = mergeTargetNameResult.value;

				// Get branch commits to check if we have unique commits
				const branchData = await getBranchCommits(this.container, repo, branchName, mergeTargetName);
				if (branchData && branchData.commits.length > 0) {
					// Found unique commits, use this merge target
					const { commits: branchCommits, baseCommit, headCommitSha } = branchData;

					// Create composer commits and hunks from branch commits
					const composerData = await createComposerCommitsFromGitCommits(repo, branchCommits);
					if (!composerData) {
						return {
							...this.initialState,
							loadingError: `Failed to process commits for branch '${branchName}'.`,
						};
					}

					const { commits, hunks } = composerData;

					// Ensure that if commitShas is provided, error out if any of the commit shas are not found in the commits
					if (commitShas) {
						const commitShasSet = new Set(commitShas);
						const missingShas = [...commitShasSet].filter(sha => !commits.some(c => c.sha === sha));
						if (missingShas.length > 0) {
							return {
								...this.initialState,
								loadingError: `The following commit shas were not found in the commits for branch '${branchName}': ${missingShas.join(
									', ',
								)}`,
							};
						}
					}

					const diffs = (await getComposerDiffs(repo, { baseSha: baseCommit.sha, headSha: headCommitSha }))!;

					// Return successful state with found commits
					return this.initializeStateAndContext(
						repo,
						hunks,
						commits,
						diffs,
						{
							sha: baseCommit.sha,
							message: baseCommit.message,
							repoName: repo.name,
							branchName: branchName,
						},
						headCommitSha,
						currentMergeTargetBranchName,
						mode,
						source,
						commitShas,
						isReload,
					);
				}

				// No unique commits found, try to resolve the merge target recursively
				// Get the branch that the current merge target points to
				const targetBranch = await repo.git.branches.getBranch(mergeTargetName);
				if (!targetBranch) {
					// Can't find the target branch, stop here
					break;
				}

				// Check if the target branch name is the same as current branch (circular reference)
				if (targetBranch.name === currentMergeTargetBranchName) {
					break;
				}

				// Move to the target branch and try again
				currentMergeTargetBranchName = targetBranch.name;
				currentMergeTargetBranch = targetBranch;
			} else {
				// No merge target found or paused, stop here
				break;
			}
		}

		// If we cannot find commits using merge target, try a range to the tip of the branch
		if (commitShas?.length) {
			// Set the head commit as the tip of the branch
			const headCommitSha = branch.sha;
			// Set the base commit as the parent commit of the last commit in the range
			const baseCommitSha = (await repo.git.commits.getCommit(commitShas[0]))?.parents[0];
			if (headCommitSha && baseCommitSha) {
				return this.initializeStateAndContextFromExplicitRange(
					repo,
					branchName,
					{ base: baseCommitSha, head: headCommitSha },
					mode,
					source,
					commitShas,
					isReload,
				);
			}
		}

		// If we get here, we couldn't find unique commits to recompose with
		return {
			...this.initialState,
			loadingError: `Could not identify unique commits for branch '${branchName}'`,
		};
	}

	/**
	 * Initializes state when an explicit range is provided.
	 * This bypasses merge target resolution and uses the provided range directly.
	 */
	private async initializeStateAndContextFromExplicitRange(
		repo: GlRepository,
		branchName: string | undefined,
		range: { base: string; head: string },
		mode: 'experimental' | 'preview' = 'preview',
		source?: Sources | Source,
		commitShas?: string[],
		isReload?: boolean,
	): Promise<State> {
		const { base: baseCommitSha, head: headCommitSha } = range;

		// Get the base commit
		const baseCommitResult = await repo.git.commits.getCommit(baseCommitSha);
		if (!baseCommitResult) {
			return {
				...this.initialState,
				loadingError: `Base commit '${baseCommitSha}' not found.`,
			};
		}

		// Validate head commit exists
		const headCommitResult = await repo.git.commits.getCommit(headCommitSha);
		if (!headCommitResult) {
			return {
				...this.initialState,
				loadingError: `Head commit '${headCommitSha}' not found.`,
			};
		}

		const log = await repo.git.commits.getLog(`${baseCommitSha}..${headCommitSha}`, { limit: 0 });
		if (!log?.commits?.size) {
			return {
				...this.initialState,
				loadingError: `No commits found between base commit and head commit.`,
			};
		}

		// Convert Map to Array and reverse to oldest first for processing
		const branchCommits = [...log.commits.values()].reverse();

		// Create composer commits and hunks from branch commits
		const composerData = await createComposerCommitsFromGitCommits(repo, branchCommits);
		if (!composerData) {
			return {
				...this.initialState,
				loadingError: branchName
					? `Failed to process commits for branch '${branchName}'.`
					: 'Failed to process commits in range.',
			};
		}

		const { commits, hunks } = composerData;

		// Validate that all provided commitShas are found in the commits (if provided)
		if (commitShas && commitShas.length > 0) {
			const commitShasSet = new Set(commitShas);
			const missingShas = [...commitShasSet].filter(sha => !commits.some(c => c.sha === sha));
			if (missingShas.length > 0) {
				return {
					...this.initialState,
					loadingError: branchName
						? `The following commit shas were not found in the commits for branch '${branchName}': ${missingShas.join(', ')}`
						: `The following commit shas were not found in the commits: ${missingShas.join(', ')}`,
				};
			}
		}

		const diffs = (await getComposerDiffs(repo, { baseSha: baseCommitSha, headSha: headCommitSha }))!;

		const baseCommit: ComposerBaseCommit = {
			sha: baseCommitSha,
			message: baseCommitResult.message ?? '',
			repoName: repo.name,
			branchName: branchName,
		};

		// Initialize state and context, then store range in _recompose
		const state = await this.initializeStateAndContext(
			repo,
			hunks,
			commits,
			diffs,
			baseCommit,
			headCommitSha,
			branchName,
			mode,
			source,
			commitShas,
			isReload,
		);

		// Store range in _recompose for reload scenarios
		if (this._recompose) {
			this._recompose.range = range;
		}

		return state;
	}

	private getRepositoryState() {
		if (this._currentRepository == null) return undefined;

		const { id, name, path, uri, virtual } = this._currentRepository;
		return {
			current: {
				id: id,
				name: name,
				path: path,
				uri: uri.toString(),
				virtual: virtual,
			},
			hasMultipleRepositories: this.container.git.openRepositoryCount > 1,
		};
	}

	@ipcCommand(OnAddHunksToCommitCommand)
	private async onAddHunksToCommit(params: IpcParams<typeof OnAddHunksToCommitCommand>) {
		if (params.source === 'unstaged') {
			// Update context to indicate unstaged changes were included
			this._context.diff.unstagedIncluded = true;
			this.host.sendTelemetryEvent('composer/action/includedUnstagedChanges');

			await this.onReloadComposer({
				repoPath: this._currentRepository!.path,
				mode: this._context.mode,
			});
		}
	}

	@ipcCommand(OnUndoCommand)
	private onUndo(): void {
		this._context.operations.undo.count++;
		this.host.sendTelemetryEvent('composer/action/undo');
	}

	@ipcCommand(OnRedoCommand)
	private onRedo(): void {
		this._context.operations.redo.count++;
	}

	@ipcCommand(OnResetCommand)
	private onReset(): void {
		this._context.operations.reset.count++;
		this._generateCommitsConversations.clear();
		this._suppressLargePromptWarning = false;
		this.host.sendTelemetryEvent('composer/action/reset');
	}

	@ipcCommand(ChooseRepositoryCommand)
	private async onChooseRepository(): Promise<void> {
		const { title, placeholder } = getRepositoryPickerTitleAndPlaceholder(
			this.container.git.openRepositories,
			'Switch',
			this._currentRepository?.name,
		);
		const pick = await showRepositoryPicker(
			this.container,
			title,
			placeholder,
			this.container.git.openRepositories,
			{ picked: this._currentRepository },
		);

		if (pick == null) return;

		await this.onReloadComposer({
			repoPath: pick.path,
			source: 'composer',
		});
	}

	@ipcCommand(ReloadComposerCommand)
	private async onReloadComposer(params: IpcParams<typeof ReloadComposerCommand>): Promise<void> {
		try {
			// Clear cache to force fresh data on reload
			this._cache.clear();
			this._generateCommitsConversations.clear();
			this._suppressLargePromptWarning = false;

			let repo = this._currentRepository;
			if (!repo || (params.repoPath != null && repo?.path !== params.repoPath)) {
				// Get the best repository
				if (params.repoPath == null) {
					repo = this.container.git.getBestRepositoryOrFirst();
				} else {
					repo = this.container.git.getRepository(params.repoPath);
				}

				if (!repo) {
					// Show error in the safety error overlay
					this._context.errors.safety.count++;
					const errorMessage = 'Repository is no longer available';
					this.host.sendTelemetryEvent('composer/reloaded', {
						'failure.reason': 'error',
						'failure.error.message': errorMessage,
					});
					await this.host.notify(DidSafetyErrorNotification, {
						error: errorMessage,
					});
					return;
				}
			}

			// Initialize composer data from the repository
			let composerData: State;
			// If range is stored, use explicit range initialization
			if (this._recompose?.range) {
				composerData = await this.initializeStateAndContextFromExplicitRange(
					repo,
					this._recompose.branchName,
					this._recompose.range,
					params.mode,
					this._context.source,
					this._recompose.commitShas,
					true,
				);
			} else if (this._recompose?.branchName) {
				composerData = await this.initializeStateAndContextFromBranch(
					repo,
					this._recompose.branchName,
					params.mode,
					this._context.source,
					this._recompose.commitShas,
					true,
				);
			} else {
				composerData = await this.initializeStateAndContextFromWorkingDirectory(
					repo,
					this._context.diff.unstagedIncluded,
					params.mode,
					this._context.source,
					true,
				);
			}

			// Check if there was a loading error
			if (composerData.loadingError) {
				// Send loading error notification instead of reload notification
				await this.host.notify(DidLoadingErrorNotification, {
					error: composerData.loadingError,
				});
				return;
			}

			// Notify the state provider with fresh data to completely reload the state
			await this.host.notify(DidReloadComposerNotification, {
				hunks: composerData.hunks,
				commits: composerData.commits,
				baseCommit: composerData.baseCommit,
				loadingError: composerData.loadingError,
				hasChanges: composerData.hasChanges,
				repositoryState: composerData.repositoryState,
			});
		} catch (error) {
			// Show error in the safety error overlay
			this.host.sendTelemetryEvent('composer/reloaded', {
				'failure.reason': 'error',
				'failure.error.message': error instanceof Error ? error.message : 'unknown error',
			});
			await this.host.notify(DidLoadingErrorNotification, {
				error: error instanceof Error ? error.message : 'Failed to reload composer',
			});
		}
	}

	@ipcCommand(CancelGenerateCommitsCommand)
	private async onCancelGenerateCommits(): Promise<void> {
		if (this._generateCommitsCancellation) {
			this._generateCommitsCancellation.cancel();
			await this.host.notify(DidCancelGenerateCommitsNotification, undefined);
		}
	}

	@ipcCommand(CancelGenerateCommitMessageCommand)
	private async onCancelGenerateCommitMessage(): Promise<void> {
		if (this._generateCommitMessageCancellation) {
			this._generateCommitMessageCancellation.cancel();
			await this.host.notify(DidCancelGenerateCommitMessageNotification, undefined);
		}
	}

	@ipcCommand(ClearAIOperationErrorCommand)
	private async onClearAIOperationError(): Promise<void> {
		// Send notification to clear the AI operation error
		await this.host.notify(DidClearAIOperationErrorNotification, undefined);
	}

	@ipcCommand(OpenOnboardingCommand)
	private onOpenOnboarding(): void {
		this.advanceOnboardingStep(1);
	}

	@ipcCommand(AdvanceOnboardingCommand)
	private onAdvanceOnboarding(params: IpcParams<typeof AdvanceOnboardingCommand>): void {
		this.advanceOnboardingStep(params.stepNumber);
	}

	private advanceOnboardingStep(stepNumber: number): void {
		if (this.isOnboardingDismissed()) {
			return;
		}

		const previousStepReached = this.container.onboarding.getItemState('composer:onboarding')?.stepReached ?? 1;
		const highestStep = Math.max(previousStepReached, stepNumber);
		this._context.onboarding.stepReached = highestStep;
		void this.container.onboarding.setItemState('composer:onboarding', { stepReached: highestStep }).catch();
	}

	@ipcCommand(DismissOnboardingCommand)
	private onDismissOnboarding(): void {
		if (this.isOnboardingDismissed()) {
			return;
		}

		this._context.onboarding.dismissed = true;
		void this.container.onboarding.dismiss('composer:onboarding').catch();
	}

	private isOnboardingDismissed(): boolean {
		return this.container.onboarding.isDismissed('composer:onboarding');
	}

	private getOnboardingStepReached(): number | undefined {
		return this.container.onboarding.getItemState('composer:onboarding')?.stepReached;
	}

	private resetContext(): void {
		this._context = { ...baseContext };
		this._suppressLargePromptWarning = false;
	}

	onShowing(
		_loading: boolean,
		_options: any,
		...args: ComposerWebviewShowingArgs
	): [boolean, Record<`context.${string}`, string | number | boolean | undefined> | undefined] {
		// Store the args for use in includeBootstrap
		if (args?.[0]) {
			// Clear cache when new args are provided (new composer session)
			this._cache.clear();
			this.resetContext();
			this._args = args[0];
			this.updateTitle(args[0].mode);
		}
		return [true, undefined];
	}

	private updateTitle(mode?: 'experimental' | 'preview'): void {
		const currentMode = mode ?? this._args?.mode ?? 'preview';
		if (currentMode === 'experimental') {
			this.host.title = 'Commit Composer (Experimental)';
		} else {
			this.host.title = 'Commit Composer';
		}
	}

	@ipcCommand(CloseComposerCommand)
	private async onClose(): Promise<void> {
		this._context.sessionDuration = Date.now() - new Date(this._context.sessionStart).getTime();
		await commands.executeCommand('workbench.action.closeActiveEditor');
	}

	private async updateAiModel(): Promise<void> {
		try {
			const model = await this.container.ai.getModel(
				{ silent: true, scope: 'compose' },
				{ source: 'composer', correlationId: this.host.instanceId },
			);
			await this.applyAiModel(model);
		} catch {
			// Ignore errors when getting AI model
		}
	}

	private async applyAiModel(model: AIModel | undefined): Promise<void> {
		this._context.ai.model = model;
		this.host.sendTelemetryEvent('composer/action/changeAiModel');
		await this.host.notify(DidChangeAiModelNotification, { model: model });
	}

	@ipcCommand(OnSelectAIModelCommand)
	private async onSelectAIModel(): Promise<void> {
		// Trigger the AI provider/model switch command, scoped to compose so picking writes
		// to the `'compose'` Memento key and leaves the global default untouched.
		await commands.executeCommand<SwitchAIModelCommandArgs>('gitlens.ai.switchProvider', {
			source: 'composer',
			correlationId: this.host.instanceId,
			detail: 'model-picker',
			scope: 'compose',
		});
	}

	@ipcCommand(AIFeedbackHelpfulCommand)
	private async onAIFeedbackHelpful(params: IpcParams<typeof AIFeedbackHelpfulCommand>): Promise<void> {
		// Send AI feedback for composer auto-composition
		this._context.operations.generateCommits.feedback.upvoteCount++;
		await this.sendComposerAIFeedback('helpful', params.sessionId);
	}

	@ipcCommand(AIFeedbackUnhelpfulCommand)
	private async onAIFeedbackUnhelpful(params: IpcParams<typeof AIFeedbackUnhelpfulCommand>): Promise<void> {
		// Send AI feedback for composer auto-composition
		this._context.operations.generateCommits.feedback.downvoteCount++;
		await this.sendComposerAIFeedback('unhelpful', params.sessionId);
	}

	private async sendComposerAIFeedback(sentiment: 'helpful' | 'unhelpful', sessionId: string | null): Promise<void> {
		try {
			// Use the cached compose-scoped model — kept fresh by initial load and `onAIModelChanged`.
			const model = this._context.ai.model;
			if (!model) return;

			// Create a synthetic context for composer AI feedback
			const context = {
				id: sessionId || 'composer-session',
				type: 'generate-commits' as const,
				feature: 'composer',
				model: {
					id: model.id,
					name: model.name,
					maxTokens: model.maxTokens,
					provider: {
						id: model.provider.id,
						name: model.provider.name,
					},
					default: model.default,
					hidden: model.hidden,
					temperature: model.temperature,
				},
				usage: undefined,
			};

			let unhelpful;
			if (sentiment === 'unhelpful') {
				unhelpful = await showUnhelpfulFeedbackPicker();
				if (unhelpful === undefined) return; // User cancelled
			}

			// Use the shared feedback event sender
			sendFeedbackEvent(
				this.container,
				{ source: 'composer', correlationId: this.host.instanceId },
				context,
				sentiment,
				unhelpful,
			);
		} catch (error) {
			// Log error but don't throw to avoid breaking the UI
			console.error('Failed to send composer AI feedback:', error);
		}
	}

	private async resolveOldestInRange(
		repo: GlRepository | undefined,
		baseSha: string,
		headSha: string,
	): Promise<string | undefined> {
		if (repo == null) return undefined;

		const log = await repo.git.commits.getLog(`${baseSha}..${headSha}`, { limit: 0 });
		if (!log?.commits.size) return undefined;
		return [...log.commits.values()].at(-1)?.sha;
	}

	private subscribeToRepository(repository: GlRepository): void {
		// Dispose existing subscription
		this._repositorySubscription?.dispose();

		// Subscribe to repository changes
		this._repositorySubscription = Disposable.from(
			repository.watchWorkingTree(1000),
			repository.onDidChangeWorkingTree(this.onRepositoryWorkingTreeChanged, this),
			repository.onDidChange(this.onRepositoryChanged, this),
		);
	}

	private async onRepositoryChanged(e: RepositoryChangeEvent): Promise<void> {
		if (e.repository.id !== this._currentRepository?.id) return;

		const ignoreIndexChange = this._ignoreIndexChange;
		this._ignoreIndexChange = false;
		// Only care about index changes (staged/unstaged changes)
		if (!e.changed('index') || (ignoreIndexChange && e.changedExclusive('index'))) {
			return;
		}

		this._context.warnings.indexChanged = true;
		await this.host.notify(DidIndexChangeNotification, undefined);
	}

	private async onRepositoryWorkingTreeChanged(e: RepositoryWorkingTreeChangeEvent): Promise<void> {
		// Working directory files have changed
		if (e.repository.id !== this._currentRepository?.id) return;

		this._context.warnings.workingDirectoryChanged = true;
		await this.host.notify(DidWorkingDirectoryChangeNotification, undefined);
	}

	@ipcCommand(GenerateCommitsCommand)
	private async onGenerateCommits(params: IpcParams<typeof GenerateCommitsCommand>): Promise<void> {
		const eventData: WebviewTelemetryEvents[`composer/action/${'compose' | 'recompose'}`] = {
			'customInstructions.used': false,
			'customInstructions.length': 0,
			'customInstructions.hash': '',
			'customInstructions.setting.used': false,
			'customInstructions.setting.length': 0,
			'customInstructions.commitMessage.setting.used': false,
			'customInstructions.commitMessage.setting.length': 0,
		};
		try {
			const commitMessageCustomInstructions = configuration.get('ai.generateCommitMessage.customInstructions');
			if (commitMessageCustomInstructions) {
				eventData['customInstructions.commitMessage.setting.used'] = true;
				eventData['customInstructions.commitMessage.setting.length'] = commitMessageCustomInstructions.length;
			}

			const generateCommitsInstructionSetting = configuration.get('ai.generateCommits.customInstructions');
			if (generateCommitsInstructionSetting) {
				eventData['customInstructions.setting.used'] = true;
				eventData['customInstructions.setting.length'] = generateCommitsInstructionSetting.length;
			}

			this._context.operations.generateCommits.count++;
			if (params.customInstructions) {
				eventData['customInstructions.used'] = true;
				eventData['customInstructions.length'] = params.customInstructions.length;
				eventData['customInstructions.hash'] = md5(params.customInstructions);
			}

			// Create cancellation token for this operation
			this._generateCommitsCancellation = new CancellationTokenSource();

			// Notify webview that generation is starting
			await this.host.notify(DidStartGeneratingNotification, undefined);

			// Transform the data for the AI service
			const hunks = [];

			if (this._recompose?.enabled && this._safetyState?.hashes.commits) {
				// In recompose mode, we need to break down the commit history and use the combined diff to generate new hunks
				// before sending them off to the AI service to compose new commits
				const baseSha = params.commitsToReplace?.baseShaForNewDiff ?? this._safetyState.baseSha!;
				let headSha = this._safetyState.headSha!;
				if (params.commitsToReplace?.commits?.length) {
					headSha = params.commitsToReplace.commits.at(-1)!.sha ?? this._safetyState.headSha!;
				}

				const shouldSkipDiffCalculation =
					params.commitsToReplace?.commits?.length && params.commitsToReplace.commits.every(c => !c.sha);
				if (shouldSkipDiffCalculation) {
					// just set hunks to the existing hunks and move on
					const hunkIndices = new Set(params.commitsToReplace!.commits.flatMap(c => c.hunkIndices));
					for (const index of hunkIndices) {
						hunks.push({ ...this._hunks.find(m => m.index === index)!, assigned: true });
					}
				} else {
					const combinedDiff = await calculateCombinedDiffBetweenCommits(
						this._currentRepository!,
						baseSha,
						headSha,
					);

					const combinedHunks = createHunksFromDiffs(combinedDiff!.contents);
					for (const hunk of combinedHunks) {
						const { author, coAuthors } = getAuthorAndCoAuthorsForCombinedDiffHunk(this._hunks, hunk);
						hunk.author = author;
						hunk.coAuthors = coAuthors.length ? coAuthors : undefined;
						hunks.push({ ...hunk, assigned: true });
					}

					// Update the hunks. Note that if params.commitsToReplace is defined, then we need to remove all the hunks with indices that match the hunkIndices of the commits to replace, then add in the new hunks and
					// reinder all of the hunks. Otherwise, we just replace the existing hunks with the new ones
					if (params.commitsToReplace) {
						const hunkIndicesToRemove = new Set(
							params.commitsToReplace.commits.flatMap(c => c.hunkIndices),
						);
						this._hunks = this._hunks.filter(h => !hunkIndicesToRemove.has(h.index));
						// Reindex the hunks
						let newIndexCounter = 1;
						this._hunks.forEach(hunk => {
							hunk.index = newIndexCounter++;
						});
						hunks.forEach(hunk => {
							hunk.index = newIndexCounter++;
						});
						this._hunks.push(...hunks);
					} else {
						this._hunks = hunks;
					}
				}
			} else {
				// Working directory mode: use existing hunks
				for (const index of params.hunkIndices) {
					hunks.push({ ...this._hunks.find(m => m.index === index)!, assigned: true });
				}
			}

			const existingCommits = params.commits.map(commit => ({
				id: commit.id,
				message: commit.message.content,
				aiExplanation: commit.aiExplanation,
				hunkIndices: commit.hunkIndices,
			}));

			const hunksHash = await sha256(hunks.map(h => `${h.index}:${h.hunkHeader}:${h.content}`).join('\n'));
			const conversation = this._generateCommitsConversations.get(hunksHash);

			// Call the AI service
			void this.container.usage.track(`action:gitlens.ai.generateCommits:happened`).catch();

			// Route through @gitkraken/compose-tools when available.
			// - `_composeTools` is undefined in the webworker/browser build (node-only library).
			// - `commitsToReplace` (subset re-generation) stays on legacy: the splice logic
			//   that renumbers `_hunks` + remaps commit hunkIndices needs careful coordination
			//   with the library's own indexing, and we don't yet have a pre-supplied-hunks
			//   plan-only mode to hand off.
			const hasCommitsToReplace = Boolean(params.commitsToReplace?.commits?.length);
			const composeTools =
				hasCommitsToReplace || this._currentRepository == null
					? undefined
					: await this.getOrCreateComposeTools();
			const useLibraryRoute = composeTools != null && this._currentRepository != null && !hasCommitsToReplace;
			let result: Awaited<ReturnType<typeof this.container.ai.actions.generateCommits>>;
			if (useLibraryRoute && composeTools != null && this._currentRepository != null) {
				// Discard any prior cached plan from an earlier compose click in this session.
				if (this._currentComposePlanCacheKey != null) {
					composeTools.discardCachedPlan(this._currentComposePlanCacheKey);
					this._currentComposePlanCacheKey = undefined;
				}

				const inRecompose = this._recompose?.enabled && this._safetyState?.hashes.commits;
				let librarySource: import('./compose/integration.js').ComposerSource;
				if (inRecompose && this._safetyState?.baseSha != null && this._safetyState?.headSha != null) {
					const oldestSha = await this.resolveOldestInRange(
						this._currentRepository,
						this._safetyState.baseSha,
						this._safetyState.headSha,
					);
					if (oldestSha == null) {
						const stagedOnly = !this._context.diff.unstagedIncluded;
						librarySource = { type: 'workdir', stagedOnly: stagedOnly };
					} else {
						librarySource = {
							type: 'commit-range',
							branch: this._recompose?.branchName ?? '',
							from: oldestSha,
							to: this._safetyState.headSha,
						};
					}
				} else {
					const stagedOnly = !this._context.diff.unstagedIncluded;
					librarySource = { type: 'workdir', stagedOnly: stagedOnly };
				}

				// Dispose the repo subscription while the library runs: even though the
				// library writes only to a temp GIT_INDEX_FILE, it still spawns git
				// processes that touch `.git/objects/` (write-tree, hash-object) and the
				// repo watcher can register those as index changes. Re-subscribe when done.
				// Matches the pattern used in onFinishAndCommit for the same reason.
				this._repositorySubscription?.dispose();
				this._repositorySubscription = undefined;

				try {
					const svc = this.container.git.getRepositoryService(this._currentRepository.path);
					const planResult = await composeTools.generatePlan({
						svc: svc,
						source: librarySource,
						customInstructions: params.customInstructions,
						cancellation: this._generateCommitsCancellation.token,
						telemetrySource: { source: 'composer', correlationId: this.host.instanceId },
						suppressLargePromptWarning: this._suppressLargePromptWarning,
						onProgress: event => {
							void this.host.notify(DidProgressGeneratingCommitsNotification, {
								phase: event.phase,
								message: event.message,
							});
						},
					});
					this._currentComposePlanCacheKey = planResult.cacheKey;

					// The library re-parses the source (workdir or commit range) into its
					// own hunk sequence, and commit.hunkIndices reference that sequence.
					// The library's parse can diverge from `createHunksFromDiffs` in both
					// ordering and hunk splits (GitLens concatenates staged+unstaged diffs;
					// the library uses a single tree-to-tree diff). Always adopt the
					// library's hunks so the commit references line up with the UI state.
					this._hunks = planResult.hunks;

					result = {
						commits: planResult.commits.map(c => ({
							message: c.message.content,
							explanation: c.aiExplanation ?? '',
							hunks: c.hunkIndices.map(i => ({ hunk: i })),
						})),
						// Library path doesn't produce an AIConversation, so "try again with same
						// hunks" re-runs the full AI pipeline rather than resuming a prior session.
						conversation: conversation ?? new AIConversation(),
					};
				} finally {
					// Restore subscription so user-driven index changes after compose are
					// observed again.
					if (this._currentRepository != null) {
						this.subscribeToRepository(this._currentRepository);
					}
				}
			} else {
				result = await this.container.ai.actions.generateCommits(
					hunks,
					existingCommits,
					this._recompose?.enabled ? (this._recompose.messages ?? []) : [],
					hunks.map(m => ({ index: m.index, hunkHeader: m.hunkHeader })),
					{ source: 'composer', correlationId: this.host.instanceId },
					{
						cancellation: this._generateCommitsCancellation.token,
						customInstructions: params.customInstructions,
						conversation: conversation,
						suppressLargePromptWarning: this._suppressLargePromptWarning,
					},
				);
			}

			if (this._generateCommitsCancellation?.token.isCancellationRequested) {
				this._context.operations.generateCommits.cancelledCount++;
				this.host.sendTelemetryEvent(
					params.isRecompose ? 'composer/action/recompose/failed' : 'composer/action/compose/failed',
					{
						...eventData,
						'failure.reason': 'cancelled',
					},
				);
				await this.host.notify(DidCancelGenerateCommitsNotification, undefined);
				return;
			}

			if (result && result !== 'cancelled') {
				// Suppress the large prompt warning for the rest of this session
				this._suppressLargePromptWarning = true;

				if (result.commits.length === 0) {
					this._context.operations.generateCommits.errorCount++;
					this._context.errors.operation.count++;
					this.host.sendTelemetryEvent(
						params.isRecompose ? 'composer/action/recompose/failed' : 'composer/action/compose/failed',
						{
							...eventData,
							'failure.reason': 'error',
							'failure.error.message': 'no commits generated',
						},
					);
					await this.host.notify(DidErrorAIOperationNotification, {
						operation: 'generate commits',
						error: 'No commits generated',
					});
					return;
				}

				this._generateCommitsConversations.set(hunksHash, result.conversation);

				// Transform AI result back to ComposerCommit format
				const newCommits = result.commits.map((commit, index) => ({
					id: `ai-commit-${index}`,
					message: { content: commit.message, isGenerated: true },
					aiExplanation: commit.explanation,
					hunkIndices: commit.hunks.map(h => h.hunk),
				}));

				// Notify the webview with the generated commits (this will also clear loading state)
				this._context.commits.autoComposedCount = newCommits.length;
				this.host.sendTelemetryEvent(
					params.isRecompose ? 'composer/action/recompose' : 'composer/action/compose',
					eventData,
				);

				// Unlock the commits in recompose mode after a successful auto-compose since they are now draft commits
				if (this._recompose?.enabled) {
					this._recompose.locked = false;
				}

				// When the library drove generation, `this._hunks` has been replaced
				// with the library's own parse (see the workdir / recompose branch
				// above). Ship those hunks to the webview so commit.hunkIndices line
				// up with the UI state. Recompose (legacy or library) also needs a
				// hunk refresh because the combined diff was rebuilt.
				const shouldSendHunks = useLibraryRoute || this._recompose?.enabled === true;
				await this.host.notify(DidGenerateCommitsNotification, {
					commits: newCommits,
					hunks: shouldSendHunks ? this._hunks : undefined,
					replacedCommitIds: params.commitsToReplace?.commits.map(c => c.id),
				});
			} else if (result === 'cancelled') {
				this._context.operations.generateCommits.cancelledCount++;
				// Send cancellation notification instead of success notification
				await this.host.notify(DidCancelGenerateCommitsNotification, undefined);
			} else {
				this._context.operations.generateCommits.errorCount++;
				this._context.errors.operation.count++;
				// Send error notification for failure (not cancellation)
				this.host.sendTelemetryEvent(
					params.isRecompose ? 'composer/action/recompose/failed' : 'composer/action/compose/failed',
					{
						...eventData,
						'failure.reason': 'error',
						'failure.error.message': 'unknown error',
					},
				);
				await this.host.notify(DidErrorAIOperationNotification, {
					operation: 'generate commits',
					error: undefined,
				});
			}
		} catch (error) {
			// Check if this was a cancellation or a real error
			if (this._generateCommitsCancellation?.token.isCancellationRequested) {
				this._context.operations.generateCommits.cancelledCount++;
				// Send cancellation notification
				this.host.sendTelemetryEvent(
					params.isRecompose ? 'composer/action/recompose/failed' : 'composer/action/compose/failed',
					{
						...eventData,
						'failure.reason': 'cancelled',
					},
				);
				await this.host.notify(DidCancelGenerateCommitsNotification, undefined);
			} else {
				this._context.operations.generateCommits.errorCount++;
				this._context.errors.operation.count++;
				this.host.sendTelemetryEvent(
					params.isRecompose ? 'composer/action/recompose/failed' : 'composer/action/compose/failed',
					{
						...eventData,
						'failure.reason': 'error',
						'failure.error.message': error instanceof Error ? error.message : 'unknown error',
					},
				);
				// Send error notification for exception
				await this.host.notify(DidErrorAIOperationNotification, {
					operation: 'generate commits',
					error: error instanceof Error ? error.message : undefined,
				});
			}
		} finally {
			// Clean up cancellation token
			this._generateCommitsCancellation?.dispose();
			this._generateCommitsCancellation = undefined;
		}
	}

	@ipcCommand(GenerateCommitMessageCommand)
	private async onGenerateCommitMessage(params: IpcParams<typeof GenerateCommitMessageCommand>): Promise<void> {
		const eventData: WebviewTelemetryEvents['composer/action/generateCommitMessage'] = {
			'customInstructions.setting.used': false,
			'customInstructions.setting.length': 0,
			overwriteExistingMessage: params.overwriteExistingMessage ?? false,
		};
		try {
			const customInstructionsSetting = configuration.get('ai.generateCommitMessage.customInstructions');
			if (customInstructionsSetting) {
				eventData['customInstructions.setting.used'] = true;
				eventData['customInstructions.setting.length'] = customInstructionsSetting.length;
			}

			this._context.operations.generateCommitMessage.count++;

			// Create cancellation token for this operation
			this._generateCommitMessageCancellation = new CancellationTokenSource();

			// Notify webview that commit message generation is starting
			await this.host.notify(DidStartGeneratingCommitMessageNotification, { commitId: params.commitId });

			// Create combined diff for the commit
			const { patch } = createCombinedDiffForCommit(
				this._hunks.filter(h => params.commitHunkIndices.includes(h.index)),
			);
			if (!patch) {
				this._context.operations.generateCommitMessage.errorCount++;
				this._context.errors.operation.count++;
				// Send error notification for failure (not cancellation)
				this.host.sendTelemetryEvent('composer/action/generateCommitMessage/failed', {
					...eventData,
					'failure.reason': 'error',
					'failure.error.message': 'Failed to create diff for commit',
				});
				await this.host.notify(DidErrorAIOperationNotification, {
					operation: 'generate commit message',
					error: 'Failed to create diff for commit',
				});
			}

			// Call the AI service to generate commit message
			const result = await this.container.ai.actions.generateCommitMessage(
				patch,
				{ source: 'composer', correlationId: this.host.instanceId },
				{
					cancellation: this._generateCommitMessageCancellation.token,
					suppressLargePromptWarning: this._suppressLargePromptWarning,
				},
			);

			if (this._generateCommitMessageCancellation?.token.isCancellationRequested) {
				this._context.operations.generateCommitMessage.cancelledCount++;
				this.host.sendTelemetryEvent('composer/action/generateCommitMessage/failed', {
					...eventData,
					'failure.reason': 'cancelled',
				});
				await this.host.notify(DidCancelGenerateCommitMessageNotification, undefined);
				return;
			}

			if (result && result !== 'cancelled') {
				// Combine summary and body into a single message
				const message = result.result.body
					? `${result.result.summary}\n\n${result.result.body}`
					: result.result.summary;

				// Notify the webview with the generated commit message
				this.host.sendTelemetryEvent('composer/action/generateCommitMessage', eventData);
				await this.host.notify(DidGenerateCommitMessageNotification, {
					commitId: params.commitId,
					message: message,
				});
			} else if (result === 'cancelled') {
				this._context.operations.generateCommitMessage.cancelledCount++;
				// Send cancellation notification instead of success notification
				this.host.sendTelemetryEvent('composer/action/generateCommitMessage/failed', {
					...eventData,
					'failure.reason': 'cancelled',
				});
				await this.host.notify(DidCancelGenerateCommitMessageNotification, undefined);
			} else {
				this._context.operations.generateCommitMessage.errorCount++;
				this._context.errors.operation.count++;
				// Send error notification for failure (not cancellation)
				this.host.sendTelemetryEvent('composer/action/generateCommitMessage/failed', {
					...eventData,
					'failure.reason': 'error',
					'failure.error.message': 'unknown error',
				});
				await this.host.notify(DidErrorAIOperationNotification, {
					operation: 'generate commit message',
					error: undefined,
				});
			}
		} catch (error) {
			// Check if this was a cancellation or a real error
			if (this._generateCommitMessageCancellation?.token.isCancellationRequested) {
				this._context.operations.generateCommitMessage.cancelledCount++;
				// Send cancellation notification
				this.host.sendTelemetryEvent('composer/action/generateCommitMessage/failed', {
					...eventData,
					'failure.reason': 'cancelled',
				});
				await this.host.notify(DidCancelGenerateCommitMessageNotification, undefined);
			} else {
				this._context.operations.generateCommitMessage.errorCount++;
				this._context.errors.operation.count++;
				// Send error notification for exception
				this.host.sendTelemetryEvent('composer/action/generateCommitMessage/failed', {
					...eventData,
					'failure.reason': 'error',
					'failure.error.message': error instanceof Error ? error.message : 'unknown error',
				});
				await this.host.notify(DidErrorAIOperationNotification, {
					operation: 'generate commit message',
					error: error instanceof Error ? error.message : undefined,
				});
			}
		} finally {
			// Clean up cancellation token
			this._generateCommitMessageCancellation?.dispose();
			this._generateCommitMessageCancellation = undefined;
		}
	}

	@ipcCommand(FinishAndCommitCommand)
	private async onFinishAndCommit(params: IpcParams<typeof FinishAndCommitCommand>): Promise<void> {
		try {
			// Notify webview that committing is starting
			await this.host.notify(DidStartCommittingNotification, undefined);

			// Get the specific repository from the safety state
			const repo = this.container.git.getRepository(this._safetyState.repoPath);
			if (!repo) {
				// Clear loading state and show safety error
				await this.host.notify(DidFinishCommittingNotification, undefined);
				this._context.errors.safety.count++;
				this._context.errors.operation.count++;
				const errorMessage = 'Repository is no longer available';
				this.host.sendTelemetryEvent('composer/action/finishAndCommit/failed', {
					'failure.reason': 'error',
					'failure.error.message': errorMessage,
				});
				await this.host.notify(DidSafetyErrorNotification, {
					error: errorMessage,
				});
				return;
			}

			// Library route: if generatePlan was run via library (cache key present),
			// route apply through applyComposePlan. The library handles its own safety
			// check (snapshot captured at compose time), commit chain construction,
			// stash management, and ref update. Defaults pick the right target from
			// the stored source (commit-range → rewrite-range, workdir → head).
			if (this._composeTools != null && this._currentComposePlanCacheKey != null) {
				// Dispose subscription while the library runs git ops.
				this._repositorySubscription?.dispose();
				this._repositorySubscription = undefined;

				try {
					const svc = this.container.git.getRepositoryService(repo.path);
					const signingConfig = await svc.config.getSigningConfig?.();
					const signing = signingConfig?.enabled
						? {
								enabled: true,
								signingKey: signingConfig.signingKey,
								gpgProgram: signingConfig.gpgProgram,
							}
						: undefined;

					const result = await this._composeTools.applyPlan({
						svc: svc,
						cacheKey: this._currentComposePlanCacheKey,
						commits: params.commits,
						signing: signing,
						telemetrySource: { source: 'composer', correlationId: this.host.instanceId },
					});

					this._currentComposePlanCacheKey = undefined;
					this._context.commits.finalCount = Object.keys(result.commitShas ?? {}).length;
					this.host.sendTelemetryEvent('composer/action/finishAndCommit');
					await this.host.notify(DidFinishCommittingNotification, undefined);
					void commands.executeCommand('workbench.action.closeActiveEditor');
					return;
				} catch (error) {
					const errCode = (error as { code?: string })?.code;
					const errMsg = error instanceof Error ? error.message : 'unknown error';

					await this.host.notify(DidFinishCommittingNotification, undefined);

					if (errCode === 'CANCELLED') {
						// User-initiated cancel — no error telemetry, no error notification.
						return;
					}

					if (errCode === 'SAFETY_CHECK_FAILED') {
						this._context.errors.safety.count++;
						this._context.errors.operation.count++;
						this._context.operations.finishAndCommit.errorCount++;
						this.host.sendTelemetryEvent('composer/action/finishAndCommit/failed', {
							'failure.reason': 'error',
							'failure.error.message': errMsg,
						});
						await this.host.notify(DidSafetyErrorNotification, { error: errMsg });
						return;
					}

					// CHERRY_PICK_CONFLICT, OPERATION_FAILED, INTERNAL, unknown — all surface
					// as a generic apply failure. `detail` on GitError may carry more info
					// (e.g. conflictingCommit) for diagnostics.
					this._context.errors.operation.count++;
					this._context.operations.finishAndCommit.errorCount++;
					this.host.sendTelemetryEvent('composer/action/finishAndCommit/failed', {
						'failure.reason': 'error',
						'failure.error.message': errMsg,
					});
					void window.showErrorMessage(`Failed to commit changes: ${errMsg}`);
					return;
				} finally {
					if (this._currentRepository != null) {
						this.subscribeToRepository(this._currentRepository);
					}
				}
			}

			// Legacy path — runs when no library cache key (generatePlan was not routed
			// through the library, e.g. because `_composeTools` is undefined in the
			// browser build, or the user is in a flow the library doesn't yet handle).

			const commitHunkIndices = params.commits.flatMap(c => c.hunkIndices);
			const hunks: ComposerHunk[] = [];
			for (const hunk of commitHunkIndices) {
				hunks.push({ ...this._hunks.find(m => m.index === hunk)!, assigned: true });
			}

			const hunksBeingCommitted = hunks.filter(hunk =>
				params.commits.some(c => c.hunkIndices.includes(hunk.index)),
			);

			// Validate repository safety state before proceeding
			// Stop repo change subscription so we can deal with untracked files
			let diffsWithUntracked: ComposerDiffs | undefined;
			if (this._context.diff.unstagedIncluded) {
				this._repositorySubscription?.dispose();
				const untrackedPaths = (await repo.git.status?.getUntrackedFiles())?.map(f => f.path);
				if (untrackedPaths?.length) {
					try {
						diffsWithUntracked = await getComposerDiffs(repo);
						await repo.git.staging?.stageFiles(untrackedPaths);
					} catch {}
				}
			}

			const validation = await validateSafetyState(
				repo,
				this._safetyState,
				hunksBeingCommitted,
				diffsWithUntracked,
			);
			if (!validation.isValid) {
				// Clear loading state and show safety error
				await this.host.notify(DidFinishCommittingNotification, undefined);
				this._context.errors.safety.count++;
				this._context.errors.operation.count++;
				this._context.operations.finishAndCommit.errorCount++;
				const errorMessage = validation.errors.join('\n');
				this.host.sendTelemetryEvent('composer/action/finishAndCommit/failed', {
					'failure.reason': 'error',
					'failure.error.message': errorMessage,
				});
				await this.host.notify(DidSafetyErrorNotification, {
					error: errorMessage,
				});
				return;
			}

			const diffInfo = convertToComposerDiffInfo(params.commits, hunks);
			const svc = this.container.git.getRepositoryService(repo.path);
			if (!svc) {
				this._context.errors.operation.count++;
				this._context.operations.finishAndCommit.errorCount++;
				const errorMessage = 'No repository service found';
				this.host.sendTelemetryEvent('composer/action/finishAndCommit/failed', {
					'failure.reason': 'error',
					'failure.error.message': errorMessage,
				});
				throw new Error(errorMessage);
			}

			if (params.baseCommit?.sha == null) {
				const initialCommitSha = await svc.patch?.createEmptyInitialCommit();
				if (initialCommitSha == null) {
					// error base we don't have an initial commit
					this._context.errors.operation.count++;
					this._context.operations.finishAndCommit.errorCount++;
					const errorMessage = 'Could not create base commit';
					this.host.sendTelemetryEvent('composer/action/finishAndCommit/failed', {
						'failure.reason': 'error',
						'failure.error.message': errorMessage,
					});
					throw new Error(errorMessage);
				}
			}

			// Create unreachable commits from patches
			// Get signing config to determine if commits should be signed
			const signingConfig = await repo.git.config.getSigningConfig?.();
			const shouldSign = signingConfig?.enabled ?? false;

			const shas = await repo.git.patch?.createUnreachableCommitsFromPatches(params.baseCommit?.sha, diffInfo, {
				sign: shouldSign,
				source: { source: 'composer' },
			});

			if (!shas?.length) {
				this._context.errors.operation.count++;
				this._context.operations.finishAndCommit.errorCount++;
				const errorMessage = 'Failed to create commits from patches';
				this.host.sendTelemetryEvent('composer/action/finishAndCommit/failed', {
					'failure.reason': 'error',
					'failure.error.message': errorMessage,
				});
				throw new Error(errorMessage);
			}

			const baseRef = params.baseCommit?.sha ?? ((await repo.git.commits.getCommit('HEAD')) ? 'HEAD' : rootSha);
			const resultingDiff = (
				await repo.git.diff.getDiff?.(shas.at(-1)!, baseRef, {
					notation: params.baseCommit?.sha ? '...' : undefined,
				})
			)?.contents;

			if (!resultingDiff) {
				this._context.errors.operation.count++;
				this._context.operations.finishAndCommit.errorCount++;
				const errorMessage = 'Failed to get combined diff';
				this.host.sendTelemetryEvent('composer/action/finishAndCommit/failed', {
					'failure.reason': 'error',
					'failure.error.message': errorMessage,
				});
				throw new Error(errorMessage);
			}

			if (
				!validateResultingDiff(
					this._safetyState,
					await sha256(resultingDiff),
					this._context.diff.unstagedIncluded,
				)
			) {
				// Clear loading state and show safety error
				await this.host.notify(DidFinishCommittingNotification, undefined);
				this._context.errors.safety.count++;
				this._context.errors.operation.count++;
				this._context.operations.finishAndCommit.errorCount++;
				const errorMessage = 'Output diff does not match input';
				this.host.sendTelemetryEvent('composer/action/finishAndCommit/failed', {
					'failure.reason': 'error',
					'failure.error.message': errorMessage,
				});
				await this.host.notify(DidSafetyErrorNotification, {
					error: errorMessage,
				});
				return;
			}

			// Capture previous stash state
			let previousStashCommit;
			let stash;
			let stashCommit;
			let stashedSuccessfully = false;
			const hasWorkingChanges = await repo.git.status.hasWorkingChanges({ throwOnError: true });
			if (hasWorkingChanges) {
				stash = await svc.stash?.getStash();
				if (stash?.stashes.size) {
					const latestStash = stash.stashes.values().next().value;
					if (latestStash) {
						previousStashCommit = latestStash;
					}
				}

				// Stash the working changes
				const stashMessage = `Commit composer: ${new Date().toLocaleString()}`;
				await svc.stash?.saveStash(stashMessage, undefined, { includeUntracked: true });

				// Get the new stash reference
				stash = await svc.stash?.getStash();
				if (stash?.stashes.size) {
					stashCommit = stash.stashes.values().next().value;
					if (
						stashCommit &&
						stashCommit.ref !== previousStashCommit?.ref &&
						stashCommit.message?.includes(stashMessage)
					) {
						stashedSuccessfully = true;
					}
				}
			}

			// Check if we're in branch mode
			if (this._recompose?.enabled && this._recompose.branchName) {
				// Branch mode: update the specific branch to point to the new commits
				// Use git update-ref to update the branch reference directly
				await repo.git.refs.updateReference(`refs/heads/${this._recompose.branchName}`, shas.at(-1)!);
			} else {
				// Working directory mode: reset the current branch to the new shas
				await svc.ops?.reset(shas.at(-1)!, { mode: 'hard' });
			}

			// Pop the stash we created to restore what is left in the working tree, preserving
			// the original staged/unstaged split so the user's pre-composer workspace round-trips.
			if (stashCommit && stashedSuccessfully) {
				const stashResult = await svc.stash?.applyStash(stashCommit.stashName, {
					deleteAfter: true,
					index: true,
				});
				if (stashResult?.conflicted) {
					void window.showInformationMessage('Stash applied with conflicts');
				}
			}

			// Clear the committing state and close the composer webview first
			this._context.commits.finalCount = shas.length;
			this.host.sendTelemetryEvent('composer/action/finishAndCommit');
			await this.host.notify(DidFinishCommittingNotification, undefined);
			void commands.executeCommand('workbench.action.closeActiveEditor');
		} catch (error) {
			// Clear loading state on error
			this._context.errors.operation.count++;
			this._context.operations.finishAndCommit.errorCount++;
			const errorMessage = error instanceof Error ? error.message : 'unknown error';
			this.host.sendTelemetryEvent('composer/action/finishAndCommit/failed', {
				'failure.reason': 'error',
				'failure.error.message': errorMessage,
			});
			await this.host.notify(DidFinishCommittingNotification, undefined);
			void window.showErrorMessage(`Failed to commit changes: ${errorMessage}`);
		}
	}

	private onAnyConfigurationChanged(e: ConfigurationChangeEvent) {
		if (configuration.changed(e, 'ai.enabled')) {
			const newSetting = this.container.ai.enabled;
			this._context.ai.enabled.config = newSetting;
			// Update AI config setting in state
			void this.host.notify(DidChangeAiEnabledNotification, { config: newSetting });
		}
	}

	private onContextChanged(key: keyof ContextKeys) {
		if (key === 'gitlens:gk:organization:ai:enabled') {
			const newSetting = this.container.ai.orgEnabled;
			this._context.ai.enabled.org = newSetting;
			// Update AI org setting in state
			void this.host.notify(DidChangeAiEnabledNotification, { org: newSetting });
		}
	}

	private onAIModelChanged(e: AIModelChangeEvent) {
		// Only refresh when the change affects the composer's scope: an explicit `'compose'`
		// scope change, or a global default change (which the composer reads as fallback
		// when its scoped value is unset). Ignore unrelated scopes like `'review'`.
		if (e.scope != null && e.scope !== 'compose') return;

		// The event payload already carries the new model, so apply it directly without
		// re-fetching. For a global change while a compose-scoped value is set, the
		// scoped value still wins — `updateAiModel()` re-reads to resolve that case.
		if (e.scope === 'compose') {
			void this.applyAiModel(e.model);
			return;
		}

		void this.updateAiModel();
	}

	private getAiEnabled() {
		return { org: this.container.ai.orgEnabled, config: this.container.ai.enabled };
	}

	private _panelWasVisible: boolean | undefined;
	private _isMaximized = false;

	async maximize(): Promise<void> {
		if (this._isMaximized) {
			// Restore panel if it was previously visible
			if (this._panelWasVisible) {
				await executeCoreCommand('workbench.action.togglePanel');
			}
			this._isMaximized = false;
			this._panelWasVisible = undefined;
		} else {
			// Check panel visibility by querying the workbench state
			// We'll use a workaround: check if the panel is focused
			try {
				// Try to focus the panel - if it succeeds, panel was visible
				await executeCoreCommand('workbench.action.focusPanel');
				this._panelWasVisible = true;
				// Now hide it
				await executeCoreCommand('workbench.action.togglePanel');
			} catch {
				// If focusing failed, panel wasn't visible
				this._panelWasVisible = false;
			}

			this._isMaximized = true;
		}
	}
}
