import { vi } from 'vitest'
import {
	Box,
	Geometry2d,
	RecordProps,
	Rectangle2d,
	ShapeUtil,
	T,
	TLShape,
	createShapeId,
	createTLStore,
} from '../..'
import { Editor } from './Editor'
import { StateNode } from './tools/StateNode'

const MY_CUSTOM_SHAPE_TYPE = 'my-custom-shape'

declare module '@tldraw/tlschema' {
	export interface TLGlobalShapePropsMap {
		[MY_CUSTOM_SHAPE_TYPE]: { w: number; h: number; text: string | undefined; isFilled: boolean }
	}
}

type ICustomShape = TLShape<typeof MY_CUSTOM_SHAPE_TYPE>

class CustomShape extends ShapeUtil<ICustomShape> {
	static override type = MY_CUSTOM_SHAPE_TYPE
	static override props: RecordProps<ICustomShape> = {
		w: T.number,
		h: T.number,
		text: T.string.optional(),
		isFilled: T.boolean,
	}
	getDefaultProps(): ICustomShape['props'] {
		return {
			w: 200,
			h: 200,
			text: '',
			isFilled: false,
		}
	}
	getGeometry(shape: ICustomShape): Geometry2d {
		return new Rectangle2d({
			width: shape.props.w,
			height: shape.props.h,
			isFilled: shape.props.isFilled,
		})
	}
	getIndicatorPath() {
		return undefined
	}
	component() {}
}

let editor: Editor

beforeEach(() => {
	editor = new Editor({
		shapeUtils: [CustomShape],
		bindingUtils: [],
		tools: [],
		store: createTLStore({ shapeUtils: [CustomShape], bindingUtils: [] }),
		getContainer: () => document.body,
	})
	editor.setCameraOptions({ isLocked: true })
	editor.setCamera = vi.fn()
	editor.user.getAnimationSpeed = vi.fn()
})

describe('centerOnPoint', () => {
	it('no-op when isLocked is set', () => {
		editor.centerOnPoint({ x: 0, y: 0 })
		expect(editor.setCamera).not.toHaveBeenCalled()
	})

	it('sets camera when isLocked is set and force flag is set', () => {
		editor.centerOnPoint({ x: 0, y: 0 }, { force: true })
		expect(editor.setCamera).toHaveBeenCalled()
	})
})

describe('updateShape', () => {
	it('updates shape props to undefined', () => {
		const id = createShapeId('sample')
		editor.createShape({
			id,
			type: 'my-custom-shape',
			props: { w: 100, h: 100, text: 'Hello' },
		})
		const shape = editor.getShape(id) as ICustomShape
		expect(shape.props).toEqual({ w: 100, h: 100, text: 'Hello', isFilled: false })

		editor.updateShape({ ...shape, props: { ...shape.props, text: undefined } })
		const updatedShape = editor.getShape(id) as ICustomShape
		expect(updatedShape.props).toEqual({ w: 100, h: 100, text: undefined, isFilled: false })
	})
})

describe('zoomToFit', () => {
	it('no-op when isLocked is set', () => {
		editor.getCurrentPageShapeIds = vi.fn(() => new Set([createShapeId('box1')]))
		editor.zoomToFit()
		expect(editor.setCamera).not.toHaveBeenCalled()
	})

	it('sets camera when isLocked is set and force flag is set', () => {
		editor.getCurrentPageShapeIds = vi.fn(() => new Set([createShapeId('box1')]))
		editor.zoomToFit({ force: true })
		expect(editor.setCamera).toHaveBeenCalled()
	})
})

describe('resetZoom', () => {
	it('no-op when isLocked is set', () => {
		editor.resetZoom()
		expect(editor.setCamera).not.toHaveBeenCalled()
	})

	it('sets camera when isLocked is set and force flag is set', () => {
		editor.resetZoom(undefined, { force: true })
		expect(editor.setCamera).toHaveBeenCalled()
	})
})

describe('zoomIn', () => {
	it('no-op when isLocked is set', () => {
		editor.zoomIn()
		expect(editor.setCamera).not.toHaveBeenCalled()
	})

	it('sets camera when isLocked is set and force flag is set', () => {
		editor.zoomIn(undefined, { force: true })
		expect(editor.setCamera).toHaveBeenCalled()
	})
})

describe('zoomOut', () => {
	it('no-op when isLocked is set', () => {
		editor.zoomOut()
		expect(editor.setCamera).not.toHaveBeenCalled()
	})

	it('sets camera when isLocked is set and force flag is set', () => {
		editor.zoomOut(undefined, { force: true })
		expect(editor.setCamera).toHaveBeenCalled()
	})
})

describe('zoomToSelection', () => {
	it('no-op when isLocked is set', () => {
		editor.getSelectionPageBounds = vi.fn(() => Box.From({ x: 0, y: 0, w: 100, h: 100 }))
		editor.zoomToSelection()
		expect(editor.setCamera).not.toHaveBeenCalled()
	})

	it('sets camera when isLocked is set and force flag is set', () => {
		editor.getSelectionPageBounds = vi.fn(() => Box.From({ x: 0, y: 0, w: 100, h: 100 }))
		editor.zoomToSelection({ force: true })
		expect(editor.setCamera).toHaveBeenCalled()
	})
})

describe('slideCamera', () => {
	it('no-op when isLocked is set', () => {
		editor.slideCamera({ speed: 1, direction: { x: 1, y: 1 } })
		expect(editor.user.getAnimationSpeed).not.toHaveBeenCalled()
	})

	it('performs animation when isLocked is set and force flag is set', () => {
		editor.slideCamera({ speed: 1, direction: { x: 1, y: 1 }, force: true })
		expect(editor.user.getAnimationSpeed).toHaveBeenCalled()
	})
})

