import type { ConfigurationChangeEvent, DecorationOptions, TextEditor, TextEditorDecorationType } from 'vscode';
import { CancellationTokenSource, Disposable, Range, window } from 'vscode';
import { GitCommit } from '@gitlens/git/models/commit.js';
import type { PullRequest } from '@gitlens/git/models/pullRequest.js';
import { debounce } from '@gitlens/utils/debounce.js';
import { debug, trace } from '@gitlens/utils/decorators/log.js';
import { once } from '@gitlens/utils/event.js';
import { getScopedLogger } from '@gitlens/utils/logger.scoped.js';
import type { MaybePausedResult } from '@gitlens/utils/promise.js';
import { getSettledValue, pauseOnCancelOrTimeoutMap } from '@gitlens/utils/promise.js';
import { GlyphChars, Schemes } from '../constants.js';
import type { Container } from '../container.js';
import { CommitFormatter } from '../git/formatters/commitFormatter.js';
import { getCommitAssociatedPullRequest } from '../git/utils/-webview/commit.utils.js';
import { getBestRemoteWithIntegration } from '../git/utils/-webview/remote.utils.js';
import { detailsMessage } from '../hovers/hovers.js';
import { toAbortSignal } from '../system/-webview/cancellation.js';
import { configuration } from '../system/-webview/configuration.js';
import { isTrackableTextEditor } from '../system/-webview/vscode/editors.js';
import type { LinesChangeEvent, LineState } from '../trackers/lineTracker.js';
import { getInlineDecoration } from './annotations.js';
import type { BlameFontOptions } from './gutterBlameAnnotationProvider.js';

const annotationDecoration: TextEditorDecorationType = window.createTextEditorDecorationType({
	after: {
		margin: '0 0 0 3em',
		textDecoration: 'none',
	},
});
const maxSmallIntegerV8 = 2 ** 30 - 1; // Max number that can be stored in V8's smis (small integers)

export class LineAnnotationController implements Disposable {
	private _cancellation: CancellationTokenSource | undefined;
	private readonly _disposable: Disposable;
	private _editor: TextEditor | undefined;
	private _enabled: boolean = false;

	constructor(private readonly container: Container) {
		this._disposable = Disposable.from(
			once(container.onReady)(this.onReady, this),
			configuration.onDidChange(this.onConfigurationChanged, this),
			container.fileAnnotations.onDidToggleAnnotations(this.onFileAnnotationsToggled, this),
			container.integrations.onDidChangeConnectionState(
				debounce(() => void this.refresh(window.activeTextEditor), 250),
			),
		);
	}

	dispose(): void {
		this.clearAnnotations(this._editor);

		this.container.lineTracker.unsubscribe(this);
		this._disposable.dispose();
	}

	private onReady(): void {
		this.onConfigurationChanged();
	}

	private onConfigurationChanged(e?: ConfigurationChangeEvent) {
		let refresh = false;

		if (configuration.changed(e, 'defaultCurrentUserNameStyle')) {
			refresh = true;
		}

		if (configuration.changed(e, 'currentLine')) {
			if (configuration.changed(e, 'currentLine.enabled')) {
				if (configuration.get('currentLine.enabled')) {
					this._enabled = true;
					this.resume();
				} else {
					this._enabled = false;
					this.setLineTracker(false);
				}
			}
			refresh = true;
		}

		if (refresh) {
			void this.refresh(window.activeTextEditor);
		}
	}

	private _suspended: boolean = false;
	get suspended(): boolean {
		return !this._enabled || this._suspended;
	}

	@debug()
	resume(): boolean {
		this.setLineTracker(true);

		if (this._suspended) {
			this._suspended = false;
			return true;
		}

		return false;
	}

	@debug()
	suspend(): boolean {
		this.setLineTracker(false);

		if (!this._suspended) {
			this._suspended = true;
			return true;
		}

		return false;
	}

	@trace({
		args: e => ({
			e: {
				editor: e.editor,
				selection: e.selections?.map(s => `[${s.anchor}-${s.active}]`).join(','),
				pending: Boolean(e.pending),
				reason: e.reason,
			},
		}),
	})
	private onActiveLinesChanged(e: LinesChangeEvent) {
		// Editing event — user is typing on the current line.
		// Clear decorations so annotations don't interfere with editing.
		// They'll reappear when the cursor moves to a different line.
		if (e.editing) {
			this.clearAnnotations(e.editor);
			return;
		}

		// Real event with blame data ready — refresh decorations
		if (!e.pending && e.selections != null) {
			void this.refresh(e.editor);
			return;
		}

		// Pending event — cursor moved, blame data not ready yet.
		// Clear stale decorations so old blame doesn't "stick" on the wrong line,
		// but DON'T cancel in-flight work — let it complete or detect staleness.
		if (e.pending) {
			if (this._editor !== e.editor && this._editor != null) {
				this.clearAnnotations(this._editor);
			}
			this.clearAnnotations(e.editor);
			return;
		}

		// No selections / suspended / error — full clear including work cancellation
		this.clear(e.editor);
	}

