/**
 * Handles pan and zoom for the graph workspace canvas.
 * Ported directly from WorkspaceNavigationManager in Picograph.
 * Grid is CSS-driven; this class updates CSS custom properties and background position.
 */
export class WorkspaceNavigator {
    /** @type {HTMLElement} */
    #canvas;

    /** @type {HTMLElement | null} */
    #worldLayer = null;

    /** @type {(() => void) | null} */
    #onAfterTransform = null;

    /** @type {{ x: number, y: number }} */
    #backgroundOffset = { x: 0, y: 0 };

    /** @type {{ x: number, y: number }} */
    #pendingLayerOffset = { x: 0, y: 0 };

    /** @type {number} */
    #zoomLevel = 1;

    /** @type {number} */
    #targetZoomLevel = 1;

    /** @type {{ min: number, max: number, step: number }} */
    #zoomConfig = { min: 0.25, max: 2.5, step: 0.1 };

    /** @type {number | null} */
    #smoothZoomRafId = null;

    /** @type {number | null} */
    #focusAnimRafId = null;

    /** @type {number} */
    #focusAnimGen = 0;

    /** @type {{ screenPoint: { x: number, y: number }, pointer: { clientX?: number, clientY?: number } } | null} */
    #smoothZoomFocus = null;

    /** @type {number | null} */
    #smoothPanRafId = null;

    /** @type {{ x: number, y: number }} */
    #targetPanOffset = { x: 0, y: 0 };

    /** @type {{ pointerId: number, startX: number, startY: number, currentClientX: number, currentClientY: number, hasMoved: boolean, backgroundOrigin: { x: number, y: number }, lastDelta: { x: number, y: number } } | null} */
    #panState = null;

    /** @type {number} */
    #lastPanTimestamp = 0;

    /** @type {{ width: number, height: number }} */
    #lastCanvasSize = { width: 0, height: 0 };

    /** @type {{ touches: Map<number, {x: number, y: number}>, initialDistance: number, initialZoom: number, initialBackgroundOffset: {x: number, y: number}, center: {x: number, y: number} } | null} */
    #pinchState = null;

    /** @type {(() => boolean) | null} */
    #isDragInProgress = null;

    /** @type {(() => void) | null} */
    #onPinchStart = null;

    /**
     * @param {HTMLElement} canvas - The `.workspace-canvas` element.
     * @param {HTMLElement | null} [worldLayer] - Optional world-space layer to transform with pan/zoom.
     * @param {(() => void) | null} [onAfterTransform] - Called after every transform update.
     */
    constructor(canvas, worldLayer = null, onAfterTransform = null) {
        this.#canvas = canvas;
        this.#worldLayer = worldLayer;
        this.#onAfterTransform = onAfterTransform;
        const initialRect = canvas.getBoundingClientRect();
        this.#lastCanvasSize = { width: initialRect.width, height: initialRect.height };
        this.#updateZoomDisplay();
        this.#bindEvents();
        this.#bindResizeObserver();
    }

    /**
     * Sets the callback fired after every transform update.
     * Assign this AFTER constructing dependent renderers to avoid temporal dead zone issues.
     *
     * @param {(() => void) | null} cb
     */
    setOnAfterTransform(cb) {
        this.#onAfterTransform = cb;
    }

    /**
     * Registers a callback that returns true when a node drag is actively in progress.
     * When true, single-finger touch and pointer pan will be suppressed so the drag
     * can move the node without competing with the pan handler.
     *
     * @param {(() => boolean) | null} fn
     */
    setDragInProgressChecker(fn) {
        this.#isDragInProgress = fn;
    }

    /**
     * Registers a callback fired when a two-finger pinch gesture begins.
     * Use this to cancel any active node drag before the zoom takes over.
     *
     * @param {(() => void) | null} fn
     */
    setOnPinchStart(fn) {
        this.#onPinchStart = fn;
    }

