import type { CancellationToken, WebviewPanel, WebviewView, WebviewViewResolveContext } from 'vscode';
import { Disposable, Uri, ViewColumn, window } from 'vscode';
import { uuid } from '@gitlens/utils/crypto.js';
import { trace } from '@gitlens/utils/decorators/log.js';
import { find, first, map } from '@gitlens/utils/iterable.js';
import { Logger } from '@gitlens/utils/logger.js';
import { maybeStartScopedLogger } from '@gitlens/utils/logger.scoped.js';
import type { GlCommands } from '../constants.commands.js';
import type { Source } from '../constants.telemetry.js';
import type { WebviewPanelIds, WebviewViewIds } from '../constants.views.js';
import type { Container } from '../container.js';
import { executeCoreCommand, registerCommand } from '../system/-webview/command.js';
import { addToContextDelimitedString, getContext } from '../system/-webview/context.js';
import { getViewFocusCommand } from '../system/-webview/vscode/views.js';
import type { WebviewCommandRegistrar } from './webviewCommandRegistrar.js';
import { WebviewController } from './webviewController.js';
import type { WebviewPanelDescriptor, WebviewViewDescriptor } from './webviewDescriptors.js';
import type { WebviewHost, WebviewProvider, WebviewShowingArgs } from './webviewProvider.js';

interface WebviewPanelRegistration<
	ID extends WebviewPanelIds,
	State,
	SerializedState = State,
	ShowingArgs extends unknown[] = unknown[],
> {
	readonly descriptor: WebviewPanelDescriptor<ID>;
	controllers?: Map<string | undefined, WebviewController<ID, State, SerializedState, ShowingArgs>> | undefined;
}

export interface WebviewPanelProxy<
	ID extends WebviewPanelIds,
	ShowingArgs extends unknown[] = unknown[],
	SerializedState = unknown,
> extends Disposable {
	readonly id: ID;
	readonly instanceId: string;
	readonly ready: boolean;
	readonly active: boolean;
	readonly visible: boolean;
	canReuseInstance(
		options?: WebviewPanelShowOptions,
		...args: WebviewShowingArgs<ShowingArgs, SerializedState>
	): boolean | undefined;
	close(): void;
	refresh(force?: boolean): Promise<void>;
	maximize(): Promise<void>;
	show(options?: WebviewPanelShowOptions, ...args: WebviewShowingArgs<ShowingArgs, SerializedState>): Promise<void>;
}

export interface WebviewPanelsProxy<
	ID extends WebviewPanelIds,
	ShowingArgs extends unknown[] = unknown[],
	SerializedState = unknown,
> extends Disposable {
	readonly id: ID;
	readonly instances: Iterable<WebviewPanelProxy<ID, ShowingArgs, SerializedState>>;
	getActiveInstance(): WebviewPanelProxy<ID, ShowingArgs, SerializedState> | undefined;
	getBestInstance(
		options?: WebviewPanelShowOptions,
		...args: WebviewShowingArgs<ShowingArgs, SerializedState>
	): WebviewPanelProxy<ID, ShowingArgs, SerializedState> | undefined;
	show(options?: WebviewPanelsShowOptions, ...args: WebviewShowingArgs<ShowingArgs, SerializedState>): Promise<void>;
	splitActiveInstance(options?: WebviewPanelsShowOptions): Promise<void>;
}

interface WebviewViewRegistration<
	ID extends WebviewViewIds,
	State,
	SerializedState = State,
	ShowingArgs extends unknown[] = unknown[],
> {
	readonly descriptor: WebviewViewDescriptor<ID>;
	controller?: WebviewController<ID, State, SerializedState, ShowingArgs>;
	pendingShowArgs?:
		| [WebviewViewShowOptions | undefined, WebviewShowingArgs<ShowingArgs, SerializedState>]
		| undefined;
}

export interface WebviewViewProxy<
	ID extends WebviewViewIds,
	ShowingArgs extends unknown[],
	SerializedState = unknown,
> extends Disposable {
	readonly id: ID;
	readonly ready: boolean;
	readonly visible: boolean;
	refresh(force?: boolean): Promise<void>;
	show(options?: WebviewViewShowOptions, ...args: WebviewShowingArgs<ShowingArgs, SerializedState>): Promise<void>;
}

export class WebviewsController implements Disposable {
	private readonly disposables: Disposable[] = [];
	private readonly _panels = new Map<string, WebviewPanelRegistration<WebviewPanelIds, any>>();
	private readonly _views = new Map<string, WebviewViewRegistration<WebviewViewIds, any>>();

