import {
	DefaultFillStyle,
	GeoShapeGeoStyle,
	TLArrowShape,
	TLFrameShape,
	TLShapeId,
	createShapeId,
} from '@tldraw/editor'
import { getArrowBindings } from '../lib/shapes/arrow/shared'
import { DEFAULT_FRAME_PADDING, fitFrameToContent, removeFrame } from '../lib/utils/frames/frames'
import { TestEditor } from './TestEditor'

let editor: TestEditor

jest.useFakeTimers()

beforeEach(() => {
	editor = new TestEditor()
})
afterEach(() => {
	editor?.dispose()
})

const ids = {
	boxA: createShapeId('boxA'),
}

describe('creating frames', () => {
	it('can be done', () => {
		editor.setCurrentTool('frame')
		editor.pointerDown(100, 100).pointerMove(200, 200).pointerUp(200, 200)
		expect(editor.getOnlySelectedShape()?.type).toBe('frame')
		expect(editor.getShapePageBounds(editor.getOnlySelectedShape()!)).toMatchObject({
			x: 100,
			y: 100,
			w: 100,
			h: 100,
		})
	})
	it('will create with a default size if no dragging happens', () => {
		editor.setCurrentTool('frame')
		editor.pointerDown(100, 100).pointerUp(100, 100)
		expect(editor.getOnlySelectedShape()?.type).toBe('frame')
		const { w, h } = editor.getShapeUtil<TLFrameShape>('frame').getDefaultProps()
		expect(editor.getShapePageBounds(editor.getOnlySelectedShape()!)).toMatchObject({
			x: 100 - w / 2,
			y: 100 - h / 2,
			w,
			h,
		})
	})
	it('can be canceled while pointing', () => {
		editor.setCurrentTool('frame')
		editor.pointerDown(100, 100).cancel().pointerUp(100, 100)
		expect(editor.getOnlySelectedShape()?.type).toBe(undefined)
		expect(editor.getCurrentPageShapes()).toHaveLength(0)
	})
	it('can be canceled while dragging', () => {
		editor.setCurrentTool('frame')
		editor.pointerDown(100, 100).pointerMove(200, 200)
		editor.expectToBeIn('select.resizing')
		editor.cancel()
		editor.pointerUp()
		expect(editor.getOnlySelectedShape()?.type).toBe(undefined)
		expect(editor.getCurrentPageShapes()).toHaveLength(0)
	})
	it('can be undone', () => {
		editor.setCurrentTool('frame')
		editor.pointerDown(100, 100).pointerMove(200, 200).pointerUp(200, 200)

		expect(editor.getOnlySelectedShape()?.type).toBe('frame')
		expect(editor.getCurrentPageShapes()).toHaveLength(1)

		editor.undo()

		expect(editor.getOnlySelectedShape()?.type).toBe(undefined)
		expect(editor.getCurrentPageShapes()).toHaveLength(0)
	})
	it('can be done inside other frames', () => {
		editor.setCurrentTool('frame')
		editor.pointerDown(100, 100).pointerMove(200, 200).pointerUp(200, 200)

		const frameAId = editor.getOnlySelectedShape()!.id

		editor.setCurrentTool('frame')
		editor.pointerDown(125, 125).pointerMove(175, 175).pointerUp(175, 175)

		expect(editor.getCurrentPageShapes()).toHaveLength(2)

		expect(editor.getOnlySelectedShape()?.parentId).toEqual(frameAId)

		expect(editor.getShapePageBounds(editor.getOnlySelectedShape()!)).toMatchObject({
			x: 125,
			y: 125,
			w: 50,
			h: 50,
		})
	})
	it('can be done inside other rotated frames', () => {
		editor.setCurrentTool('frame')
		editor.pointerDown(100, 100).pointerMove(200, 200).pointerUp(200, 200)

		const frameAId = editor.getOnlySelectedShape()!.id

		editor.rotateSelection(Math.PI / 2)

		editor.setCurrentTool('frame')
		editor.pointerDown(125, 125).pointerMove(175, 175).pointerUp(175, 175)

		expect(editor.getCurrentPageShapes()).toHaveLength(2)

		expect(editor.getOnlySelectedShape()?.parentId).toEqual(frameAId)

		expect(editor.getShapePageBounds(editor.getOnlySelectedShape()!)).toCloselyMatchObject({
			x: 125,
			y: 125,
			w: 50,
			h: 50,
		})
	})

	it('parents a shape when drag-creating a frame over it', () => {
		const rectId: TLShapeId = createRect({ pos: [10, 10], size: [20, 20] })
		const frameId = dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
		const parent = editor.getShape(rectId)?.parentId
		expect(parent).toBe(frameId)
	})

	it('does not parent a shape when click-creating a frame over it', () => {
		const rectId: TLShapeId = createRect({ pos: [10, 10], size: [20, 20] })
		editor.setCurrentTool('frame')
		editor.pointerDown(0, 0)
		editor.pointerUp(0, 0)
		const parent = editor.getShape(rectId)?.parentId
		expect(parent).toBe('page:page')
	})

	it('can snap', () => {
		editor.createShapes([
			{ type: 'geo', id: ids.boxA, x: 0, y: 0, props: { w: 50, h: 50, fill: 'solid' } },
		])

		editor.setCurrentTool('frame')
		editor.pointerDown(100, 100).pointerMove(49, 149)

		expect(editor.getShapePageBounds(editor.getOnlySelectedShape()!)).toMatchObject({
			x: 49,
			y: 100,
			w: 51,
			h: 49,
		})

		// x should snap
		editor.keyDown('Control')
		expect(editor.snaps.getIndicators()).toHaveLength(1)
		expect(editor.getShapePageBounds(editor.getOnlySelectedShape()!)).toMatchObject({
			x: 50,
			y: 100,
			w: 50,
			h: 49,
		})
	})

	it('switches back to the select tool after creating', () => {
		editor.setCurrentTool('frame')
		editor.pointerDown(100, 100).pointerMove(49, 149).pointerUp()
		editor.expectToBeIn('select.idle')
	})
})