	private onFileAnnotationsToggled() {
		void this.refresh(window.activeTextEditor);
	}

	@trace({ args: false, onlyExit: true })
	clear(editor: TextEditor | undefined): void {
		this._cancellation?.cancel();
		if (this._editor !== editor && this._editor != null) {
			this.clearAnnotations(this._editor);
		}
		this.clearAnnotations(editor);
	}

	@debug({ args: false })
	async toggle(editor: TextEditor | undefined): Promise<void> {
		this._enabled = !(this._enabled && !this.suspended);

		if (this._enabled) {
			if (this.resume()) {
				await this.refresh(editor);
			}
		} else if (this.suspend()) {
			await this.refresh(editor);
		}
	}

	private clearAnnotations(editor: TextEditor | undefined) {
		if (editor === undefined || (editor as any)._disposed === true) return;

		editor.setDecorations(annotationDecoration, []);
	}

	private getPullRequestsForLines(
		repoPath: string,
		lines: Map<number, LineState>,
	): Map<string, Promise<PullRequest | undefined>> {
		const prs = new Map<string, Promise<PullRequest | undefined>>();
		if (lines.size === 0) return prs;

		const remotePromise = getBestRemoteWithIntegration(repoPath);

		for (const [, state] of lines) {
			if (state.commit.isUncommitted) continue;

			let pr = prs.get(state.commit.ref);
			if (pr == null) {
				pr = remotePromise.then(remote =>
					getCommitAssociatedPullRequest(state.commit.repoPath, state.commit.sha, remote),
				);
				prs.set(state.commit.ref, pr);
			}
		}

		return prs;
	}