    /** True while a two-finger pinch gesture is in progress. */
    get isPinching() { return this.#pinchState !== null; }

    /**
     * Pans the viewport by a world-space delta.
     * Used by ItemDragger's edge-pan loop to scroll while dragging a node.
     *
     * @param {number} worldDx
     * @param {number} worldDy
     */
    panBy(worldDx, worldDy) {
        this.#setBackgroundOffset({
            x: this.#backgroundOffset.x + worldDx,
            y: this.#backgroundOffset.y + worldDy,
        });
    }

    // ─── Public ───────────────────────────────────────────────────────────────

    /** Current zoom level. */
    get zoom() { return this.#zoomLevel; }

    /** @returns {number} */
    get zoomLevel() { return this.#zoomLevel; }

    /**
     * Checks if a pan operation occurred recently (within the threshold).
     * Useful for suppressing context menus after right-click drag pans.
     *
     * @param {number} [thresholdMs=100] - Time window in milliseconds
     * @returns {boolean}
     */
    didPanRecently(thresholdMs = 100) {
        return (this.#timestamp() - this.#lastPanTimestamp) < thresholdMs;
    }

    /**
     * Returns the total effective offset (backgroundOffset + pendingLayerOffset).
     * Matches WorkspaceNavigationManager.getEffectiveOffset() in Picograph.
     *
     * @returns {{ x: number, y: number }}
     */
    getEffectiveOffset() {
        return {
            x: this.#backgroundOffset.x + this.#pendingLayerOffset.x,
            y: this.#backgroundOffset.y + this.#pendingLayerOffset.y,
        };
    }

    /** Reset view to origin, zoom 1. */
    reset() {
        if (this.#smoothZoomRafId !== null) {
            cancelAnimationFrame(this.#smoothZoomRafId);
            this.#smoothZoomRafId = null;
            this.#smoothZoomFocus = null;
        }
        this.#backgroundOffset = { x: 0, y: 0 };
        this.#pendingLayerOffset = { x: 0, y: 0 };
        this.#zoomLevel = 1;
        this.#targetZoomLevel = 1;
        this.#updateZoomDisplay();
    }

    /**
     * Animates the viewport to frame a world-space rect.
     * Respects a minimum framing fraction so nodes never appear too small.
     *
     * @param {{ x: number, y: number, width: number, height: number }} worldRect World-space bounds to frame.
     * @param {{ paddingFraction?: number, durationMs?: number, minWorldBox?: { width: number, height: number }, responsiveWorldBox?: { minViewportWidth: number, minWorldBox: { width: number, height: number }, anchorX?: number, anchorY?: number } | Array<{ minViewportWidth: number, minWorldBox: { width: number, height: number }, anchorX?: number, anchorY?: number }>, anchorX?: number, anchorY?: number }} [options]
     *   anchorX/anchorY are screen-space fractions (0–1) for where the rect centre lands.
     *   0.5,0.5 = screen centre (default). 0.0,0.5 = left edge centre. 0.0,0.0 = top-left.
     *   responsiveWorldBox allows overriding minWorldBox, anchorX, anchorY based on viewport width.
     *   Can be a single breakpoint object or an array of breakpoints (evaluated largest to smallest).
     */
    animateFocusOnRect(worldRect, options = {}) {
        let {
            paddingFraction  = 0.0,
            durationMs       = 550,
            minWorldBox      = null,   // { width, height } — minimum world-space area that must be visible
            responsiveWorldBox = null, // { minViewportWidth, minWorldBox, anchorX?, anchorY? } — overrides when viewport is smaller
            anchorX          = 0.5,    // screen-space X fraction where rect centre is placed
            anchorY          = 0.5,    // screen-space Y fraction where rect centre is placed
        } = options;

        // Cancel any ongoing smooth zoom and commit pending offset
        if (this.#smoothZoomRafId !== null) {
            cancelAnimationFrame(this.#smoothZoomRafId);
            this.#smoothZoomRafId = null;
            this.#smoothZoomFocus = null;
            const pending = this.#pendingLayerOffset;
            this.#pendingLayerOffset = { x: 0, y: 0 };
            if (Math.abs(pending.x) > 0.0001 || Math.abs(pending.y) > 0.0001) {
                this.#setBackgroundOffset({
                    x: this.#backgroundOffset.x + pending.x,
                    y: this.#backgroundOffset.y + pending.y,
                });
            }
        }

        const canvasRect = this.#canvas.getBoundingClientRect();
        const vw = canvasRect.width;
        const vh = canvasRect.height;

        // Check if we should use responsive settings
        if (responsiveWorldBox) {
            const breakpoints = Array.isArray(responsiveWorldBox) ? responsiveWorldBox : [responsiveWorldBox];
            // Sort ascending — smallest threshold that still exceeds window width wins (most specific match)
            const sorted = [...breakpoints].sort((a, b) => (a.minViewportWidth ?? 0) - (b.minViewportWidth ?? 0));
            
            // Find first matching breakpoint where window is smaller than threshold
            const windowWidth = window.innerWidth;
            for (const bp of sorted) {
                if (windowWidth < (bp.minViewportWidth ?? Infinity)) {
                    minWorldBox = bp.minWorldBox ?? minWorldBox;
                    anchorX = bp.anchorX ?? anchorX;
                    anchorY = bp.anchorY ?? anchorY;
                    break;
                }
            }
        }

        const pad = Math.min(vw, vh) * paddingFraction;
        const availW = Math.max(1, vw - pad * 2);
        const availH = Math.max(1, vh - pad * 2);

        // The framing rect is the union of worldRect and the minWorldBox centred on it
        let frameW = Math.max(1, worldRect.width);
        let frameH = Math.max(1, worldRect.height);

        if (minWorldBox) {
            // Expand frame to at least minWorldBox dimensions, centred on worldRect centre
            frameW = Math.max(frameW, minWorldBox.width  ?? 0);
            frameH = Math.max(frameH, minWorldBox.height ?? 0);
        }

        // Zoom to fit the frame rect inside the available viewport
        let targetZoom = Math.min(availW / frameW, availH / frameH);
        targetZoom = Math.max(this.#zoomConfig.min, Math.min(this.#zoomConfig.max, targetZoom));

        // Calculate the target world center based on node and minWorldBox
        const worldCx = worldRect.x + Math.max(1, worldRect.width)  / 2;
        const worldCy = worldRect.y + Math.max(1, worldRect.height) / 2;

        let targetWorldCenterX = worldCx;
        let targetWorldCenterY = worldCy;

        if (minWorldBox) {
            // When minWorldBox exists, anchor positions the node WITHIN the box.
            // The box itself should be centered on screen (ignoring anchor for box positioning).
            // Calculate worldbox bounds with node positioned at anchor within it
            const boxX = worldCx - minWorldBox.width * anchorX;
            const boxY = worldCy - minWorldBox.height * anchorY;
            targetWorldCenterX = boxX + minWorldBox.width / 2;
            targetWorldCenterY = boxY + minWorldBox.height / 2;
        }

        // Position the target point at viewport center
        const centerScreenX = pad + availW / 2;
        const centerScreenY = pad + availH / 2;
        const targetOffsetX = centerScreenX / targetZoom - targetWorldCenterX;
        const targetOffsetY = centerScreenY / targetZoom - targetWorldCenterY;

        const fromZoom    = this.#zoomLevel;
        const fromOffsetX = this.#backgroundOffset.x;
        const fromOffsetY = this.#backgroundOffset.y;

        const gen = ++this.#focusAnimGen;
        const start = this.#timestamp();

        const tick = () => {
            if (this.#focusAnimGen !== gen) return;
            const elapsed = this.#timestamp() - start;
            const t = Math.min(elapsed / durationMs, 1);
            const eased = 1 - Math.pow(1 - t, 3);

            this.#zoomLevel = fromZoom + (targetZoom - fromZoom) * eased;
            this.#targetZoomLevel = this.#zoomLevel;
            this.#setBackgroundOffset({
                x: fromOffsetX + (targetOffsetX - fromOffsetX) * eased,
                y: fromOffsetY + (targetOffsetY - fromOffsetY) * eased,
            });
            this.#updateZoomDisplay();

            if (t < 1) {
                this.#focusAnimRafId = requestAnimationFrame(tick);
            } else {
                this.#focusAnimRafId = null;
                this.#zoomLevel = targetZoom;
                this.#targetZoomLevel = targetZoom;
            }
        };
        this.#focusAnimRafId = requestAnimationFrame(tick);
    }

    /**
     * Instantly fits a world-space rect into the viewport with optional padding.
     * The zoom is chosen to fit the rect; aspect-ratio mismatches are letter-boxed.
     *
     * @param {{ x: number, y: number, width: number, height: number }} worldRect
     * @param {{ paddingFraction?: number }} [options]
     */
    focusOnRect(worldRect, { paddingFraction = 0, anchorX = 0.5, anchorY = 0.5 } = {}) {
        if (this.#smoothZoomRafId !== null) {
            cancelAnimationFrame(this.#smoothZoomRafId);
            this.#smoothZoomRafId = null;
            this.#smoothZoomFocus = null;
        }
        if (this.#focusAnimRafId !== null) {
            cancelAnimationFrame(this.#focusAnimRafId);
            this.#focusAnimRafId = null;
            this.#focusAnimGen++;
        }
        this.#pendingLayerOffset = { x: 0, y: 0 };
        const canvas = this.#canvas.getBoundingClientRect();
        const vw = canvas.width;
        const vh = canvas.height;
        const pad  = Math.min(vw, vh) * paddingFraction;
        const availW = Math.max(1, vw - pad * 2);
        const availH = Math.max(1, vh - pad * 2);
        const zoom = Math.max(
            this.#zoomConfig.min,
            Math.min(this.#zoomConfig.max,
                Math.min(availW / Math.max(1, worldRect.width), availH / Math.max(1, worldRect.height))
            )
        );
        const centerScreenX = pad + anchorX * availW;
        const centerScreenY = pad + anchorY * availH;
        const cx = worldRect.x + worldRect.width  / 2;
        const cy = worldRect.y + worldRect.height / 2;
        this.#zoomLevel = zoom;
        this.#targetZoomLevel = zoom;
        this.#setBackgroundOffset({
            x: centerScreenX / zoom - cx,
            y: centerScreenY / zoom - cy,
        });
        this.#updateZoomDisplay();
    }

    /**
     * Immediately centres the viewport on a world-space point at the given zoom.
     * @param {{ x: number, y: number }} worldPoint World-space coordinates to centre on.
     * @param {number} [zoom] Zoom level to snap to.
     */
    focusOnWorld(worldPoint, zoom = 1) {
        if (this.#smoothZoomRafId !== null) {
            cancelAnimationFrame(this.#smoothZoomRafId);
            this.#smoothZoomRafId = null;
            this.#smoothZoomFocus = null;
        }
        this.#pendingLayerOffset = { x: 0, y: 0 };
        const clamped = Math.max(this.#zoomConfig.min, Math.min(this.#zoomConfig.max, zoom));
        this.#zoomLevel = clamped;
        this.#targetZoomLevel = clamped;
        const rect = this.#canvas.getBoundingClientRect();
        this.#setBackgroundOffset({
            x: rect.width  / 2 / clamped - worldPoint.x,
            y: rect.height / 2 / clamped - worldPoint.y,
        });
        this.#updateZoomDisplay();
    }

    /**
     * Returns the current viewport info in world space.
     *
     * @returns {{ zoom: number, center: { x: number, y: number }, viewbox: { x: number, y: number, width: number, height: number } }}
     */
    getViewInfo() {
        const rect = this.#canvas.getBoundingClientRect();
        const zoom = this.#zoomLevel || 1;
        const eff  = this.getEffectiveOffset();
        const vx   = -eff.x;
        const vy   = -eff.y;
        const vw   = rect.width  / zoom;
        const vh   = rect.height / zoom;
        return {
            zoom,
            viewbox: { x: vx, y: vy, width: vw, height: vh },
            center:  { x: vx + vw / 2, y: vy + vh / 2 },
        };
    }

    // ─── Events ───────────────────────────────────────────────────────────────

    #bindResizeObserver() {
        const observer = new ResizeObserver((entries) => {
            for (const entry of entries) {
                const { inlineSize: newW, blockSize: newH } = entry.contentBoxSize[0];
                const dw = newW - this.#lastCanvasSize.width;
                const dh = newH - this.#lastCanvasSize.height;
                this.#lastCanvasSize = { width: newW, height: newH };
                if (Math.abs(dw) < 0.5 && Math.abs(dh) < 0.5) continue;
                const zoom = this.#zoomLevel || 1;
                this.#setBackgroundOffset({
                    x: this.#backgroundOffset.x + dw / 2 / zoom,
                    y: this.#backgroundOffset.y + dh / 2 / zoom,
                });
                this.#updateZoomDisplay();
            }
        });
        observer.observe(this.#canvas);
    }

    #bindEvents() {
        this.#canvas.addEventListener("wheel", (e) => this.#onWheel(e), { passive: false });
        this.#canvas.addEventListener("pointerdown", (e) => this.#onPointerDown(e));
        this.#canvas.addEventListener("touchstart", (e) => this.#onTouchStart(e), { passive: false });
        this.#canvas.addEventListener("touchmove", (e) => this.#onTouchMove(e), { passive: false });
        this.#canvas.addEventListener("touchend", (e) => this.#onTouchEnd(e), { passive: false });
        this.#canvas.addEventListener("touchcancel", (e) => this.#onTouchEnd(e), { passive: false });
        this.#canvas.addEventListener("contextmenu", (e) => {
            // Suppress context menu if pan just ended (matches original 200ms threshold)
            if (this.#timestamp() - this.#lastPanTimestamp < 200) {
                e.preventDefault();
                return;
            }
            e.preventDefault();
        });
    }

    /** @param {WheelEvent} e */
    #onWheel(e) {
        if (!Number.isFinite(e.deltaY) || e.deltaY === 0) return;

        const rect = this.#canvas.getBoundingClientRect();
        const screenPoint = {
            x: e.clientX - rect.left,
            y: e.clientY - rect.top,
        };

        if (screenPoint.x < 0 || screenPoint.y < 0 || screenPoint.x > rect.width || screenPoint.y > rect.height) return;

        // Check if scrolling over a code block with max-height - let it scroll
        const codeBlock = e.target.closest('.md-pre');
        if (codeBlock && codeBlock.scrollHeight > codeBlock.clientHeight) {
            const scrollTop = codeBlock.scrollTop;
            const scrollHeight = codeBlock.scrollHeight;
            const clientHeight = codeBlock.clientHeight;
            const tolerance = 1; // Add tolerance for precision issues
            
            const canScrollDown = scrollTop < (scrollHeight - clientHeight - tolerance);
            const canScrollUp = scrollTop > tolerance;
            
            // Allow scrolling if there's room to scroll in that direction
            if ((e.deltaY > 0 && canScrollDown) || (e.deltaY < 0 && canScrollUp)) {
                return; // Don't prevent default, let the element scroll
            }
            // If at the edge, fall through to camera pan
        }

        // Check if scrolling over an info or blog_post node - pan the camera vertically
        const blueprintNode = e.target.closest('.blueprint-node');
        if (blueprintNode) {
            const nodeType = blueprintNode.dataset.nodeType;
            if (nodeType === 'info' || nodeType === 'blog_post') {
                e.preventDefault();
                const panAmount = e.deltaY * 0.5;
                this.#pushSmoothPan(panAmount);
                return;
            }
        }

        // Not over a scrollable node or code block, proceed with zoom
        e.preventDefault();
        const direction = e.deltaY < 0 ? 1 : -1;
        this.#pushSmoothZoom(screenPoint, direction, { clientX: e.clientX, clientY: e.clientY });
    }

    /** @param {PointerEvent} e */
    #onPointerDown(e) {
        if (e.button !== 0 && e.button !== 2) return;
        
        // Allow text selection in blog/info nodes
        const nodeBody = e.target.closest('.node-body');
        if (nodeBody) {
            const blueprintNode = nodeBody.closest('.blueprint-node');
            if (blueprintNode) {
                const nodeType = blueprintNode.dataset.nodeType;
                if (nodeType === 'info' || nodeType === 'blog_post') {
                    return; // Don't pan, allow text selection
                }
            }
        }
        
        this.#beginPan(e);
    }

    // ─── Touch (Pinch-to-Zoom) ────────────────────────────────────────────────

    /** @param {TouchEvent} e */
    #onTouchStart(e) {
        if (e.touches.length === 2) {
            e.preventDefault();
            // Drop any active node drag before pinch-zoom takes control
            this.#onPinchStart?.();
            const rect = this.#canvas.getBoundingClientRect();
            const t1 = e.touches[0];
            const t2 = e.touches[1];
            
            const p1 = { x: t1.clientX - rect.left, y: t1.clientY - rect.top };
            const p2 = { x: t2.clientX - rect.left, y: t2.clientY - rect.top };
            
            const distance = Math.hypot(p2.x - p1.x, p2.y - p1.y);
            const center = {
                x: (p1.x + p2.x) / 2,
                y: (p1.y + p2.y) / 2
            };
            
            this.#pinchState = {
                touches: new Map([
                    [t1.identifier, p1],
                    [t2.identifier, p2]
                ]),
                initialDistance: distance,
                initialZoom: this.#zoomLevel,
                initialBackgroundOffset: { ...this.#backgroundOffset },
                center: center
            };
            
            // Cancel pan state when pinch starts
            this.#panState = null;
        } else if (e.touches.length === 1 && !this.#panState) {
            // If a node drag is in progress, suppress single-finger pan so the node moves instead
            if (this.#isDragInProgress?.()) return;
            // Single finger pan - only if not already panning
            const touch = e.touches[0];
            this.#panState = {
                pointerId: touch.identifier,
                startX: touch.clientX,
                startY: touch.clientY,
                currentClientX: touch.clientX,
                currentClientY: touch.clientY,
                hasMoved: false,
                backgroundOrigin: { ...this.#backgroundOffset },
                lastDelta: { x: 0, y: 0 },
            };
        }
    }

    /** @param {TouchEvent} e */
    #onTouchMove(e) {
        // Handle two-finger pinch + pan
        if (this.#pinchState && e.touches.length === 2) {
            e.preventDefault();
            const rect = this.#canvas.getBoundingClientRect();
            const t1 = e.touches[0];
            const t2 = e.touches[1];
            
            const p1 = { x: t1.clientX - rect.left, y: t1.clientY - rect.top };
            const p2 = { x: t2.clientX - rect.left, y: t2.clientY - rect.top };
            
            // Calculate current distance for zoom
            const currentDistance = Math.hypot(p2.x - p1.x, p2.y - p1.y);
            const scale = currentDistance / this.#pinchState.initialDistance;
            
            let newZoom = this.#pinchState.initialZoom * scale;
            newZoom = Math.max(this.#zoomConfig.min, Math.min(this.#zoomConfig.max, newZoom));
            
            // Calculate current center for pan
            const currentCenter = {
                x: (p1.x + p2.x) / 2,
                y: (p1.y + p2.y) / 2
            };
            
            // Pan delta in screen space
            const panDeltaScreen = {
                x: currentCenter.x - this.#pinchState.center.x,
                y: currentCenter.y - this.#pinchState.center.y
            };
            
            const previousZoom = this.#zoomLevel;
            const initialCenter = this.#pinchState.center;
            
            // World point under initial pinch center (using initial background offset)
            const worldPoint = {
                x: initialCenter.x / this.#pinchState.initialZoom - this.#pinchState.initialBackgroundOffset.x,
                y: initialCenter.y / this.#pinchState.initialZoom - this.#pinchState.initialBackgroundOffset.y
            };
            
            this.#zoomLevel = newZoom;
            this.#targetZoomLevel = newZoom;
            
            // Adjust offset to keep world point under pinch center AND apply pan
            this.#setBackgroundOffset({
                x: initialCenter.x / newZoom - worldPoint.x + panDeltaScreen.x / newZoom,
                y: initialCenter.y / newZoom - worldPoint.y + panDeltaScreen.y / newZoom
            });
            
            this.#updateZoomDisplay();
            return;
        }
        
        // Handle single-finger pan
        if (this.#panState && e.touches.length === 1) {
            const touch = e.touches[0];
            
            this.#panState.currentClientX = touch.clientX;
            this.#panState.currentClientY = touch.clientY;

            const deltaXScreen = touch.clientX - this.#panState.startX;
            const deltaYScreen = touch.clientY - this.#panState.startY;
            const zoom = this.#zoomLevel || 1;
            const worldDelta = { x: deltaXScreen / zoom, y: deltaYScreen / zoom };

            if (!this.#panState.hasMoved) {
                if (Math.hypot(deltaXScreen, deltaYScreen) < 3) return;
                e.preventDefault();
                this.#panState.hasMoved = true;
            }

            if (this.#panState.hasMoved) {
                e.preventDefault();
            }

            this.#panState.lastDelta = worldDelta;
            this.#setBackgroundOffset({
                x: this.#panState.backgroundOrigin.x + worldDelta.x,
                y: this.#panState.backgroundOrigin.y + worldDelta.y,
            });
            this.#updateZoomDisplay();
        }
    }

    /** @param {TouchEvent} e */
    #onTouchEnd(e) {
        // Clear pinch state if we go below 2 touches
        if (this.#pinchState && e.touches.length < 2) {
            this.#pinchState = null;
        }
        
        // Clear pan state if no touches remain
        if (this.#panState && e.touches.length === 0) {
            const finalState = this.#panState;
            this.#panState = null;
            if (finalState.hasMoved) {
                this.#lastPanTimestamp = this.#timestamp();
            }
        }
    }

    // ─── Pan ─────────────────────────────────────────────────────────────────

    /** @param {PointerEvent} event */
    #beginPan(event) {
        if (this.#panState || this.#pinchState) return;
        // Do not start a pan while a node drag is actively in progress
        if (this.#isDragInProgress?.()) return;

        // Cancel any in-progress smooth pan
        if (this.#smoothPanRafId !== null) {
            cancelAnimationFrame(this.#smoothPanRafId);
            this.#smoothPanRafId = null;
        }

        const state = {
            pointerId: event.pointerId,
            startX: event.clientX,
            startY: event.clientY,
            currentClientX: event.clientX,
            currentClientY: event.clientY,
            hasMoved: false,
            backgroundOrigin: { ...this.#backgroundOffset },
            lastDelta: { x: 0, y: 0 },
        };

        /** @param {PointerEvent} moveEvent */
        const handlePointerMove = (moveEvent) => {
            if (!this.#panState || moveEvent.pointerId !== this.#panState.pointerId) return;

            this.#panState.currentClientX = moveEvent.clientX;
            this.#panState.currentClientY = moveEvent.clientY;

            const deltaXScreen = moveEvent.clientX - this.#panState.startX;
            const deltaYScreen = moveEvent.clientY - this.#panState.startY;
            const zoom = this.#zoomLevel || 1;
            const worldDelta = { x: deltaXScreen / zoom, y: deltaYScreen / zoom };

            if (!this.#panState.hasMoved) {
                if (Math.hypot(deltaXScreen, deltaYScreen) < 3) return;
                moveEvent.preventDefault();
                this.#panState.hasMoved = true;
            }

            this.#panState.lastDelta = worldDelta;
            this.#setBackgroundOffset({
                x: this.#panState.backgroundOrigin.x + worldDelta.x,
                y: this.#panState.backgroundOrigin.y + worldDelta.y,
            });
            this.#updateZoomDisplay();
        };

        /** @param {PointerEvent} upEvent */
        const handlePointerUp = (upEvent) => {
            if (!this.#panState || upEvent.pointerId !== this.#panState.pointerId) return;
            window.removeEventListener("pointermove", handlePointerMove);
            window.removeEventListener("pointerup", handlePointerUp);
            window.removeEventListener("pointercancel", handlePointerUp);
            const finalState = this.#panState;
            this.#panState = null;
            if (finalState.hasMoved) {
                this.#lastPanTimestamp = this.#timestamp();
            }
        };

        this.#panState = state;
        window.addEventListener("pointermove", handlePointerMove);
        window.addEventListener("pointerup", handlePointerUp);
        window.addEventListener("pointercancel", handlePointerUp);
    }

    // ─── Smooth Zoom ──────────────────────────────────────────────────────────

    /**
     * @param {{ x: number, y: number }} screenPoint
     * @param {number} direction
     * @param {{ clientX?: number, clientY?: number }} [pointer]
     */
    #pushSmoothZoom(screenPoint, direction, pointer = {}) {
        if (!direction) return;
        // Cancel any in-progress rect focus animation
        if (this.#focusAnimRafId !== null) {
            cancelAnimationFrame(this.#focusAnimRafId);
            this.#focusAnimRafId = null;
            this.#focusAnimGen++;
        }
        const step = direction * this.#zoomConfig.step;
        this.#targetZoomLevel = Math.max(
            this.#zoomConfig.min,
            Math.min(this.#zoomConfig.max, this.#targetZoomLevel + step)
        );
        this.#smoothZoomFocus = { screenPoint, pointer };
        if (this.#smoothZoomRafId === null) {
            this.#smoothZoomRafId = requestAnimationFrame(() => this.#tickSmoothZoom());
        }
    }

    #tickSmoothZoom() {
        this.#smoothZoomRafId = null;
        if (!this.#smoothZoomFocus) return;

        const target = this.#targetZoomLevel;
        const current = this.#zoomLevel;
        const diff = target - current;
        const settled = Math.abs(diff) < 0.001;
        const newZoom = settled
            ? target
            : Math.max(this.#zoomConfig.min, Math.min(this.#zoomConfig.max, current + diff * 0.25));

        const { screenPoint } = this.#smoothZoomFocus;
        const previousZoom = current;

        // World point under cursor (using old zoom)
        const worldPoint = {
            x: screenPoint.x / previousZoom,
            y: screenPoint.y / previousZoom,
        };

        this.#zoomLevel = newZoom;

        const scale = previousZoom / newZoom;
        const shift = {
            x: worldPoint.x * (scale - 1),
            y: worldPoint.y * (scale - 1),
        };

        this.#pendingLayerOffset.x += shift.x;
        this.#pendingLayerOffset.y += shift.y;

        // Rebase active pan so world deltas stay stable mid-zoom
        if (Math.abs(shift.x) > 0.0001 || Math.abs(shift.y) > 0.0001) {
            this.#rebasePanAfterZoom(this.#smoothZoomFocus.pointer);
        }

        this.#canvas.style.setProperty("--workspace-zoom", `${newZoom}`);
        this.#applyBackgroundOffset();

        if (settled) {
            this.#smoothZoomFocus = null;
            const finalOffset = this.#pendingLayerOffset;
            this.#pendingLayerOffset = { x: 0, y: 0 };
            if (Math.abs(finalOffset.x) > 0.0001 || Math.abs(finalOffset.y) > 0.0001) {
                // Commit pivot into backgroundOffset (no nodes to translate on the website)
                this.#setBackgroundOffset({
                    x: this.#backgroundOffset.x + finalOffset.x,
                    y: this.#backgroundOffset.y + finalOffset.y,
                });
                // Also rebase pan origin so pan doesn't jump
                if (this.#panState) {
                    this.#panState.backgroundOrigin.x += finalOffset.x;
                    this.#panState.backgroundOrigin.y += finalOffset.y;
                }
            }
            this.#updateZoomDisplay();
        } else {
            this.#smoothZoomRafId = requestAnimationFrame(() => this.#tickSmoothZoom());
        }
    }

    // ─── Smooth Pan ───────────────────────────────────────────────────────────

    /**
     * @param {number} deltaY
     */
    #pushSmoothPan(deltaY) {
        // Cancel any in-progress zoom or focus animation
        if (this.#smoothZoomRafId !== null) {
            cancelAnimationFrame(this.#smoothZoomRafId);
            this.#smoothZoomRafId = null;
            this.#smoothZoomFocus = null;
        }
        if (this.#focusAnimRafId !== null) {
            cancelAnimationFrame(this.#focusAnimRafId);
            this.#focusAnimRafId = null;
            this.#focusAnimGen++;
        }
        
        // Accumulate the pan delta
        this.#targetPanOffset.y -= deltaY / this.#zoomLevel;
        
        // Start animation loop if not already running
        if (this.#smoothPanRafId === null) {
            this.#smoothPanRafId = requestAnimationFrame(() => this.#tickSmoothPan());
        }
    }

    #tickSmoothPan() {
        this.#smoothPanRafId = null;
        
        const targetY = this.#targetPanOffset.y;
        const currentY = this.#backgroundOffset.y;
        const diffY = targetY - currentY;
        const settled = Math.abs(diffY) < 0.01;
        
        if (settled) {
            // Snap to target and stop
            this.#backgroundOffset.y = targetY;
            this.#targetPanOffset.y = targetY;
            this.#applyBackgroundOffset();
        } else {
            // Ease toward target
            this.#backgroundOffset.y += diffY * 0.25;
            this.#applyBackgroundOffset();
            this.#smoothPanRafId = requestAnimationFrame(() => this.#tickSmoothPan());
        }
    }

    // ─── Transform helpers ────────────────────────────────────────────────────

    /** @param {{ x: number, y: number }} offset */
    #setBackgroundOffset(offset) {
        this.#backgroundOffset = {
            x: Number.isFinite(offset.x) ? offset.x : 0,
            y: Number.isFinite(offset.y) ? offset.y : 0,
        };
        // Keep target pan offset in sync
        this.#targetPanOffset = { ...this.#backgroundOffset };
        this.#applyBackgroundOffset();
    }

    #applyBackgroundOffset() {
        const zoom = this.#zoomLevel || 1;
        const scaledX = (this.#backgroundOffset.x + this.#pendingLayerOffset.x) * zoom;
        const scaledY = (this.#backgroundOffset.y + this.#pendingLayerOffset.y) * zoom;
        const position = `${scaledX}px ${scaledY}px`;
        this.#canvas.style.backgroundPosition = `${position}, ${position}, ${position}, ${position}`;
        this.#applyWorldLayerTransform();
    }

    #updateZoomDisplay() {
        const zoom = this.#zoomLevel || 1;
        this.#canvas.style.setProperty("--workspace-zoom", `${zoom}`);
        this.#applyBackgroundOffset(); // also calls #applyWorldLayerTransform
    }

    #applyWorldLayerTransform() {
        if (!this.#worldLayer) return;
        const zoom = this.#zoomLevel || 1;
        const tx = (this.#backgroundOffset.x + this.#pendingLayerOffset.x) * zoom;
        const ty = (this.#backgroundOffset.y + this.#pendingLayerOffset.y) * zoom;
        this.#worldLayer.style.transformOrigin = "0 0";
        this.#worldLayer.style.transform = `translate(${tx}px, ${ty}px) scale(${zoom})`;
        this.#onAfterTransform?.();
    }

    /** @param {{ clientX?: number, clientY?: number }} [pointer] */
    #rebasePanAfterZoom(pointer) {
        if (!this.#panState) return;
        const zoom = this.#zoomLevel || 1;
        const clientX = Number.isFinite(this.#panState.currentClientX)
            ? this.#panState.currentClientX
            : pointer?.clientX;
        const clientY = Number.isFinite(this.#panState.currentClientY)
            ? this.#panState.currentClientY
            : pointer?.clientY;
        if (Number.isFinite(clientX)) {
            this.#panState.startX = clientX - this.#panState.lastDelta.x * zoom;
        }
        if (Number.isFinite(clientY)) {
            this.#panState.startY = clientY - this.#panState.lastDelta.y * zoom;
        }
    }

    /** @returns {number} */
    #timestamp() {
        return typeof performance !== "undefined" ? performance.now() : Date.now();
    }
}
