import {
	GeoShapeGeoStyle,
	Group2d,
	IndexKey,
	TLGeoShape,
	TLShapeId,
	createShapeId,
	toRichText,
} from '@tldraw/editor'
import { vi } from 'vitest'
import { TestEditor } from '../../../test/TestEditor'
import { PathBuilder } from '../shared/PathBuilder'
import { GeoShapeUtil } from './GeoShapeUtil'

let editor: TestEditor
let ids: Record<string, TLShapeId>

beforeEach(() => {
	editor = new TestEditor()
})

afterEach(() => {
	editor?.dispose()
})

describe('Handle snapping', () => {
	beforeEach(() => {
		ids = {
			geo: createShapeId('geo'),
			line: createShapeId('line'),
		}
		editor.createShapes([
			{ id: ids.geo, type: 'geo', x: 0, y: 0, props: { geo: 'rectangle', w: 100, h: 100 } },
			{
				id: ids.line,
				type: 'line',
				x: 0,
				y: 0,
				props: {
					points: {
						a1: { id: 'a1', index: 'a1' as IndexKey, x: 200, y: 0 },
						a2: { id: 'a2', index: 'a2' as IndexKey, x: 200, y: 100 },
					},
				},
			},
		])
	})

	const geoShape = () => {
		const shape = editor.getShape(ids.geo)!
		assert(editor.isShapeOfType(shape, 'geo'))
		return shape
	}
	const lineShape = () => {
		const shape = editor.getShape(ids.line)!
		assert(editor.isShapeOfType(shape, 'line'))
		return shape
	}
	const lineHandles = () => editor.getShapeUtil('line').getHandles!(lineShape())!

	function startDraggingHandle() {
		editor
			.select(ids.line)
			.pointerDown(200, 0, { target: 'handle', shape: lineShape(), handle: lineHandles()[0] })
	}

	test('handles snap to the edges of the shape', () => {
		startDraggingHandle()
		editor.pointerMove(50, 5, undefined, { ctrlKey: true })
		expect(editor.snaps.getIndicators()).toHaveLength(1)
		expect(lineHandles()[0]).toMatchObject({ x: 50, y: 0 })
	})

	test('handles snap to the corner of the shape', () => {
		startDraggingHandle()
		editor.pointerMove(0, 5, undefined, { ctrlKey: true })
		expect(editor.snaps.getIndicators()).toHaveLength(1)
		expect(lineHandles()[0]).toMatchObject({ x: 0, y: 0 })
	})

	test('handles snap to the center of the shape', () => {
		startDraggingHandle()
		editor.pointerMove(51, 45, undefined, { ctrlKey: true })
		expect(editor.snaps.getIndicators()).toHaveLength(1)
		expect(lineHandles()[0]).toMatchObject({ x: 50, y: 50 })
	})

	test('does not snap to the label of the shape', () => {
		startDraggingHandle()
		const geometry = editor.getShapeUtil('geo').getGeometry(geoShape()) as Group2d
		const label = geometry.children.find((c) => c.isLabel)!
		const labelVertex = label.vertices[0]
		editor.pointerMove(labelVertex.x + 2, labelVertex.y + 2, undefined, { ctrlKey: true })
		expect(editor.snaps.getIndicators()).toHaveLength(0)
		expect(lineHandles()[0]).toMatchObject({ x: labelVertex.x + 2, y: labelVertex.y + 2 })
	})
})