describe('zoomToBounds', () => {
	it('no-op when isLocked is set', () => {
		editor.zoomToBounds({ x: 0, y: 0, w: 100, h: 100 })
		expect(editor.setCamera).not.toHaveBeenCalled()
	})

	it('sets camera when isLocked is set and force flag is set', () => {
		editor.zoomToBounds({ x: 0, y: 0, w: 100, h: 100 }, { force: true })
		expect(editor.setCamera).toHaveBeenCalled()
	})
})

describe('getShapesAtPoint', () => {
	const ids = {
		shape1: createShapeId('shape1'),
		shape2: createShapeId('shape2'),
		shape3: createShapeId('shape3'),
		shape4: createShapeId('shape4'),
		shape5: createShapeId('shape5'),
		overlap1: createShapeId('overlap1'),
		overlap2: createShapeId('overlap2'),
		filledShape: createShapeId('filledShape'),
		hollowShape: createShapeId('hollowShape'),
		hiddenShape: createShapeId('hiddenShape'),
	}

	beforeEach(() => {
		// Create test shapes with different z-index positions
		// Shape 1: Bottom layer, large square
		editor.createShape({
			id: ids.shape1,
			type: 'my-custom-shape',
			x: 0,
			y: 0,
			props: { w: 200, h: 200, text: 'Bottom' },
		})

		// Shape 2: Middle layer, overlapping square
		editor.createShape({
			id: ids.shape2,
			type: 'my-custom-shape',
			x: 100,
			y: 0,
			props: { w: 200, h: 200, text: 'Middle' },
		})

		// Shape 3: Top layer, small square
		editor.createShape({
			id: ids.shape3,
			type: 'my-custom-shape',
			x: 50,
			y: 50,
			props: { w: 100, h: 100, text: 'Top' },
		})

		// Shape 4: Separate area, no overlap
		editor.createShape({
			id: ids.shape4,
			type: 'my-custom-shape',
			x: 50,
			y: 100,
			props: { w: 100, h: 100, text: 'Separate' },
		})
	})

	it('returns shapes at a point in reverse z-index order', () => {
		// Point at (50, 50) should hit shape3's edge (since it's at 50,50 with size 100x100)
		// This point is exactly at the top-left corner of shape3
		const shapes = editor.getShapesAtPoint({ x: 50, y: 50 })
		const shapeIds = shapes.map((s) => s.id)

		expect(shapeIds).toEqual([ids.shape3])
		expect(shapes).toHaveLength(1)
	})

	it('returns empty array when no shapes at point', () => {
		const shapes = editor.getShapesAtPoint({ x: 1000, y: 1000 })
		expect(shapes).toEqual([])
	})

	it('returns single shape when point hits only one shape', () => {
		// Point at right edge of shape2 where it doesn't overlap with other shapes
		// Shape2 is at (100,0) with size 200x200, so right edge is at x=300
		const shapes = editor.getShapesAtPoint({ x: 300, y: 100 })
		expect(shapes).toHaveLength(1)
		expect(shapes[0].id).toBe(ids.shape2)
	})

	it('returns shapes on edge when point is exactly on boundary', () => {
		// Point at exact edge of shape1
		const shapes = editor.getShapesAtPoint({ x: 0, y: 0 })
		expect(shapes).toHaveLength(1)
		expect(shapes[0].id).toBe(ids.shape1)
	})

	it('respects hitInside option when false (default)', () => {
		// Point inside shape1 (at 0,0 with size 200x200) but with hitInside false should not hit
		const shapes = editor.getShapesAtPoint({ x: 25, y: 25 }, { hitInside: false })
		expect(shapes).toEqual([])
	})

	it('respects hitInside option when true', () => {
		// Point inside shape1 (at 0,0 with size 200x200) with hitInside true should hit
		const shapes = editor.getShapesAtPoint({ x: 25, y: 25 }, { hitInside: true })
		expect(shapes).toHaveLength(1)
		expect(shapes[0].id).toBe(ids.shape1)
	})

	it('respects margin option', () => {
		// Point slightly outside shape1 at bottom edge but within margin should hit only shape1
		// Shape1 is at (0,0) with size 200x200, shape2 goes to (300,200) so avoid overlap at (200,200)
		const shapes = editor.getShapesAtPoint({ x: 205, y: 100 }, { margin: 10 })
		expect(shapes).toHaveLength(1)
		expect(shapes[0].id).toBe(ids.shape1)
	})

	it('filters out hidden shapes', () => {
		// Create a spy to mock isShapeHidden
		const isShapeHiddenSpy = vi.spyOn(editor, 'isShapeHidden')
		isShapeHiddenSpy.mockImplementation((shape) => {
			return typeof shape === 'string' ? shape === ids.shape3 : shape.id === ids.shape3
		})

		const shapes = editor.getShapesAtPoint({ x: 50, y: 50 })
		const shapeIds = shapes.map((s) => s.id)

		// Should not include shape3 since it's hidden, and no other shapes are at this point
		expect(shapeIds).toEqual([])
		expect(shapes).toHaveLength(0)

		isShapeHiddenSpy.mockRestore()
	})

	it('handles point exactly at shape corner', () => {
		// Point at bottom-left corner of shape1 where it doesn't overlap with other shapes
		const shapes = editor.getShapesAtPoint({ x: 0, y: 200 })
		expect(shapes).toHaveLength(1)
		expect(shapes[0].id).toBe(ids.shape1)
	})

	it('handles overlapping shapes with different hit areas', () => {
		// Point that hits both shape1 and shape2 edges (they overlap at x=100,y=0)
		const shapes = editor.getShapesAtPoint({ x: 100, y: 0 })
		const shapeIds = shapes.map((s) => s.id)

		// Both shapes should be detected at this overlapping point (reversed order - top-most first)
		expect(shapeIds).toEqual([ids.shape2, ids.shape1])
		expect(shapes).toHaveLength(2)
	})

	it('maintains reverse shape order and responds to z-index changes', () => {
		// Create filled shape that overlaps with shape2
		editor.createShape({
			id: ids.shape5,
			type: 'my-custom-shape',
			x: 110,
			y: 110,
			props: { w: 200, h: 200, isFilled: true, text: 'Shape5' },
		})

		// Test with hitInside to detect multiple shapes
		// Point (120,120) will hit shape1, shape2, shape3, shape4, and shape5 with hitInside: true
		const shapes = editor.getShapesAtPoint({ x: 120, y: 120 }, { hitInside: true })
		const shapeIds = shapes.map((s) => s.id)

		// All shapes that contain this point should be returned in reverse z-index order (top-most first)
		expect(shapeIds).toEqual([ids.shape5, ids.shape4, ids.shape3, ids.shape2, ids.shape1])

		// After bringing shape2 to front, order should change (shape2 becomes top-most)
		editor.bringToFront([ids.shape2])
		const shapes2 = editor.getShapesAtPoint({ x: 120, y: 120 }, { hitInside: true })
		const shapeIds2 = shapes2.map((s) => s.id)
		expect(shapeIds2).toEqual([ids.shape2, ids.shape5, ids.shape4, ids.shape3, ids.shape1])
	})

	it('combines hitInside and margin options', () => {
		// Point inside shape1 (at 0,0 with size 200x200) with hitInside and margin
		const shapes = editor.getShapesAtPoint({ x: 25, y: 25 }, { hitInside: true, margin: 5 })
		expect(shapes).toHaveLength(1)
		expect(shapes[0].id).toBe(ids.shape1)
	})

	it('returns empty array when all shapes are hidden', () => {
		// Mock all shapes as hidden
		const isShapeHiddenSpy = vi.spyOn(editor, 'isShapeHidden')
		isShapeHiddenSpy.mockReturnValue(true)

		const shapes = editor.getShapesAtPoint({ x: 50, y: 50 })
		expect(shapes).toEqual([])

		isShapeHiddenSpy.mockRestore()
	})

	it('returns multiple shapes at same point in reverse z-index order', () => {
		// Create two shapes at exactly the same position (away from existing shapes)
		editor.createShape({
			id: ids.overlap1,
			type: 'my-custom-shape',
			x: 600,
			y: 600,
			props: { w: 100, h: 100, text: 'First' },
		})

		editor.createShape({
			id: ids.overlap2,
			type: 'my-custom-shape',
			x: 600,
			y: 600,
			props: { w: 100, h: 100, text: 'Second' },
		})

		// Test at corner where both shapes' edges meet
		const shapes = editor.getShapesAtPoint({ x: 600, y: 600 })
		const shapeIds = shapes.map((s) => s.id)

		// Should return both shapes in reverse z-index order (top-most first)
		expect(shapeIds).toEqual([ids.overlap2, ids.overlap1])
		expect(shapes).toHaveLength(2)
	})

	it('respects isFilled property for hit detection', () => {
		// Create a filled shape
		editor.createShape({
			id: ids.filledShape,
			type: 'my-custom-shape',
			x: 300,
			y: 300,
			props: { w: 100, h: 100, isFilled: true, text: 'Filled' },
		})

		// Create a hollow shape at the same position
		editor.createShape({
			id: ids.hollowShape,
			type: 'my-custom-shape',
			x: 400,
			y: 300,
			props: { w: 100, h: 100, isFilled: false, text: 'Hollow' },
		})

		// Test point inside filled shape - should hit without hitInside option
		const filledShapes = editor.getShapesAtPoint({ x: 350, y: 350 })
		expect(filledShapes).toHaveLength(1)
		expect(filledShapes[0].id).toBe(ids.filledShape)

		// Test point inside hollow shape - should not hit without hitInside option
		const hollowShapes = editor.getShapesAtPoint({ x: 450, y: 350 })
		expect(hollowShapes).toHaveLength(0)

		// Test point inside hollow shape with hitInside - should hit
		const hollowShapesWithHitInside = editor.getShapesAtPoint(
			{ x: 450, y: 350 },
			{ hitInside: true }
		)
		expect(hollowShapesWithHitInside).toHaveLength(1)
		expect(hollowShapesWithHitInside[0].id).toBe(ids.hollowShape)
	})
})

