import { parse as parseDiff } from 'diff2html/lib-esm/diff-parser.js';
import type { DiffFile } from 'diff2html/lib-esm/types';
import { ColorSchemeType } from 'diff2html/lib-esm/types';
import type { Diff2HtmlUIConfig } from 'diff2html/lib-esm/ui/js/diff2html-ui-base.js';
import { Diff2HtmlUI } from 'diff2html/lib-esm/ui/js/diff2html-ui-base.js';
import { css, html, LitElement } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import type { ComposerHunk } from '../../../../../plus/composer/protocol.js';
import { focusableBaseStyles } from '../../../../shared/components/styles/lit/a11y.css.js';
import { boxSizingBase, scrollableBase } from '../../../../shared/components/styles/lit/base.css.js';
import { compiledComposerTemplates } from './diff-templates.compiled.js';
import { diff2htmlStyles, diffStyles, hljsStyles } from './diff.css.js';
import '../../../../shared/components/code-icon.js';

@customElement('gl-diff-file')
export class GlDiffFile extends LitElement {
	static override styles = [
		boxSizingBase,
		scrollableBase,
		focusableBaseStyles,
		css`
			[hidden] {
				display: none !important;
			}

			:host {
				display: block;
			}
		`,
		hljsStyles,
		diff2htmlStyles,
		diffStyles,
	];

	@property({ type: String })
	filename?: string;

	@property({ type: Array })
	hunks?: ComposerHunk[];

	@property({ type: Boolean, attribute: 'side-by-side' })
	sideBySide = false;

	@property({ type: Boolean, attribute: 'default-expanded' })
	defaultExpanded = true;

	@query('#diff')
	targetElement!: HTMLDivElement;

	private diffText?: string;

	@state()
	private parsedDiff?: DiffFile[];

	@state()
	private hasRendered = false;

	@state()
	private _isVisible = false;

	@property({ type: Boolean, reflect: true, attribute: 'is-visible' })
	get isVisible(): boolean {
		return this._isVisible;
	}

	private set isVisible(value: boolean) {
		this._isVisible = value;
	}

	@state()
	private userExpandedState?: boolean;

	// should only ever be one file
	get diffFile(): DiffFile | undefined {
		return this.parsedDiff?.[0];
	}

	private diff2htmlUi?: Diff2HtmlUI;
	private intersectionObserver?: IntersectionObserver;
	private detailsToggleListener?: () => void;
	private _processingTimer?: ReturnType<typeof setTimeout>;

	override connectedCallback() {
		super.connectedCallback?.();
		this.setupIntersectionObserver();
	}

	override disconnectedCallback() {
		super.disconnectedCallback?.();
		this.cancelScheduledProcessing();
		this.intersectionObserver?.disconnect();
		this.intersectionObserver = undefined;
	}

	override updated(changedProperties: Map<string | number | symbol, unknown>) {
		super.updated(changedProperties);

		if (changedProperties.has('filename') || changedProperties.has('hunks')) {
			// Invalidate stale parsed data so that if the element is offscreen
			// (and scheduleProcessing no-ops), the isVisible handler will know
			// reprocessing is needed when the element scrolls back into view.
			this.parsedDiff = undefined;
			this.scheduleProcessing();
		}

		if (changedProperties.has('parsedDiff') || changedProperties.has('sideBySide')) {
			this.renderDiff(true);
		} else if (changedProperties.has('defaultExpanded')) {
			this.userExpandedState = undefined;
			this.renderDiff();
		} else if (changedProperties.has('isVisible')) {
			if (this.isVisible && !this.parsedDiff && this.hunks?.length) {
				// Element became visible but hasn't been processed yet — schedule it
				this.scheduleProcessing();
			}
			this.renderDiff();
		}
	}

	/**
	 * Defers diff processing to a macrotask so it doesn't block the browser's
	 * paint. Without this, selecting a commit with many files causes all
	 * `parseDiff()` calls to run synchronously in Lit's microtask batch,
	 * delaying the commits panel's selection highlight until every diff is parsed.
	 */
	private scheduleProcessing() {
		this.cancelScheduledProcessing();
		this._processingTimer = setTimeout(() => {
			this._processingTimer = undefined;
			if (!this.isConnected || !this.isVisible) return;

			this.processDiff();
		}, 0);
	}