describe('Resizing geo shapes with labels', () => {
	const geoId = createShapeId('geo')

	function getGeo() {
		return editor.getShape<TLGeoShape>(geoId)!
	}

	test('can be resized narrower than the initial label width', () => {
		// Create a wide shape with a short label
		editor.createShapes([
			{
				id: geoId,
				type: 'geo',
				props: { w: 300, h: 100, richText: toRichText('Hi'), geo: 'rectangle' },
			},
		])
		const initialW = getGeo().props.w

		// Resize to half width
		editor.resizeShape(geoId, { x: 0.5, y: 1 })

		// Should be narrower than initial — not locked to the initial label measurement width
		const afterW = getGeo().props.w
		expect(afterW).toBeLessThan(initialW)
	})

	test('height grows to accommodate wrapped text when resized narrower', () => {
		// Create a shape where the label fits without wrapping.
		// "Hello World" (11 chars) at medium size has raw text width = 121,
		// so min label width = 153. At initial w=300 the text is one line.
		editor.createShapes([
			{
				id: geoId,
				type: 'geo',
				props: { w: 300, h: 100, richText: toRichText('Hello World'), geo: 'rectangle' },
			},
		])

		// Resize to a narrow width AND short height — text must wrap at the narrower width,
		// and the height should grow to fit the taller wrapped text.
		editor.resizeShape(geoId, { x: 0.2, y: 0.3 })

		const geo = getGeo()
		// The height should be greater than the naive 100 * 0.3 = 30 target,
		// because the text wraps at the narrower width and needs more vertical space.
		expect(geo.props.h).toBeGreaterThan(30)
	})

	test('width is constrained to at least the minimum label width', () => {
		// "Hello World" has a minimum label width of 153 (text raw width 121 + padding 32)
		editor.createShapes([
			{
				id: geoId,
				type: 'geo',
				props: { w: 300, h: 100, richText: toRichText('Hello World'), geo: 'rectangle' },
			},
		])

		// Try to resize to something very small
		editor.resizeShape(geoId, { x: 0.1, y: 1 })

		const geo = getGeo()
		// The width should be at least MIN_SIZE_WITH_LABEL (51) — the text's minimum width
		// is actually larger (153), so the shape should be constrained to the label width
		expect(geo.props.w).toBeGreaterThanOrEqual(51)
		// And specifically, it should be at least the label's minimum width
		expect(geo.props.w).toBeGreaterThan(100)
	})

	test('shape without label can be resized freely', () => {
		editor.createShapes([
			{
				id: geoId,
				type: 'geo',
				props: { w: 300, h: 200, geo: 'rectangle' },
			},
		])

		editor.resizeShape(geoId, { x: 0.1, y: 0.1 })

		const geo = getGeo()
		expect(geo.props.w).toBeLessThan(50)
		expect(geo.props.h).toBeLessThan(50)
	})
})

describe('Geo shapes with programmatically-authored empty rich text', () => {
	const geoId = createShapeId('geo')

	function getGeo() {
		return editor.getShape<TLGeoShape>(geoId)!
	}

	// An empty paragraph can be encoded two equally-valid ways. The interactive
	// editor emits the first (no `content` key); programmatic authoring (snapshot
	// load, agents emitting tldraw JSON) commonly emits the second (`content: []`).
	// Both mean "no label", so neither should grow the shape to fit a phantom line.
	const emptyForms = [
		{
			label: 'paragraph with no content key (toRichText output)',
			richText: toRichText(''),
		},
		{
			label: 'paragraph with empty content array',
			richText: {
				type: 'doc',
				content: [{ type: 'paragraph', attrs: { dir: 'auto' }, content: [] }],
			} as TLGeoShape['props']['richText'],
		},
		{
			label: 'doc with empty content array',
			richText: { type: 'doc', content: [] } as TLGeoShape['props']['richText'],
		},
	]

	for (const { label, richText } of emptyForms) {
		test(`does not grow height for an empty label: ${label}`, () => {
			editor.createShapes([
				{
					id: geoId,
					type: 'geo',
					props: { w: 110, h: 24, geo: 'rectangle', size: 's', richText },
				},
			])

			const geo = getGeo()
			expect(geo.props.h).toBe(24)
			expect(geo.props.growY).toBe(0)
		})
	}
})

