import { computed } from '@tldraw/state'
import { TLHandle, TLShape, TLShapeId, VecModel } from '@tldraw/tlschema'
import { assertExists, uniqueId } from '@tldraw/utils'
import { Vec } from '../../../primitives/Vec'
import { Geometry2d } from '../../../primitives/geometry/Geometry2d'
import { Editor } from '../../Editor'
import { SnapData, SnapManager } from './SnapManager'

/**
 * When dragging a handle, users can snap the handle to key geometry on other nearby shapes.
 * Customize how handles snap to a shape by returning this from
 * {@link ShapeUtil.getHandleSnapGeometry}.
 *
 * Any co-ordinates here should be in the shape's local space.
 *
 * @public
 */
export interface HandleSnapGeometry {
	/**
	 * A `Geometry2d` that describe the outline of the shape that the handle will snap to - fills
	 * are ignored. By default, this is the same geometry returned by {@link ShapeUtil.getGeometry}.
	 * Set this to `null` to disable handle snapping to this shape's outline.
	 */
	outline?: Geometry2d | null
	/**
	 * Key points on the shape that the handle will snap to. For example, the corners of a
	 * rectangle, or the centroid of a triangle. By default, no points are used.
	 */
	points?: VecModel[]
	/**
	 * By default, handles can't snap to their own shape because moving the handle might change the
	 * snapping location which can cause feedback loops. You can override this by returning a
	 * version of `outline` that won't be affected by the current handle's position to use for
	 * self-snapping.
	 */
	getSelfSnapOutline?(handle: TLHandle): Geometry2d | null
	/**
	 * By default, handles can't snap to their own shape because moving the handle might change the
	 * snapping location which can cause feedback loops. You can override this by returning a
	 * version of `points` that won't be affected by the current handle's position to use for
	 * self-snapping.
	 */
	getSelfSnapPoints?(handle: TLHandle): VecModel[]
}

const defaultGetSelfSnapOutline = () => null
const defaultGetSelfSnapPoints = () => []

/** @public */
export class HandleSnaps {
	readonly editor: Editor
	constructor(readonly manager: SnapManager) {
		this.editor = manager.editor
	}

	@computed private getSnapGeometryCache() {
		const { editor } = this
		return editor.store.createComputedCache('handle snap geometry', (shape: TLShape) => {
			const snapGeometry = editor.getShapeUtil(shape).getHandleSnapGeometry(shape)
			const getSelfSnapOutline = snapGeometry.getSelfSnapOutline
				? snapGeometry.getSelfSnapOutline.bind(snapGeometry)
				: defaultGetSelfSnapOutline
			const getSelfSnapPoints = snapGeometry.getSelfSnapPoints
				? snapGeometry.getSelfSnapPoints.bind(snapGeometry)
				: defaultGetSelfSnapPoints

			return {
				outline:
					snapGeometry.outline === undefined
						? editor.getShapeGeometry(shape)
						: snapGeometry.outline,

				points: snapGeometry.points ?? [],
				getSelfSnapOutline,
				getSelfSnapPoints,
			}
		})
	}

	private *iterateSnapPointsInPageSpace(currentShapeId: TLShapeId, currentHandle: TLHandle) {
		const selfSnapPoints = this.getSnapGeometryCache()
			.get(currentShapeId)
			?.getSelfSnapPoints(currentHandle)
		if (selfSnapPoints && selfSnapPoints.length) {
			const shapePageTransform = assertExists(this.editor.getShapePageTransform(currentShapeId))
			for (const point of selfSnapPoints) {
				yield shapePageTransform.applyToPoint(point)
			}
		}

		for (const shapeId of this.manager.getSnappableShapes()) {
			if (shapeId === currentShapeId) continue
			const snapPoints = this.getSnapGeometryCache().get(shapeId)?.points
			if (!snapPoints || !snapPoints.length) continue

			const shapePageTransform = assertExists(this.editor.getShapePageTransform(shapeId))
			for (const point of snapPoints) {
				yield shapePageTransform.applyToPoint(point)
			}
		}
	}

	private *iterateSnapOutlines(currentShapeId: TLShapeId, currentHandle: TLHandle) {
		const selfSnapOutline = this.getSnapGeometryCache()
			.get(currentShapeId)
			?.getSelfSnapOutline(currentHandle)
		if (selfSnapOutline) {
			yield { shapeId: currentShapeId, outline: selfSnapOutline }
		}

		for (const shapeId of this.manager.getSnappableShapes()) {
			if (shapeId === currentShapeId) continue

			const snapOutline = this.getSnapGeometryCache().get(shapeId)?.outline
			if (!snapOutline) continue

			yield { shapeId, outline: snapOutline }
		}
	}

	private getHandleSnapPosition({
		currentShapeId,
		handle,
		handleInPageSpace,
	}: {
		currentShapeId: TLShapeId
		handle: TLHandle
		handleInPageSpace: Vec
	}): Vec | null {
		const snapThreshold = this.manager.getSnapThreshold()

		// We snap to two different parts of the shape's handle snap geometry:
		// 1. The `points`. These are handles or other key points that we want to snap to with a
		//    higher priority than the normal outline snapping.
		// 2. The `outline`. This describes the outline of the shape, and we just snap to the
		//    nearest point on that outline.

		// Start with the points:
		let minDistanceForSnapPoint = snapThreshold
		let nearestSnapPoint: Vec | null = null
		for (const snapPoint of this.iterateSnapPointsInPageSpace(currentShapeId, handle)) {
			if (Vec.DistMin(handleInPageSpace, snapPoint, minDistanceForSnapPoint)) {
				minDistanceForSnapPoint = Vec.Dist(handleInPageSpace, snapPoint)
				nearestSnapPoint = snapPoint
			}
		}

		// if we found a snap point, return it - we don't need to check the outlines because points
		// have a higher priority
		if (nearestSnapPoint) return nearestSnapPoint

		let minDistanceForOutline = snapThreshold
		let nearestPointOnOutline: Vec | null = null

		for (const { shapeId, outline } of this.iterateSnapOutlines(currentShapeId, handle)) {
			const shapePageTransform = assertExists(this.editor.getShapePageTransform(shapeId))
			const pointInShapeSpace = this.editor.getPointInShapeSpace(shapeId, handleInPageSpace)

			const nearestShapePointInShapeSpace = outline.nearestPoint(pointInShapeSpace)
			const nearestInPageSpace = shapePageTransform.applyToPoint(nearestShapePointInShapeSpace)

			if (Vec.DistMin(handleInPageSpace, nearestInPageSpace, minDistanceForOutline)) {
				minDistanceForOutline = Vec.Dist(handleInPageSpace, nearestInPageSpace)
				nearestPointOnOutline = nearestInPageSpace
			}
		}

		// if we found a point on the outline, return it
		if (nearestPointOnOutline) return nearestPointOnOutline

		// if not, there's no nearby snap point
		return null
	}

	snapHandle({
		currentShapeId,
		handle,
	}: {
		currentShapeId: TLShapeId
		handle: TLHandle
	}): SnapData | null {
		const currentShapeTransform = assertExists(this.editor.getShapePageTransform(currentShapeId))
		const handleInPageSpace = currentShapeTransform.applyToPoint(handle)
		const snapPosition = this.getHandleSnapPosition({ currentShapeId, handle, handleInPageSpace })

		// If we found a point, display snap lines, and return the nudge
		if (snapPosition) {
			this.manager.setIndicators([
				{
					id: uniqueId(),
					type: 'points',
					points: [snapPosition],
				},
			])

			return { nudge: Vec.Sub(snapPosition, handleInPageSpace) }
		}

		return null
	}
}