describe('selectAll', () => {
	const selectAllIds = {
		pageShape1: createShapeId('pageShape1'),
		pageShape2: createShapeId('pageShape2'),
		pageShape3: createShapeId('pageShape3'),
		container1: createShapeId('container1'),
		containerChild1: createShapeId('containerChild1'),
		containerChild2: createShapeId('containerChild2'),
		containerChild3: createShapeId('containerChild3'),
		containerGrandchild1: createShapeId('containerGrandchild1'),
		container2: createShapeId('container2'),
		container2Child1: createShapeId('container2Child1'),
		container2Child2: createShapeId('container2Child2'),
		container2Grandchild1: createShapeId('container2Grandchild1'),
		lockedShape: createShapeId('lockedShape'),
	}

	beforeEach(() => {
		// Clear any existing shapes
		editor.selectAll().deleteShapes(editor.getSelectedShapeIds())

		// Create shapes directly on the page (no parentId means they're children of the page)
		editor.createShapes([
			{
				id: selectAllIds.pageShape1,
				type: 'my-custom-shape',
				x: 100,
				y: 100,
				props: { w: 100, h: 100 },
			},
			{
				id: selectAllIds.pageShape2,
				type: 'my-custom-shape',
				x: 300,
				y: 100,
				props: { w: 100, h: 100 },
			},
			{
				id: selectAllIds.pageShape3,
				type: 'my-custom-shape',
				x: 500,
				y: 100,
				props: { w: 100, h: 100 },
			},
			{
				id: selectAllIds.lockedShape,
				type: 'my-custom-shape',
				x: 700,
				y: 100,
				props: { w: 100, h: 100 },
				isLocked: true,
			},
		])

		// Create a container shape (simulating a frame or group)
		editor.createShape({
			id: selectAllIds.container1,
			type: 'my-custom-shape',
			x: 100,
			y: 300,
			props: { w: 400, h: 200 },
		})

		// Create children inside the container (parentId set to container1)
		editor.createShapes([
			{
				id: selectAllIds.containerChild1,
				type: 'my-custom-shape',
				parentId: selectAllIds.container1,
				x: 120,
				y: 320,
				props: { w: 50, h: 50 },
			},
			{
				id: selectAllIds.containerChild2,
				type: 'my-custom-shape',
				parentId: selectAllIds.container1,
				x: 200,
				y: 320,
				props: { w: 50, h: 50 },
			},
			{
				id: selectAllIds.containerChild3,
				type: 'my-custom-shape',
				parentId: selectAllIds.container1,
				x: 280,
				y: 320,
				props: { w: 50, h: 50 },
			},
		])

		// Create a grandchild inside one of the container children
		editor.createShape({
			id: selectAllIds.containerGrandchild1,
			type: 'my-custom-shape',
			parentId: selectAllIds.containerChild3,
			x: 290,
			y: 330,
			props: { w: 30, h: 30 },
		})

		// Create a second container (simulating a group)
		editor.createShape({
			id: selectAllIds.container2,
			type: 'my-custom-shape',
			x: 600,
			y: 300,
			props: { w: 200, h: 200 },
		})

		// Create children inside the second container
		editor.createShapes([
			{
				id: selectAllIds.container2Child1,
				type: 'my-custom-shape',
				parentId: selectAllIds.container2,
				x: 620,
				y: 320,
				props: { w: 50, h: 50 },
			},
			{
				id: selectAllIds.container2Child2,
				type: 'my-custom-shape',
				parentId: selectAllIds.container2,
				x: 680,
				y: 320,
				props: { w: 50, h: 50 },
			},
		])

		// Create a grandchild in the second container
		editor.createShape({
			id: selectAllIds.container2Grandchild1,
			type: 'my-custom-shape',
			parentId: selectAllIds.container2Child1,
			x: 630,
			y: 330,
			props: { w: 30, h: 30 },
		})

		// Clear selection
		editor.selectNone()
	})

	it('when no shapes are selected, selects all page-level shapes (excluding locked ones)', () => {
		// Initially no shapes selected
		expect(editor.getSelectedShapeIds()).toEqual([])

		// Call selectAll
		editor.selectAll()

		// Should select all page-level shapes (excluding locked ones)
		const selectedIds = editor.getSelectedShapeIds()
		expect(Array.from(selectedIds).sort()).toEqual(
			[
				selectAllIds.pageShape1,
				selectAllIds.pageShape2,
				selectAllIds.pageShape3,
				selectAllIds.container1,
				selectAllIds.container2,
			].sort()
		)

		// Should NOT include locked shape or children/grandchildren
		expect(selectedIds).not.toContain(selectAllIds.lockedShape)
		expect(selectedIds).not.toContain(selectAllIds.containerChild1)
		expect(selectedIds).not.toContain(selectAllIds.containerChild2)
		expect(selectedIds).not.toContain(selectAllIds.containerChild3)
		expect(selectedIds).not.toContain(selectAllIds.containerGrandchild1)
		expect(selectedIds).not.toContain(selectAllIds.container2Child1)
		expect(selectedIds).not.toContain(selectAllIds.container2Child2)
		expect(selectedIds).not.toContain(selectAllIds.container2Grandchild1)
	})

	it('when shapes are selected only on the page, all children of the page should be selected (but not their descendants)', () => {
		// Select some page-level shapes
		editor.select(selectAllIds.pageShape1, selectAllIds.pageShape2)

		// Call selectAll
		editor.selectAll()

		// Should select all page-level shapes (excluding locked ones), but not descendants
		const selectedIds = editor.getSelectedShapeIds()
		expect(Array.from(selectedIds).sort()).toEqual(
			[
				selectAllIds.pageShape1,
				selectAllIds.pageShape2,
				selectAllIds.pageShape3,
				selectAllIds.container1,
				selectAllIds.container2,
			].sort()
		)

		// Should NOT include children or grandchildren or locked shapes
		expect(selectedIds).not.toContain(selectAllIds.containerChild1)
		expect(selectedIds).not.toContain(selectAllIds.containerChild2)
		expect(selectedIds).not.toContain(selectAllIds.containerChild3)
		expect(selectedIds).not.toContain(selectAllIds.containerGrandchild1)
		expect(selectedIds).not.toContain(selectAllIds.container2Child1)
		expect(selectedIds).not.toContain(selectAllIds.container2Child2)
		expect(selectedIds).not.toContain(selectAllIds.container2Grandchild1)
		expect(selectedIds).not.toContain(selectAllIds.lockedShape)
	})

	it('when shapes are selected within a container, only children of the container should be selected (not their descendants)', () => {
		// Select some container children
		editor.select(selectAllIds.containerChild1, selectAllIds.containerChild2)

		// Call selectAll
		editor.selectAll()

		// Should select all container children (but not their descendants)
		const selectedIds = editor.getSelectedShapeIds()
		expect(Array.from(selectedIds).sort()).toEqual(
			[
				selectAllIds.containerChild1,
				selectAllIds.containerChild2,
				selectAllIds.containerChild3,
			].sort()
		)

		// Should NOT include page-level shapes or grandchildren
		expect(selectedIds).not.toContain(selectAllIds.pageShape1)
		expect(selectedIds).not.toContain(selectAllIds.pageShape2)
		expect(selectedIds).not.toContain(selectAllIds.pageShape3)
		expect(selectedIds).not.toContain(selectAllIds.container1)
		expect(selectedIds).not.toContain(selectAllIds.container2)
		expect(selectedIds).not.toContain(selectAllIds.containerGrandchild1)
		expect(selectedIds).not.toContain(selectAllIds.container2Child1)
		expect(selectedIds).not.toContain(selectAllIds.container2Child2)
		expect(selectedIds).not.toContain(selectAllIds.container2Grandchild1)
	})

	it('when shapes are selected within a second container, only children of that container should be selected', () => {
		// Select some second container children
		editor.select(selectAllIds.container2Child1)

		// Call selectAll
		editor.selectAll()

		// Should select all second container children (but not their descendants)
		const selectedIds = editor.getSelectedShapeIds()
		expect(Array.from(selectedIds).sort()).toEqual(
			[selectAllIds.container2Child1, selectAllIds.container2Child2].sort()
		)

		// Should NOT include page-level shapes or other container's children or grandchildren
		expect(selectedIds).not.toContain(selectAllIds.pageShape1)
		expect(selectedIds).not.toContain(selectAllIds.pageShape2)
		expect(selectedIds).not.toContain(selectAllIds.pageShape3)
		expect(selectedIds).not.toContain(selectAllIds.container1)
		expect(selectedIds).not.toContain(selectAllIds.container2)
		expect(selectedIds).not.toContain(selectAllIds.containerChild1)
		expect(selectedIds).not.toContain(selectAllIds.containerChild2)
		expect(selectedIds).not.toContain(selectAllIds.containerChild3)
		expect(selectedIds).not.toContain(selectAllIds.containerGrandchild1)
		expect(selectedIds).not.toContain(selectAllIds.container2Grandchild1)
	})

	it('when shapes are selected that belong to different parents, no change/history entry should be made', () => {
		// Select shapes from different parents (page and container)
		editor.select(selectAllIds.pageShape1, selectAllIds.containerChild1)

		const initialSelectedIds = editor.getSelectedShapeIds()

		// Spy on setSelectedShapes to verify it's not called
		const setSelectedShapesSpy = vi.spyOn(editor, 'setSelectedShapes')

		// Call selectAll
		editor.selectAll()

		// Selection should remain unchanged
		expect(editor.getSelectedShapeIds()).toEqual(initialSelectedIds)

		// setSelectedShapes should not have been called (the method returns early)
		expect(setSelectedShapesSpy).not.toHaveBeenCalled()

		setSelectedShapesSpy.mockRestore()
	})

	it('when shapes are selected that belong to different containers, no change/history entry should be made', () => {
		// Select shapes from different containers
		editor.select(selectAllIds.containerChild1, selectAllIds.container2Child1)

		const initialSelectedIds = editor.getSelectedShapeIds()

		// Spy on setSelectedShapes to verify it's not called
		const setSelectedShapesSpy = vi.spyOn(editor, 'setSelectedShapes')

		// Call selectAll
		editor.selectAll()

		// Selection should remain unchanged
		expect(editor.getSelectedShapeIds()).toEqual(initialSelectedIds)

		// setSelectedShapes should not have been called
		expect(setSelectedShapesSpy).not.toHaveBeenCalled()

		setSelectedShapesSpy.mockRestore()
	})

	it('should not select locked shapes', () => {
		// Select a page-level shape
		editor.select(selectAllIds.pageShape1)

		// Call selectAll
		editor.selectAll()

		// Should select all page-level shapes except locked ones
		const selectedIds = editor.getSelectedShapeIds()
		expect(selectedIds).not.toContain(selectAllIds.lockedShape)
		expect(selectedIds).toContain(selectAllIds.pageShape1)
		expect(selectedIds).toContain(selectAllIds.pageShape2)
		expect(selectedIds).toContain(selectAllIds.pageShape3)
		expect(selectedIds).toContain(selectAllIds.container1)
		expect(selectedIds).toContain(selectAllIds.container2)
	})

	it('should handle empty container by selecting all siblings at the same level', () => {
		// Create an empty container
		const emptyContainerId = createShapeId('emptyContainer')
		editor.createShape({
			id: emptyContainerId,
			type: 'my-custom-shape',
			x: 800,
			y: 400,
			props: { w: 100, h: 100 },
		})

		// Clear selection first
		editor.selectNone()

		// Select the empty container
		editor.select(emptyContainerId)

		// Call selectAll - since the empty container has no children, it should select all siblings (page-level shapes)
		editor.selectAll()

		// Should select all page-level shapes (including the empty container itself)
		const selectedIds = editor.getSelectedShapeIds()
		expect(Array.from(selectedIds).sort()).toEqual(
			[
				selectAllIds.pageShape1,
				selectAllIds.pageShape2,
				selectAllIds.pageShape3,
				selectAllIds.container1,
				selectAllIds.container2,
				emptyContainerId,
			].sort()
		)

		// Should NOT include locked shapes or children/grandchildren
		expect(selectedIds).not.toContain(selectAllIds.lockedShape)
		expect(selectedIds).not.toContain(selectAllIds.containerChild1)
		expect(selectedIds).not.toContain(selectAllIds.containerChild2)
		expect(selectedIds).not.toContain(selectAllIds.containerChild3)
		expect(selectedIds).not.toContain(selectAllIds.containerGrandchild1)
		expect(selectedIds).not.toContain(selectAllIds.container2Child1)
		expect(selectedIds).not.toContain(selectAllIds.container2Child2)
		expect(selectedIds).not.toContain(selectAllIds.container2Grandchild1)
	})

	it('should work correctly when selecting all shapes of same parent type', () => {
		// Select all container children
		editor.select(
			selectAllIds.containerChild1,
			selectAllIds.containerChild2,
			selectAllIds.containerChild3
		)

		// Call selectAll - should maintain the same selection since all children are already selected
		editor.selectAll()

		// Should still have all container children selected
		const selectedIds = editor.getSelectedShapeIds()
		expect(Array.from(selectedIds).sort()).toEqual(
			[
				selectAllIds.containerChild1,
				selectAllIds.containerChild2,
				selectAllIds.containerChild3,
			].sort()
		)
	})

	it('should handle mixed selection levels gracefully by doing nothing', () => {
		// Select a mix: page shape (parent=page), container (parent=page), and container child (parent=container1)
		// These all have different parent IDs so selectAll should do nothing
		editor.select(selectAllIds.pageShape1, selectAllIds.containerChild1)

		const initialSelectedIds = Array.from(editor.getSelectedShapeIds())

		// Spy on setSelectedShapes to verify it's not called
		const setSelectedShapesSpy = vi.spyOn(editor, 'setSelectedShapes')

		// Call selectAll
		editor.selectAll()

		// Selection should remain unchanged since shapes have different parents
		expect(Array.from(editor.getSelectedShapeIds())).toEqual(initialSelectedIds)

		// setSelectedShapes should not have been called
		expect(setSelectedShapesSpy).not.toHaveBeenCalled()

		setSelectedShapesSpy.mockRestore()
	})
})

