import type { CanvasContent, CanvasNode } from "~/types/canvas";
import type { CanvasPreferences } from "~/types/general";
import type { Position, Box, Direction } from "./canvas.util";

interface SnapPoint {
    pos: Position;
    type: TYPE;
    side?: Direction;
}

export interface SnapHint {
    start: Position,
    end?: Position,
}

interface SnapConfig {
    preferences: CanvasPreferences;
    threshold: number;
    cellSize: number;
    gridSize: number;
}

const enum TYPE {
    CENTER,
    CORNER,
    EDGE,
}

class SpatialGrid {
    private cells: Map<number, Map<number, Set<string>>> = new Map();
    private cellSize: number;

    private minx: number = Infinity;
    private miny: number = Infinity;
    private maxx: number = -Infinity;
    private maxy: number = -Infinity;

    private cacheSet: Set<string> = new Set<string>();

    constructor(cellSize: number) {
        this.cellSize = cellSize;
    }

    private updateBorders(startX: number, startY: number, endX: number, endY: number) {
        this.minx = Math.min(this.minx, startX);
        this.miny = Math.min(this.miny, startY);
        this.maxx = Math.max(this.maxx, endX);
        this.maxy = Math.max(this.maxy, endY);
    }

    insert(node: CanvasNode): void {
        const startX = Math.floor(node.x / this.cellSize);
        const startY = Math.floor(node.y / this.cellSize);
        const endX = Math.ceil((node.x + node.width) / this.cellSize);
        const endY = Math.ceil((node.y + node.height) / this.cellSize);

        this.updateBorders(startX, startY, endX, endY);

        for (let i = startX; i <= endX; i++) {
            let gridX = this.cells.get(i);
            if (!gridX) {
                gridX = new Map<number, Set<string>>();
                this.cells.set(i, gridX);
            }

            for (let j = startY; j <= endY; j++) {
                let gridY = gridX.get(j);
                if (!gridY) {
                    gridY = new Set<string>();
                    gridX.set(j, gridY);
                }
                gridY.add(node.id);
            }
        }
    }

    remove(node: CanvasNode): void {
        const startX = Math.floor(node.x / this.cellSize);
        const startY = Math.floor(node.y / this.cellSize);
        const endX = Math.ceil((node.x + node.width) / this.cellSize);
        const endY = Math.ceil((node.y + node.height) / this.cellSize);

        for (let i = startX; i <= endX; i++) {
            const gridX = this.cells.get(i);
            if (gridX) {
                for (let j = startY; j <= endY; j++) {
                    gridX.get(j)?.delete(node.id);
                }
            }
        }
    }

    fetch(x: number, y: number): Set<string> | undefined {
        return this.query(x, y, x, y);
    }

    query(x1: number, y1: number, x2: number, y2: number): Set<string> {
        this.cacheSet.clear();

        const startX = Math.floor(x1 / this.cellSize);
        const startY = Math.floor(y1 / this.cellSize);
        const endX = Math.ceil(x2 / this.cellSize);
        const endY = Math.ceil(y2 / this.cellSize);

        for (let dx = startX; dx <= endX; dx++) {
            const gridX = this.cells.get(dx);
            if (gridX) {
                for (let dy = startY; dy <= endY; dy++) {
                    const cellNodes = gridX.get(dy);
                    if (cellNodes) {
                        cellNodes.forEach(neighbor => this.cacheSet.add(neighbor));
                    }
                }
            }
        }

        return this.cacheSet;
    }

