import { deflateSync, strFromU8, strToU8 } from 'fflate';
import type { Event, ViewBadge, Webview, WebviewPanel, WebviewView, WindowState } from 'vscode';
import { CancellationTokenSource, Disposable, EventEmitter, Uri, ViewColumn, window, workspace } from 'vscode';
import { base64 } from '@gitlens/utils/base64.js';
import { isCancellationError } from '@gitlens/utils/cancellation.js';
import { getScopedCounter } from '@gitlens/utils/counter.js';
import { getNonce } from '@gitlens/utils/crypto.js';
import { logName, trace } from '@gitlens/utils/decorators/log.js';
import { sequentialize } from '@gitlens/utils/decorators/sequentialize.js';
import { getLoggableName, Logger } from '@gitlens/utils/logger.js';
import { getScopedLogger, maybeStartScopedLogger } from '@gitlens/utils/logger.scoped.js';
import { pauseOnCancelOrTimeout } from '@gitlens/utils/promise.js';
import { maybeStopWatch, Stopwatch } from '@gitlens/utils/stopwatch.js';
import type { GlWebviewCommands } from '../constants.commands.js';
import type {
	Source,
	TelemetryEvents,
	WebviewTelemetryContext,
	WebviewTelemetryEvents,
} from '../constants.telemetry.js';
import type {
	CustomEditorIds,
	CustomEditorTypes,
	WebviewIds,
	WebviewPanelIds,
	WebviewPanelTypes,
	WebviewTypeFromId,
	WebviewViewIds,
	WebviewViewTypes,
} from '../constants.views.js';
import type { Container } from '../container.js';
import { getSubscriptionNextPaidPlanId } from '../plus/gk/utils/subscription.utils.js';
import { executeCommand, executeCoreCommand } from '../system/-webview/command.js';
import {
	includesContextDelimitedString,
	removeFromContextDelimitedString,
	setContext,
} from '../system/-webview/context.js';
import { getViewFocusCommand } from '../system/-webview/vscode/views.js';
import { serializeIpcData } from '../system/ipcSerialize.js';
import type { WebviewContext } from '../system/webview.js';
import { dispatchIpcMessage } from './ipc/handlerRegistry.js';
import type { IpcPromise } from './ipc/models/dataTypes.js';
import type {
	IpcCallMessageType,
	IpcCallParamsType,
	IpcCallResponseParamsType,
	IpcMessage,
	IpcNotification,
	IpcRequest,
} from './ipc/models/ipc.js';
import type { WebviewFocusChangedParams, WebviewState } from './protocol.js';
import {
	ApplicablePromoRequest,
	DidChangeHostWindowFocusNotification,
	DidChangeWebviewFocusNotification,
	DidChangeWebviewVisibilityNotification,
	ExecuteCommand,
	IpcPromiseSettled,
	TelemetrySendEventCommand,
	WebviewFocusChangedCommand,
	WebviewReadyRequest,
} from './protocol.js';
import { isRpcMessage } from './rpc/constants.js';
import { EventVisibilityBuffer, SubscriptionTracker } from './rpc/eventVisibilityBuffer.js';
import { RpcHost } from './rpc/rpcHost.js';
import type { WebviewCommandCallback, WebviewCommandRegistrar } from './webviewCommandRegistrar.js';
import type { CustomEditorDescriptor, WebviewPanelDescriptor, WebviewViewDescriptor } from './webviewDescriptors.js';
import type { WebviewHost, WebviewProvider, WebviewShowingArgs } from './webviewProvider.js';
import type { WebviewShowOptions } from './webviewsController.js';

const ipcSequencer = getScopedCounter();

type GetWebviewDescriptor<T extends CustomEditorIds | WebviewIds> = T extends CustomEditorIds
	? CustomEditorDescriptor<T>
	: T extends WebviewPanelIds
		? WebviewPanelDescriptor<T>
		: T extends WebviewViewIds
			? WebviewViewDescriptor<T>
			: never;

type GetWebviewParent<T extends CustomEditorIds | WebviewIds> = T extends WebviewViewIds ? WebviewView : WebviewPanel;

@logName(c => `WebviewController(${c.id}${c.instanceId != null ? `|${c.instanceId}` : ''})`)
export class WebviewController<
	ID extends CustomEditorIds | WebviewIds,
	State,
	SerializedState = State,
	ShowingArgs extends unknown[] = unknown[],