describe('frame shapes', () => {
	it('can receive new children when shapes are drawn on top and the frame is rotated', () => {
		// We should be starting from an empty canvas
		expect(editor.getCurrentPageShapes()).toHaveLength(0)

		const frameId = createShapeId('frame')

		editor
			// Create a frame
			.createShapes([{ id: frameId, type: 'frame', x: 100, y: 100, props: { w: 100, h: 100 } }])
			.select(frameId)
			// Rotate it by PI/2
			.rotateSelection(Math.PI / 2)
			// Draw a shape into the frame
			.setCurrentTool('draw')
			.pointerDown(125, 125)
			.pointerMove(175, 175)
			.pointerUp()
			// Draw another shape
			.pointerDown(150, 150)
			.pointerMove(200, 200)
			.pointerUp()

		// The two shapes should have been created
		expect(editor.getCurrentPageShapes()).toHaveLength(3)

		// The shapes should be the child of the frame
		const childIds = editor.getSortedChildIdsForParent(frameId)
		expect(childIds.length).toBe(2)

		// The absolute rotation should be zero
		childIds.forEach((id) => expect(editor.getPageRotationById(id)).toBe(0))
		// Which means the local rotation should be -PI/2
		childIds.forEach((id) => expect(editor.getShape(id)!.rotation).toBe(-Math.PI / 2))
	})

	it('can be resized', () => {
		editor.setCurrentTool('frame')
		editor.pointerDown(100, 100).pointerMove(200, 200).pointerUp(200, 200)

		editor.resizeSelection({ scaleX: 0.5, scaleY: 0.5 }, 'bottom_right')
		expect(editor.getShapePageBounds(editor.getOnlySelectedShape()!)).toCloselyMatchObject({
			x: 100,
			y: 100,
			w: 50,
			h: 50,
		})

		editor.undo()
	})

	it('can be reiszied from the center', () => {
		editor.setCurrentTool('frame')
		editor.pointerDown(100, 100).pointerMove(200, 200).pointerUp(200, 200)
		editor.resizeSelection({ scaleX: 0.5, scaleY: 0.5 }, 'bottom_right', { altKey: true })
		expect(editor.getShapePageBounds(editor.getOnlySelectedShape()!)).toCloselyMatchObject({
			x: 125,
			y: 125,
			w: 50,
			h: 50,
		})
	})

	it('does not resize the children', () => {
		editor.setCurrentTool('frame')
		editor.pointerDown(100, 100).pointerMove(200, 200).pointerUp(200, 200)

		const frameId = editor.getOnlySelectedShape()!.id

		editor.setCurrentTool('geo')
		editor.pointerDown(125, 125).pointerMove(175, 175).pointerUp(175, 175)

		const boxId = editor.getOnlySelectedShape()!.id

		editor.select(frameId)

		editor.resizeSelection({ scaleX: 0.5, scaleY: 0.5 }, 'bottom_right')

		expect(editor.getShapePageBounds(frameId)).toCloselyMatchObject({
			x: 100,
			y: 100,
			w: 50,
			h: 50,
		})
		expect(editor.getShapePageBounds(boxId)).toCloselyMatchObject({
			x: 125,
			y: 125,
			w: 50,
			h: 50,
		})
	})
	it('unparents a shape when resize causes it to be out of bounds', () => {
		const rectId: TLShapeId = createRect({ pos: [70, 10], size: [20, 20] })
		dragCreateFrame({ down: [10, 10], move: [100, 100], up: [100, 100] })
		// resize the frame so the shape is out of bounds
		editor.pointerDown(100, 50, { target: 'selection', handle: 'right' })
		editor.pointerMove(50, 50)
		editor.pointerUp(50, 50)
		const parent = editor.getShape(rectId)?.parentId
		expect(parent).toBe('page:page')
	})

	it('doesnt unparent a shape that is only partially out of bounds', () => {
		const rectId: TLShapeId = createRect({ pos: [70, 10], size: [20, 20] })
		const frameId = dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
		const parentBefore = editor.getShape(rectId)?.parentId
		expect(parentBefore).toBe(frameId)
		// resize the frame so the shape is partially out of bounds
		editor.pointerDown(100, 50, { target: 'selection', handle: 'right' })
		editor.pointerMove(80, 50)
		editor.pointerUp(80, 50)
		const parentAfter = editor.getShape(rectId)?.parentId
		expect(parentAfter).toBe(frameId)
	})

	it('does not parent a shape when resizing over it', () => {
		const rectId = createRect({ pos: [70, 10], size: [20, 20] })
		// create frame next to shape
		dragCreateFrame({ down: [10, 10], move: [60, 100], up: [60, 100] })
		// resize the frame so the shape is totally covered
		editor.pointerDown(60, 50, { target: 'selection', handle: 'right' })
		editor.pointerMove(100, 50)
		editor.pointerUp(100, 50)
		const parent = editor.getShape(rectId)?.parentId
		expect(parent).toBe('page:page')
	})

	it('moves children when resizing a parent frame', () => {
		const rectId: TLShapeId = createRect({ size: [20, 20], pos: [10, 10] })
		dragCreateFrame({ down: [10, 10], move: [100, 100], up: [100, 100] })
		editor.pointerDown(0, 0, { target: 'selection', handle: 'top_left' })
		expect(editor.getShape(rectId)?.y).toBe(10)
		editor.pointerMove(-50, -50)
		editor.pointerUp(-50, -50)
		expect(editor.getShape(rectId)?.y).toBe(10)
	})

	it('does not move children when resizing with cmd key held down', () => {
		const rectId: TLShapeId = createRect({ size: [20, 20], pos: [10, 10] })
		dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
		editor.pointerDown(0, 0, { target: 'selection', handle: 'top_left' })
		editor.keyDown('Control')
		editor.pointerMove(-50, -50)
		editor.pointerUp(-50, -50)
		expect(editor.getShape(rectId)?.x).toBe(60)
	})

	it('can have shapes dragged on top and back out', () => {
		editor.setCurrentTool('frame')
		editor.pointerDown(100, 100).pointerMove(200, 200).pointerUp(200, 200)

		const frameId = editor.getOnlySelectedShape()!.id

		editor.createShapes([
			{ type: 'geo', id: ids.boxA, x: 250, y: 250, props: { w: 50, h: 50, fill: 'solid' } },
		])

		expect(editor.getOnlySelectedShape()!.parentId).toBe(editor.getCurrentPageId())

		editor.setCurrentTool('select')
		editor.pointerDown(275, 275).pointerMove(150, 150)

		jest.advanceTimersByTime(300)

		expect(editor.getOnlySelectedShape()!.id).toBe(ids.boxA)
		expect(editor.getOnlySelectedShape()!.parentId).toBe(frameId)

		editor.pointerMove(275, 275)
		jest.advanceTimersByTime(250)

		expect(editor.getOnlySelectedShape()!.parentId).toBe(editor.getCurrentPageId())

		editor.pointerMove(150, 150)
		jest.advanceTimersByTime(250)

		expect(editor.getOnlySelectedShape()!.parentId).toBe(frameId)

		editor.pointerUp(150, 150)

		expect(editor.getOnlySelectedShape()!.parentId).toBe(frameId)
	})

	it('can have shapes dragged on top and dropped before the timeout fires', () => {
		editor.setCurrentTool('frame')
		editor.pointerDown(100, 100).pointerMove(200, 200).pointerUp(200, 200)

		const frameId = editor.getOnlySelectedShape()!.id

		// Create a new shape off of the frame
		editor.createShapes([
			{ type: 'geo', id: ids.boxA, x: 250, y: 250, props: { w: 50, h: 50, fill: 'solid' } },
		])

		// It should be a child of the page
		expect(editor.getOnlySelectedShape()!.parentId).toBe(editor.getCurrentPageId())

		// Drag the shape on top of the frame
		editor.setCurrentTool('select')
		editor.pointerDown(275, 275, ids.boxA).pointerMove(150, 150)

		// The timeout has not fired yet, so the shape is still a child of the current page
		expect(editor.getOnlySelectedShape()!.parentId).toBe(editor.getCurrentPageId())

		// On pointer up, the shape should be dropped into the frame
		editor.pointerUp()
		expect(editor.getOnlySelectedShape()!.parentId).toBe(frameId)
	})

	it('does not reparent shapes that are being dragged from within the frame', () => {
		editor.setCurrentTool('frame')
		editor.pointerDown(100, 100).pointerMove(200, 200).pointerUp(200, 200)

		const frameId = editor.getOnlySelectedShape()!.id

		// create a box within the frame
		editor.setCurrentTool('geo')
		editor.pointerDown(125, 125).pointerMove(175, 175).pointerUp(175, 175)
		expect(editor.getOnlySelectedShape()!.parentId).toBe(frameId)
		const boxAid = editor.getOnlySelectedShape()!.id

		// create another box within the frame
		editor.setCurrentTool('geo')
		editor.pointerDown(130, 130).pointerMove(180, 180).pointerUp(180, 180)
		expect(editor.getOnlySelectedShape()!.parentId).toBe(frameId)
		const boxBid = editor.getOnlySelectedShape()!.id

		// dragging box A around should not cause the index to change or the frame to be highlighted

		editor.setCurrentTool('select')
		editor.pointerDown(125, 125, boxAid).pointerMove(130, 130)

		jest.advanceTimersByTime(2500)

		editor.pointerMove(175, 175)

		jest.advanceTimersByTime(2500)

		expect(editor.getOnlySelectedShape()!.id).toBe(boxAid)
		expect(editor.getOnlySelectedShape()!.parentId).toBe(frameId)
		expect(editor.getHintingShapeIds()).toHaveLength(1)
		// box A should still be beneath box B
		expect(editor.getShape(boxAid)!.index.localeCompare(editor.getShape(boxBid)!.index)).toBe(-1)
	})

	it('can be snapped to when dragging other shapes', () => {
		editor.setCurrentTool('frame')
		editor.pointerDown(100, 100).pointerMove(200, 200).pointerUp(200, 200)

		editor.createShapes([
			{ type: 'geo', id: ids.boxA, x: 250, y: 250, props: { w: 50, h: 50, fill: 'solid' } },
		])

		editor.setCurrentTool('select')
		editor.select(ids.boxA)
		editor.pointerDown(275, 275, ids.boxA).pointerMove(275, 74)
		expect(editor.getShapePageBounds(ids.boxA)).toMatchObject({ y: 49 })
		editor.keyDown('Control')
		expect(editor.getShapePageBounds(ids.boxA)).toMatchObject({ y: 50 })
		expect(editor.snaps.getIndicators()).toHaveLength(1)
	})

	it("does not allow outside shapes to snap to the frame's children", () => {
		editor.setCurrentTool('frame')
		editor.pointerDown(100, 100).pointerMove(200, 200).pointerUp(200, 200)

		editor.setCurrentTool('geo')
		editor.pointerDown(125, 125).pointerMove(175, 175).pointerUp(175, 175)
		const innerBoxId = editor.getOnlySelectedShape()!.id

		// make a shape outside the frame
		editor.setCurrentTool('geo')
		editor.pointerDown(275, 125).pointerMove(280, 130).pointerUp(280, 130)
		expect(editor.getShapePageBounds(editor.getOnlySelectedShape()!)).toMatchObject({
			x: 275,
			y: 125,
			w: 5,
			h: 5,
		})

		// drag it a pixel up, it should not snap even though it's at the same y as the box inside the frame
		editor.setCurrentTool('select')
		editor
			.pointerDown(277.5, 127.5, editor.getOnlySelectedShape()!.id)
			.pointerMove(287.5, 126.5)
			.pointerMove(277.5, 126.5)

		// now try to snap
		editor.keyDown('Control')
		expect(editor.getShapePageBounds(editor.getOnlySelectedShape()!)).toMatchObject({
			x: 275,
			y: 124,
			w: 5,
			h: 5,
		})
		expect(editor.snaps.getIndicators()).toHaveLength(0)
		// and if we unparent the box it should snap
		editor.reparentShapes([innerBoxId], editor.getCurrentPageId())

		editor.pointerMove(287.5, 126.5).pointerMove(277.5, 126.5)
		expect(editor.snaps.getIndicators()).toHaveLength(1)
		expect(editor.getShapePageBounds(editor.getOnlySelectedShape()!)).toMatchObject({
			x: 275,
			y: 125,
			w: 5,
			h: 5,
		})
	})

	it('children of a frame will not snap to shapes outside the frame', () => {
		editor.setCurrentTool('frame')
		editor.pointerDown(100, 100).pointerMove(200, 200).pointerUp(200, 200)
		const frameId = editor.getOnlySelectedShape()!.id

		// make a shape inside the frame
		editor.setCurrentTool('geo')
		editor.pointerDown(125, 125).pointerMove(175, 175).pointerUp(175, 175)
		const innerBoxId = editor.getOnlySelectedShape()!.id

		// make a shape outside the frame
		editor.setCurrentTool('geo')
		editor.pointerDown(275, 125).pointerMove(280, 130).pointerUp(280, 130)
		const outerBoxId = editor.getOnlySelectedShape()!.id

		editor.setCurrentTool('select')
		editor.pointerDown(150, 150, innerBoxId).pointerMove(150, 50).pointerMove(150, 148)
		editor.keyDown('Control')
		let shapes = editor.snaps.getSnappableShapes()
		// We can snap to the parent frame
		expect(shapes.size).toBe(1)
		expect(shapes).toContain(frameId)

		// move shape inside the frame to make sure it snaps in there
		editor.reparentShapes([outerBoxId], frameId).pointerMove(150, 149, { ctrlKey: true })

		shapes = editor.snaps.getSnappableShapes()
		expect(shapes.size).toBe(2)
		expect(shapes).toContain(frameId)
		expect(shapes).toContain(outerBoxId)
	})

	it('masks its children', () => {
		editor.setCurrentTool('frame')
		editor.pointerDown(100, 100).pointerMove(200, 200).pointerUp(200, 200)

		editor.setCurrentTool('geo')
		editor.pointerDown(150, 150).pointerMove(250, 250).pointerUp(250, 250)

		expect(editor.getShapePageBounds(editor.getOnlySelectedShape()!)).toMatchObject({
			x: 150,
			y: 150,
			w: 100,
			h: 100,
		})

		// mask should be a 50px box around the top left corner
		expect(editor.getShapeClipPath(editor.getOnlySelectedShape()!.id)).toMatchInlineSnapshot(
			`"polygon(-50px -50px,50px -50px,50px 50px,-50px 50px)"`
		)

		editor.reparentShapes([editor.getOnlySelectedShape()!.id], editor.getCurrentPageId())

		expect(editor.getShapeClipPath(editor.getOnlySelectedShape()!.id)).toBeUndefined()
	})

	it('masks its nested children', () => {
		editor.setCurrentTool('frame')
		editor.pointerDown(100, 100).pointerMove(200, 200).pointerUp(200, 200)

		editor.setCurrentTool('frame')
		editor.pointerDown(150, 150).pointerMove(250, 250).pointerUp(250, 250)

		const innerFrameId = editor.getOnlySelectedShape()!.id

		editor.setCurrentTool('geo')
		editor.pointerDown(100, 100).pointerMove(250, 250).pointerUp(250, 250)

		const boxId = editor.getOnlySelectedShape()!.id

		editor.reparentShapes([boxId], innerFrameId)

		// should be a 50px box starting in the middle of the outer frame
		expect(editor.getShapeClipPath(boxId)).toMatchInlineSnapshot(
			`"polygon(50px 50px,100px 50px,100px 100px,50px 100px)"`
		)
	})

	it('arrows started within the frame will bind to it and have the page as their parent', () => {
		editor.setCurrentTool('frame')
		editor.pointerDown(100, 100).pointerMove(200, 200).pointerUp(200, 200)
		const frameId = editor.getOnlySelectedShape()!.id

		editor.setCurrentTool('arrow')
		editor.pointerDown(150, 150).pointerMove(250, 250).pointerUp(250, 250)

		const arrow = editor.getOnlySelectedShape()! as TLArrowShape
		const bindings = getArrowBindings(editor, arrow)

		expect(bindings.start).toMatchObject({ toId: frameId })
		expect(bindings.end).toBeUndefined()

		expect(arrow.parentId).toBe(editor.getCurrentPageId())
	})

	it('arrows started within the frame can bind to a shape within the frame ', () => {
		editor.setCurrentTool('frame')
		editor.pointerDown(100, 100).pointerMove(200, 200).pointerUp(200, 200)
		const frameId = editor.getOnlySelectedShape()!.id

		editor.setCurrentTool('geo')
		editor
			.pointerDown(125, 125)
			.pointerMove(175, 175)
			.pointerUp(175, 175)
			.setStyleForSelectedShapes(DefaultFillStyle, 'solid')
			.setStyleForNextShapes(DefaultFillStyle, 'solid')
		const boxId = editor.getOnlySelectedShape()!.id

		editor.setCurrentTool('arrow')
		editor.pointerDown(150, 150).pointerMove(190, 190).pointerUp(190, 190)

		const arrow = editor.getOnlySelectedShape()! as TLArrowShape
		const bindings = getArrowBindings(editor, arrow)

		expect(bindings.start).toMatchObject({ toId: boxId })
		expect(bindings.end).toMatchObject({ toId: frameId })

		expect(arrow.parentId).toBe(editor.getCurrentPageId())
	})

	it('can be edited', () => {
		editor.setCurrentTool('frame')
		editor.pointerDown(100, 100).pointerMove(200, 200).pointerUp(200, 200)

		const frameId = editor.getOnlySelectedShape()!.id

		expect(editor.getSelectedShapeIds()[0]).toBe(frameId)
		expect(editor.getCurrentPageState().editingShapeId).toBe(null)

		editor.setCurrentTool('select')

		editor.keyDown('Enter')
		editor.keyUp('Enter')

		expect(editor.getCurrentPageState().editingShapeId).toBe(frameId)
	})

	it('can be selected with box brushing only if the whole frame is selected', () => {
		editor.setCurrentTool('frame')
		editor.pointerDown(100, 100).pointerMove(200, 200).pointerUp(200, 200)
		const frameId = editor.getOnlySelectedShape()!.id

		// select from outside the frame
		editor.setCurrentTool('select')
		editor.pointerDown(50, 50).pointerMove(150, 150)
		editor.expectToBeIn('select.brushing')

		expect(editor.getSelectedShapeIds()).toHaveLength(0)

		editor.pointerMove(250, 250)

		expect(editor.getSelectedShapeIds()).toHaveLength(1)
		expect(editor.getOnlySelectedShape()!.id).toBe(frameId)
	})

	it('can be selected with scribble brushing only if the drag starts outside the frame', () => {
		editor.setCurrentTool('frame')
		editor.pointerDown(100, 100).pointerMove(200, 200).pointerUp(200, 200)
		editor.expectToBeIn('select.idle')

		// select from inside the frame
		editor.selectNone()
		editor.setCurrentTool('select')
		editor.pointerDown(150, 150).pointerMove(250, 250)
		editor.expectToBeIn('select.brushing')

		expect(editor.getSelectedShapeIds()).toHaveLength(0)
	})

	it('children of a frame will not be selected from outside of the frame', () => {
		editor.setCurrentTool('frame')
		editor.pointerDown(100, 100).pointerMove(200, 200).pointerUp(200, 200)
		expect(editor.getOnlySelectedShape()!.id).toBeDefined()

		// make a shape inside the frame that extends out of the frame
		editor.setCurrentTool('geo')
		editor.pointerDown(150, 150).pointerMove(400, 400).pointerUp(400, 400)
		const innerBoxId = editor.getOnlySelectedShape()!.id

		// select from outside the frame via box brushing
		editor.setCurrentTool('select')
		editor.pointerDown(500, 500).pointerMove(300, 300).pointerUp(300, 300)

		// Check if the inner box was selected
		expect(editor.getSelectedShapeIds()).toHaveLength(0)

		// Select from outside the frame via box brushing
		// but also include the frame in the selection
		editor.pointerDown(400, 0).pointerMove(195, 175).pointerUp(195, 175)

		// Check if the inner box was selected
		expect(editor.getSelectedShapeIds()).toHaveLength(1)
		expect(editor.getOnlySelectedShape()!.id).toBe(innerBoxId)

		// Deselect everything
		editor.deselect()

		// Select from outside the frame via scribble brushing
		editor.keyDown('alt').pointerDown(500, 500).pointerMove(300, 300)

		// Check if in scribble brushing mode
		editor.expectToBeIn('select.brushing')

		// Check if the inner box was selected
		editor.pointerUp(300, 300)
		expect(editor.getSelectedShapeIds()).toHaveLength(0)
	})

	it('arrows will not bind to parts of shapes outside the frame', () => {
		editor.setCurrentTool('frame')
		editor.pointerDown(100, 100).pointerMove(200, 200).pointerUp(200, 200)
		expect(editor.getOnlySelectedShape()!.id).toBeDefined()

		// make a shape inside the frame that extends out of the frame
		editor.setCurrentTool('geo')
		editor
			.pointerDown(150, 150)
			.pointerMove(400, 400)
			.pointerUp(400, 400)
			.setStyleForSelectedShapes(DefaultFillStyle, 'solid')
			.setStyleForNextShapes(DefaultFillStyle, 'solid')
		const innerBoxId = editor.getOnlySelectedShape()!.id

		// Make an arrow that binds to the inner box's bottom right corner
		editor.setCurrentTool('arrow')
		editor.pointerDown(500, 500).pointerMove(375, 375)

		// Check if the arrow's handles remain points
		let arrow = editor.getOnlySelectedShape()! as TLArrowShape
		expect(arrow.props.start).toMatchObject({ x: 0, y: 0 })
		expect(arrow.props.end).toMatchObject({ x: -125, y: -125 })

		// Move the end handle inside the frame
		editor.pointerMove(175, 175).pointerUp(175, 175)

		// Check if arrow's end handle is bound to the inner box
		arrow = editor.getOnlySelectedShape()! as TLArrowShape
		const bindings = getArrowBindings(editor, arrow)
		expect(bindings.end).toMatchObject({ toId: innerBoxId })
	})

	it('correctly fits to its content', () => {
		// Create two rects, their bounds are from [100, 100] to [400, 400],
		// so the frame that fits them (with 50px offset) should be from [50, 50] to [450, 450].
		const rectAId = createRect({ pos: [100, 100], size: [100, 100] })
		const rectBId = createRect({ pos: [300, 300], size: [100, 100] })

		// Create the frame that encloses both rects
		const frameId = dragCreateFrame({ down: [0, 0], move: [700, 700], up: [700, 700] })
		const frame = editor.getShape(frameId)! as TLFrameShape

		const rectA = editor.getShape(rectAId)!
		const rectB = editor.getShape(rectBId)!
		expect(rectA.parentId).toBe(frameId)
		expect(rectB.parentId).toBe(frameId)

		fitFrameToContent(editor, frame.id)
		const newFrame = editor.getShape(frameId)! as TLFrameShape
		expect(newFrame.x).toBe(50)
		expect(newFrame.y).toBe(50)
		expect(newFrame.props.w).toBe(400)
		expect(newFrame.props.h).toBe(400)

		const newRectA = editor.getShape(rectAId)!
		const newRectB = editor.getShape(rectBId)!
		// Rect positions should change by 50px since the frame moved
		// This keeps them in the same relative position
		expect(newRectA.x).toBe(DEFAULT_FRAME_PADDING)
		expect(newRectA.y).toBe(DEFAULT_FRAME_PADDING)
		expect(newRectB.x).toBe(250)
		expect(newRectB.y).toBe(250)
	})

	it('uses padding option', () => {
		// Create two rects, their bounds are from [100, 100] to [400, 400],
		// so the frame that fits them (with 50px offset) should be from [50, 50] to [450, 450].
		const rectAId = createRect({ pos: [100, 100], size: [100, 100] })
		const rectBId = createRect({ pos: [300, 300], size: [100, 100] })

		// Create the frame that encloses both rects
		const frameId = dragCreateFrame({ down: [0, 0], move: [700, 700], up: [700, 700] })
		const frame = editor.getShape(frameId)! as TLFrameShape

		const rectA = editor.getShape(rectAId)!
		const rectB = editor.getShape(rectBId)!
		expect(rectA.parentId).toBe(frameId)
		expect(rectB.parentId).toBe(frameId)

		fitFrameToContent(editor, frame.id, { padding: 100 })
		const newFrame = editor.getShape(frameId)! as TLFrameShape
		expect(newFrame.x).toBe(0)
		expect(newFrame.y).toBe(0)
		expect(newFrame.props.w).toBe(500)
		expect(newFrame.props.h).toBe(500)

		const newRectA = editor.getShape(rectAId)!
		const newRectB = editor.getShape(rectBId)!

		// frame is at 0,0 so positions should be the same for this test
		expect(newRectA.x).toBe(100)
		expect(newRectA.y).toBe(100)
		expect(newRectB.x).toBe(300)
		expect(newRectB.y).toBe(300)
	})

	it('preserves the order of shapes when enclosing over them', () => {
		const rectAId = createRect({ pos: [100, 100], size: [100, 100] })
		const rectBId = createRect({ pos: [300, 300], size: [100, 100] })
		const pageId = editor.getCurrentPageId()
		expect(editor.getSortedChildIdsForParent(pageId)).toStrictEqual([rectAId, rectBId])

		// Create the frame that encloses both rects
		let frameId = dragCreateFrame({ down: [0, 0], move: [700, 700], up: [700, 700] })

		// The order should be the same as before
		expect(editor.getSortedChildIdsForParent(frameId)).toStrictEqual([rectAId, rectBId])

		removeFrame(editor, [frameId])
		expect(editor.getSortedChildIdsForParent(pageId)).toStrictEqual([rectAId, rectBId])

		// Now let's push the second rect to the back
		editor.sendToBack([rectBId])
		expect(editor.getSortedChildIdsForParent(pageId)).toStrictEqual([rectBId, rectAId])

		frameId = dragCreateFrame({ down: [0, 0], move: [700, 700], up: [700, 700] })
		expect(editor.getSortedChildIdsForParent(frameId)).toStrictEqual([rectBId, rectAId])
	})

	it('allows us to frame inside of frames', () => {
		const rectAId = createRect({ pos: [100, 100], size: [100, 100] })
		const rectBId = createRect({ pos: [300, 300], size: [100, 100] })
		const pageId = editor.getCurrentPageId()
		expect(editor.getSortedChildIdsForParent(pageId)).toStrictEqual([rectAId, rectBId])

		const outsideFrameId = dragCreateFrame({ down: [0, 0], move: [700, 700], up: [700, 700] })
		expect(editor.getSortedChildIdsForParent(outsideFrameId)).toStrictEqual([rectAId, rectBId])

		// Create a frame inside the frame
		const insideFrameId = dragCreateFrame({ down: [50, 50], move: [600, 600], up: [600, 600] })
		expect(editor.getSortedChildIdsForParent(insideFrameId)).toStrictEqual([rectAId, rectBId])
		expect(editor.getSortedChildIdsForParent(outsideFrameId)).toStrictEqual([insideFrameId])
	})
})

