import {
	BindingOnChangeOptions,
	BindingOnCreateOptions,
	BindingOnDeleteOptions,
	BindingOnShapeChangeOptions,
	BindingOnShapeDeleteOptions,
	BindingOnShapeIsolateOptions,
	BindingUtil,
	TLBinding,
	TLShapeId,
	createBindingId,
	createShapeId,
} from '@tldraw/editor'
import { vi } from 'vitest'
import { TestEditor } from './TestEditor'

let editor: TestEditor

const ids = {
	box1: createShapeId('box1'),
	box2: createShapeId('box2'),
	box3: createShapeId('box3'),
	box4: createShapeId('box4'),
}

const mockOnOperationComplete = vi.fn()
const mockOnBeforeDelete = vi.fn()
const mockOnAfterDelete = vi.fn()
const mockOnBeforeFromShapeDelete = vi.fn()
const mockOnBeforeToShapeDelete = vi.fn()
const mockOnBeforeFromShapeIsolate = vi.fn()
const mockOnBeforeToShapeIsolate = vi.fn()
const mockOnBeforeCreate = vi.fn()
const mockOnAfterCreate = vi.fn()
const mockOnBeforeChange = vi.fn()
const mockOnAfterChange = vi.fn()
const mockOnAfterChangeFromShape = vi.fn()
const mockOnAfterChangeToShape = vi.fn()

const calls: string[] = []

const registerCall = (method: string, binding: TLBinding) => {
	calls.push(
		`${method}: ${binding.fromId.slice('shape:'.length)}->${binding.toId.slice('shape:'.length)}`
	)
}

class TestBindingUtil extends BindingUtil {
	static override type = 'test'

	static override props = {}

	override getDefaultProps(): object {
		return {}
	}

	override onOperationComplete(): void {
		calls.push('onOperationComplete')
		mockOnOperationComplete()
	}

	override onBeforeDelete(options: BindingOnDeleteOptions): void {
		registerCall('onBeforeDelete', options.binding)
		mockOnBeforeDelete(options)
	}

	override onAfterDelete(options: BindingOnDeleteOptions): void {
		registerCall('onAfterDelete', options.binding)
		mockOnAfterDelete(options)
	}

	override onBeforeDeleteFromShape(options: BindingOnShapeDeleteOptions): void {
		registerCall('onBeforeDeleteFromShape', options.binding)
		mockOnBeforeFromShapeDelete(options)
	}

	override onBeforeDeleteToShape(options: BindingOnShapeDeleteOptions): void {
		registerCall('onBeforeDeleteToShape', options.binding)
		mockOnBeforeToShapeDelete(options)
	}

	override onBeforeIsolateFromShape(options: BindingOnShapeIsolateOptions): void {
		registerCall('onBeforeIsolateFromShape', options.binding)
		mockOnBeforeFromShapeIsolate(options)
	}

	override onBeforeIsolateToShape(options: BindingOnShapeIsolateOptions): void {
		registerCall('onBeforeIsolateToShape', options.binding)
		mockOnBeforeToShapeIsolate(options)
	}

	override onBeforeCreate(options: BindingOnCreateOptions): void {
		registerCall('onBeforeCreate', options.binding)
		mockOnBeforeCreate(options)
	}

	override onAfterCreate(options: BindingOnCreateOptions): void {
		registerCall('onAfterCreate', options.binding)
		mockOnAfterCreate(options)
	}

	override onBeforeChange(options: BindingOnChangeOptions): void {
		registerCall('onBeforeChange', options.bindingAfter)
		mockOnBeforeChange(options)
	}

	override onAfterChange(options: BindingOnChangeOptions): void {
		registerCall('onAfterChange', options.bindingAfter)
		mockOnAfterChange(options)
	}

	override onAfterChangeFromShape(options: BindingOnShapeChangeOptions): void {
		registerCall('onAfterChangeFromShape', options.binding)
		mockOnAfterChangeFromShape(options)
	}