describe('putExternalContent', () => {
	let mockHandler: any

	beforeEach(() => {
		mockHandler = vi.fn()
		editor.registerExternalContentHandler('text', mockHandler)
	})

	it('calls external content handler when not readonly', async () => {
		vi.spyOn(editor, 'getIsReadonly').mockReturnValue(false)

		const info = { type: 'text' as const, text: 'test-data' }
		await editor.putExternalContent(info)

		expect(mockHandler).toHaveBeenCalledWith(info)
	})

	it('does not call external content handler when readonly', async () => {
		vi.spyOn(editor, 'getIsReadonly').mockReturnValue(true)

		const info = { type: 'text' as const, text: 'test-data' }
		await editor.putExternalContent(info)

		expect(mockHandler).not.toHaveBeenCalled()
	})

	it('calls external content handler when readonly but force is true', async () => {
		vi.spyOn(editor, 'getIsReadonly').mockReturnValue(true)

		const info = { type: 'text' as const, text: 'test-data' }
		await editor.putExternalContent(info, { force: true })

		expect(mockHandler).toHaveBeenCalledWith(info)
	})

	it('calls external content handler when force is false and not readonly', async () => {
		vi.spyOn(editor, 'getIsReadonly').mockReturnValue(false)

		const info = { type: 'text' as const, text: 'test-data' }
		await editor.putExternalContent(info, { force: false })

		expect(mockHandler).toHaveBeenCalledWith(info)
	})
})