	constructor(
		private readonly container: Container,
		private readonly commandRegistrar: WebviewCommandRegistrar,
	) {}

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

	@trace({
		args: descriptor => ({ descriptor: descriptor.id }),
		onlyExit: true,
	})
	registerWebviewView<
		ID extends WebviewViewIds,
		State,
		SerializedState = State,
		ShowingArgs extends unknown[] = unknown[],
	>(
		descriptor: WebviewViewDescriptor<ID>,
		resolveProvider: (
			container: Container,
			host: WebviewHost<ID>,
		) => Promise<WebviewProvider<State, SerializedState, ShowingArgs>>,
		onBeforeShow?: (...args: WebviewShowingArgs<ShowingArgs, SerializedState>) => void | Promise<void>,
	): WebviewViewProxy<ID, ShowingArgs, SerializedState> {
		using scope = maybeStartScopedLogger(`WebviewView(${descriptor.id})`);

		const registration: WebviewViewRegistration<ID, State, SerializedState, ShowingArgs> = {
			descriptor: descriptor,
		};
		this._views.set(descriptor.id, registration);

		const disposables: Disposable[] = [];
		disposables.push(
			window.registerWebviewViewProvider(
				descriptor.id,
				{
					resolveWebviewView: async (
						webviewView: WebviewView,
						context: WebviewViewResolveContext<SerializedState>,
						token: CancellationToken,
					) => {
						if (token.isCancellationRequested) return;

						const instanceId = uuid();

						scope?.trace(`Resolving view (${instanceId})`);

						Logger.info(
							`WebviewsController.resolveWebviewView(${descriptor.id}|${instanceId}): hasRestoreState=${isSerializedState<State>(context)}, parentVisible=${webviewView.visible}`,
						);

						webviewView.webview.options = {
							enableCommandUris: true,
							enableScripts: true,
							localResourceRoots: [Uri.file(this.container.context.extensionPath)],
							...descriptor.webviewOptions,
						};

						webviewView.title = descriptor.title;

						const controller = await WebviewController.create<ID, State, SerializedState, ShowingArgs>(
							this.container,
							this.commandRegistrar,
							descriptor,
							instanceId,
							webviewView,
							resolveProvider,
						);

						registration.controller?.dispose();
						registration.controller = controller;

						disposables.push(
							controller.onDidDispose(() => {
								scope?.trace(`Disposing view (${instanceId})`);

								registration.pendingShowArgs = undefined;
								registration.controller = undefined;
							}),
							controller,
						);

						let [options, args] = registration.pendingShowArgs ?? [];
						registration.pendingShowArgs = undefined;
						if (args == null && isSerializedState<State>(context)) {
							args = [{ state: context.state }];
						}

						scope?.trace(`Showing view (${instanceId})`);
						try {
							await controller.show(true, options, ...(args ?? []));
						} catch (ex) {
							scope?.error(ex, `Failed to show view (${instanceId})`);
						}
					},
				},
				descriptor.webviewHostOptions != null ? { webviewOptions: descriptor.webviewHostOptions } : undefined,
			),
		);

		const disposable = Disposable.from(...disposables);
		this.disposables.push(disposable);
		return {
			id: descriptor.id,
			get ready() {
				return registration.controller?.ready ?? false;
			},
			get visible() {
				return registration.controller?.visible ?? false;
			},
			dispose: function () {
				disposable.dispose();
			},
			refresh: function (force?: boolean) {
				return registration.controller != null ? registration.controller.refresh(force) : Promise.resolve();
			},
			show: async function (
				options?: WebviewViewShowOptions,
				...args: WebviewShowingArgs<ShowingArgs, SerializedState>
			) {
				scope?.trace('Showing view');

				if (registration.controller != null) {
					return registration.controller.show(false, options, ...args);
				}

				registration.pendingShowArgs = [options, args];

				if (onBeforeShow != null) {
					await onBeforeShow?.(...args);
				}

				if (descriptor.plusFeature && getContext('gitlens:plus:disabled') === true) {
					await addToContextDelimitedString('gitlens:plus:disabled:view:overrides', [descriptor.id]);
				}

				return void executeCoreCommand(getViewFocusCommand(descriptor.id), options);
			},
		} satisfies WebviewViewProxy<ID, ShowingArgs, SerializedState>;
	}