describe('Flipping geo shapes', () => {
	const geoId = createShapeId('geo')

	function getGeo() {
		return editor.getShape<TLGeoShape>(geoId)!
	}

	beforeEach(() => {
		editor.createShapes([
			{ id: geoId, type: 'geo', x: 0, y: 0, props: { geo: 'arrow-left', w: 100, h: 80 } },
		])
	})

	test('flip command toggles flipX / flipY like the image shape', () => {
		expect(getGeo().props.flipX).toBe(false)

		editor.flipShapes([geoId], 'horizontal')
		expect(getGeo().props).toMatchObject({ flipX: true, flipY: false })

		editor.flipShapes([geoId], 'vertical')
		expect(getGeo().props).toMatchObject({ flipX: true, flipY: true })

		// flipping again on the same axis restores the original state
		editor.flipShapes([geoId], 'horizontal')
		expect(getGeo().props).toMatchObject({ flipX: false, flipY: true })
	})

	test('flipping a lone geo shape keeps its position and size', () => {
		const before = getGeo()
		editor.flipShapes([geoId], 'horizontal')
		const after = getGeo()
		expect(after).toMatchObject({
			x: before.x,
			y: before.y,
			props: { w: before.props.w, h: before.props.h, flipX: true },
		})
	})

	test('mirrors the geometry when flipped, keeping the bounds unchanged', () => {
		const util = editor.getShapeUtil('geo')
		const outlineBefore = (util.getGeometry(getGeo()) as Group2d).children[0]
		const vertsBefore = outlineBefore.vertices.map((v) => ({ x: v.x, y: v.y }))

		editor.updateShape({ id: geoId, type: 'geo', props: { flipX: true } })

		const outlineAfter = (util.getGeometry(getGeo()) as Group2d).children[0]

		// bounds are unchanged: we mirror geometry within the shape's box, not its w/h
		expect(outlineAfter.bounds.width).toBeCloseTo(outlineBefore.bounds.width)
		expect(outlineAfter.bounds.height).toBeCloseTo(outlineBefore.bounds.height)

		// every vertex is mirrored around the horizontal center (w = 100)
		const vertsAfter = outlineAfter.vertices
		expect(vertsAfter).toHaveLength(vertsBefore.length)
		for (const v of vertsBefore) {
			const mirrored = { x: 100 - v.x, y: v.y }
			expect(
				vertsAfter.some(
					(a) => Math.abs(a.x - mirrored.x) < 0.01 && Math.abs(a.y - mirrored.y) < 0.01
				)
			).toBe(true)
		}
	})
})

describe('GeoShapeUtil.configure with customGeoTypes', () => {
	// Snapshot the built-in geo values so we can clean up any custom keys added
	// during these tests. `GeoShapeUtil.configure({ customGeoTypes })` mutates
	// `GeoShapeGeoStyle.values` globally via `addValues`, so without cleanup the
	// state would leak between tests in this describe block.
	const builtinGeoValues = [...GeoShapeGeoStyle.values]
	afterEach(() => {
		const toRemove = GeoShapeGeoStyle.values.filter((v) => !builtinGeoValues.includes(v))
		if (toRemove.length > 0) {
			GeoShapeGeoStyle.removeValues(...toRemove)
		}
	})

	const validDef = {
		getPath: (w: number, h: number) =>
			new PathBuilder()
				.moveTo(0, 0, { geometry: { isFilled: true } })
				.lineTo(w, 0)
				.lineTo(w, h)
				.lineTo(0, h)
				.close(),
		snapType: 'polygon' as const,
		icon: 'geo-rectangle',
		defaultSize: { w: 999, h: 999 },
	}

	function getConfiguredOptions(customGeoTypes: Record<string, typeof validDef>) {
		const Configured = GeoShapeUtil.configure({ customGeoTypes })
		const localEditor = new TestEditor({ shapeUtils: [Configured] })
		try {
			const util = localEditor.getShapeUtil('geo') as GeoShapeUtil
			return util.options.customGeoTypes
		} finally {
			localEditor.dispose()
		}
	}

	test('keeps non-colliding entries in options.customGeoTypes', () => {
		const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
		try {
			expect(getConfiguredOptions({ 'my-shape': validDef })).toEqual({ 'my-shape': validDef })
			expect(warn).not.toHaveBeenCalled()
		} finally {
			warn.mockRestore()
		}
	})

	test('strips colliding keys from options so runtime lookups do not see them', () => {
		const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
		try {
			expect(getConfiguredOptions({ rectangle: validDef, 'my-shape': validDef })).toEqual({
				'my-shape': validDef,
			})
			expect(warn).toHaveBeenCalledWith(expect.stringMatching(/customGeoTypes key "rectangle"/))
		} finally {
			warn.mockRestore()
		}
	})

	test('reusing the same custom key across configure() calls still keeps the entry', () => {
		const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
		try {
			expect(getConfiguredOptions({ 'my-shape': validDef })).toEqual({ 'my-shape': validDef })
			// A second configure() call with the same key should not treat it as a
			// collision with built-ins, so the entry is preserved.
			expect(getConfiguredOptions({ 'my-shape': validDef })).toEqual({ 'my-shape': validDef })
			expect(warn).not.toHaveBeenCalled()
		} finally {
			warn.mockRestore()
		}
	})
})