	override onAfterChangeToShape(options: BindingOnShapeChangeOptions): void {
		registerCall('onAfterChangeToShape', options.binding)
		mockOnAfterChangeToShape(options)
	}
}

beforeEach(() => {
	editor = new TestEditor({ bindingUtils: [TestBindingUtil] })

	editor.createShapes([
		{ id: ids.box1, type: 'geo', x: 0, y: 0, props: {} },
		{ id: ids.box2, type: 'geo', x: 0, y: 0, props: {} },
		{ id: ids.box3, type: 'geo', x: 0, y: 0, props: {} },
		{ id: ids.box4, type: 'geo', x: 0, y: 0, props: {} },
	])

	mockOnOperationComplete.mockReset()
	mockOnBeforeDelete.mockReset()
	mockOnAfterDelete.mockReset()
	mockOnBeforeFromShapeDelete.mockReset()
	mockOnBeforeToShapeDelete.mockReset()
	mockOnBeforeFromShapeIsolate.mockReset()
	mockOnBeforeToShapeIsolate.mockReset()
	mockOnBeforeCreate.mockReset()
	mockOnAfterCreate.mockReset()
	mockOnBeforeChange.mockReset()
	mockOnAfterChange.mockReset()
	mockOnAfterChangeFromShape.mockReset()
	mockOnAfterChangeToShape.mockReset()
})

const TEST_TYPE = 'test'

declare module '@tldraw/tlschema' {
	export interface TLGlobalBindingPropsMap {
		[TEST_TYPE]: Record<string, never>
	}
}

function bindShapes(fromId: TLShapeId, toId: TLShapeId) {
	const bindingId = createBindingId()
	editor.createBinding({
		id: bindingId,
		type: TEST_TYPE,
		fromId,
		toId,
	})
	return bindingId
}

test('deleting the from shape', () => {
	bindShapes(ids.box1, ids.box2)
	calls.length = 0
	editor.deleteShape(ids.box1)
	expect(calls).toMatchInlineSnapshot(`
		[
		  "onBeforeIsolateToShape: box1->box2",
		  "onBeforeDeleteFromShape: box1->box2",
		  "onBeforeDelete: box1->box2",
		  "onAfterDelete: box1->box2",
		  "onOperationComplete",
		]
	`)
})

test('deleting the to shape', () => {
	bindShapes(ids.box1, ids.box2)
	calls.length = 0
	editor.deleteShape(ids.box2)
	expect(calls).toMatchInlineSnapshot(`
		[
		  "onBeforeIsolateFromShape: box1->box2",
		  "onBeforeDeleteToShape: box1->box2",
		  "onBeforeDelete: box1->box2",
		  "onAfterDelete: box1->box2",
		  "onOperationComplete",
		]
	`)
})

test('deleting the binding', () => {
	const bindingId = bindShapes(ids.box1, ids.box2)
	calls.length = 0
	editor.deleteBinding(bindingId)
	expect(calls).toMatchInlineSnapshot(`
		[
		  "onBeforeDelete: box1->box2",
		  "onAfterDelete: box1->box2",
		  "onOperationComplete",
		]
	`)
})

test('deleting the binding while isolating', () => {
	const bindingId = bindShapes(ids.box1, ids.box2)
	calls.length = 0
	editor.deleteBinding(bindingId, { isolateShapes: true })
	expect(calls).toMatchInlineSnapshot(`
		[
		  "onBeforeIsolateFromShape: box1->box2",
		  "onBeforeIsolateToShape: box1->box2",
		  "onBeforeDelete: box1->box2",
		  "onAfterDelete: box1->box2",
		  "onOperationComplete",
		]
	`)
})

test('copying both bound shapes does not trigger the isolation operations', () => {
	bindShapes(ids.box1, ids.box2)
	editor.select(ids.box1, ids.box2)
	calls.length = 0
	editor.copy()
	expect(calls).toMatchInlineSnapshot(`[]`)
})

