import type { WheelEvent as ReactWheelEvent } from "react";
import { TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD } from "@/timeline/components/interaction";
import { timelineTimeToPixels } from "@/timeline/pixel-utils";
import { TIMELINE_ZOOM_MAX } from "@/timeline/scale";
import { zoomToSlider } from "@/timeline/zoom-utils";

type ZoomUpdater = number | ((prev: number) => number);

export interface ZoomConfig {
	minZoom: number;
	getContainerEl: () => HTMLDivElement | null;
	getTracksScrollEl: () => HTMLDivElement | null;
	getRulerScrollEl: () => HTMLDivElement | null;
	getCurrentPlayheadTime: () => number;
	seek: (time: number) => void;
	setTimelineViewState: (viewState: {
		zoomLevel: number;
		scrollLeft: number;
		playheadTime: number;
	}) => void;
}

export interface ZoomConfigRef {
	readonly current: ZoomConfig;
}

function clampZoom(zoomLevel: number, minZoom: number): number {
	return Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, zoomLevel));
}

export class ZoomController {
	private readonly configRef: ZoomConfigRef;
	private readonly subscribers = new Set<() => void>();

	private zoomLevelValue: number;
	private hasInitialized = false;
	private hasRestoredPlayhead = false;
	private hasRestoredScroll = false;
	private previousZoom: number;
	private preZoomScrollLeft = 0;
	private prePlayheadAnchorScrollLeft = 0;
	private isInPlayheadAnchorMode = false;
	private scrollSaveTimeout: ReturnType<typeof setTimeout> | null = null;

	constructor(deps: { configRef: ZoomConfigRef; initialZoom?: number }) {
		this.configRef = deps.configRef;

		const minZoom = this.config.minZoom;
		this.zoomLevelValue =
			deps.initialZoom !== undefined
				? clampZoom(deps.initialZoom, minZoom)
				: minZoom;
		this.previousZoom = this.zoomLevelValue;
		this.hasInitialized = deps.initialZoom !== undefined;

		this.setZoomLevel = this.setZoomLevel.bind(this);
		this.handleWheel = this.handleWheel.bind(this);
		this.saveScrollPosition = this.saveScrollPosition.bind(this);
	}

	private get config(): ZoomConfig {
		return this.configRef.current;
	}

	get zoomLevel(): number {
		return this.zoomLevelValue;
	}

	subscribe(fn: () => void): () => void {
		this.subscribers.add(fn);
		return () => this.subscribers.delete(fn);
	}

	destroy(): void {
		if (this.scrollSaveTimeout) {
			clearTimeout(this.scrollSaveTimeout);
			this.scrollSaveTimeout = null;
		}
	}

	setZoomLevel(zoomLevelOrUpdater: ZoomUpdater): void {
		const scrollElement = this.config.getTracksScrollEl();
		if (scrollElement) {
			this.preZoomScrollLeft = scrollElement.scrollLeft;
		}

		const nextZoomRaw =
			typeof zoomLevelOrUpdater === "function"
				? zoomLevelOrUpdater(this.zoomLevelValue)
				: zoomLevelOrUpdater;
		const nextZoom = clampZoom(nextZoomRaw, this.config.minZoom);
		if (nextZoom === this.zoomLevelValue) return;

		this.zoomLevelValue = nextZoom;
		this.notify();
	}

	handleWheel(event: ReactWheelEvent): void {
		const isZoomGesture = event.ctrlKey || event.metaKey;
		const isHorizontalScrollGesture =
			event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY);

		if (isHorizontalScrollGesture) {
			return;
		}

