/**
 * Graph SVG canvas — pan, zoom, viewBox management.
 */

const MIN_ZOOM = 0.15;
const MAX_ZOOM = 3;
const ZOOM_SENSITIVITY = 0.001;
const PAN_DEAD_ZONE = 4; // px before drag starts
const BOUNDS_MARGIN_FACTOR = 0.5; // allow panning half a viewport past data bounds

export class GraphCanvas {
    svg: SVGSVGElement;
    root: SVGGElement;
    blockPan: boolean;

    private _vx: number;
    private _vy: number;
    private _zoom: number;
    private _panning: boolean;
    private _panPending: boolean;
    private _panStart: { x: number; y: number } | null;
    private _panViewStart: { x: number; y: number } | null;
    private _listeners: any[];
    private _onZoomChange: ((zoom: number) => void) | null;
    private _onViewChange: ((viewport: any) => void) | null;
    private _justPanned: boolean;
    private _bounds: { x: number; y: number; width: number; height: number } | null;
    private _zoomVelocity: number;
    private _zoomInertiaAnim: number | null;
    private _lastWheelX: number;
    private _lastWheelY: number;
    private _pointers: Map<number, { x: number; y: number }>;
    private _pinchStartDist: number;
    private _pinchStartZoom: number;
    private _pinchMidX: number;
    private _pinchMidY: number;
    private _lastTapTime: number;
    private _lastTapX: number;
    private _lastTapY: number;
    private _isTouch: boolean;
    private _zoomAnim: number | null;
    private _resizeObs: ResizeObserver | null;
    private _lastSvgRect: DOMRect;
    private _panDeadZone: number;

    constructor(svg: SVGSVGElement) {
        this.svg = svg;
        this.root = svg.querySelector('.graph-root') as SVGGElement;
        this._vx = 0;
        this._vy = 0;
        this._zoom = 1;
        this._panning = false;
        this._panPending = false; // left-click pending dead-zone check
        this._panStart = null;
        this._panViewStart = null;
        this._listeners = [];
        this._onZoomChange = null;
        this._onViewChange = null; // called on every pan/zoom with getViewport()
        this._justPanned = false; // true briefly after a pan ends, survives through click event
        this._bounds = null; // data bounds for view clamping {x, y, width, height}
        /** Set to true externally to suppress pan on left-click (e.g. during node drag). */
        this.blockPan = false;
        // Zoom inertia state
        this._zoomVelocity = 0;
        this._zoomInertiaAnim = null;
        this._lastWheelX = 0;
        this._lastWheelY = 0;
        // Touch / multi-pointer state
        this._pointers = new Map(); // pointerId → {x, y}
        this._pinchStartDist = 0;
        this._pinchStartZoom = 1;
        this._pinchMidX = 0;
        this._pinchMidY = 0;
        // Double-tap detection
        this._lastTapTime = 0;
        this._lastTapX = 0;
        this._lastTapY = 0;
        this._isTouch = false;
        this._zoomAnim = null;
        this._resizeObs = null;
        this._panDeadZone = PAN_DEAD_ZONE;
        this._lastSvgRect = this.svg.getBoundingClientRect();
        this._bind();
    }

    get zoom() { return this._zoom; }
    get viewX() { return this._vx; }
    get viewY() { return this._vy; }
    /** True while the user is actively panning (drag started). */
    get isPanning() { return this._panning; }
    /** True briefly after a pan gesture ends — use to suppress click-after-pan. */
    get wasPanning() { return this._justPanned; }

    set onZoomChange(fn: ((zoom: number) => void) | null) { this._onZoomChange = fn; }
    set onViewChange(fn: ((viewport: any) => void) | null) { this._onViewChange = fn; }

    /** Set data bounds for view clamping. */
    setBounds(bounds: { x: number; y: number; width: number; height: number } | null) { this._bounds = bounds; }

    /** Get the visible viewport in graph coordinates. */
    getViewport() {
        const r = this.svg.getBoundingClientRect();
        return {
            x: this._vx,
            y: this._vy,
            width: r.width / this._zoom,
            height: r.height / this._zoom,
        };
    }

    /** Convert screen (client) coordinates to graph coordinates. */
    screenToGraph(sx: number, sy: number) {
        const r = this.svg.getBoundingClientRect();
        return {
            x: (sx - r.left) / this._zoom + this._vx,
            y: (sy - r.top) / this._zoom + this._vy,
        };
    }

    /** Set view to center on a point at current zoom. */
    panTo(gx: number, gy: number, animate = true) {
        const r = this.svg.getBoundingClientRect();
        this._vx = gx - (r.width / this._zoom) / 2;
        this._vy = gy - (r.height / this._zoom) / 2;
        this._applyTransform(animate);
    }