test('copying the from shape on its own does trigger isolation operations', () => {
	bindShapes(ids.box1, ids.box2)
	editor.select(ids.box1)
	calls.length = 0
	editor.copy()
	expect(calls).toMatchInlineSnapshot(`
		[
		  "onBeforeIsolateFromShape: box1->box2",
		  "onBeforeIsolateToShape: box1->box2",
		  "onBeforeDelete: box1->box2",
		  "onAfterDelete: box1->box2",
		  "onOperationComplete",
		]
	`)
})

test('copying the to shape on its own does trigger the unbind operation', () => {
	bindShapes(ids.box1, ids.box2)
	editor.select(ids.box2)
	calls.length = 0
	editor.copy()
	expect(calls).toMatchInlineSnapshot(`
		[
		  "onBeforeIsolateFromShape: box1->box2",
		  "onBeforeIsolateToShape: box1->box2",
		  "onBeforeDelete: box1->box2",
		  "onAfterDelete: box1->box2",
		  "onOperationComplete",
		]
	`)
})

test('cascading deletes in beforeFromShapeDelete are handled correctly', () => {
	mockOnBeforeFromShapeDelete.mockImplementation((options: BindingOnShapeDeleteOptions) => {
		editor.deleteShape(options.binding.toId)
	})

	bindShapes(ids.box1, ids.box2)
	bindShapes(ids.box2, ids.box3)
	bindShapes(ids.box3, ids.box4)

	calls.length = 0
	editor.deleteShape(ids.box1)

	expect(editor.getShape(ids.box1)).toBeUndefined()
	expect(editor.getShape(ids.box2)).toBeUndefined()
	expect(editor.getShape(ids.box3)).toBeUndefined()
	expect(editor.getShape(ids.box4)).toBeUndefined()

	expect(calls.at(-1)).toBe('onOperationComplete')

	expect(
		[
			'onBeforeIsolateToShape: box1->box2',
			'onBeforeDeleteFromShape: box1->box2',
			'onBeforeIsolateFromShape: box1->box2',
			'onBeforeDeleteToShape: box1->box2',
			'onBeforeIsolateToShape: box2->box3',
			'onBeforeDeleteFromShape: box2->box3',
			'onBeforeIsolateToShape: box3->box4',
			'onBeforeDeleteFromShape: box3->box4',
			'onBeforeIsolateFromShape: box3->box4',
			'onBeforeDeleteToShape: box3->box4',
			'onBeforeDelete: box3->box4',
			'onBeforeIsolateFromShape: box2->box3',
			'onBeforeDeleteToShape: box2->box3',
			'onBeforeDelete: box2->box3',
			'onBeforeDelete: box1->box2',
			'onAfterDelete: box3->box4',
			'onAfterDelete: box2->box3',
			'onAfterDelete: box1->box2',
		].every((call) => calls.includes(call))
	).toBe(true)
})

test('cascading deletes in beforeToShapeDelete are handled correctly', () => {
	mockOnBeforeToShapeDelete.mockImplementation((options: BindingOnShapeDeleteOptions) => {
		editor.deleteShape(options.binding.fromId)
	})

	bindShapes(ids.box1, ids.box2)
	bindShapes(ids.box2, ids.box3)
	bindShapes(ids.box3, ids.box4)

	calls.length = 0
	editor.deleteShape(ids.box4)

	expect(editor.getShape(ids.box1)).toBeUndefined()
	expect(editor.getShape(ids.box2)).toBeUndefined()
	expect(editor.getShape(ids.box3)).toBeUndefined()
	expect(editor.getShape(ids.box4)).toBeUndefined()

	expect(calls.at(-1)).toBe('onOperationComplete')

	expect(
		[
			'onBeforeIsolateFromShape: box3->box4',
			'onBeforeDeleteToShape: box3->box4',
			'onBeforeIsolateFromShape: box2->box3',
			'onBeforeDeleteToShape: box2->box3',
			'onBeforeIsolateFromShape: box1->box2',
			'onBeforeDeleteToShape: box1->box2',
			'onBeforeIsolateToShape: box1->box2',
			'onBeforeDeleteFromShape: box1->box2',
			'onBeforeDelete: box1->box2',
			'onBeforeIsolateToShape: box2->box3',
			'onBeforeDeleteFromShape: box2->box3',
			'onBeforeDelete: box2->box3',
			'onBeforeIsolateToShape: box3->box4',
			'onBeforeDeleteFromShape: box3->box4',
			'onBeforeDelete: box3->box4',
			'onAfterDelete: box1->box2',
			'onAfterDelete: box2->box3',
			'onAfterDelete: box3->box4',
			'onOperationComplete',
		].every((call) => calls.includes(call))
	).toBe(true)
})

