import {
	Arc2d,
	Box,
	Circle2d,
	Edge2d,
	Editor,
	Geometry2d,
	Polygon2d,
	TLArrowInfo,
	TLArrowShape,
	Vec,
	VecLike,
	angleDistance,
	clamp,
	getPointOnCircle,
	intersectCirclePolygon,
	intersectLineSegmentPolygon,
} from '@tldraw/editor'
import {
	ARROW_LABEL_FONT_SIZES,
	ARROW_LABEL_PADDING,
	FONT_FAMILIES,
	LABEL_TO_ARROW_PADDING,
	STROKE_SIZES,
	TEXT_PROPS,
} from '../shared/default-shape-constants'

const labelSizeCache = new WeakMap<TLArrowShape, Vec>()

export function getArrowLabelSize(editor: Editor, shape: TLArrowShape) {
	const cachedSize = labelSizeCache.get(shape)
	if (cachedSize) return cachedSize

	const info = editor.getArrowInfo(shape)!
	let width = 0
	let height = 0

	const bodyGeom = info.isStraight
		? new Edge2d({
				start: Vec.From(info.start.point),
				end: Vec.From(info.end.point),
			})
		: new Arc2d({
				center: Vec.Cast(info.handleArc.center),
				radius: info.handleArc.radius,
				start: Vec.Cast(info.start.point),
				end: Vec.Cast(info.end.point),
				sweepFlag: info.bodyArc.sweepFlag,
				largeArcFlag: info.bodyArc.largeArcFlag,
			})

	if (shape.props.text.trim()) {
		const bodyBounds = bodyGeom.bounds

		const { w, h } = editor.textMeasure.measureText(shape.props.text, {
			...TEXT_PROPS,
			fontFamily: FONT_FAMILIES[shape.props.font],
			fontSize: ARROW_LABEL_FONT_SIZES[shape.props.size],
			maxWidth: null,
		})

		width = w
		height = h

		if (bodyBounds.width > bodyBounds.height) {
			width = Math.max(Math.min(w, 64), Math.min(bodyBounds.width - 64, w))

			const { w: squishedWidth, h: squishedHeight } = editor.textMeasure.measureText(
				shape.props.text,
				{
					...TEXT_PROPS,
					fontFamily: FONT_FAMILIES[shape.props.font],
					fontSize: ARROW_LABEL_FONT_SIZES[shape.props.size],
					maxWidth: width,
				}
			)

			width = squishedWidth
			height = squishedHeight
		}

		if (width > 16 * ARROW_LABEL_FONT_SIZES[shape.props.size]) {
			width = 16 * ARROW_LABEL_FONT_SIZES[shape.props.size]

			const { w: squishedWidth, h: squishedHeight } = editor.textMeasure.measureText(
				shape.props.text,
				{
					...TEXT_PROPS,
					fontFamily: FONT_FAMILIES[shape.props.font],
					fontSize: ARROW_LABEL_FONT_SIZES[shape.props.size],
					maxWidth: width,
				}
			)

			width = squishedWidth
			height = squishedHeight
		}
	}

	const size = new Vec(width, height).addScalar(ARROW_LABEL_PADDING * 2)
	labelSizeCache.set(shape, size)
	return size
}

function getLabelToArrowPadding(editor: Editor, shape: TLArrowShape) {
	const strokeWidth = STROKE_SIZES[shape.props.size]
	const labelToArrowPadding =
		LABEL_TO_ARROW_PADDING +
		(strokeWidth - STROKE_SIZES.s) * 2 +
		(strokeWidth === STROKE_SIZES.xl ? 20 : 0)

	return labelToArrowPadding
}

/**
 * Return the range of possible label positions for a straight arrow. The full possible range is 0
 * to 1, but as the label itself takes up space the usable range is smaller.
 */