describe('replaceExternalContent', () => {
	let mockHandler: any

	beforeEach(() => {
		mockHandler = vi.fn()
		editor.registerExternalContentHandler('text', mockHandler)
	})

	it('calls external content handler when not readonly', async () => {
		vi.spyOn(editor, 'getIsReadonly').mockReturnValue(false)

		const info = { type: 'text' as const, text: 'test-data' }
		await editor.replaceExternalContent(info)

		expect(mockHandler).toHaveBeenCalledWith(info)
	})

	it('does not call external content handler when readonly', async () => {
		vi.spyOn(editor, 'getIsReadonly').mockReturnValue(true)

		const info = { type: 'text' as const, text: 'test-data' }
		await editor.replaceExternalContent(info)

		expect(mockHandler).not.toHaveBeenCalled()
	})

	it('calls external content handler when readonly but force is true', async () => {
		vi.spyOn(editor, 'getIsReadonly').mockReturnValue(true)

		const info = { type: 'text' as const, text: 'test-data' }
		await editor.replaceExternalContent(info, { force: true })

		expect(mockHandler).toHaveBeenCalledWith(info)
	})

	it('calls external content handler when force is false and not readonly', async () => {
		vi.spyOn(editor, 'getIsReadonly').mockReturnValue(false)

		const info = { type: 'text' as const, text: 'test-data' }
		await editor.replaceExternalContent(info, { force: false })

		expect(mockHandler).toHaveBeenCalledWith(info)
	})
})