test('onBeforeCreate is called before the binding is created', () => {
	mockOnBeforeCreate.mockImplementationOnce(() => {
		expect(editor.getBindingsFromShape(ids.box1, 'test')).toHaveLength(0)
	})
	bindShapes(ids.box1, ids.box2)
	expect(editor.getBindingsFromShape(ids.box1, 'test')).toHaveLength(1)
})

test('onAfterCreate is called after the binding is created', () => {
	mockOnAfterCreate.mockImplementationOnce(() => {
		expect(editor.getBindingsFromShape(ids.box1, 'test')).toHaveLength(1)
	})
	bindShapes(ids.box1, ids.box2)
	expect(editor.getBindingsFromShape(ids.box1, 'test')).toHaveLength(1)
	expect.assertions(2)
})

test('onBeforeChange is called before the binding is updated', () => {
	const bindingId = bindShapes(ids.box1, ids.box2)
	mockOnBeforeChange.mockImplementationOnce(() => {
		expect(editor.getBinding(bindingId)?.meta).toEqual({})
	})
	editor.updateBindings([
		{
			id: bindingId,
			type: 'test',
			meta: { foo: 'bar' },
		},
	])
	expect(editor.getBinding(bindingId)?.meta).toEqual({ foo: 'bar' })
	expect.assertions(2)
})

test('onAfterChange is called after the binding is updated', () => {
	const bindingId = bindShapes(ids.box1, ids.box2)
	expect(editor.getBinding(bindingId)?.meta).toEqual({})
	mockOnAfterChange.mockImplementationOnce(() => {
		expect(editor.getBinding(bindingId)?.meta).toEqual({ foo: 'bar' })
	})
	editor.updateBindings([
		{
			id: bindingId,
			type: 'test',
			meta: { foo: 'bar' },
		},
	])
	expect(editor.getBinding(bindingId)?.meta).toEqual({ foo: 'bar' })
	expect.assertions(3)
})

test('onAfterChangeFromShape is called after the from shape is updated', () => {
	bindShapes(ids.box1, ids.box2)

	expect(editor.getShape(ids.box1)?.meta).toEqual({})
	mockOnAfterChangeFromShape.mockImplementationOnce(() => {
		expect(editor.getShape(ids.box1)?.meta).toMatchObject({
			foo: 'bar',
		})
	})
	editor.updateShapes([
		{
			id: ids.box1,
			type: 'geo',
			meta: { foo: 'bar' },
		},
	])
	expect(editor.getShape(ids.box1)?.meta).toMatchObject({
		foo: 'bar',
	})
	expect.assertions(3)
})

test('onAfterChangeToShape is called after the to shape is updated', () => {
	bindShapes(ids.box1, ids.box2)

	expect(editor.getShape(ids.box2)?.meta).toEqual({})
	mockOnAfterChangeToShape.mockImplementationOnce(() => {
		expect(editor.getShape(ids.box2)?.meta).toMatchObject({
			foo: 'bar',
		})
	})
	editor.updateShapes([
		{
			id: ids.box2,
			type: 'geo',
			meta: { foo: 'bar' },
		},
	])
	expect(editor.getShape(ids.box2)?.meta).toMatchObject({
		foo: 'bar',
	})
	expect.assertions(3)
})