    /** Fit all content within the viewport with padding. */
    fitAll(bounds: { x: number; y: number; width: number; height: number } | null, animate = true) {
        if (!bounds) return;
        const r = this.svg.getBoundingClientRect();
        const pad = 60;
        const bw = bounds.width + pad * 2;
        const bh = bounds.height + pad * 2;
        const zx = r.width / bw;
        const zy = r.height / bh;
        this._zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, Math.min(zx, zy)));
        this._vx = bounds.x - pad - (r.width / this._zoom - bw) / 2;
        this._vy = bounds.y - pad - (r.height / this._zoom - bh) / 2;
        this._applyTransform(animate);
        if (this._onZoomChange) this._onZoomChange(this._zoom);
    }

    /** Set zoom level centered on screen point. */
    zoomTo(level: number, cx?: number, cy?: number) {
        const r = this.svg.getBoundingClientRect();
        const mx = cx !== undefined ? cx : r.width / 2 + r.left;
        const my = cy !== undefined ? cy : r.height / 2 + r.top;
        const gx = (mx - r.left) / this._zoom + this._vx;
        const gy = (my - r.top) / this._zoom + this._vy;
        this._zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, level));
        this._vx = gx - (mx - r.left) / this._zoom;
        this._vy = gy - (my - r.top) / this._zoom;
        this._applyTransform(false);
        if (this._onZoomChange) this._onZoomChange(this._zoom);
    }

    /**
     * Animate zoom + pan to center on a graph-space point.
     * Interpolates the view center in graph-space so the target smoothly
     * slides to screen center while zoom changes simultaneously.
     */
    zoomToPoint(level: number, gx: number, gy: number, duration = 500) {
        if (this._zoomAnim) cancelAnimationFrame(this._zoomAnim);

        const r = this.svg.getBoundingClientRect();
        const hw = r.width / 2;
        const hh = r.height / 2;

        const startZoom = this._zoom;
        const targetZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, level));
        // Current view center in graph-space
        const startCx = this._vx + hw / startZoom;
        const startCy = this._vy + hh / startZoom;
        const t0 = performance.now();

        const step = (now: number) => {
            const elapsed = now - t0;
            const p = Math.min(elapsed / duration, 1);
            // Ease-in-out cubic
            const t = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;

            // Interpolate zoom and view center together
            this._zoom = startZoom + (targetZoom - startZoom) * t;
            const cx = startCx + (gx - startCx) * t;
            const cy = startCy + (gy - startCy) * t;
            // Convert center back to vx/vy for current zoom
            this._vx = cx - hw / this._zoom;
            this._vy = cy - hh / this._zoom;

            this._applyTransform(false);
            if (this._onZoomChange) this._onZoomChange(this._zoom);

            if (p < 1) {
                this._zoomAnim = requestAnimationFrame(step);
            } else {
                this._zoomAnim = null;
            }
        };
        this._zoomAnim = requestAnimationFrame(step);
    }

    zoomIn() { this._buttonZoomKick(0.06); }
    zoomOut() { this._buttonZoomKick(-0.06); }

    _buttonZoomKick(impulse: number) {
        if (this._zoomAnim) { cancelAnimationFrame(this._zoomAnim); this._zoomAnim = null; }
        const r = this.svg.getBoundingClientRect();
        this._lastWheelX = r.left + r.width / 2;
        this._lastWheelY = r.top + r.height / 2;
        this._zoomVelocity = this._zoomVelocity * 0.5 + impulse;
        const newZoom = this._zoom * (1 + impulse);
        this.zoomTo(newZoom, this._lastWheelX, this._lastWheelY);
        if (!this._zoomInertiaAnim) this._tickZoomInertia();
    }

    destroy() {
        for (const [el, ev, fn, opts] of this._listeners) {
            el.removeEventListener(ev, fn, opts);
        }
        this._listeners = [];
        if (this._resizeObs) { this._resizeObs.disconnect(); this._resizeObs = null; }
    }

    // ── Private ──

    _on(el: any, ev: string, fn: any, opts?: any) {
        el.addEventListener(ev, fn, opts);
        this._listeners.push([el, ev, fn, opts]);
    }

    _bind() {
        this._on(this.svg, 'wheel', this._onWheel.bind(this), { passive: false });
        this._on(this.svg, 'pointerdown', this._onPointerDown.bind(this));
        this._on(window, 'pointermove', this._onPointerMove.bind(this));
        this._on(window, 'pointerup', this._onPointerUp.bind(this));
        this._on(window, 'pointercancel', this._onPointerUp.bind(this));
        // Preserve view center on container resize (fullscreen, window resize)
        this._resizeObs = new ResizeObserver(() => this._onResize());
        this._resizeObs.observe(this.svg);
        this._lastSvgRect = this.svg.getBoundingClientRect();
        // Prevent default touch actions on the SVG (browser pan/zoom)
        (this.svg as any).style.touchAction = 'none';
    }

    _onResize() {
        const prev = this._lastSvgRect;
        const next = this.svg.getBoundingClientRect();
        this._lastSvgRect = next;
        if (!prev || prev.width === 0 || prev.height === 0) return;
        // Compute center in graph-space using old dimensions
        const cx = this._vx + (prev.width / 2) / this._zoom;
        const cy = this._vy + (prev.height / 2) / this._zoom;
        // Recompute vx/vy to keep same center with new dimensions
        this._vx = cx - (next.width / 2) / this._zoom;
        this._vy = cy - (next.height / 2) / this._zoom;
        this._applyTransform(false);
    }

    _onWheel(e: WheelEvent) {
        e.preventDefault();
        if (this._zoomAnim) { cancelAnimationFrame(this._zoomAnim); this._zoomAnim = null; }

        // Accumulate velocity for inertia (capped to prevent runaway)
        const impulse = -e.deltaY * ZOOM_SENSITIVITY;
        this._zoomVelocity = this._zoomVelocity * 0.5 + impulse * 0.3;
        this._lastWheelX = e.clientX;
        this._lastWheelY = e.clientY;

        // Apply immediate step
        const newZoom = this._zoom * (1 + impulse);
        this.zoomTo(newZoom, e.clientX, e.clientY);

        // Start inertia decay if not already running
        if (!this._zoomInertiaAnim) this._tickZoomInertia();
    }

    _tickZoomInertia() {
        const FRICTION = 0.85;
        const MIN_VEL = 0.0003;

        this._zoomVelocity *= FRICTION;

        if (Math.abs(this._zoomVelocity) < MIN_VEL) {
            this._zoomVelocity = 0;
            this._zoomInertiaAnim = null;
            return;
        }

        const newZoom = this._zoom * (1 + this._zoomVelocity);
        this.zoomTo(newZoom, this._lastWheelX, this._lastWheelY);

        this._zoomInertiaAnim = requestAnimationFrame(() => this._tickZoomInertia());
    }

    _pointerDist() {
        if (this._pointers.size < 2) return 0;
        const pts = [...this._pointers.values()];
        const dx = pts[1].x - pts[0].x;
        const dy = pts[1].y - pts[0].y;
        return Math.sqrt(dx * dx + dy * dy);
    }

    _pointerMid() {
        if (this._pointers.size < 2) return null;
        const pts = [...this._pointers.values()];
        return { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 };
    }

    _onPointerDown(e: PointerEvent) {
        this._isTouch = e.pointerType === 'touch';
        const deadZone = this._isTouch ? 10 : PAN_DEAD_ZONE;

        // Track pointer for multi-touch
        this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });

        // Second finger → start pinch-to-zoom
        if (this._pointers.size === 2) {
            this._panning = false;
            this._panPending = false;
            this.svg.classList.remove('panning');
            this._pinchStartDist = this._pointerDist();
            this._pinchStartZoom = this._zoom;
            const mid = this._pointerMid()!;
            this._pinchMidX = mid.x;
            this._pinchMidY = mid.y;
            this._panStart = { x: mid.x, y: mid.y };
            this._panViewStart = { x: this._vx, y: this._vy };
            return;
        }

        // Ignore 3+ fingers
        if (this._pointers.size > 2) return;

        // Middle button or Ctrl/Meta+left → immediate pan
        if (e.button === 1 || (e.button === 0 && (e.ctrlKey || e.metaKey))) {
            e.preventDefault();
            this._startPan(e);
            return;
        }

        // Left-click / single touch on SVG background → pending pan
        if (e.button === 0 && !this.blockPan) {
            const target = e.target as Element;
            const onNode = target.closest('.graph-node');
            if (!onNode) {
                this._panPending = true;
                this._panDeadZone = deadZone;
                this._panStart = { x: e.clientX, y: e.clientY };
                this._panViewStart = { x: this._vx, y: this._vy };
            }
        }
    }

    _onPointerMove(e: PointerEvent) {
        // Update tracked pointer position
        if (this._pointers.has(e.pointerId)) {
            this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
        }

        // Pinch-to-zoom (2 fingers)
        if (this._pointers.size === 2 && this._pinchStartDist > 0) {
            const dist = this._pointerDist();
            const scale = dist / this._pinchStartDist;
            const newZoom = this._pinchStartZoom * scale;
            const mid = this._pointerMid()!;

            // Zoom around pinch midpoint
            this.zoomTo(newZoom, mid.x, mid.y);

            // Pan with pinch movement
            const dx = (mid.x - this._panStart!.x) / this._zoom;
            const dy = (mid.y - this._panStart!.y) / this._zoom;
            this._vx = this._panViewStart!.x - dx;
            this._vy = this._panViewStart!.y - dy;
            this._applyTransform(false);
            if (this._onZoomChange) this._onZoomChange(this._zoom);
            return;
        }

        // Check dead-zone for pending single-finger pan
        const dz = this._panDeadZone || PAN_DEAD_ZONE;
        if (this._panPending && !this._panning) {
            const dx = e.clientX - this._panStart!.x;
            const dy = e.clientY - this._panStart!.y;
            if (Math.abs(dx) > dz || Math.abs(dy) > dz) {
                this._panning = true;
                this.svg.classList.add('panning');
                this.svg.setPointerCapture(e.pointerId);
            }
        }

        if (!this._panning) return;
        const dx = (e.clientX - this._panStart!.x) / this._zoom;
        const dy = (e.clientY - this._panStart!.y) / this._zoom;
        this._vx = this._panViewStart!.x - dx;
        this._vy = this._panViewStart!.y - dy;
        this._applyTransform(false);
    }

    _onPointerUp(e: PointerEvent) {
        this._pointers.delete(e.pointerId);

        // If we were pinching and one finger lifts, reset pinch state
        if (this._pinchStartDist > 0 && this._pointers.size < 2) {
            this._pinchStartDist = 0;
            // If one finger remains, restart pan from it
            if (this._pointers.size === 1) {
                const pt = [...this._pointers.values()][0];
                this._panPending = true;
                this._panStart = { x: pt.x, y: pt.y };
                this._panViewStart = { x: this._vx, y: this._vy };
            }
            return;
        }

        this._panPending = false;
        if (this._panning) {
            this._panning = false;
            this._justPanned = true;
            this.svg.classList.remove('panning');
            requestAnimationFrame(() => { this._justPanned = false; });
        }

        // Double-tap detection (touch only)
        if (this._isTouch && this._pointers.size === 0) {
            const now = performance.now();
            const dt = now - this._lastTapTime;
            const tapDx = e.clientX - this._lastTapX;
            const tapDy = e.clientY - this._lastTapY;
            if (dt < 350 && Math.abs(tapDx) < 30 && Math.abs(tapDy) < 30 && !this._justPanned) {
                // Double-tap → zoom in/out toggle
                const r = this.svg.getBoundingClientRect();
                const gx = (e.clientX - r.left) / this._zoom + this._vx;
                const gy = (e.clientY - r.top) / this._zoom + this._vy;
                const targetZoom = this._zoom < 1.2 ? 1.8 : 1.0;
                this.zoomToPoint(targetZoom, gx, gy, 300);
                this._lastTapTime = 0; // reset so triple-tap doesn't re-trigger
                return;
            }
            this._lastTapTime = now;
            this._lastTapX = e.clientX;
            this._lastTapY = e.clientY;
        }
    }

    _startPan(e: PointerEvent) {
        if (this._zoomAnim) { cancelAnimationFrame(this._zoomAnim); this._zoomAnim = null; }
        this._panning = true;
        this._panPending = false;
        this._panStart = { x: e.clientX, y: e.clientY };
        this._panViewStart = { x: this._vx, y: this._vy };
        this.svg.classList.add('panning');
        this.svg.setPointerCapture(e.pointerId);
    }

    _clampView() {
        if (!this._bounds) return;
        const r = this.svg.getBoundingClientRect();
        const vw = r.width / this._zoom;
        const vh = r.height / this._zoom;
        const mx = vw * BOUNDS_MARGIN_FACTOR;
        const my = vh * BOUNDS_MARGIN_FACTOR;
        const b = this._bounds;
        this._vx = Math.max(b.x - mx, Math.min(b.x + b.width - vw + mx, this._vx));
        this._vy = Math.max(b.y - my, Math.min(b.y + b.height - vh + my, this._vy));
    }

    _applyTransform(animate = false) {
        this._clampView();
        const t = `scale(${this._zoom}) translate(${-this._vx}px, ${-this._vy}px)`;
        if (animate) {
            this.root.style.transition = 'transform 0.35s ease';
            this.root.style.transform = t;
            setTimeout(() => { this.root.style.transition = ''; }, 360);
        } else {
            this.root.style.transition = '';
            this.root.style.transform = t;
        }
        if (this._onViewChange) this._onViewChange(this.getViewport());
    }
}