	@trace({
		args: (command, descriptor) => ({ command: command.id, descriptor: descriptor.id }),
		onlyExit: true,
	})
	registerWebviewPanel<
		ID extends WebviewPanelIds,
		State,
		SerializedState = State,
		ShowingArgs extends unknown[] = unknown[],
	>(
		command: {
			id: GlCommands;
			options?: WebviewPanelsShowOptions;
		},
		descriptor: WebviewPanelDescriptor<ID>,
		resolveProvider: (
			container: Container,
			host: WebviewHost<ID>,
		) => Promise<WebviewProvider<State, SerializedState, ShowingArgs>>,
	): WebviewPanelsProxy<ID, ShowingArgs, SerializedState> {
		using scope = maybeStartScopedLogger(`WebviewPanel(${descriptor.id})`);

		const registration: WebviewPanelRegistration<ID, State, SerializedState, ShowingArgs> = {
			descriptor: descriptor,
		};
		this._panels.set(descriptor.id, registration);

		const disposables: Disposable[] = [];
		const { container, commandRegistrar } = this;

		let serializedPanel: WebviewPanel | undefined;

		async function show(
			options?: WebviewPanelsShowOptions,
			...args: WebviewShowingArgs<ShowingArgs, SerializedState>
		): Promise<void> {
			const { descriptor } = registration;
			void container.usage.track(`${descriptor.trackingFeature}:shown`).catch();

			let column = options?.column ?? descriptor.column ?? ViewColumn.Beside;
			// Only try to open beside if there is an active tab
			if (column === ViewColumn.Beside && window.tabGroups.activeTabGroup.activeTab == null) {
				column = ViewColumn.Active;
			}

			let controller = getBestController(registration, options, ...args);
			if (controller == null) {
				let panel: WebviewPanel;
				if (serializedPanel != null) {
					scope?.trace('Restoring panel');

					panel = serializedPanel;
					serializedPanel = undefined;
				} else {
					scope?.trace('Creating panel');

					panel = window.createWebviewPanel(
						descriptor.id,
						descriptor.title,
						{ viewColumn: column, preserveFocus: options?.preserveFocus ?? false },
						{
							enableCommandUris: true,
							enableScripts: true,
							localResourceRoots: [Uri.file(container.context.extensionPath)],
							...descriptor.webviewOptions,
							...descriptor.webviewHostOptions,
						},
					);
				}

				panel.iconPath = Uri.file(container.context.asAbsolutePath(descriptor.iconPath));

				controller = await WebviewController.create<ID, State, SerializedState, ShowingArgs>(
					container,
					commandRegistrar,
					descriptor,
					uuid(),
					panel,
					resolveProvider,
				);

				registration.controllers ??= new Map();
				registration.controllers.set(controller.instanceId, controller);

				disposables.push(
					controller.onDidDispose(() => {
						scope?.trace(`Disposing panel (${controller!.instanceId})`);

						registration.controllers?.delete(controller!.instanceId);
					}),
					controller,
				);

				scope?.trace(`Showing panel (${controller.instanceId})`);
				try {
					await controller.show(true, options, ...args);
				} catch (ex) {
					scope?.error(ex, `Failed to show panel (${controller.instanceId})`);
				}
			} else {
				scope?.trace(`Showing existing panel (${controller.instanceId})`);
				try {
					await controller.show(false, options, ...args);
				} catch (ex) {
					scope?.error(ex, `Failed to show existing panel (${controller.instanceId})`);
				}
			}
		}

		async function deserializeWebviewPanel(panel: WebviewPanel, state: SerializedState) {
			// TODO@eamodio: We are currently storing nothing or way too much in serialized state. We should start storing maybe both "client" and "server" state
			// Where as right now our webviews are only saving "client" state, e.g. the entire state sent to the webview, rather than key pieces of state
			// We probably need to separate state into actual "state" and all the data that is sent to the webview, e.g. for the Graph state might be the selected repo, selected sha, etc vs the entire data set to render the Graph
			serializedPanel = panel;
			scope?.trace(`Deserializing panel state=${state != null ? '<state>' : 'undefined'}`);
			await show(
				{ column: panel.viewColumn, preserveFocus: true, preserveInstance: false },
				...(state != null ? [{ state: state }] : []),
			);
		}

		const disposable = Disposable.from(
			...disposables,
			window.registerWebviewPanelSerializer(descriptor.id, {
				deserializeWebviewPanel: deserializeWebviewPanel,
			}),
			registerCommand(
				command.id,
				(opts: WebviewPanelShowOptions | undefined, ...args: unknown[]) => {
					return show({ ...command.options, ...opts }, ...(args as ShowingArgs));
				},
				this,
			),
		);
		this.disposables.push(disposable);
		return {
			id: descriptor.id,
			get instances() {
				if (!registration.controllers?.size) return [];

				return map(registration.controllers.values(), c => convertToWebviewPanelProxy(c));
			},
			getActiveInstance: function () {
				if (!registration.controllers?.size) return undefined;

				const controller = find(registration.controllers.values(), c => c.active ?? false);
				return controller != null ? convertToWebviewPanelProxy(controller) : undefined;
			},
			getBestInstance: function (
				options?: WebviewPanelShowOptions,
				...args: WebviewShowingArgs<ShowingArgs, SerializedState>
			) {
				const controller = getBestController(registration, options, ...args);
				return controller != null ? convertToWebviewPanelProxy(controller) : undefined;
			},
			splitActiveInstance: function (options?: WebviewPanelsShowOptions) {
				const controller =
					registration.controllers != null
						? find(registration.controllers.values(), c => c.active ?? false)
						: undefined;
				const args = controller?.getSplitArgs() ?? [];
				return show({ ...options, preserveInstance: false }, ...args);
			},
			dispose: function () {
				disposable.dispose();
			},
			show: show,
		} satisfies WebviewPanelsProxy<ID, ShowingArgs, SerializedState>;
	}
}