test('arrows bound to a shape within a group within a frame are reparented if the group is moved outside of the frame', () => {
	// frame
	// ┌─────────----------------─┐
	// │         group            │
	// │         ┌──────────────┐ │
	// │  ┌───┐  │       ┌───┐  │ │
	// │  │ a ┼──┼──────►│ b │  │ │
	// │  └───┘  │       └───┘  │ │
	// │         │              │ │
	// │         │  ┌───┐       │ │
	// │         │  │ c │       │ │
	// │         │  └───┘       │ │
	// │         │              │ │
	// │         └──────────────┘ │
	// └──────────────────────────┘
	editor.setCurrentTool('frame')
	editor.pointerDown(100, 100).pointerMove(200, 200).pointerUp(200, 200)
	const frameId = editor.getOnlySelectedShape()!.id

	editor.setCurrentTool('geo')
	editor
		.pointerDown(110, 110)
		.pointerMove(120, 120)
		.pointerUp(120, 120)
		.setStyleForSelectedShapes(DefaultFillStyle, 'solid')
		.setStyleForNextShapes(DefaultFillStyle, 'solid')
	const boxAId = editor.getOnlySelectedShape()!.id

	editor.setCurrentTool('geo')
	editor
		.pointerDown(180, 110)
		.pointerMove(190, 120)
		.pointerUp(190, 120)
		.setStyleForSelectedShapes(DefaultFillStyle, 'solid')
		.setStyleForNextShapes(DefaultFillStyle, 'solid')
	const boxBId = editor.getOnlySelectedShape()!.id

	editor.setCurrentTool('geo')
	editor
		.pointerDown(160, 160)
		.pointerMove(170, 170)
		.pointerUp(170, 170)
		.setStyleForSelectedShapes(DefaultFillStyle, 'solid')
		.setStyleForNextShapes(DefaultFillStyle, 'solid')
	const boxCId = editor.getOnlySelectedShape()!.id

	editor.setCurrentTool('select')
	editor.select(boxBId, boxCId)
	editor.groupShapes(editor.getSelectedShapeIds())
	const groupId = editor.getOnlySelectedShape()!.id

	editor.setCurrentTool('arrow')
	editor.pointerDown(115, 115).pointerMove(185, 115).pointerUp(185, 115)
	const arrowId = editor.getOnlySelectedShape()!.id

	expect(editor.getArrowsBoundTo(boxAId)).toHaveLength(1)
	expect(editor.getArrowsBoundTo(boxBId)).toHaveLength(1)
	expect(editor.getArrowsBoundTo(boxCId)).toHaveLength(0)

	// expect group parent to be the frame
	expect(editor.getShape(groupId)!.parentId).toBe(frameId)

	// move the group outside of the frame
	editor.setCurrentTool('select')
	editor.select(groupId)
	editor.translateSelection(200, 0)

	// expect group parent to be the page
	expect(editor.getShape(groupId)!.parentId).toBe(editor.getCurrentPageId())
	// expect arrow parent to be the page
	expect(editor.getShape(arrowId)!.parentId).toBe(editor.getCurrentPageId())
	// expect arrow index to be greater than group index
	expect(editor.getShape(arrowId)?.index.localeCompare(editor.getShape(groupId)!.index)).toBe(1)
})