	private cancelScheduledProcessing() {
		if (this._processingTimer != null) {
			clearTimeout(this._processingTimer);
			this._processingTimer = undefined;
		}
	}

	override render() {
		return html`<div id="diff" class="diff-container"></div>`;
	}

	private setupIntersectionObserver() {
		this.intersectionObserver = new IntersectionObserver(
			entries => {
				for (const entry of entries) {
					this.isVisible = entry.isIntersecting;
				}
			},
			{
				// Use a margin to start rendering slightly before the element enters the viewport
				rootMargin: '100px',
			},
		);
		this.intersectionObserver.observe(this);
	}

	private clearDiff() {
		if (this.targetElement) {
			this.captureUserExpandedState();
			this.removeDetailsToggleListener();
			const currentHeight = this.targetElement.offsetHeight;
			if (currentHeight > 0) {
				this.style.minHeight = `${currentHeight}px`;
			}
			this.targetElement.innerHTML = '';
		}
		this.diff2htmlUi = undefined;
		this.hasRendered = false;
	}

	private renderDiff(force = false) {
		// If not visible or no data, clear and return
		if (!this.isVisible || !this.parsedDiff || !this.filename) {
			this.clearDiff();
			return;
		}

		// Don't re-render if already rendered
		if (this.hasRendered && !force) {
			return;
		}

		this.style.minHeight = '';

		if (!this.diff2htmlUi || force) {
			const config: Diff2HtmlUIConfig = {
				colorScheme: ColorSchemeType.AUTO,
				outputFormat: this.sideBySide ? 'side-by-side' : 'line-by-line',
				drawFileList: false,
				highlight: false,
				// NOTE: Avoiding passing rawTemplates to Diff2HtmlUI to prevent Diff2Html from
				// compiling templates at runtime via Hogan.compile (which uses eval), which violates
				// the webview CSP (no 'unsafe-eval'). If we need to customize templates in the future,
				// switch to providing precompiled templates in the bundle instead of raw strings.
				compiledTemplates: compiledComposerTemplates,
			};
			this.diff2htmlUi = new Diff2HtmlUI(this.targetElement, this.parsedDiff, config);
		}
		this.diff2htmlUi.draw();
		// this.diff2htmlUi.highlightCode();

		const detailsElement = this.targetElement?.querySelector('details');
		if (detailsElement) {
			detailsElement.open = this.userExpandedState ?? this.defaultExpanded;
			this.setupDetailsToggleListener(detailsElement);
		}

		this.hasRendered = true;
	}

	private processDiff() {
		if (!this.filename || !this.hunks || this.hunks.length === 0) {
			this.diffText = undefined;
			this.parsedDiff = undefined;
			return;
		}

		const diffLines = this.hunks
			.map((hunk, i) => {
				if (i === 0) {
					return `${hunk.diffHeader}\n${hunk.hunkHeader}\n${hunk.content}`;
				}
				return `\n${hunk.hunkHeader}\n${hunk.content}`;
			})
			.join('\n');

		this.diffText = diffLines.trim();
		const parsedDiff = parseDiff(this.diffText, {
			diffMaxChanges: 10000,
		});
		this.parsedDiff = parsedDiff;
		const lineCount = this.diffFile?.blocks.reduce((p, c) => p + 1 + c.lines.length, 0) ?? -1;
		this.style.setProperty('--d2h-intrinsic-line-count', lineCount > -1 ? `${lineCount}` : '50');
	}

	private captureUserExpandedState() {
		const detailsElement = this.targetElement?.querySelector('details');
		if (detailsElement) {
			this.userExpandedState = detailsElement.open;
		}
	}

	private setupDetailsToggleListener(detailsElement: HTMLDetailsElement) {
		this.removeDetailsToggleListener();

		this.detailsToggleListener = () => {
			this.userExpandedState = detailsElement.open;
		};

		detailsElement.addEventListener('toggle', this.detailsToggleListener);
	}

	private removeDetailsToggleListener() {
		if (this.detailsToggleListener) {
			const detailsElement = this.targetElement?.querySelector('details');
			if (detailsElement) {
				detailsElement.removeEventListener('toggle', this.detailsToggleListener);
			}
			this.detailsToggleListener = undefined;
		}
	}
}
