/**
 * AvatarCropModal — opened after the user picks an avatar file in
 * the profile settings. Lets them drag to reposition + zoom the
 * image inside a circular crop frame before we upload the result.
 *
 * Output is a square PNG (default 512×512) of the visible crop
 * region. We output PNG instead of re-encoding the source format
 * so transparent avatars don't get a white background and so the
 * alpha channel (transparent PNG, GIF frame 0) round-trips.
 *
 * The modal is built on the shared `Modal` primitive, so backdrop
 * chrome + Esc handling + portal routing come for free. Drag and
 * zoom are handled with pointer events (no external dependency)
 * so we can ship this inside the existing Vite bundle without
 * adding react-easy-crop or react-image-crop to the dep tree.
 */
import {
	useCallback,
	useEffect,
	useMemo,
	useRef,
	useState,
	type PointerEvent as ReactPointerEvent,
	type WheelEvent as ReactWheelEvent,
} from 'react';
import { Modal } from '@discord-clone/ui';
import {
	ArrowClockwise,
	FrameCorners,
} from '@phosphor-icons/react';
import styles from './AvatarCropModal.module.css';

const EDITOR_SIZE = 360; // CSS pixels of the square editor viewport
const OUTPUT_SIZE = 512; // PNG pixels of the exported crop
const ZOOM_MIN = 1;
const ZOOM_MAX = 4;
const ZOOM_STEP = 0.05;

interface AvatarCropModalProps {
	isOpen: boolean;
	onClose: () => void;
	/** The raw File the user picked — gets decoded into an Image
	 *  and rendered inside the editor. */
	file: File | null;
	/** Called with the cropped PNG Blob when the user hits Save.
	 *  The caller should treat it as a fresh upload candidate
	 *  (same path as picking a brand new file). */
	onCropped: (blob: Blob) => void;
	/** Called when the user clicks "Skip Cropping" — the raw file
	 *  is accepted as-is without any client-side processing. Used
	 *  for animated GIFs where cropping would flatten to a single
	 *  frame and the user would rather upload the original. */
	onSkipCrop: () => void;
}

interface Offset {
	x: number;
	y: number;
}