describe('When dragging a shape inside a group inside a frame', () => {
	const ids = {
		frame1: createShapeId('frame'),
		box1: createShapeId('geo1'),
		box2: createShapeId('geo2'),
		group1: createShapeId('group1'),
	}

	beforeEach(() => {
		editor.createShapes([
			{ id: ids.frame1, type: 'frame', x: 0, y: 0, props: { w: 500, h: 500 } },
			{ id: ids.box1, type: 'geo', parentId: ids.frame1, x: 100, y: 100 },
			{ id: ids.box2, type: 'geo', parentId: ids.frame1, x: 300, y: 300 },
		])
	})

	it('When dragging a shape out of a frame', () => {
		editor.select(ids.box1, ids.box2)

		expect(editor.getSelectedShapeIds()).toHaveLength(2)

		editor.groupShapes(editor.getSelectedShapeIds(), { groupId: ids.group1 })

		expect(editor.getShape(ids.box1)!.parentId).toBe(ids.group1)

		editor.pointerMove(100, 100).click().click()

		expect(editor.getOnlySelectedShapeId()).toBe(ids.box1)

		editor.pointerMove(150, 150).pointerDown().pointerMove(140, 140)

		jest.advanceTimersByTime(300)

		expect(editor.getShape(ids.box1)!.parentId).toBe(ids.group1)
	})

	it('reparents the shape to the page if it leaves the frame', () => {
		editor.select(ids.box1, ids.box2)

		expect(editor.getSelectedShapeIds()).toHaveLength(2)

		editor.groupShapes(editor.getSelectedShapeIds(), { groupId: ids.group1 })

		expect(editor.getShape(ids.box1)!.parentId).toBe(ids.group1)

		editor.pointerMove(100, 100).click().click()

		expect(editor.getOnlySelectedShapeId()).toBe(ids.box1)
		expect(editor.getFocusedGroupId()).toBe(ids.group1)

		editor
			.pointerMove(150, 150)
			.pointerDown()
			.pointerMove(-200, -200)
			.pointerMove(-200, -200)
			.pointerUp()

		jest.advanceTimersByTime(300)

		expect(editor.getShape(ids.box1)!.parentId).toBe(editor.getCurrentPageId())
	})
})