>
	implements WebviewHost<ID>, Disposable
{
	static async create<
		ID extends WebviewPanelIds,
		State,
		SerializedState = State,
		ShowingArgs extends unknown[] = unknown[],
	>(
		container: Container,
		commandRegistrar: WebviewCommandRegistrar,
		descriptor: WebviewPanelDescriptor<ID>,
		instanceId: string,
		parent: WebviewPanel,
		resolveProvider: (
			container: Container,
			host: WebviewHost<ID>,
		) => Promise<WebviewProvider<State, SerializedState, ShowingArgs>>,
	): Promise<WebviewController<ID, State, SerializedState, ShowingArgs>>;
	static async create<
		ID extends WebviewViewIds,
		State,
		SerializedState = State,
		ShowingArgs extends unknown[] = unknown[],
	>(
		container: Container,
		commandRegistrar: WebviewCommandRegistrar,
		descriptor: WebviewViewDescriptor<ID>,
		instanceId: string,
		parent: WebviewView,
		resolveProvider: (
			container: Container,
			host: WebviewHost<ID>,
		) => Promise<WebviewProvider<State, SerializedState, ShowingArgs>>,
	): Promise<WebviewController<ID, State, SerializedState, ShowingArgs>>;
	static async create<
		ID extends CustomEditorIds,
		State,
		SerializedState = State,
		ShowingArgs extends unknown[] = unknown[],
	>(
		container: Container,
		commandRegistrar: WebviewCommandRegistrar,
		// oxlint-disable-next-line typescript/unified-signatures
		descriptor: CustomEditorDescriptor<ID>,
		instanceId: string,
		parent: WebviewPanel,
		resolveProvider: (
			container: Container,
			host: WebviewHost<ID>,
		) => Promise<WebviewProvider<State, SerializedState, ShowingArgs>>,
	): Promise<WebviewController<ID, State, SerializedState, ShowingArgs>>;
	static async create<
		ID extends CustomEditorIds | WebviewIds,
		State,
		SerializedState = State,
		ShowingArgs extends unknown[] = unknown[],
	>(
		container: Container,
		commandRegistrar: WebviewCommandRegistrar,
		descriptor: GetWebviewDescriptor<ID>,
		instanceId: string,
		parent: GetWebviewParent<ID>,
		resolveProvider: (
			container: Container,
			host: WebviewHost<ID>,
		) => Promise<WebviewProvider<State, SerializedState, ShowingArgs>>,
	): Promise<WebviewController<ID, State, SerializedState, ShowingArgs>> {
		const controller = new WebviewController<ID, State, SerializedState, ShowingArgs>(
			container,
			commandRegistrar,
			descriptor,
			instanceId,
			parent,
			resolveProvider,
		);
		await controller.initialize();
		return controller;
	}

	private readonly _onDidDispose = new EventEmitter<void>();
	get onDidDispose(): Event<void> {
		return this._onDidDispose.event;
	}

	readonly id: ID;

	get type(): WebviewTypeFromId<ID> {
		return this.descriptor.type as WebviewTypeFromId<ID>;
	}

	private _ready: boolean = false;
	get ready(): boolean {
		return this._ready;
	}

	private _readyCount = 0;
	private _lastHtmlSetAt: number | undefined;

	/**
	 * Replay-buffer window for the post-bootstrap IPC log. We only need this around startup, since the iframe is only
	 * re-mounted under a live controller during VS Code's window-startup / panel-restoration / layout-settle phase.
	 *
	 * Two anchors gate the buffer:
	 *  1. Anchor 1 (per-controller, latched at construction): is this controller within the activation window?
	 *  2. Anchor 2 (per-controller, latched on first ready): once enabled, buffer for {@link replayWindowMs} then stop.
	 *
	 * A reconnect inside the window replays the log; a reconnect outside falls back to a full {@link refresh}.
	 */
	private static readonly activationWindowMs = 60_000;
	private static readonly replayWindowMs = 30_000;
	private readonly _replayEligible: boolean;
	private _replayEnabled = false;
	private _replayBuffer: IpcMessage[] = [];
	private _replayWindowTimer: ReturnType<typeof setTimeout> | undefined;

	/**
	 * Refresh coalescing: `refresh(force=true)` tears down the iframe and re-renders the HTML, which is
	 * destructive while the iframe is mid-render. Coalesce in-flight calls and drop rapid back-to-back
	 * calls within {@link refreshCoalesceMs} of the previous refresh completing.
	 */
	private static readonly refreshCoalesceMs = 1000;
	private _refreshing: Promise<void> | undefined;
	private _lastRefreshCompletedAt = 0;

	/** Used to cancel pending ipc promise operations */
	private cancellation: CancellationTokenSource | undefined;
	private disposable: Disposable | undefined;
	private _isInEditor: boolean;
	private /*readonly*/ provider!: WebviewProvider<State, SerializedState, ShowingArgs>;
	private _eventBuffer: EventVisibilityBuffer | undefined;
	private _rpcHost: RpcHost<object> | undefined;
	private _rpcExposed = false;
	private readonly webview: Webview;

	private _viewColumn: ViewColumn | undefined;
	get viewColumn(): ViewColumn | undefined {
		return this._viewColumn;
	}

	private constructor(
		private readonly container: Container,
		private readonly _commandRegistrar: WebviewCommandRegistrar,
		private readonly descriptor: GetWebviewDescriptor<ID>,
		public readonly instanceId: string,
		public readonly parent: GetWebviewParent<ID>,
		resolveProvider: (
			container: Container,
			host: WebviewHost<ID>,
		) => Promise<WebviewProvider<State, SerializedState, ShowingArgs>>,
	) {
		this.id = descriptor.id as ID;
		this.webview = parent.webview;

		const readyAt = container.readyAt;
		this._replayEligible = readyAt == null || Date.now() - readyAt < WebviewController.activationWindowMs;

		const isInEditor = 'onDidChangeViewState' in parent;
		this._isInEditor = isInEditor;
		this._viewColumn = isInEditor ? parent.viewColumn : undefined;
		this._originalTitle = descriptor.title;
		parent.title = descriptor.title;

		this._initializing = resolveProvider(container, this).then(provider => {
			this.provider = provider;
			if (this._disposed) {
				provider.dispose();
				return;
			}

			// Set up RPC services if the provider exposes them.
			// The RpcHost creates a Connection but defers expose() until the
			// webview sends WebviewReadyRequest (handled in onMessageReceivedCore).
			const eventBuffer = this.descriptor.webviewHostOptions?.retainContextWhenHidden
				? new EventVisibilityBuffer()
				: undefined;
			this._eventBuffer = eventBuffer;
			const tracker = new SubscriptionTracker();
			const rpcServices = this.provider.getRpcServices?.(eventBuffer, tracker);
			if (rpcServices != null) {
				try {
					this._rpcHost = new RpcHost(
						this.webview,
						rpcServices,
						{ webviewId: this.id, webviewInstanceId: this.instanceId },
						tracker,
					);
					Logger.debug(`WebviewController(${this.id}): RPC host created, awaiting connect`);
				} catch (ex) {
					Logger.error(ex, `WebviewController(${this.id}): Failed to create RPC host`);
				}
			}

			this.disposable = Disposable.from(
				this._onDidDispose,
				window.onDidChangeWindowState(this.onWindowStateChanged, this),
				parent.webview.onDidReceiveMessage(this.onMessageReceivedCore, this),
				isInEditor
					? parent.onDidChangeViewState(({ webviewPanel }) => {
							const { visible, active, viewColumn } = webviewPanel;
							// Only treat a viewColumn change as a forceReload-worthy "move" if the webview was
							// already alive (`_ready`). During panel restoration the first view-state event is
							// the panel settling into its restored column — not a user move — and forcing a
							// reload there tears down the just-created iframe mid-bootstrap, cancelling the
							// deferred-rows delivery and leaving the Graph stuck on its loading spinner.
							this.onParentVisibilityChanged(
								visible,
								active,
								this._ready && this.viewColumn != null && this.viewColumn !== viewColumn,
							);
							this._viewColumn = viewColumn;
						})
					: parent.onDidChangeVisibility(() => this.onParentVisibilityChanged(this.visible, this.active)),
				parent.onDidDispose(this.onParentDisposed, this),
				...(this.provider.registerCommands?.() ?? []),
				this.provider,
				...(this._rpcHost != null ? [this._rpcHost] : []),
			);
		});
	}

	private async removePlusFeatureOverride() {
		if (!this.descriptor.plusFeature) {
			return;
		}

		if (includesContextDelimitedString('gitlens:plus:disabled:view:overrides', this.descriptor.id)) {
			const action = 'Enable Pro Features';
			void window
				.showInformationMessage(
					`${this.descriptor.title} was closed as Pro features have been disabled.`,
					action,
				)
				.then(selection => {
					if (selection === action) {
						void executeCommand('gitlens.plus.restore');
					}
				});
		}

		return removeFromContextDelimitedString('gitlens:plus:disabled:view:overrides', [this.descriptor.id]);
	}

	private _disposed: boolean = false;
	dispose(): void {
		this._disposed = true;
		this.cancellation?.cancel();
		this.cancellation?.dispose();
		if (this._replayWindowTimer != null) {
			clearTimeout(this._replayWindowTimer);
			this._replayWindowTimer = undefined;
		}
		this._replayEnabled = false;
		this._replayBuffer.length = 0;
		resetContextKeys(this.descriptor.contextKeyPrefix);

		void this.removePlusFeatureOverride();

		this.provider?.onFocusChanged?.(false);
		this.provider?.onVisibilityChanged?.(false);

		this.sendTelemetryEvent(`${this.descriptor.type}/closed`, {});

		this._ready = false;

		this._onDidDispose.fire();
		this.disposable?.dispose();
	}

	registerWebviewCommand<T extends Partial<WebviewContext>>(
		command: GlWebviewCommands,
		callback: WebviewCommandCallback<T>,
	): Disposable {
		return this._commandRegistrar.registerCommand(this.provider, this.id, this.instanceId, command, callback);
	}

	registerWebviewCommandForId<T extends Partial<WebviewContext>>(
		webviewId: string,
		command: GlWebviewCommands,
		callback: WebviewCommandCallback<T>,
	): Disposable {
		return this._commandRegistrar.registerCommand(this.provider, webviewId, this.instanceId, command, callback);
	}

	private _initializing: Promise<void> | undefined;
	private async initialize() {
		if (this._initializing == null) return;

		await this._initializing;
		this._initializing = undefined;
	}

	private exposeRpc(): void {
		if (this._rpcExposed || this._rpcHost == null) return;

		this._rpcExposed = true;
		try {
			this._rpcHost.expose();
		} catch (ex) {
			Logger.error(ex, `WebviewController(${this.id}): Failed to expose RPC services`);
		}
	}

	getTelemetryContext(): WebviewTelemetryContext {
		return {
			'context.webview.id': this.id,
			'context.webview.type': this.descriptor.type,
			'context.webview.instanceId': this.instanceId,
			'context.webview.host': this.is('editor')
				? 'editor'
				: (this.descriptor as WebviewViewDescriptor).location === 'panel'
					? 'panel'
					: 'view',
		};
	}

	sendTelemetryEvent<T extends keyof TelemetryEvents>(
		name: T,
		...args: [keyof WebviewTelemetryEvents[T]] extends [never]
			? [data?: never, source?: Source]
			: [data: WebviewTelemetryEvents[T], source?: Source]
	): void {
		if (!this.container.telemetry.enabled) return;

		this.container.telemetry.sendEvent(
			name,
			{
				...this.getTelemetryContext(),
				...this.provider.getTelemetryContext?.(),
				...(args[0] as any),
			},
			args[1],
		);
	}

	is(
		type: 'editor',
	): this is WebviewController<ID & (WebviewPanelIds | CustomEditorIds), State, SerializedState, ShowingArgs>;
	is(type: 'view'): this is WebviewController<ID & WebviewViewIds, State, SerializedState, ShowingArgs>;
	is(
		type: 'editor' | 'view',
	): this is WebviewController<ID & (CustomEditorIds | WebviewIds), State, SerializedState, ShowingArgs> {
		return type === 'editor' ? this._isInEditor : !this._isInEditor;
	}

	get active(): boolean | undefined {
		if ('active' in this.parent) {
			return this._disposed ? false : this.parent.active;
		}
		return this._disposed ? false : undefined;
	}

	get badge(): ViewBadge | undefined {
		return 'badge' in this.parent ? this.parent.badge : undefined;
	}
	set badge(value: ViewBadge | undefined) {
		if ('badge' in this.parent) {
			this.parent.badge = value;
		} else {
			throw new Error("The 'badge' property not supported on Webview parent");
		}
	}

	private _description: string | undefined;
	get description(): string | undefined {
		if ('description' in this.parent) {
			return this.parent.description;
		}
		return this._description;
	}
	set description(value: string | undefined) {
		if ('description' in this.parent) {
			this.parent.description = value;
		}
		this._description = value;
	}

	private _originalTitle: string;
	get originalTitle(): string {
		return this._originalTitle;
	}

	get title(): string {
		return this.parent.title ?? this._originalTitle;
	}
	set title(value: string) {
		this.parent.title = value;
	}

	get visible(): boolean {
		return this._disposed ? false : this.parent.visible;
	}

	canReuseInstance(
		options?: WebviewShowOptions,
		...args: WebviewShowingArgs<ShowingArgs, SerializedState>
	): boolean | undefined {
		if (!this.is('editor')) return undefined;

		if (options?.column != null && options.column !== this.parent.viewColumn) return false;
		return this.provider.canReuseInstance?.(...args);
	}

	getSplitArgs(): WebviewShowingArgs<ShowingArgs, SerializedState> {
		if (this.is('view')) return [];

		return this.provider.getSplitArgs?.() ?? [];
	}

	@trace({ args: false })
	async show(
		loading: boolean,
		options?: WebviewShowOptions,
		...args: WebviewShowingArgs<ShowingArgs, SerializedState>
	): Promise<void> {
		options ??= {};

		using sw = new Stopwatch(`WebviewController.show(${this.id})`);

		let context;
		const result = await this.provider.onShowing?.(loading, options, ...args);
		if (result != null) {
			let show;
			[show, context] = result;
			if (show === false) {
				this.sendTelemetryEvent(`${this.descriptor.type}/showAborted`, {
					loading: loading,
					duration: sw.elapsed(),
				});
				return;
			}
		}

		if (loading) {
			this.cancellation ??= new CancellationTokenSource();
			try {
				const html = await this.getHtml(this.webview);
				Logger.info(
					`WebviewController(${this.id}|${this.instanceId}): webview.html set (reason=show:loading, length=${html.length})`,
				);
				this._lastHtmlSetAt = Date.now();
				this.webview.html = html;
			} catch (ex) {
				if (isCancellationError(ex)) {
					this.cancellation.cancel();
					return;
				}

				throw ex;
			}
		}

		if (this.is('editor')) {
			if (!loading) {
				this.parent.reveal(
					options.column ?? this.parent.viewColumn ?? this.descriptor.column ?? ViewColumn.Beside,
					options.preserveFocus ?? false,
				);
			}
		} else if (this.is('view')) {
			await executeCoreCommand(getViewFocusCommand(this.id), options);
			if (loading) {
				this.provider.onVisibilityChanged?.(true);
			}
		}

		setContextKeys(this.descriptor.contextKeyPrefix);

		this.sendTelemetryEvent(
			`${this.descriptor.type}/shown`,
			{
				loading: loading,
				duration: sw.elapsed(),
				...context,
			},
			options.source,
		);
	}

	get baseWebviewState(): WebviewState<ID> {
		return {
			webviewId: this.id,
			webviewInstanceId: this.instanceId,
			timestamp: Date.now(),
		};
	}

	private readonly _cspNonce = getNonce();
	get cspNonce(): string {
		return this._cspNonce;
	}

	asWebviewUri(uri: Uri): Uri {
		return this.webview.asWebviewUri(uri);
	}

	@trace()
	async refresh(force?: boolean): Promise<void> {
		// In-flight coalesce: piggyback on an existing refresh rather than tearing down the iframe twice.
		if (this._refreshing != null) return this._refreshing;

		// Post-refresh quiet window: drop rapid back-to-back invocations. Protects against a second
		// invocation (VS Code title-bar command double-dispatch, user double-click, or a late async
		// caller) firing shortly after the previous refresh completes and tearing down a mid-render iframe.
		if (Date.now() - this._lastRefreshCompletedAt < WebviewController.refreshCoalesceMs) {
			getScopedLogger()?.info(`refresh coalesced (within ${WebviewController.refreshCoalesceMs}ms of previous)`);
			return;
		}

		this._refreshing = this.refreshCore(force).finally(() => {
			this._lastRefreshCompletedAt = Date.now();
			this._refreshing = undefined;
		});
		return this._refreshing;
	}

	private async refreshCore(force?: boolean): Promise<void> {
		this.cancellation?.cancel();
		this.cancellation = new CancellationTokenSource();

		if (force) {
			this.clearPendingIpcNotifications();
			// Iframe is about to be torn down and re-mounted with a fresh bootstrap; any buffered messages were
			// addressed at the soon-to-be-dead iframe and would double-apply against the new iframe's fresh
			// bootstrap on a subsequent reconnect. Anchor 2's timer keeps running so post-refresh notifications
			// continue to be buffered.
			this._replayBuffer.length = 0;
		}
		this.provider.onRefresh?.(force);

		// Mark the webview as not ready, until we know if we are changing the html
		const wasReady = this._ready;
		const wasRpcExposed = this._rpcExposed;
		this._ready = false;
		this._rpcExposed = false;

		let html;
		try {
			html = await this.getHtml(this.webview);
		} catch (ex) {
			if (isCancellationError(ex)) {
				this.cancellation.cancel();
				return;
			}

			throw ex;
		}

		if (force) {
			// Reset the html to get the webview to reload
			Logger.info(
				`WebviewController(${this.id}|${this.instanceId}): webview.html set (reason=refresh:reset, length=0)`,
			);
			this._lastHtmlSetAt = Date.now();
			this.webview.html = '';
		}

		// If we aren't changing the html, mark the webview as ready again
		if (this.webview.html === html) {
			if (wasReady) {
				this._ready = true;
			}
			this._rpcExposed = wasRpcExposed;
			return;
		}

		Logger.info(
			`WebviewController(${this.id}|${this.instanceId}): webview.html set (reason=refresh:apply, length=${html.length})`,
		);
		this._lastHtmlSetAt = Date.now();
		this.webview.html = html;
	}

	@trace()
	private onParentDisposed() {
		this.dispose();
	}

	@trace({
		args: e => ({ e: e != null ? `${e.id}, method=${e.method}` : '<undefined>' }),
	})
	private async onMessageReceivedCore(e: IpcMessage) {
		if (e == null) return;
		// Skip RPC transport messages — these are handled by the Supertalk endpoint
		if (isRpcMessage(e)) return;

		const scope = getScopedLogger();
		scope?.addExitInfo(`ipc (webview -> host) duration=${Date.now() - e.timestamp}ms`);

		switch (true) {
			case WebviewReadyRequest.is(e): {
				this._readyCount++;
				const sinceLastHtmlSet = this._lastHtmlSetAt != null ? Date.now() - this._lastHtmlSetAt : -1;
				// A re-ready means the webview's iframe was reloaded under us (e.g., panel layout settle, window
				// reload restoring a serialized panel). The host's prior IPC pipe is severed; the new iframe loaded
				// a stale bootstrap (frozen at HTML-generation time) and won't re-fetch state on its own. If we're
				// still inside the replay window, replay the buffered post-bootstrap IPC log so the new iframe sees
				// the same state the dead one had. Outside the window — where reconnects are anomalous — fall back
				// to a full refresh, which is the honest answer at that point.
				const isReconnect = this._ready;
				const canSoftReconnect = isReconnect && this._replayEnabled;
				Logger.info(
					`WebviewController(${this.id}|${this.instanceId}): WebviewReadyRequest #${this._readyCount} (id=${e.id}, clientId=${e.params.clientId ?? '?'}, clientLoadedAt=${e.params.clientLoadedAt ?? '?'}, bootstrap=${e.params.bootstrap}, msgAge=${Date.now() - e.timestamp}ms, sinceLastHtmlSet=${sinceLastHtmlSet}ms, wasAlreadyReady=${this._ready}, replayEligible=${this._replayEligible}, replayEnabled=${this._replayEnabled}, replayBufferSize=${this._replayBuffer.length}, parentVisible=${this.parent.visible})`,
				);

				if (isReconnect && !canSoftReconnect) {
					Logger.info(
						`WebviewController(${this.id}|${this.instanceId}): reconnect outside replay window — forcing refresh`,
					);
					void this.refresh(true);
					break;
				}

				if (isReconnect) {
					this.cancellation?.cancel();
					this.cancellation = new CancellationTokenSource();
					this._rpcExposed = false;
					this.clearPendingIpcNotifications();
				}

				if (isReconnect) {
					Logger.info(
						`WebviewController(${this.id}|${this.instanceId}): soft reconnect (replaying ${this._replayBuffer.length} buffered messages)`,
					);
				}

				this._ready = true;
				this.exposeRpc();
				void this.respond(WebviewReadyRequest, e, {
					// Honor the iframe's `bootstrap` flag literally — including on reconnect. The new iframe's
					// HTML re-evaluates its `<script>window.bootstrap=…</script>` tag with the original T=0 payload,
					// and replay below brings it forward by re-delivering the post-bootstrap delta log. Sending a
					// fresh `includeBootstrap()` on reconnect would override the original starting point and let
					// the replayed deltas double-apply on top of an already-current snapshot. The only iframes
					// that ask for bootstrap on the re-ready are deferred-bootstrap providers — for those we do
					// return the resolved bootstrap because they explicitly await it.
					state: e.params.bootstrap ? this.provider.includeBootstrap?.(false) : undefined,
				});
				// Start the replay window BEFORE flushing pending so any pending IpcMessages flushed below land in
				// the replay buffer. Deferred-bootstrap providers (e.g. Graph's deferRows path) push the initial
				// rows notification BEFORE first-ready; without this ordering it would never make it into the buffer
				// and a subsequent panel-layout-settle reconnect would replay state minus the rows — empty graph.
				if (!isReconnect) {
					this.startReplayWindow();
				}
				this.sendPendingIpcNotifications();
				if (isReconnect) {
					this.replayBufferedMessages();
					void this.provider.onReconnect?.();
				} else {
					void this.provider.onReady?.();
				}

				break;
			}

			case WebviewFocusChangedCommand.is(e):
				this.onViewFocusChanged(e.params);

				break;

			case ExecuteCommand.is(e):
				if (e.params.args != null) {
					void executeCommand(e.params.command, ...e.params.args);
				} else {
					void executeCommand(e.params.command);
				}
				break;

			case ApplicablePromoRequest.is(e): {
				const subscription = await this.container.subscription.getSubscription();
				const promo = await this.container.productConfig.getApplicablePromo(
					subscription.state,
					e.params.plan ?? getSubscriptionNextPaidPlanId(subscription),
					e.params.location,
				);
				void this.respond(ApplicablePromoRequest, e, { promo: promo });
				break;
			}
			case TelemetrySendEventCommand.is(e):
				this.sendTelemetryEvent(
					e.params.name,
					e.params.data != null ? { ...(e.params.data as any) } : undefined,
					e.params.source,
				);
				break;

			default:
				// Try @ipc decorated handlers first, then fall back to onMessageReceived
				if (!(await dispatchIpcMessage(this, this.provider, e))) {
					this.provider.onMessageReceived?.(e);
				}
				break;
		}
	}

	@trace({
		args: e => ({ e: `focused=${e.focused}, inputFocused=${e.inputFocused}` }),
	})
	onViewFocusChanged(e: WebviewFocusChangedParams): void {
		setContextKeys(this.descriptor.contextKeyPrefix);
		this.handleFocusChanged(e.focused);
	}

	@trace()
	private onParentVisibilityChanged(visible: boolean, active?: boolean, forceReload?: boolean) {
		if (this.descriptor.webviewHostOptions?.retainContextWhenHidden !== true) {
			if (visible) {
				if (this._ready) {
					this.sendPendingIpcNotifications();
				} else if (this.provider.onReloaded != null) {
					this.clearPendingIpcNotifications();
					this.provider.onReloaded();
				} else {
					void this.refresh();
				}
			} else {
				this._ready = false;
				this._rpcExposed = false;
			}
		} else if (forceReload) {
			void this.refresh();
		}

		if (visible) {
			void this.container.usage.track(`${this.descriptor.trackingFeature}:shown`).catch();

			setContextKeys(this.descriptor.contextKeyPrefix);
			if (active != null) {
				this.provider.onActiveChanged?.(active);
				if (!active) {
					this.handleFocusChanged(false);

					void this.removePlusFeatureOverride();
				}
			}
		} else {
			resetContextKeys(this.descriptor.contextKeyPrefix);

			void this.removePlusFeatureOverride();

			if (active != null) {
				this.provider.onActiveChanged?.(false);
			}
			this.handleFocusChanged(false);
		}

		void this.notify(DidChangeWebviewVisibilityNotification, { visible: visible });
		this._eventBuffer?.setVisible(visible);
		this._rpcHost?.setVisible(visible);
		this.provider.onVisibilityChanged?.(visible);
	}

	private onWindowStateChanged(e: WindowState) {
		if (!this.visible) return;

		void this.notify(DidChangeHostWindowFocusNotification, { focused: e.focused });
		this.provider.onWindowFocusChanged?.(e.focused);
	}

	private handleFocusChanged(focused: boolean) {
		void this.notify(DidChangeWebviewFocusNotification, { focused: focused });
		this.provider.onFocusChanged?.(focused);
	}

	getRootUri(): Uri {
		return this.container.context.extensionUri;
	}

	private _webRoot: string | undefined;
	getWebRoot(): string {
		this._webRoot ??= this.asWebviewUri(this.getWebRootUri()).toString();
		return this._webRoot;
	}

	private _webRootUri: Uri | undefined;
	getWebRootUri(): Uri {
		this._webRootUri ??= Uri.joinPath(this.getRootUri(), 'dist', 'webviews');
		return this._webRootUri;
	}

	@trace({ args: false })
	private async getHtml(webview: Webview): Promise<string> {
		const scope = getScopedLogger();

		const webRootUri = this.getWebRootUri();
		const uri = Uri.joinPath(webRootUri, this.descriptor.fileName);

		const [bytes, bootstrap, head, body, endOfBody] = await Promise.all([
			workspace.fs.readFile(uri),
			this.provider.includeBootstrap?.(true),
			this.provider.includeHead?.(),
			this.provider.includeBody?.(),
			this.provider.includeEndOfBody?.(),
		]);

		const sw = maybeStopWatch(scope, { log: { onlyExit: true, level: 'debug' } });
		const serialized = this.serializeIpcData(bootstrap);
		sw?.stop({ message: `\u2022 serialized bootstrap; length=${serialized.length}` });

		const html = replaceWebviewHtmlTokens(
			strFromU8(bytes),
			this.id,
			this.instanceId,
			webview.cspSource,
			this._cspNonce,
			this.asWebviewUri(this.getRootUri()).toString(),
			this.getWebRoot(),
			this.is('editor')
				? 'editor'
				: (this.descriptor as WebviewViewDescriptor).location === 'panel'
					? 'panel'
					: 'view',
			serialized,
			head,
			body,
			endOfBody,
		);
		return html;
	}

	nextIpcId(): string {
		return `host:${ipcSequencer.next()}`;
	}

	async notify<T extends IpcNotification<unknown>>(
		notificationType: T,
		params: IpcCallParamsType<T>,
		completionId?: string,
	): Promise<boolean> {
		const id = this.nextIpcId();
		const timestamp = Date.now();

		using scope = maybeStartScopedLogger(`${getLoggableName(this)}.notify(${id}|${notificationType.method})`);
		const sw = maybeStopWatch(scope, { log: { onlyExit: true, level: 'debug' } });

		const serializedParams = params != null ? this.serializeIpcData(params) : undefined;

		sw?.restart({ message: `\u2022 serialized params; length=${serializedParams?.length ?? 0}` });

		let bytes: Uint8Array | undefined;
		let compression: IpcMessage['compressed'] = false;
		if (serializedParams != null && serializedParams.length > 1024) {
			bytes = strToU8(serializedParams);
			compression = 'utf8';

			const originalSize = bytes.byteLength;

			try {
				bytes = deflateSync(bytes, { level: 1 });
				compression = 'deflate';

				sw?.stop({
					message: `\u2022 compressed (${compression}) serialized params (${Math.round((1 - bytes.byteLength / originalSize) * 100)}% reduction) ${originalSize} → ${bytes.byteLength} bytes`,
				});
			} catch (ex) {
				debugger;
				// Compression failed, keep uncompressed data
				scope?.error(ex, 'IPC deflate compression failed');
				sw?.stop({
					message: `\u2022 failed deflate compression, using uncompressed data`,
					suffix: `failed`,
				});
			}
		}

		const msg: IpcMessage<IpcCallParamsType<T> | Uint8Array> = {
			id: id,
			scope: notificationType.scope,
			method: notificationType.method,
			params: bytes ?? serializedParams,
			compressed: compression,
			timestamp: timestamp,
			completionId: completionId,
		};

		const success = await this.postMessage(msg);
		if (success) {
			this._pendingIpcNotifications.clear();
			// While the replay window is open, append every successfully delivered message to the log in send order
			// so that on reconnect we can replay the exact sequence the previous iframe saw — including
			// IpcPromiseSettled and request responses, so embedded IpcPromise placeholders in earlier messages
			// resolve correctly on the new iframe. WebviewReadyRequest's own response is regenerated per reconnect
			// (with a fresh bootstrap) and would be ID-mismatched on replay, so skip it. Honor reset semantics:
			// a reset-type notification supersedes all prior state, so prune the log before storing.
			if (this._replayEnabled && notificationType !== WebviewReadyRequest.response) {
				if (notificationType.reset) {
					this._replayBuffer.length = 0;
				}
				this._replayBuffer.push(msg);
			}
		} else if (notificationType === IpcPromiseSettled) {
			this._pendingIpcPromiseNotifications.add({ msg: msg, timestamp: Date.now() });
		} else {
			this.addPendingIpcNotificationCore(notificationType, msg);
		}
		return success;
	}

	respond<T extends IpcRequest<unknown, unknown>>(
		requestType: T,
		msg: IpcCallMessageType<T>,
		params: IpcCallResponseParamsType<T>,
	): Promise<boolean> {
		return this.notify(requestType.response, params, msg.completionId);
	}

	private serializeIpcData(data: unknown): string {
		const pendingPromises: IpcPromise[] = [];
		const serialized = serializeIpcData(data, () => this.nextIpcId(), pendingPromises);

		if (pendingPromises.length) {
			const cancellation = this.cancellation?.token;
			queueMicrotask(() => {
				for (const ipcPromise of pendingPromises) {
					ipcPromise.__promise.then(
						r => {
							if (cancellation?.isCancellationRequested) {
								debugger;
								return;
							}
							return this.notify(
								IpcPromiseSettled,
								{ status: 'fulfilled', value: r },
								ipcPromise.value.id,
							);
						},
						(ex: unknown) => {
							if (cancellation?.isCancellationRequested) {
								debugger;
								return;
							}
							return this.notify(
								IpcPromiseSettled,
								{ status: 'rejected', reason: ex },
								ipcPromise.value.id,
							);
						},
					);
				}
			});
		}

		return serialized;
	}

	@sequentialize({ getDedupingKey: (message: IpcMessage) => message.id })
	@trace({
		args: message => ({
			message: `${message.id}|${message.method}${message.completionId ? `+${message.completionId}` : ''}`,
		}),
	})
	private async postMessage(message: IpcMessage): Promise<boolean> {
		if (!this._ready) return Promise.resolve(false);

		const scope = getScopedLogger();
		let timeout: ReturnType<typeof setTimeout> | undefined;

		// It looks like there is a bug where `postMessage` can sometimes just hang infinitely. Not sure why, but ensure we don't hang forever
		const promise = Promise.race<boolean>([
			this.webview.postMessage(message).then(
				s => {
					clearTimeout(timeout);
					return s;
				},
				(ex: unknown) => {
					clearTimeout(timeout);
					scope?.error(ex);
					debugger;
					return false;
				},
			),
			new Promise<boolean>(resolve => {
				timeout = setTimeout(() => {
					debugger;
					scope?.setFailed('TIMEDOUT');
					resolve(false);
				}, 30000);
			}),
		]);

		let success;

		if (this.is('view')) {
			// If we are in a view, show progress if we are waiting too long
			const result = await pauseOnCancelOrTimeout(promise, undefined, 100);
			if (result.paused) {
				success = await window.withProgress({ location: { viewId: this.id } }, () => result.value);
			} else {
				success = result.value;
			}
		} else {
			success = await promise;
		}

		return success;
	}

	private _pendingIpcNotifications = new Map<
		IpcNotification,
		{ msg: IpcMessage | (() => Promise<boolean | void>); timestamp: number }
	>();
	private _pendingIpcPromiseNotifications = new Set<{ msg: IpcMessage; timestamp: number }>();

	addPendingIpcNotification(
		type: IpcNotification<any>,
		mapping: Map<IpcNotification<any>, () => Promise<boolean | void>>,
		thisArg: any,
	): void {
		this.addPendingIpcNotificationCore(type, mapping.get(type)?.bind(thisArg));
	}

	private addPendingIpcNotificationCore(
		type: IpcNotification<any>,
		msgOrFn: IpcMessage | (() => Promise<boolean | void>) | undefined,
	) {
		if (type.reset) {
			this._pendingIpcNotifications.clear();
		}

		if (msgOrFn == null) {
			debugger;
			return;
		}

		this._pendingIpcNotifications.set(type, { msg: msgOrFn, timestamp: Date.now() });
	}

	clearPendingIpcNotifications(): boolean {
		const hadPending = this._pendingIpcNotifications.size > 0;
		this._pendingIpcNotifications.clear();
		return hadPending;
	}

	sendPendingIpcNotifications(): void {
		if (
			!this._ready ||
			(this._pendingIpcNotifications.size === 0 && this._pendingIpcPromiseNotifications.size === 0)
		) {
			return;
		}

		// Preserve the IpcNotification type alongside each pending entry so we can decide buffer eligibility
		// below. Promise-settled entries carry no type — they're addressed at specific call ids and aren't
		// independently replayable.
		const typedEntries = Array.from(this._pendingIpcNotifications.entries(), ([type, value]) => ({
			type: type,
			...value,
		}));
		const untyped = Array.from(this._pendingIpcPromiseNotifications.values(), value => ({
			type: undefined,
			...value,
		}));
		const ipcs = [...typedEntries, ...untyped].sort((a, b) => a.timestamp - b.timestamp);
		this._pendingIpcNotifications.clear();
		this._pendingIpcPromiseNotifications.clear();

		for (const { type, msg } of ipcs) {
			if (typeof msg === 'function') {
				void msg();
				continue;
			}

			void this.postMessage(msg);
			// Mirror notify()'s success-path buffer push for pending IpcMessages that just got flushed —
			// otherwise pre-first-ready notifications (e.g. Graph's deferred-rows microtask firing during
			// HTML generation) never enter the replay buffer and a panel-layout-settle reconnect would
			// deliver bootstrap without them.
			if (
				this._replayEnabled &&
				type != null &&
				(type as IpcNotification<unknown>) !== WebviewReadyRequest.response
			) {
				if (type.reset) {
					this._replayBuffer.length = 0;
				}
				this._replayBuffer.push(msg);
			}
		}
	}

	/**
	 * Replays the buffered post-bootstrap IPC log to the webview, in original send order. Called on reconnect
	 * inside the replay window so the freshly-mounted iframe converges to the same state the previous one had,
	 * without requiring per-provider reconnect logic.
	 */
	private replayBufferedMessages(): void {
		for (const msg of this._replayBuffer) {
			void this.postMessage(msg);
		}
	}

	/**
	 * Starts the replay-buffer window on first ready (subject to anchor 1: `_replayEligible`). After
	 * {@link replayWindowMs} elapses, buffering is disabled and the log is dropped — reconnects past this
	 * point fall back to a full refresh in the WebviewReadyRequest handler.
	 */
	private startReplayWindow(): void {
		if (!this._replayEligible || this._replayWindowTimer != null) return;

		this._replayEnabled = true;
		Logger.info(
			`WebviewController(${this.id}|${this.instanceId}): replay window opened (${WebviewController.replayWindowMs}ms)`,
		);
		this._replayWindowTimer = setTimeout(() => {
			const bufferedCount = this._replayBuffer.length;
			this._replayEnabled = false;
			this._replayBuffer.length = 0;
			this._replayWindowTimer = undefined;
			Logger.info(
				`WebviewController(${this.id}|${this.instanceId}): replay window closed; discarded ${bufferedCount} buffered messages`,
			);
		}, WebviewController.replayWindowMs);
	}

	async maximize(): Promise<void> {
		if (this.provider && 'maximize' in this.provider && typeof this.provider.maximize === 'function') {
			await this.provider.maximize();
		}
	}
}