describe('dispatch event emission', () => {
	let testEditor: Editor

	beforeEach(() => {
		testEditor = new Editor({
			shapeUtils: [CustomShape],
			bindingUtils: [],
			tools: [],
			store: createTLStore({ shapeUtils: [CustomShape], bindingUtils: [] }),
			getContainer: () => document.body,
		})
		// Ensure camera is unlocked so events are processed
		testEditor.setCameraOptions({ isLocked: false })
	})

	it('emits wheel events through the event emitter', () => {
		const eventHandler = vi.fn()
		testEditor.on('event', eventHandler)

		const wheelEvent = {
			type: 'wheel' as const,
			name: 'wheel' as const,
			delta: { x: 0, y: 10, z: 0 },
			point: { x: 100, y: 100, z: 1 },
			shiftKey: false,
			altKey: false,
			ctrlKey: false,
			metaKey: false,
			accelKey: false,
		}

		testEditor.dispatch(wheelEvent)
		// Wheel events are batched for the next tick, so emit a tick to flush them
		testEditor.emit('tick', 16)

		expect(eventHandler).toHaveBeenCalledWith(wheelEvent)
	})

	it('emits pinch_start events through the event emitter', () => {
		const eventHandler = vi.fn()
		testEditor.on('event', eventHandler)

		const pinchStartEvent = {
			type: 'pinch' as const,
			name: 'pinch_start' as const,
			point: { x: 100, y: 100, z: 1 },
			delta: { x: 0, y: 0, z: 0 },
			shiftKey: false,
			altKey: false,
			ctrlKey: false,
			metaKey: false,
			accelKey: false,
		}

		testEditor.dispatch(pinchStartEvent)
		// Pinch events are batched for the next tick, so emit a tick to flush them
		testEditor.emit('tick', 16)

		expect(eventHandler).toHaveBeenCalledWith(pinchStartEvent)
	})

	it('emits pinch events through the event emitter', () => {
		const eventHandler = vi.fn()
		testEditor.on('event', eventHandler)

		// First dispatch pinch_start to set isPinching
		const pinchStartEvent = {
			type: 'pinch' as const,
			name: 'pinch_start' as const,
			point: { x: 100, y: 100, z: 1 },
			delta: { x: 0, y: 0, z: 0 },
			shiftKey: false,
			altKey: false,
			ctrlKey: false,
			metaKey: false,
			accelKey: false,
		}
		testEditor.dispatch(pinchStartEvent)
		testEditor.emit('tick', 16)

		eventHandler.mockClear()

		const pinchEvent = {
			type: 'pinch' as const,
			name: 'pinch' as const,
			point: { x: 100, y: 100, z: 1.5 },
			delta: { x: 10, y: 10, z: 0 },
			shiftKey: false,
			altKey: false,
			ctrlKey: false,
			metaKey: false,
			accelKey: false,
		}

		testEditor.dispatch(pinchEvent)
		testEditor.emit('tick', 16)

		expect(eventHandler).toHaveBeenCalledWith(pinchEvent)
	})

	it('emits pinch_end events through the event emitter', () => {
		const eventHandler = vi.fn()
		testEditor.on('event', eventHandler)

		// First dispatch pinch_start to set isPinching
		const pinchStartEvent = {
			type: 'pinch' as const,
			name: 'pinch_start' as const,
			point: { x: 100, y: 100, z: 1 },
			delta: { x: 0, y: 0, z: 0 },
			shiftKey: false,
			altKey: false,
			ctrlKey: false,
			metaKey: false,
			accelKey: false,
		}
		testEditor.dispatch(pinchStartEvent)
		testEditor.emit('tick', 16)

		eventHandler.mockClear()

		const pinchEndEvent = {
			type: 'pinch' as const,
			name: 'pinch_end' as const,
			point: { x: 100, y: 100, z: 1.5 },
			delta: { x: 0, y: 0, z: 0 },
			shiftKey: false,
			altKey: false,
			ctrlKey: false,
			metaKey: false,
			accelKey: false,
		}

		testEditor.dispatch(pinchEndEvent)
		testEditor.emit('tick', 16)

		expect(eventHandler).toHaveBeenCalledWith(pinchEndEvent)
	})
})