export interface WebviewPanelShowOptions {
	column?: ViewColumn;
	preserveFocus?: boolean;
	preserveVisibility?: boolean;
	source?: Source;
}

interface WebviewPanelsShowOptions extends WebviewPanelShowOptions {
	preserveInstance?: string | boolean;
}

export type WebviewPanelShowCommandArgs<ShowingArgs extends unknown[] = unknown[]> = [
	WebviewPanelsShowOptions | undefined,
	...args: ShowingArgs,
];

export interface WebviewViewShowOptions {
	column?: never;
	preserveFocus?: boolean;
	preserveVisibility?: boolean;
	source?: Source;
}

export type WebviewShowOptions = WebviewPanelShowOptions | WebviewViewShowOptions;

function convertToWebviewPanelProxy<
	ID extends WebviewPanelIds,
	State,
	SerializedState,
	ShowingArgs extends unknown[] = unknown[],
>(
	controller: WebviewController<ID, State, SerializedState, ShowingArgs>,
): WebviewPanelProxy<ID, ShowingArgs, SerializedState> {
	return {
		id: controller.id,
		instanceId: controller.instanceId,
		ready: controller.ready,
		active: controller.active ?? false,
		visible: controller.visible,
		canReuseInstance: function (
			options?: WebviewPanelShowOptions,
			...args: WebviewShowingArgs<ShowingArgs, SerializedState>
		) {
			return controller.canReuseInstance(options, ...args);
		},
		close: function () {
			controller.parent.dispose();
		},
		dispose: function () {
			controller.dispose();
		},
		refresh: function (force?: boolean) {
			return controller.refresh(force);
		},
		show: function (options?: WebviewPanelShowOptions, ...args: WebviewShowingArgs<ShowingArgs, SerializedState>) {
			return controller.show(false, options, ...args);
		},
		maximize: function () {
			return controller.maximize();
		},
	};
}

function getBestController<ID extends WebviewPanelIds, State, SerializedState, ShowingArgs extends unknown[]>(
	registration: WebviewPanelRegistration<ID, State, SerializedState, ShowingArgs>,
	options: WebviewPanelsShowOptions | undefined,
	...args: WebviewShowingArgs<ShowingArgs, SerializedState>
) {
	let controller;
	if (registration.controllers?.size) {
		if (registration.descriptor.allowMultipleInstances) {
			if (options?.preserveInstance !== false) {
				if (options?.preserveInstance != null && typeof options.preserveInstance === 'string') {
					controller = registration.controllers.get(options.preserveInstance);
				}

				if (controller == null) {
					let active;
					let first;

					// Sort active controllers first
					const sortedControllers = [...registration.controllers.values()].sort(
						(a, b) => (a.active ? -1 : 1) - (b.active ? -1 : 1),
					);

					for (const c of sortedControllers) {
						first ??= c;
						if (c.active) {
							active = c;
						}

						const canReuse = c.canReuseInstance(options, ...args);
						if (canReuse === true) {
							// If the webview says it should be reused, use it
							controller = c;
							break;
						} else if (canReuse === false) {
							// If the webview says it should not be reused don't and clear it from being first/active
							if (first === c) {
								first = undefined;
							}
							if (active === c) {
								active = undefined;
							}
						}
					}

					if (controller == null && options?.preserveInstance === true) {
						controller = active ?? first;
					}
				}
			}
		} else {
			controller = first(registration.controllers)?.[1];
		}
	}

	return controller;
}

export function isSerializedState<State>(o: unknown): o is { state: Partial<State> } {
	return o != null && typeof o === 'object' && 'state' in o && o.state != null && typeof o.state === 'object';
}