const htmlTokensRegex =
	/#{(head|body|endOfBody|webviewId|webviewInstanceId|placement|cspSource|cspNonce|root|webroot|state)}/g;

export function replaceWebviewHtmlTokens<SerializedState>(
	html: string,
	webviewId: string,
	webviewInstanceId: string | undefined,
	cspSource: string,
	cspNonce: string,
	root: string,
	webRoot: string,
	placement: 'editor' | 'view' | 'panel',
	bootstrap?: SerializedState | string,
	head?: string,
	body?: string,
	endOfBody?: string,
): string {
	return html.replace(htmlTokensRegex, (_substring: string, token: string) => {
		switch (token) {
			case 'head':
				return head ?? '';
			case 'body':
				return body ?? '';
			case 'state':
				return bootstrap != null
					? base64(typeof bootstrap === 'string' ? bootstrap : JSON.stringify(bootstrap))
					: '';
			case 'endOfBody':
				return `${
					bootstrap != null
						? `<script type="text/javascript" nonce="${cspNonce}">window.bootstrap=${
								typeof bootstrap === 'string' ? bootstrap : JSON.stringify(bootstrap)
							};</script>`
						: ''
				}${endOfBody ?? ''}`;
			case 'webviewId':
				return webviewId;
			case 'webviewInstanceId':
				return webviewInstanceId ?? '';
			case 'placement':
				return placement;
			case 'cspSource':
				return cspSource;
			case 'cspNonce':
				return cspNonce;
			case 'root':
				return root;
			case 'webroot':
				return webRoot;
			default:
				return '';
		}
	});
}

export function resetContextKeys(
	contextKeyPrefix:
		| `gitlens:webview:${WebviewPanelTypes | CustomEditorTypes}`
		| `gitlens:webviewView:${WebviewViewTypes}`,
): void {
	void setContext(`${contextKeyPrefix}:visible`, false);
}

export function setContextKeys(
	contextKeyPrefix:
		| `gitlens:webview:${WebviewPanelTypes | CustomEditorTypes}`
		| `gitlens:webviewView:${WebviewViewTypes}`,
): void {
	void setContext(`${contextKeyPrefix}:visible`, true);
}