describe('setTool', () => {
	class CustomToolA extends StateNode {
		static override id = 'custom-tool-a'
	}

	class CustomToolB extends StateNode {
		static override id = 'custom-tool-b'
	}

	class CustomToolC extends StateNode {
		static override id = 'custom-tool-c'
	}

	class ParentTool extends StateNode {
		static override id = 'parent-tool'
		static override initial = 'child-tool-1'
		static override children() {
			return [ChildTool1]
		}
	}

	class ChildTool1 extends StateNode {
		static override id = 'child-tool-1'
	}

	class ChildTool2 extends StateNode {
		static override id = 'child-tool-2'
	}

	let toolEditor: Editor

	beforeEach(() => {
		toolEditor = new Editor({
			shapeUtils: [],
			bindingUtils: [],
			tools: [CustomToolA, ParentTool],
			store: createTLStore({ shapeUtils: [], bindingUtils: [] }),
			getContainer: () => document.body,
		})
	})

	it('should add a tool to the root state', () => {
		// Initially CustomToolB should not exist
		expect(toolEditor.root.children!['custom-tool-b']).toBeUndefined()

		// Add CustomToolB
		toolEditor.setTool(CustomToolB)

		// CustomToolB should now exist in root
		expect(toolEditor.root.children!['custom-tool-b']).toBeDefined()
		expect(toolEditor.root.children!['custom-tool-b']).toBeInstanceOf(CustomToolB)
	})

	it('should add a tool to a specific parent state', () => {
		const parentTool = toolEditor.root.children!['parent-tool'] as ParentTool

		// Initially should only have child-tool-1
		expect(Object.keys(parentTool.children!)).toHaveLength(1)
		expect(parentTool.children!['child-tool-1']).toBeDefined()
		expect(parentTool.children!['child-tool-2']).toBeUndefined()

		// Add ChildTool2 to ParentTool
		toolEditor.setTool(ChildTool2, parentTool)

		// Should now have both children
		expect(Object.keys(parentTool.children!)).toHaveLength(2)
		expect(parentTool.children!['child-tool-1']).toBeDefined()
		expect(parentTool.children!['child-tool-2']).toBeDefined()
		expect(parentTool.children!['child-tool-2']).toBeInstanceOf(ChildTool2)
	})

	it('should throw an error when trying to override an existing tool', () => {
		// CustomToolA is already in the root (added in beforeEach)
		expect(toolEditor.root.children!['custom-tool-a']).toBeDefined()

		// Should throw error when trying to add another tool with the same ID
		expect(() => {
			toolEditor.setTool(CustomToolA)
		}).toThrow('Can\'t override tool with id "custom-tool-a"')
	})

	it('should allow transitioning to a newly added tool', () => {
		// Add CustomToolB
		toolEditor.setTool(CustomToolB)

		// Should be able to transition to the new tool
		expect(() => {
			toolEditor.setCurrentTool('custom-tool-b')
		}).not.toThrow()

		// Should now be on the new tool
		expect(toolEditor.getCurrentToolId()).toBe('custom-tool-b')
	})

	it('should create the tool with the correct editor and parent', () => {
		// Add CustomToolB to root
		toolEditor.setTool(CustomToolB)

		const customToolB = toolEditor.root.children!['custom-tool-b'] as CustomToolB

		expect(customToolB.editor).toBe(toolEditor)
		expect(customToolB.parent).toBe(toolEditor.root)
	})

	it('should maintain existing tools when adding new ones', () => {
		const originalTool = toolEditor.root.children!['custom-tool-a']

		// Add CustomToolB
		toolEditor.setTool(CustomToolB)

		// Original tool should still exist
		expect(toolEditor.root.children!['custom-tool-a']).toBe(originalTool)
		expect(toolEditor.root.children!['custom-tool-a']).toBeInstanceOf(CustomToolA)
	})

	it('should allow adding multiple tools', () => {
		// Add multiple tools
		toolEditor.setTool(CustomToolB)
		toolEditor.setTool(CustomToolC)

		// All tools should exist
		expect(toolEditor.root.children!['custom-tool-a']).toBeDefined()
		expect(toolEditor.root.children!['custom-tool-b']).toBeDefined()
		expect(toolEditor.root.children!['custom-tool-c']).toBeDefined()
		expect(toolEditor.root.children!['custom-tool-b']).toBeInstanceOf(CustomToolB)
		expect(toolEditor.root.children!['custom-tool-c']).toBeInstanceOf(CustomToolC)
	})
})