describe('When deleting/removing a frame', () => {
	it('deletes a frame and its children', () => {
		const rectId: TLShapeId = createRect({ size: [20, 20], pos: [10, 10] })
		const frameId = dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
		editor.deleteShape(frameId)
		expect(editor.getShape(rectId)).toBeUndefined()
	})
	it('removes a frame but not its children', () => {
		const rectId: TLShapeId = createRect({ size: [20, 20], pos: [10, 10] })
		const frameId = dragCreateFrame({ down: [10, 10], move: [100, 100], up: [100, 100] })
		removeFrame(editor, [frameId])
		expect(editor.getShape(rectId)).toBeDefined()
	})
	it('reparents the children of a frame when removing it', () => {
		const rectId: TLShapeId = createRect({ size: [20, 20], pos: [10, 10] })
		const frame1Id = dragCreateFrame({ down: [10, 10], move: [100, 100], up: [100, 100] })
		const frame2Id = dragCreateFrame({ down: [0, 0], move: [110, 110], up: [110, 110] })
		removeFrame(editor, [frame1Id])
		expect(editor.getShape(rectId)?.parentId).toBe(frame2Id)
	})
})

describe('When dragging a shape', () => {
	it('parents a shape when dragging it into a frame', () => {
		const rectId: TLShapeId = createRect({ pos: [70, 10], size: [20, 20] })
		// create frame next to shape
		const frameId = dragCreateFrame({ down: [0, 0], move: [60, 100], up: [60, 100] })
		// drag shape into frame
		editor.pointerDown(80, 15)
		editor.pointerMove(30, 50)
		editor.pointerUp(30, 50)
		const parent = editor.getShape(rectId)?.parentId
		expect(parent).toBe(frameId)
	})
	it('Unparents a shape when dragging it out of a frame', () => {
		const rectId: TLShapeId = createRect({ pos: [10, 10], size: [20, 20] })
		editor.pointerDown(15, 15, { target: 'selection' })
		editor.pointerMove(-100, -100)
		editor.pointerUp(-100, -100)
		const parent = editor.getShape(rectId)?.parentId
		expect(parent).toBe('page:page')
	})
})