		if (isZoomGesture) {
			const normalizedDelta =
				event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
			const cappedDelta =
				Math.sign(normalizedDelta) * Math.min(Math.abs(normalizedDelta), 30);
			const zoomFactor = Math.exp(-cappedDelta / 300);
			this.setZoomLevel((prev) => prev * zoomFactor);
		}
	}

	reconcileInitialAndMinZoom(minZoom: number, initialZoom?: number): void {
		if (initialZoom !== undefined && !this.hasInitialized) {
			this.hasInitialized = true;
			this.setZoomLevel(clampZoom(initialZoom, minZoom));
			return;
		}

		if (this.zoomLevelValue < minZoom) {
			this.setZoomLevel(minZoom);
		}
	}

	applyZoomLayout(zoomLevel: number): void {
		const previousZoom = this.previousZoom;
		if (previousZoom === zoomLevel) return;

		const scrollElement = this.config.getTracksScrollEl();
		if (!scrollElement) {
			this.previousZoom = zoomLevel;
			return;
		}

		const currentScrollLeft = this.preZoomScrollLeft;
		const playheadTime = this.config.getCurrentPlayheadTime();
		const sliderPercent = zoomToSlider({
			zoomLevel,
			minZoom: this.config.minZoom,
		});
		const previousSliderPercent = zoomToSlider({
			zoomLevel: previousZoom,
			minZoom: this.config.minZoom,
		});
		const isCrossingThresholdUp =
			previousSliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
			sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
		const isCrossingThresholdDown =
			previousSliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
			sliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;

		const syncScroll = (scrollLeft: number) => {
			scrollElement.scrollLeft = scrollLeft;
			const ruler = this.config.getRulerScrollEl();
			if (ruler) {
				ruler.scrollLeft = scrollLeft;
			}
		};

		const clampScrollLeft = (scrollLeft: number) => {
			const maxScrollLeft =
				scrollElement.scrollWidth - scrollElement.clientWidth;
			return Math.max(0, Math.min(maxScrollLeft, scrollLeft));
		};

		if (isCrossingThresholdUp) {
			this.prePlayheadAnchorScrollLeft = currentScrollLeft;
			this.isInPlayheadAnchorMode = true;
		}

		if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
			const playheadPixelsBefore = timelineTimeToPixels({
				time: playheadTime,
				zoomLevel: previousZoom,
			});
			const playheadPixelsAfter = timelineTimeToPixels({
				time: playheadTime,
				zoomLevel,
			});
			const viewportOffset = playheadPixelsBefore - currentScrollLeft;
			const nextScrollLeft = playheadPixelsAfter - viewportOffset;
			syncScroll(clampScrollLeft(nextScrollLeft));
		} else if (isCrossingThresholdDown && this.isInPlayheadAnchorMode) {
			syncScroll(clampScrollLeft(this.prePlayheadAnchorScrollLeft));
			this.isInPlayheadAnchorMode = false;
		}

		this.previousZoom = zoomLevel;

		this.config.setTimelineViewState({
			zoomLevel,
			scrollLeft: scrollElement.scrollLeft,
			playheadTime,
		});
	}

	saveScrollPosition(): void {
		if (this.scrollSaveTimeout) {
			clearTimeout(this.scrollSaveTimeout);
		}

		this.scrollSaveTimeout = setTimeout(() => {
			const scrollElement = this.config.getTracksScrollEl();
			if (!scrollElement) return;

			this.config.setTimelineViewState({
				zoomLevel: this.zoomLevelValue,
				scrollLeft: scrollElement.scrollLeft,
				playheadTime: this.config.getCurrentPlayheadTime(),
			});
		}, 300);
	}

	restoreInitialScrollIfNeeded(
		initialScrollLeft?: number,
	): (() => void) | undefined {
		if (initialScrollLeft === undefined) return;
		if (this.hasRestoredScroll) return;

		const scrollElement = this.config.getTracksScrollEl();
		if (!scrollElement) return;

		const restoreScroll = () => {
			scrollElement.scrollLeft = initialScrollLeft;
			const ruler = this.config.getRulerScrollEl();
			if (ruler) {
				ruler.scrollLeft = initialScrollLeft;
			}
			this.hasRestoredScroll = true;
			this.preZoomScrollLeft = initialScrollLeft;
		};

		if (scrollElement.scrollWidth > 0) {
			restoreScroll();
			return;
		}

		const observer = new ResizeObserver(() => {
			if (scrollElement.scrollWidth > 0) {
				restoreScroll();
				observer.disconnect();
			}
		});
		observer.observe(scrollElement);
		return () => observer.disconnect();
	}

	restoreInitialPlayheadIfNeeded(initialPlayheadTime?: number): void {
		if (initialPlayheadTime === undefined) return;
		if (this.hasRestoredPlayhead) return;

		this.hasRestoredPlayhead = true;
		this.config.seek(initialPlayheadTime);
	}

	bindPreventBrowserZoom(): () => void {
		const preventZoom = (event: WheelEvent) => {
			const isZoomKeyPressed = event.ctrlKey || event.metaKey;
			const container = this.config.getContainerEl();
			const isInContainer = container?.contains(event.target as Node) ?? false;
			if (isZoomKeyPressed && isInContainer) {
				event.preventDefault();
			}
		};

		document.addEventListener("wheel", preventZoom, {
			passive: false,
			capture: true,
		});

		return () => {
			document.removeEventListener("wheel", preventZoom, { capture: true });
		};
	}

	private notify(): void {
		for (const fn of this.subscribers) {
			fn();
		}
	}
}