export function AvatarCropModal({
	isOpen,
	onClose,
	file,
	onCropped,
	onSkipCrop,
}: AvatarCropModalProps) {
	const [imageUrl, setImageUrl] = useState<string | null>(null);
	const [imageEl, setImageEl] = useState<HTMLImageElement | null>(null);
	// Crop transform state — `offset` is the image centre's
	// position inside the editor viewport in CSS pixels, `zoom` is
	// a multiplier on top of the "cover" scale computed once per
	// image. Keeping them separate lets the zoom slider and drag
	// pan compose cleanly.
	const [offset, setOffset] = useState<Offset>({ x: 0, y: 0 });
	const [zoom, setZoom] = useState(1);
	const [rotation, setRotation] = useState(0);
	const [isSaving, setIsSaving] = useState(false);
	const [error, setError] = useState<string | null>(null);

	const editorRef = useRef<HTMLDivElement>(null);
	// Pointer drag bookkeeping — we track the start pointer + the
	// offset at the moment the drag began, and compute the live
	// offset as (current - start + startOffset). Kept in a ref so
	// mid-drag updates don't re-render 60 times a second.
	const dragRef = useRef<{
		pointerId: number;
		startX: number;
		startY: number;
		startOffset: Offset;
	} | null>(null);

	// ── Source image decoding ─────────────────────────────────

	// Decode the picked File into an <img> element so we know its
	// natural dimensions before laying out the cover scale.
	useEffect(() => {
		if (!isOpen || !file) {
			setImageUrl(null);
			setImageEl(null);
			return;
		}
		const url = URL.createObjectURL(file);
		setImageUrl(url);
		const img = new Image();
		img.onload = () => setImageEl(img);
		img.onerror = () => setError('Could not read that image.');
		img.src = url;
		return () => {
			URL.revokeObjectURL(url);
		};
	}, [isOpen, file]);

	// Reset transform whenever a new file lands. Otherwise the
	// user's last crop would persist across picks which is
	// confusing — fresh file should open uncropped.
	useEffect(() => {
		if (!imageEl) return;
		setOffset({ x: 0, y: 0 });
		setZoom(1);
		setRotation(0);
		setError(null);
	}, [imageEl]);

	// ── Cover scale ───────────────────────────────────────────
	//
	// The baseline scale that makes the source image exactly cover
	// the editor viewport (the `object-fit: cover` equivalent).
	// All further zooming is multiplied on top of this so zoom=1
	// always means "source fits edges".
	const coverScale = useMemo(() => {
		if (!imageEl) return 1;
		return Math.max(
			EDITOR_SIZE / imageEl.naturalWidth,
			EDITOR_SIZE / imageEl.naturalHeight,
		);
	}, [imageEl]);

	const effectiveScale = coverScale * zoom;

	const displayWidth = imageEl
		? imageEl.naturalWidth * effectiveScale
		: EDITOR_SIZE;
	const displayHeight = imageEl
		? imageEl.naturalHeight * effectiveScale
		: EDITOR_SIZE;

	// Clamp the drag offset so the image edge can never move
	// inside the crop circle's bounding box. Matches the Fluxer
	// behaviour where you physically can't pan past "image
	// touching the edge of the viewport".
	const clampOffset = useCallback(
		(raw: Offset): Offset => {
			const maxX = Math.max(0, (displayWidth - EDITOR_SIZE) / 2);
			const maxY = Math.max(0, (displayHeight - EDITOR_SIZE) / 2);
			return {
				x: Math.max(-maxX, Math.min(maxX, raw.x)),
				y: Math.max(-maxY, Math.min(maxY, raw.y)),
			};
		},
		[displayWidth, displayHeight],
	);

	// Re-clamp whenever zoom or image size changes so a zoom-out
	// doesn't leave the image stranded off-centre.
	useEffect(() => {
		setOffset((prev) => clampOffset(prev));
	}, [clampOffset]);

	// ── Pointer drag ──────────────────────────────────────────

	const handlePointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
		if (!imageEl) return;
		(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
		dragRef.current = {
			pointerId: e.pointerId,
			startX: e.clientX,
			startY: e.clientY,
			startOffset: offset,
		};
	};

	const handlePointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
		const drag = dragRef.current;
		if (!drag || drag.pointerId !== e.pointerId) return;
		const dx = e.clientX - drag.startX;
		const dy = e.clientY - drag.startY;
		setOffset(
			clampOffset({
				x: drag.startOffset.x + dx,
				y: drag.startOffset.y + dy,
			}),
		);
	};

	const handlePointerUp = (e: ReactPointerEvent<HTMLDivElement>) => {
		const drag = dragRef.current;
		if (!drag || drag.pointerId !== e.pointerId) return;
		try {
			(e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId);
		} catch {
			// Already released — non-issue.
		}
		dragRef.current = null;
	};

	// ── Wheel zoom ────────────────────────────────────────────

	const handleWheel = (e: ReactWheelEvent<HTMLDivElement>) => {
		if (!imageEl) return;
		// Negative deltaY = scroll up = zoom in. Convert to the
		// same 0.05 steps the slider uses for a consistent feel.
		const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
		setZoom((prev) => Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, prev + delta)));
	};

	// ── Footer actions ────────────────────────────────────────

	const handleReset = () => {
		setOffset({ x: 0, y: 0 });
		setZoom(1);
		setRotation(0);
	};

	const handleRotate = () => {
		// Clockwise 90° increments. Rotation composes on top of
		// the drag/zoom transform at render time.
		setRotation((prev) => (prev + 90) % 360);
	};

	const handleFitFrame = () => {
		// Snap zoom back to 1 (cover) and recentre.
		setZoom(1);
		setOffset({ x: 0, y: 0 });
	};

	const handleSkip = () => {
		onSkipCrop();
		onClose();
	};

	const handleSave = async () => {
		if (!imageEl) return;
		setIsSaving(true);
		setError(null);
		try {
			const blob = await renderCrop({
				image: imageEl,
				editorSize: EDITOR_SIZE,
				outputSize: OUTPUT_SIZE,
				offset,
				scale: effectiveScale,
				rotation,
			});
			onCropped(blob);
			onClose();
		} catch (err) {
			setError(err instanceof Error ? err.message : 'Failed to crop image.');
			setIsSaving(false);
		}
	};

	// ── Render ────────────────────────────────────────────────

	// Compose CSS transform from the three axes. Order matters —
	// we translate THEN rotate THEN scale so the pivot is the
	// image centre and the user's pan/zoom feel intuitive.
	const imageTransform = imageEl
		? `translate(-50%, -50%) translate(${offset.x}px, ${offset.y}px) rotate(${rotation}deg) scale(${effectiveScale})`
		: undefined;

	return (
		<Modal.Root isOpen={isOpen} onClose={onClose} size="medium">
			<Modal.Header title="Crop Avatar" onClose={onClose} />
			<Modal.Content>
				<div className={styles.body}>
					<p className={styles.description}>
						Drag to reposition your avatar and use the scroll wheel or
						pinch to zoom. The recommended minimum size is 256×256 pixels.
					</p>

					<div
						ref={editorRef}
						className={styles.editor}
						style={{ width: EDITOR_SIZE, height: EDITOR_SIZE }}
						onPointerDown={handlePointerDown}
						onPointerMove={handlePointerMove}
						onPointerUp={handlePointerUp}
						onPointerCancel={handlePointerUp}
						onWheel={handleWheel}
					>
						{imageUrl && (
							<img
								src={imageUrl}
								alt=""
								className={styles.editorImage}
								style={{
									transform: imageTransform,
									// The image is rendered at its NATURAL size
									// and we scale it with `transform: scale()`
									// so the pan math stays in source-pixel
									// space no matter the zoom.
									width: imageEl?.naturalWidth ?? 0,
									height: imageEl?.naturalHeight ?? 0,
								}}
								draggable={false}
							/>
						)}
						{/* Circular crop mask — absolutely positioned ring
						    on top of the image with a 9999px outer shadow
						    that dims everything outside the ring. */}
						<div className={styles.cropMask} aria-hidden />
					</div>

					<div className={styles.zoomRow}>
						<label className={styles.zoomLabel} htmlFor="avatar-zoom">
							Zoom
						</label>
						<input
							id="avatar-zoom"
							type="range"
							className={styles.zoomSlider}
							min={ZOOM_MIN}
							max={ZOOM_MAX}
							step={ZOOM_STEP}
							value={zoom}
							onChange={(e) => setZoom(Number(e.target.value))}
							disabled={!imageEl}
						/>
						<button
							type="button"
							className={styles.iconButton}
							onClick={handleFitFrame}
							disabled={!imageEl}
							aria-label="Fit to frame"
							title="Fit to frame"
						>
							<FrameCorners size={20} weight="regular" />
						</button>
						<button
							type="button"
							className={styles.iconButton}
							onClick={handleRotate}
							disabled={!imageEl}
							aria-label="Rotate 90°"
							title="Rotate 90°"
						>
							<ArrowClockwise size={20} weight="regular" />
						</button>
					</div>

					{error && <div className={styles.error}>{error}</div>}

					<div className={styles.actions}>
						<button
							type="button"
							className={styles.resetButton}
							onClick={handleReset}
							disabled={!imageEl}
						>
							Reset
						</button>
						<div className={styles.actionsSpacer} />
						<button
							type="button"
							className={styles.secondaryButton}
							onClick={onClose}
							disabled={isSaving}
						>
							Cancel
						</button>
						<button
							type="button"
							className={styles.tertiaryButton}
							onClick={handleSkip}
							disabled={isSaving}
							title="Upload the original file without cropping"
						>
							Skip Cropping
						</button>
						<button
							type="button"
							className={styles.primaryButton}
							onClick={handleSave}
							disabled={!imageEl || isSaving}
						>
							{isSaving ? 'Saving…' : 'Save Avatar'}
						</button>
					</div>
				</div>
			</Modal.Content>
		</Modal.Root>
	);
}