function getStraightArrowLabelRange(
	editor: Editor,
	shape: TLArrowShape,
	info: Extract<TLArrowInfo, { isStraight: true }>
): { start: number; end: number } {
	const labelSize = getArrowLabelSize(editor, shape)
	const labelToArrowPadding = getLabelToArrowPadding(editor, shape)

	// take the start and end points of the arrow, and nudge them in a bit to give some spare space:
	const startOffset = Vec.Nudge(info.start.point, info.end.point, labelToArrowPadding)
	const endOffset = Vec.Nudge(info.end.point, info.start.point, labelToArrowPadding)

	// assuming we just stick the label in the middle of the shape, where does the arrow intersect the label?
	const intersectionPoints = intersectLineSegmentPolygon(
		startOffset,
		endOffset,
		Box.FromCenter(info.middle, labelSize).corners
	)
	if (!intersectionPoints || intersectionPoints.length !== 2) {
		return { start: 0.5, end: 0.5 }
	}

	// there should be two intersection points - one near the start, and one near the end
	let [startIntersect, endIntersect] = intersectionPoints
	if (Vec.Dist2(startIntersect, startOffset) > Vec.Dist2(endIntersect, startOffset)) {
		;[endIntersect, startIntersect] = intersectionPoints
	}

	// take our nudged start and end points and scooch them in even further to give us the possible
	// range for the position of the _center_ of the label
	const startConstrained = startOffset.add(Vec.Sub(info.middle, startIntersect))
	const endConstrained = endOffset.add(Vec.Sub(info.middle, endIntersect))

	// now we can work out the range of possible label positions
	const start = Vec.Dist(info.start.point, startConstrained) / info.length
	const end = Vec.Dist(info.start.point, endConstrained) / info.length
	return { start, end }
}

/**
 * Return the range of possible label positions for a curved arrow. The full possible range is 0
 * to 1, but as the label itself takes up space the usable range is smaller.
 */
function getCurvedArrowLabelRange(
	editor: Editor,
	shape: TLArrowShape,
	info: Extract<TLArrowInfo, { isStraight: false }>
): { start: number; end: number; dbg?: Geometry2d[] } {
	const labelSize = getArrowLabelSize(editor, shape)
	const labelToArrowPadding = getLabelToArrowPadding(editor, shape)
	const direction = Math.sign(shape.props.bend)

	// take the start and end points of the arrow, and nudge them in a bit to give some spare space:
	const labelToArrowPaddingRad = (labelToArrowPadding / info.handleArc.radius) * direction
	const startOffsetAngle = Vec.Angle(info.bodyArc.center, info.start.point) - labelToArrowPaddingRad
	const endOffsetAngle = Vec.Angle(info.bodyArc.center, info.end.point) + labelToArrowPaddingRad
	const startOffset = getPointOnCircle(info.bodyArc.center, info.bodyArc.radius, startOffsetAngle)
	const endOffset = getPointOnCircle(info.bodyArc.center, info.bodyArc.radius, endOffsetAngle)

	const dbg: Geometry2d[] = []

	// unlike the straight arrow, we can't just stick the label in the middle of the shape when
	// we're working out the range. this is because as the label moves along the curve, the place
	// where the arrow intersects with label changes. instead, we have to stick the label center on
	// the `startOffset` (the start-most place where it can go), then find where it intersects with
	// the arc. because of the symmetry of the label rectangle, we can move the label to that new
	// center and take that as the start-most possible point.
	const startIntersections = intersectArcPolygon(
		info.bodyArc.center,
		info.bodyArc.radius,
		startOffsetAngle,
		endOffsetAngle,
		direction,
		Box.FromCenter(startOffset, labelSize).corners
	)

	dbg.push(
		new Polygon2d({
			points: Box.FromCenter(startOffset, labelSize).corners,
			debugColor: 'lime',
			isFilled: false,
			ignore: true,
		})
	)

	const endIntersections = intersectArcPolygon(
		info.bodyArc.center,
		info.bodyArc.radius,
		startOffsetAngle,
		endOffsetAngle,
		direction,
		Box.FromCenter(endOffset, labelSize).corners
	)

	dbg.push(
		new Polygon2d({
			points: Box.FromCenter(endOffset, labelSize).corners,
			debugColor: 'lime',
			isFilled: false,
			ignore: true,
		})
	)
	for (const pt of [
		...(startIntersections ?? []),
		...(endIntersections ?? []),
		startOffset,
		endOffset,
	]) {
		dbg.push(
			new Circle2d({
				x: pt.x - 3,
				y: pt.y - 3,
				radius: 3,
				isFilled: false,
				debugColor: 'magenta',
				ignore: true,
			})
		)
	}

	// if we have one or more intersections (we shouldn't have more than two) then the one we need
	// is the one furthest from the arrow terminal
	const startConstrained =
		(startIntersections && furthest(info.start.point, startIntersections)) ?? info.middle
	const endConstrained =
		(endIntersections && furthest(info.end.point, endIntersections)) ?? info.middle

	const startAngle = Vec.Angle(info.bodyArc.center, info.start.point)
	const endAngle = Vec.Angle(info.bodyArc.center, info.end.point)
	const constrainedStartAngle = Vec.Angle(info.bodyArc.center, startConstrained)
	const constrainedEndAngle = Vec.Angle(info.bodyArc.center, endConstrained)

	// if the arc is small enough that there's no room for the label to move, we constrain it to the middle.
	if (
		angleDistance(startAngle, constrainedStartAngle, direction) >
		angleDistance(startAngle, constrainedEndAngle, direction)
	) {
		return { start: 0.5, end: 0.5, dbg }
	}

	// now we can work out the range of possible label positions
	const fullDistance = angleDistance(startAngle, endAngle, direction)
	const start = angleDistance(startAngle, constrainedStartAngle, direction) / fullDistance
	const end = angleDistance(startAngle, constrainedEndAngle, direction) / fullDistance
	return { start, end, dbg }
}