describe('removeTool', () => {
	class CustomToolA extends StateNode {
		static override id = 'custom-tool-a'
	}

	class CustomToolB extends StateNode {
		static override id = 'custom-tool-b'
	}

	class CustomToolC extends StateNode {
		static override id = 'custom-tool-c'
	}

	class ParentTool extends StateNode {
		static override id = 'parent-tool'
		static override initial = 'child-tool-1'
		static override children() {
			return [ChildTool1, ChildTool2]
		}
	}

	class ChildTool1 extends StateNode {
		static override id = 'child-tool-1'
	}

	class ChildTool2 extends StateNode {
		static override id = 'child-tool-2'
	}

	let toolEditor: Editor

	beforeEach(() => {
		toolEditor = new Editor({
			shapeUtils: [],
			bindingUtils: [],
			tools: [CustomToolA, CustomToolB, CustomToolC, ParentTool],
			store: createTLStore({ shapeUtils: [], bindingUtils: [] }),
			getContainer: () => document.body,
		})
	})

	it('should remove a tool from the root state', () => {
		// CustomToolB should exist initially
		expect(toolEditor.root.children!['custom-tool-b']).toBeDefined()

		// Remove CustomToolB
		toolEditor.removeTool(CustomToolB)

		// CustomToolB should no longer exist
		expect(toolEditor.root.children!['custom-tool-b']).toBeUndefined()
	})

	it('should remove a tool from a specific parent state', () => {
		const parentTool = toolEditor.root.children!['parent-tool'] as ParentTool

		// Initially should have both children
		expect(Object.keys(parentTool.children!)).toHaveLength(2)
		expect(parentTool.children!['child-tool-1']).toBeDefined()
		expect(parentTool.children!['child-tool-2']).toBeDefined()

		// Remove ChildTool2 from ParentTool
		toolEditor.removeTool(ChildTool2, parentTool)

		// Should now only have child-tool-1
		expect(Object.keys(parentTool.children!)).toHaveLength(1)
		expect(parentTool.children!['child-tool-1']).toBeDefined()
		expect(parentTool.children!['child-tool-2']).toBeUndefined()
	})

	it('should not throw an error when trying to remove a non-existent tool', () => {
		// First remove CustomToolB
		toolEditor.removeTool(CustomToolB)
		expect(toolEditor.root.children!['custom-tool-b']).toBeUndefined()

		// Trying to remove it again should not throw
		expect(() => {
			toolEditor.removeTool(CustomToolB)
		}).not.toThrow()
	})

	it('should maintain other tools when removing one', () => {
		const originalToolA = toolEditor.root.children!['custom-tool-a']
		const originalToolC = toolEditor.root.children!['custom-tool-c']

		// Remove CustomToolB
		toolEditor.removeTool(CustomToolB)

		// Other tools should still exist
		expect(toolEditor.root.children!['custom-tool-a']).toBe(originalToolA)
		expect(toolEditor.root.children!['custom-tool-c']).toBe(originalToolC)
		expect(toolEditor.root.children!['custom-tool-a']).toBeInstanceOf(CustomToolA)
		expect(toolEditor.root.children!['custom-tool-c']).toBeInstanceOf(CustomToolC)
	})

	it('should not be able to transition to a removed tool', () => {
		// Remove CustomToolB
		toolEditor.removeTool(CustomToolB)

		// Should throw when trying to transition to removed tool
		expect(() => {
			toolEditor.setCurrentTool('custom-tool-b')
		}).toThrow()
	})

	it('should allow removing multiple tools', () => {
		// Remove multiple tools
		toolEditor.removeTool(CustomToolB)
		toolEditor.removeTool(CustomToolC)

		// Removed tools should not exist
		expect(toolEditor.root.children!['custom-tool-b']).toBeUndefined()
		expect(toolEditor.root.children!['custom-tool-c']).toBeUndefined()

		// Other tools should still exist
		expect(toolEditor.root.children!['custom-tool-a']).toBeDefined()
		expect(toolEditor.root.children!['parent-tool']).toBeDefined()
	})

	it('should allow re-adding a tool after removing it', () => {
		// Remove CustomToolB
		toolEditor.removeTool(CustomToolB)
		expect(toolEditor.root.children!['custom-tool-b']).toBeUndefined()

		// Re-add CustomToolB
		toolEditor.setTool(CustomToolB)

		// CustomToolB should exist again
		expect(toolEditor.root.children!['custom-tool-b']).toBeDefined()
		expect(toolEditor.root.children!['custom-tool-b']).toBeInstanceOf(CustomToolB)

		// Should be able to transition to it
		expect(() => {
			toolEditor.setCurrentTool('custom-tool-b')
		}).not.toThrow()
		expect(toolEditor.getCurrentToolId()).toBe('custom-tool-b')
	})
})