// ─────────────────────────────────────────────────────────────────
// Canvas render helper
// ─────────────────────────────────────────────────────────────────

interface RenderCropArgs {
	image: HTMLImageElement;
	editorSize: number;
	outputSize: number;
	offset: Offset;
	scale: number;
	rotation: number;
}

/**
 * Bakes the current editor transform into a square PNG Blob. The
 * math mirrors the DOM transform chain exactly: translate to the
 * editor centre, pan by the drag offset, rotate, then draw the
 * source image scaled by `scale` centred at (0, 0).
 *
 * Output is `outputSize × outputSize` pixels regardless of how
 * much the user zoomed in — we let the browser's native image
 * scaling handle super-sampling from the source bitmap.
 */
function renderCrop(args: RenderCropArgs): Promise<Blob> {
	return new Promise((resolve, reject) => {
		const { image, editorSize, outputSize, offset, scale, rotation } = args;
		const canvas = document.createElement('canvas');
		canvas.width = outputSize;
		canvas.height = outputSize;
		const ctx = canvas.getContext('2d');
		if (!ctx) {
			reject(new Error('Could not get 2D canvas context.'));
			return;
		}

		// Convert from editor-pixel space (360px) to output-pixel
		// space (512px) by uniformly scaling every transform.
		const ratio = outputSize / editorSize;

		ctx.save();
		// Move the origin to the centre of the output canvas
		// (where the crop circle is centred in the editor).
		ctx.translate(outputSize / 2, outputSize / 2);
		// Apply the user's pan offset, rescaled to output space.
		ctx.translate(offset.x * ratio, offset.y * ratio);
		// Rotate around the new origin.
		ctx.rotate((rotation * Math.PI) / 180);
		// Scale the source image by the effective scale * ratio.
		// The image is drawn from its own centre so the origin
		// lines up with the editor's crop centre.
		const drawScale = scale * ratio;
		ctx.drawImage(
			image,
			(-image.naturalWidth * drawScale) / 2,
			(-image.naturalHeight * drawScale) / 2,
			image.naturalWidth * drawScale,
			image.naturalHeight * drawScale,
		);
		ctx.restore();

		canvas.toBlob(
			(blob) => {
				if (!blob) {
					reject(new Error('Canvas export failed.'));
					return;
				}
				resolve(blob);
			},
			'image/png',
			0.95,
		);
	});
}