    getViewportNeighbors(node: CanvasNode, viewport?: Box): Set<string> {
        this.cacheSet.clear();

        const startX = Math.floor(node.x / this.cellSize);
        const startY = Math.floor(node.y / this.cellSize);
        const endX = Math.ceil((node.x + node.width) / this.cellSize);
        const endY = Math.ceil((node.y + node.height) / this.cellSize);

        const minX = Math.max(viewport ? Math.max(this.minx, Math.floor(viewport.x / this.cellSize)) : this.minx, startX - 8);
        const minY = Math.max(viewport ? Math.max(this.miny, Math.floor(viewport.y / this.cellSize)) : this.miny, startY - 8);
        const maxX = Math.min(viewport ? Math.min(this.maxx, Math.ceil((viewport.x + viewport.w) / this.cellSize)) : this.maxx, endX + 8);
        const maxY = Math.min(viewport ? Math.min(this.maxy, Math.ceil((viewport.y + viewport.h) / this.cellSize)) : this.maxy, endY + 8);

        for (let dx = minX; dx <= maxX; dx++) {
            const gridX = this.cells.get(dx);
            if (gridX) {
                for (let dy = startY; dy <= endY; dy++) {
                    const cellNodes = gridX.get(dy);
                    if (cellNodes) {
                        cellNodes.forEach(neighbor => {
                            if (neighbor !== node.id) this.cacheSet.add(neighbor);
                        });
                    }
                }
            }
        }
        for (let dx = startX; dx <= endX; dx++) {
            const gridX = this.cells.get(dx);
            if (gridX) {
                for (let dy = minY; dy <= maxY; dy++) {
                    const cellNodes = gridX.get(dy);
                    if (cellNodes) {
                        cellNodes.forEach(neighbor => {
                            if (neighbor !== node.id) this.cacheSet.add(neighbor);
                        });
                    }
                }
            }
        }

        return this.cacheSet;
    }
}

class SnapPointCache {
    private cache: Map<string, SnapPoint[]>;

    constructor() {
        this.cache = new Map();
    }

    getSnapPoints(node: string): SnapPoint[] | undefined {
        return this.cache.get(node);
    }

    private calculateSnapPoints(node: CanvasNode): SnapPoint[] {
        const centerX = node.x + node.width / 2;
        const centerY = node.y + node.height / 2;
        return [
            { pos: { x: centerX, y: centerY }, type: TYPE.CENTER },
            { pos: { x: node.x, y: node.y }, type: TYPE.CORNER },
            { pos: { x: node.x + node.width, y: node.y }, type: TYPE.CORNER },
            { pos: { x: node.x, y: node.y + node.height }, type: TYPE.CORNER },
            { pos: { x: node.x + node.width, y: node.y + node.height }, type: TYPE.CORNER },
            { pos: { x: centerX, y: node.y }, type: TYPE.EDGE, side: 'top' },
            { pos: { x: node.x, y: centerY }, type: TYPE.EDGE, side: 'left' },
            { pos: { x: centerX, y: node.y + node.height }, type: TYPE.EDGE, side: 'bottom' },
            { pos: { x: node.x + node.width, y: centerY }, type: TYPE.EDGE, side: 'right' },
        ];
    }

    insert(node: CanvasNode): void {
        if (!this.cache.has(node.id)) {
            this.cache.set(node.id, this.calculateSnapPoints(node));
        }
    }

    invalidate(node: CanvasNode): void {
        this.cache.delete(node.id);
    }
}

export class SnapFinder {
    private spatialGrid: SpatialGrid;
    private snapPointCache: SnapPointCache;
    config: SnapConfig;

    hints: Ref<SnapHint[]>;
    viewport: Ref<Box>;

    constructor(hints: Ref<SnapHint[]>, viewport: Ref<Box>, config: SnapConfig) {
        this.spatialGrid = new SpatialGrid(config.cellSize);
        this.snapPointCache = new SnapPointCache();
        this.config = config;

        this.hints = hints;
        this.viewport = viewport;
    }

    add(node: CanvasNode): void
    {
        this.spatialGrid.insert(node);
        this.snapPointCache.insert(node);
        this.hints.value.length = 0;
    }

    remove(node: CanvasNode): void
    {
        this.spatialGrid.remove(node);
        this.snapPointCache.invalidate(node);
        this.hints.value.length = 0;
    }

    update(node: CanvasNode): void
    {
        this.remove(node);
        this.add(node);
    }

    findEdgeSnapPosition(node: string, x: number, y: number): { x: number, y: number, node: string, direction: Direction } | undefined
    {
        const near = [...this.spatialGrid.fetch(x, y)?.values().filter(e => e !== node).flatMap(e => this.snapPointCache.getSnapPoints(e)?.map(_e => ({ ..._e, node: e })) ?? []) ?? []].filter(e => e.type === TYPE.EDGE);
        let nearestDistance = this.config.threshold, nearest = undefined;

        for (const point of near) {
            const distance = Math.hypot(point.pos.x - x, point.pos.y - y);
            if (distance < nearestDistance) {
                nearestDistance = distance;
                nearest = { ...point.pos, node: point.node, direction: point.side! };
            }
        }

        return nearest;
    }