function dragCreateFrame({
	down,
	move,
	up,
}: {
	down: [number, number]
	move: [number, number]
	up: [number, number]
}): TLShapeId {
	editor.setCurrentTool('frame')
	editor.pointerDown(...down)
	editor.pointerMove(...move)
	editor.pointerUp(...up)
	const shapes = editor.getSelectedShapes()
	const frameId = shapes[0].id
	return frameId
}

function dragCreateRect({
	down,
	move,
	up,
}: {
	down: [number, number]
	move: [number, number]
	up: [number, number]
}): TLShapeId {
	editor.setCurrentTool('geo')
	editor.pointerDown(...down)
	editor.pointerMove(...move)
	editor.pointerUp(...up)
	const shapes = editor.getSelectedShapes()
	const rectId = shapes[0].id
	return rectId
}

function dragCreateTriangle({
	down,
	move,
	up,
}: {
	down: [number, number]
	move: [number, number]
	up: [number, number]
}): TLShapeId {
	editor.setCurrentTool('geo')
	const originalStyle = editor.getStyleForNextShape(GeoShapeGeoStyle)
	editor.setStyleForNextShapes(GeoShapeGeoStyle, 'triangle')
	editor.pointerDown(...down)
	editor.pointerMove(...move)
	editor.pointerUp(...up)
	const shapes = editor.getSelectedShapes()
	editor.selectNone()
	editor.setStyleForNextShapes(GeoShapeGeoStyle, originalStyle)
	const rectId = shapes[0].id
	editor.select(shapes[0].id)
	return rectId
}