	@trace()
	private async refresh(editor: TextEditor | undefined) {
		if (editor == null && this._editor == null) return;

		const scope = getScopedLogger();

		const selections = this.container.lineTracker.selections;
		const selectionsVersion = this.container.lineTracker.selectionsVersion;
		if (editor == null || selections == null || !isTrackableTextEditor(editor)) {
			scope?.addExitInfo('Skipped because there is no valid editor or no valid selections');

			this.clear(this._editor);
			return;
		}

		if (this._editor !== editor) {
			// Clear any annotations on the previously active editor
			this.clear(this._editor);

			this._editor = editor;
		}

		const cfg = configuration.get('currentLine');
		if (this.suspended) {
			scope?.addExitInfo('Skipped because the controller is suspended');

			this.clear(editor);
			return;
		}

		// Don't show annotations while actively editing the current line
		if (this.container.lineTracker.isEditing) {
			scope?.addExitInfo('Skipped because the user is actively editing');

			this.clearAnnotations(editor);
			return;
		}

		const trackedDocument = await this.container.documentTracker.getOrAdd(editor.document);
		const status = await trackedDocument?.getStatus();
		if (!status?.blameable || this.suspended) {
			scope?.addExitInfo(
				`Skipped because the ${this.suspended ? 'controller is suspended' : 'document is not blameable'}`,
			);

			this.clear(editor);
			return;
		}

		// Make sure the editor hasn't died since the await above and that we are still on the same line(s)
		if (editor.document == null || selectionsVersion !== this.container.lineTracker.selectionsVersion) {
			scope?.addExitInfo(
				`Skipped because the ${
					editor.document == null
						? 'editor is gone'
						: `selection=${selections.map(s => `[${s.anchor}-${s.active}]`).join()} are no longer current`
				}`,
			);
			this.clearAnnotations(editor);
			return;
		}

		scope?.addExitInfo(`selection=${selections.map(s => `[${s.anchor}-${s.active}]`).join()}`);

		let uncommittedOnly = true;

		let hoverOptions:
			| RequireSome<Parameters<typeof detailsMessage>[4], 'autolinks' | 'pullRequests' | 'sourceName'>
			| undefined;
		// Live Share (vsls schemes) don't support `languages.registerHoverProvider` so we'll need to add them to the decoration directly
		if (editor.document.uri.scheme === Schemes.Vsls || editor.document.uri.scheme === Schemes.VslsScc) {
			const hoverCfg = configuration.get('hovers');
			hoverOptions = {
				autolinks: hoverCfg.autolinks.enabled,
				dateFormat: configuration.get('defaultDateFormat'),
				format: hoverCfg.detailsMarkdownFormat,
				pullRequests: hoverCfg.pullRequests.enabled,
				sourceName: 'editor:hover',
			};
		}

		const commitPromises = new Map<string, Promise<void>>();
		const lines = new Map<number, LineState>();
		for (const selection of selections) {
			const state = this.container.lineTracker.getState(selection.active);
			if (state?.commit == null) {
				scope?.trace(`Line ${selection.active} returned no commit`);
				continue;
			}

			// Only ensure the full details if we have to add the hover eagerly (Live Share) and we don't have a message
			if (hoverOptions != null && state.commit.message == null && !commitPromises.has(state.commit.ref)) {
				commitPromises.set(state.commit.ref, GitCommit.ensureFullDetails(state.commit));
			}
			lines.set(selection.active, state);
			if (!state.commit.isUncommitted) {
				uncommittedOnly = false;
			}
		}

		const repoPath = trackedDocument.uri.repoPath;

		const getPullRequests =
			!uncommittedOnly &&
			repoPath != null &&
			cfg.pullRequests.enabled &&
			CommitFormatter.has(
				cfg.format,
				'pullRequest',
				'pullRequestAgo',
				'pullRequestAgoOrDate',
				'pullRequestDate',
				'pullRequestState',
			);

		this._cancellation?.cancel();
		this._cancellation = new CancellationTokenSource();
		const cancellation = this._cancellation.token;

		const getBranchAndTagTipsPromise =
			repoPath && CommitFormatter.has(cfg.format, 'tips')
				? this.container.git.getRepositoryService(repoPath).getBranchesAndTagsTipsLookup()
				: undefined;

		async function updateDecorations(
			container: Container,
			editor: TextEditor,
			getBranchAndTagTips: Awaited<typeof getBranchAndTagTipsPromise> | undefined,
			prs: Map<string, MaybePausedResult<PullRequest | undefined>> | undefined,
			timeout?: number,
		) {
			const fontOptions: BlameFontOptions = {
				family: cfg.fontFamily,
				size: cfg.fontSize,
				style: cfg.fontStyle,
				weight: cfg.fontWeight,
			};

			const decorations = [];

			for (const [l, state] of lines) {
				const commit = state.commit;
				if (commit == null || (commit.isUncommitted && cfg.uncommittedChangesFormat === '')) continue;

				const pr = prs?.get(commit.ref);

				const decoration = getInlineDecoration(
					commit,
					// await GitUri.fromUri(editor.document.uri),
					// l,
					commit.isUncommitted ? (cfg.uncommittedChangesFormat ?? cfg.format) : cfg.format,
					{
						dateFormat: cfg.dateFormat === null ? configuration.get('defaultDateFormat') : cfg.dateFormat,
						getBranchAndTagTips: getBranchAndTagTips,
						pullRequest: pr?.value,
						pullRequestPendingMessage: `PR ${GlyphChars.Ellipsis}`,
						source: { source: 'editor:hover' },
					},
					fontOptions,
					cfg.scrollable,
				) as DecorationOptions;
				decoration.range = editor.document.validateRange(new Range(l, maxSmallIntegerV8, l, maxSmallIntegerV8));

				if (hoverOptions != null) {
					decoration.hoverMessage = await detailsMessage(container, commit, trackedDocument.uri, l, {
						...hoverOptions,
						pullRequest: pr?.value,
						timeout: timeout,
					});
				}

				decorations.push(decoration);
			}

			editor.setDecorations(annotationDecoration, decorations);
		}

		// TODO: Make this configurable?
		const timeout = 100;
		const prsResult = getPullRequests
			? await pauseOnCancelOrTimeoutMap(
					this.getPullRequestsForLines(repoPath, lines),
					true,
					toAbortSignal(cancellation),
					timeout,
					async result => {
						if (
							result.reason !== 'timedout' ||
							cancellation.isCancellationRequested ||
							editor !== this._editor
						) {
							return;
						}

						// If the PRs are taking too long, refresh the decorations once they complete

						scope?.warn(`\u2022 pull request queries took too long (over ${timeout} ms)`);

						const [getBranchAndTagTipsResult, prsResult] = await Promise.allSettled([
							getBranchAndTagTipsPromise,
							result.value,
						]);

						if (cancellation.isCancellationRequested || editor !== this._editor) return;

						const prs = getSettledValue(prsResult);
						const getBranchAndTagTips = getSettledValue(getBranchAndTagTipsResult);

						scope?.trace(`\u2022 pull request queries completed; updating...`);

						void updateDecorations(this.container, editor, getBranchAndTagTips, prs);
					},
				)
			: undefined;

		const [getBranchAndTagTipsResult] = await Promise.allSettled([
			getBranchAndTagTipsPromise,
			...commitPromises.values(),
		]);

		if (cancellation.isCancellationRequested) {
			this.clearAnnotations(editor);
			return;
		}

		await updateDecorations(this.container, editor, getSettledValue(getBranchAndTagTipsResult), prsResult, 100);
	}

	private setLineTracker(enabled: boolean) {
		if (enabled) {
			if (!this.container.lineTracker.subscribed(this)) {
				this.container.lineTracker.subscribe(
					this,
					this.container.lineTracker.onDidChangeActiveLines(this.onActiveLinesChanged, this),
				);
			}

			return;
		}

		this.container.lineTracker.unsubscribe(this);
	}
}