export function getArrowLabelPosition(editor: Editor, shape: TLArrowShape) {
	let labelCenter
	const debugGeom: Geometry2d[] = []
	const info = editor.getArrowInfo(shape)!

	const hasStartArrowhead = info.start.arrowhead !== 'none'
	const hasEndArrowhead = info.end.arrowhead !== 'none'
	if (info.isStraight) {
		const range = getStraightArrowLabelRange(editor, shape, info)
		let clampedPosition = clamp(
			shape.props.labelPosition,
			hasStartArrowhead ? range.start : 0,
			hasEndArrowhead ? range.end : 1
		)
		// This makes the position snap in the middle.
		clampedPosition = clampedPosition >= 0.48 && clampedPosition <= 0.52 ? 0.5 : clampedPosition
		labelCenter = Vec.Lrp(info.start.point, info.end.point, clampedPosition)
	} else {
		const range = getCurvedArrowLabelRange(editor, shape, info)
		if (range.dbg) debugGeom.push(...range.dbg)
		let clampedPosition = clamp(
			shape.props.labelPosition,
			hasStartArrowhead ? range.start : 0,
			hasEndArrowhead ? range.end : 1
		)
		// This makes the position snap in the middle.
		clampedPosition = clampedPosition >= 0.48 && clampedPosition <= 0.52 ? 0.5 : clampedPosition
		const labelAngle = interpolateArcAngles(
			Vec.Angle(info.bodyArc.center, info.start.point),
			Vec.Angle(info.bodyArc.center, info.end.point),
			Math.sign(shape.props.bend),
			clampedPosition
		)
		labelCenter = getPointOnCircle(info.bodyArc.center, info.bodyArc.radius, labelAngle)
	}

	const labelSize = getArrowLabelSize(editor, shape)

	return { box: Box.FromCenter(labelCenter, labelSize), debugGeom }
}

function intersectArcPolygon(
	center: VecLike,
	radius: number,
	angleStart: number,
	angleEnd: number,
	direction: number,
	polygon: VecLike[]
) {
	const intersections = intersectCirclePolygon(center, radius, polygon)

	// filter the circle intersections to just the ones from the arc
	const fullArcDistance = angleDistance(angleStart, angleEnd, direction)
	return intersections?.filter((pt) => {
		const pDistance = angleDistance(angleStart, Vec.Angle(center, pt), direction)
		return pDistance >= 0 && pDistance <= fullArcDistance
	})
}

function furthest(from: VecLike, candidates: VecLike[]): VecLike | null {
	let furthest: VecLike | null = null
	let furthestDist = -Infinity

	for (const candidate of candidates) {
		const dist = Vec.Dist2(from, candidate)
		if (dist > furthestDist) {
			furthest = candidate
			furthestDist = dist
		}
	}

	return furthest
}

/**
 *
 * @param angleStart - The angle of the start of the arc
 * @param angleEnd - The angle of the end of the arc
 * @param direction - The direction of the arc (1 = counter-clockwise, -1 = clockwise)
 * @param t - A number between 0 and 1 representing the position along the arc
 * @returns
 */
function interpolateArcAngles(angleStart: number, angleEnd: number, direction: number, t: number) {
	const dist = angleDistance(angleStart, angleEnd, direction)
	return angleStart + dist * t * direction * -1
}