function dragCreateLine({
	down,
	move,
	up,
}: {
	down: [number, number]
	move: [number, number]
	up: [number, number]
}): TLShapeId {
	editor.setCurrentTool('line')
	editor.pointerDown(...down)
	editor.pointerMove(...move)
	editor.pointerUp(...up)
	const shapes = editor.getSelectedShapes()
	const lineId = shapes[0].id
	return lineId
}

function createRect({ pos, size }: { pos: [number, number]; size: [number, number] }) {
	const rectId: TLShapeId = createShapeId()
	editor.createShapes([
		{
			id: rectId,
			x: pos[0],
			y: pos[1],
			props: { w: size[0], h: size[1] },
			type: 'geo',
		},
	])
	return rectId
}

describe('Unparenting behavior', () => {
	it("unparents a shape when it's completely dragged out of a frame, even when the pointer doesn't move across the edge of the frame", () => {
		dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
		dragCreateRect({ down: [80, 50], move: [120, 60], up: [120, 60] })
		const [frame, rect] = editor.getLastCreatedShapes(2)

		expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
		editor.pointerDown(110, 50)
		editor.pointerMove(140, 50)
		expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
		editor.pointerUp(140, 50)
		expect(editor.getShape(rect.id)!.parentId).toBe(editor.getCurrentPageId())
	})

	it("doesn't unparent a shape when it's partially dragged out of a frame, when the pointer doesn't move across the edge of the frame", () => {
		dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
		dragCreateRect({ down: [80, 50], move: [120, 60], up: [120, 60] })
		const [frame, rect] = editor.getLastCreatedShapes(2)

		expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
		editor.pointerDown(110, 50)
		editor.pointerMove(120, 50)
		expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
		editor.pointerUp(120, 50)
		expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
	})

	it('unparents a shape when the pointer drags across the edge of a frame, even if its geometry overlaps with the frame', () => {
		dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
		dragCreateRect({ down: [80, 50], move: [120, 60], up: [120, 60] })
		const [frame, rect] = editor.getLastCreatedShapes(2)

		expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
		editor.pointerDown(90, 50)
		editor.pointerMove(110, 50)
		jest.advanceTimersByTime(200)
		expect(editor.getShape(rect.id)!.parentId).toBe(editor.getCurrentPageId())
		editor.pointerUp(110, 50)
		expect(editor.getShape(rect.id)!.parentId).toBe(editor.getCurrentPageId())
	})

	it("unparents a shape when it's rotated out of a frame", () => {
		dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
		dragCreateRect({ down: [95, 10], move: [200, 20], up: [200, 20] })
		const [frame, rect] = editor.getLastCreatedShapes(2)

		expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
		editor.pointerDown(200, 20, {
			target: 'selection',
			handle: 'top_right_rotate',
		})
		editor.pointerMove(200, 200)
		expect(editor.getShape(rect.id)!.parentId).toBe(frame.id)
		editor.pointerUp(200, 200)
		expect(editor.getShape(rect.id)!.parentId).toBe(editor.getCurrentPageId())
	})

	it("unparents shapes if they're resized out of a frame", () => {
		dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
		dragCreateRect({ down: [10, 10], move: [20, 20], up: [20, 20] })
		dragCreateRect({ down: [80, 80], move: [90, 90], up: [90, 90] })
		const [frame, rect1, rect2] = editor.getLastCreatedShapes(3)

		editor.select(rect1.id, rect2.id)
		editor.pointerDown(90, 90, { target: 'selection', handle: 'top_right' })
		expect(editor.getShape(rect2.id)!.parentId).toBe(frame.id)
		editor.pointerMove(200, 200)
		expect(editor.getShape(rect2.id)!.parentId).toBe(frame.id)
		editor.pointerUp(200, 200)
		expect(editor.getShape(rect2.id)!.parentId).toBe(editor.getCurrentPageId())
	})

	it("unparents a shape if its geometry doesn't overlap with the frame", () => {
		dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
		dragCreateTriangle({ down: [80, 80], move: [120, 120], up: [120, 120] })
		const [frame, triangle] = editor.getLastCreatedShapes(2)

		expect(editor.getShape(triangle.id)!.parentId).toBe(frame.id)
		editor.pointerDown(85, 85)
		editor.pointerMove(95, 95)
		expect(editor.getShape(triangle.id)!.parentId).toBe(frame.id)
		editor.pointerUp(95, 95)
		expect(editor.getShape(triangle.id)!.parentId).toBe(editor.getCurrentPageId())
	})

	it("only parents on pointer up if the shape's geometry overlaps with the frame", () => {
		dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
		dragCreateTriangle({ down: [120, 120], move: [160, 160], up: [160, 160] })
		const [frame, triangle] = editor.getLastCreatedShapes(2)

		expect(editor.getShape(triangle.id)!.parentId).toBe(editor.getCurrentPageId())
		editor.pointerDown(125, 125)
		editor.pointerMove(95, 95)
		jest.advanceTimersByTime(200)
		expect(editor.getShape(triangle.id)!.parentId).toBe(frame.id)
		expect(editor.getHintingShapeIds()).toHaveLength(0)
		editor.pointerUp(95, 95)
		expect(editor.getShape(triangle.id)!.parentId).toBe(editor.getCurrentPageId())
	})

	it('unparents an occluded shape after dragging a handle out of a frame', () => {
		dragCreateFrame({ down: [0, 0], move: [100, 100], up: [100, 100] })
		dragCreateLine({ down: [90, 90], move: [120, 120], up: [120, 120] })
		const [frame, line] = editor.getLastCreatedShapes(2)

		expect(editor.getShape(line.id)!.parentId).toBe(frame.id)
		editor.pointerDown(90, 90)
		editor.pointerMove(110, 110)
		expect(editor.getShape(line.id)!.parentId).toBe(frame.id)
		editor.pointerUp(110, 110)
		expect(editor.getShape(line.id)!.parentId).toBe(editor.getCurrentPageId())
	})
})