    findNodeSnapPosition(node: CanvasNode, resizeHandle?: Box): Partial<Box>
    {
        const result: Partial<Box> = {
            x: undefined,
            y: undefined,
            w: undefined,
            h: undefined,
        };

        if(!this.config.preferences.neighborSnap)
        {
            result.x = this.snapToGrid(node.x);
            result.w = this.snapToGrid(node.width);
            result.y = this.snapToGrid(node.y);
            result.h = this.snapToGrid(node.height);

            return result;
        }
        
        this.hints.value.length = 0;

        this.snapPointCache.invalidate(node);
        this.snapPointCache.insert(node);

        const neighbors = [...this.spatialGrid.getViewportNeighbors(node, this.viewport.value)].flatMap(e => this.snapPointCache.getSnapPoints(e)).filter(e => !!e);
        const bestSnap = this.findBestSnap(this.snapPointCache.getSnapPoints(node.id)!, neighbors, this.config.threshold, resizeHandle);

        return this.applySnap(node, bestSnap.x, bestSnap.y, resizeHandle);
    }

    private findBestSnap(activePoints: SnapPoint[], otherPoints: SnapPoint[], threshold: number, resizeHandle?: Box): Partial<Position>
    {
        let bestSnap: Partial<Position> = {};
        let bestDiffX = threshold, bestDiffY = threshold;
		let xHints: SnapHint[] = [], yHints: SnapHint[] = [];

        for (const activePoint of activePoints) {
            if (activePoint.type === TYPE.EDGE) continue;
            if (!!resizeHandle && activePoint.type !== TYPE.CORNER) continue;

            for (const otherPoint of otherPoints) {
                if (otherPoint.type === TYPE.EDGE) continue;
                if (!!resizeHandle && otherPoint.type !== TYPE.CORNER) continue;

                const diffX = Math.abs(otherPoint.pos.x - activePoint.pos.x);
                const diffY = Math.abs(otherPoint.pos.y - activePoint.pos.y);

                if (diffX < bestDiffX) {
                    bestDiffX = diffX;
                    bestSnap.x = otherPoint.pos.x - activePoint.pos.x;
					xHints = [{ start: { x: otherPoint.pos.x, y: activePoint.pos.y }, end: { x: otherPoint.pos.x, y: otherPoint.pos.y } }];
                } else if(diffX === bestDiffX) {
                    xHints.push({ start: { x: otherPoint.pos.x, y: activePoint.pos.y }, end: { x: otherPoint.pos.x, y: otherPoint.pos.y } });
                }

                if (diffY < bestDiffY) {
                    bestDiffY = diffY;
                    bestSnap.y = otherPoint.pos.y - activePoint.pos.y;
                    yHints = [{ start: { x: activePoint.pos.x, y: otherPoint.pos.y }, end: { x: otherPoint.pos.x, y: otherPoint.pos.y } }];
                } else if(diffY === bestDiffY) {
                    yHints.push({ start: { x: activePoint.pos.x, y: otherPoint.pos.y }, end: { x: otherPoint.pos.x, y: otherPoint.pos.y } });
                }
            }
        }

		if(bestSnap.x && bestSnap.y)
		{
			xHints.forEach(e => e.start.y += bestSnap.y!);
			yHints.forEach(e => e.start.x += bestSnap.x!);
		}

		this.hints.value = [...xHints, ...yHints];

        return bestSnap;
    }

    private snapToGrid(pos?: number): number | undefined
    {
        return pos && this.config.preferences.gridSnap && this.config.preferences.spacing ? Math.round(pos / this.config.preferences.spacing) * this.config.preferences.spacing : undefined;
    }

    private applySnap(node: CanvasNode, offsetx?: number, offsety?: number, resizeHandle?: Box): Partial<Box>
    {
        const result: Partial<Box> = { x: undefined, y: undefined, w: undefined, h: undefined };

        if (resizeHandle)
        {
            result.x = offsetx ? node.x + offsetx * resizeHandle.x : this.snapToGrid(node.x);
            result.w = offsetx ? node.width + offsetx * resizeHandle.w : this.snapToGrid(node.width);
            result.y = offsety ? node.y + offsety * resizeHandle.y : this.snapToGrid(node.y);
            result.h = offsety ? node.height - offsety * resizeHandle.h : this.snapToGrid(node.height);
        }
        else
        {
            result.x = offsetx ? node.x + offsetx : this.snapToGrid(node.x);
            result.y = offsety ? node.y + offsety : this.snapToGrid(node.y);
        }

        return result;
    }
}