import { Computed, react, RESET_VALUE, transact } from '@tldraw/state'
import { vi } from 'vitest'
import { BaseRecord, RecordId } from '../BaseRecord'
import { createMigrationSequence } from '../migrate'
import { RecordsDiff, reverseRecordsDiff } from '../RecordsDiff'
import { createRecordType } from '../RecordType'
import { CollectionDiff, HistoryEntry, Store } from '../Store'
import { StoreSchema } from '../StoreSchema'

interface Book extends BaseRecord<'book', RecordId<Book>> {
	title: string
	author: RecordId<Author>
	numPages: number
}

const Book = createRecordType<Book>('book', {
	validator: { validate: (book) => book as Book },
	scope: 'document',
})

interface Author extends BaseRecord<'author', RecordId<Author>> {
	name: string
	isPseudonym: boolean
}

const Author = createRecordType<Author>('author', {
	validator: { validate: (author) => author as Author },
	scope: 'document',
}).withDefaultProperties(() => ({
	isPseudonym: false,
}))

interface Visit extends BaseRecord<'visit', RecordId<Visit>> {
	visitorName: string
	booksInBasket: RecordId<Book>[]
}

const Visit = createRecordType<Visit>('visit', {
	validator: { validate: (visit) => visit as Visit },
	scope: 'session',
}).withDefaultProperties(() => ({
	visitorName: 'Anonymous',
	booksInBasket: [],
}))

type LibraryType = Book | Author | Visit

describe('Store', () => {
	let store: Store<Book | Author | Visit>
	beforeEach(() => {
		store = new Store({
			props: {},
			schema: StoreSchema.create<LibraryType>({
				book: Book,
				author: Author,
				visit: Visit,
			}),
		})
	})

	it('allows records to be added', () => {
		store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })])
		expect(store.query.records('author').get()).toEqual([
			{ id: 'author:tolkein', typeName: 'author', name: 'J.R.R Tolkein', isPseudonym: false },
		])

		store.put([
			{
				id: Book.createId('the-hobbit'),
				typeName: 'book',
				title: 'The Hobbit',
				numPages: 423,
				author: Author.createId('tolkein'),
			},
		])

		expect(store.query.records('book').get()).toEqual([
			{
				id: 'book:the-hobbit',
				typeName: 'book',
				title: 'The Hobbit',
				numPages: 423,
				author: 'author:tolkein',
			},
		])
	})

	describe('with history', () => {
		let authorHistory: Computed<number, RecordsDiff<Author>>
		let lastDiff: RecordsDiff<Author>[] | typeof RESET_VALUE = 'undefined' as any
		beforeEach(() => {
			authorHistory = store.query.filterHistory('author')
			react('', (lastReactedEpoch) => {
				lastDiff = authorHistory.getDiffSince(lastReactedEpoch)
			})

			expect(lastDiff!).toBe(RESET_VALUE)
		})

		it('allows listening to the change history', () => {
			store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })])

			expect(lastDiff!).toMatchInlineSnapshot(`
			        [
			          {
			            "added": {
			              "author:tolkein": {
			                "id": "author:tolkein",
			                "isPseudonym": false,
			                "name": "J.R.R Tolkein",
			                "typeName": "author",
			              },
			            },
			            "removed": {},
			            "updated": {},
			          },
			        ]
		      `)

			store.update(Author.createId('tolkein'), (r) => ({ ...r, name: 'Jimmy Tolks' }))

			expect(lastDiff!).toMatchInlineSnapshot(`
			        [
			          {
			            "added": {},
			            "removed": {},
			            "updated": {
			              "author:tolkein": [
			                {
			                  "id": "author:tolkein",
			                  "isPseudonym": false,
			                  "name": "J.R.R Tolkein",
			                  "typeName": "author",
			                },
			                {
			                  "id": "author:tolkein",
			                  "isPseudonym": false,
			                  "name": "Jimmy Tolks",
			                  "typeName": "author",
			                },
			              ],
			            },
			          },
			        ]
		      `)

			store.remove([Author.createId('tolkein')])

			expect(lastDiff!).toMatchInlineSnapshot(`
			        [
			          {
			            "added": {},
			            "removed": {
			              "author:tolkein": {
			                "id": "author:tolkein",
			                "isPseudonym": false,
			                "name": "Jimmy Tolks",
			                "typeName": "author",
			              },
			            },
			            "updated": {},
			          },
			        ]
		      `)

			transact(() => {
				store.put([
					Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') }),
					Author.create({ name: 'David Foster Wallace', id: Author.createId('dfw') }),
					Author.create({ name: 'Cynan Jones', id: Author.createId('cj') }),
				])

				store.update(Author.createId('tolkein'), (r) => ({ ...r, name: 'Jimmy Tolks' }))
				store.update(Author.createId('cj'), (r) => ({ ...r, name: 'Carter, Jimmy' }))
			})

			expect(lastDiff!).toMatchInlineSnapshot(`
			[
			  {
			    "added": {
			      "author:cj": {
			        "id": "author:cj",
			        "isPseudonym": false,
			        "name": "Carter, Jimmy",
			        "typeName": "author",
			      },
			      "author:dfw": {
			        "id": "author:dfw",
			        "isPseudonym": false,
			        "name": "David Foster Wallace",
			        "typeName": "author",
			      },
			      "author:tolkein": {
			        "id": "author:tolkein",
			        "isPseudonym": false,
			        "name": "Jimmy Tolks",
			        "typeName": "author",
			      },
			    },
			    "removed": {},
			    "updated": {},
			  },
			]
		`)
		})
	})

	it('allows adding onAfterChange callbacks that see the final state of the world', () => {
		/* ADDING */
		const onAfterCreate = vi.fn((current) => {
			expect(current).toEqual(
				Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })
			)
			expect([...store.query.ids('author').get()]).toEqual([Author.createId('tolkein')])
		})
		store.sideEffects.registerAfterCreateHandler('author', onAfterCreate)
		store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })])

		expect(onAfterCreate).toHaveBeenCalledTimes(1)

		/* UPDATING */
		const onAfterChange = vi.fn((prev, current) => {
			expect(prev.name).toBe('J.R.R Tolkein')
			expect(current.name).toBe('Butch Cassidy')

			expect(store.get(Author.createId('tolkein'))!.name).toBe('Butch Cassidy')
		})
		store.sideEffects.registerAfterChangeHandler('author', onAfterChange)

		store.update(Author.createId('tolkein'), (r) => ({ ...r, name: 'Butch Cassidy' }))

		expect(onAfterChange).toHaveBeenCalledTimes(1)

		/* REMOVING */
		const onAfterDelete = vi.fn((prev) => {
			if (prev.typeName === 'author') {
				expect(prev.name).toBe('Butch Cassidy')
			}
		})
		store.sideEffects.registerAfterDeleteHandler('author', onAfterDelete)

		store.remove([Author.createId('tolkein')])

		expect(onAfterDelete).toHaveBeenCalledTimes(1)
	})

	it('allows finding and filtering records with a predicate', () => {
		store.put([
			Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') }),
			Author.create({ name: 'James McAvoy', id: Author.createId('mcavoy') }),
			Author.create({ name: 'Butch Cassidy', id: Author.createId('cassidy') }),
			Author.create({ name: 'Cynan Jones', id: Author.createId('cj') }),
			Author.create({ name: 'David Foster Wallace', id: Author.createId('dfw') }),
		])
		const Js = store.query
			.records('author')
			.get()
			.filter((r) => r.name.startsWith('J'))
		expect(Js.map((j) => j.name).sort()).toEqual(['J.R.R Tolkein', 'James McAvoy'])

		const david = store.query
			.records('author')
			.get()
			.find((r) => r.name.startsWith('David'))
		expect(david?.name).toBe('David Foster Wallace')
	})

	it('allows keeping track of the ids of a particular type', () => {
		let lastIdDiff: CollectionDiff<RecordId<Author>>[] | RESET_VALUE = []

		const authorIds = store.query.ids('author')

		react('', (lastReactedEpoch) => {
			lastIdDiff = authorIds.getDiffSince(lastReactedEpoch)
		})

		expect(lastIdDiff).toBe(RESET_VALUE)

		store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })])

		expect(lastIdDiff).toMatchInlineSnapshot(`
		      [
		        {
		          "added": Set {
		            "author:tolkein",
		          },
		        },
		      ]
	    `)

		transact(() => {
			store.put([Author.create({ name: 'James McAvoy', id: Author.createId('mcavoy') })])
			store.put([Author.create({ name: 'Butch Cassidy', id: Author.createId('cassidy') })])
			store.remove([Author.createId('tolkein')])
		})

		expect(lastIdDiff).toMatchInlineSnapshot(`
		      [
		        {
		          "added": Set {
		            "author:mcavoy",
		            "author:cassidy",
		          },
		          "removed": Set {
		            "author:tolkein",
		          },
		        },
		      ]
	    `)
	})

	it('supports listening for changes to the whole store', async () => {
		const listener = vi.fn()
		store.listen(listener)

		transact(() => {
			store.put([
				Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') }),
				Author.create({ name: 'James McAvoy', id: Author.createId('mcavoy') }),
				Author.create({ name: 'Butch Cassidy', id: Author.createId('cassidy') }),
				Book.create({
					title: 'The Hobbit',
					id: Book.createId('hobbit'),
					author: Author.createId('tolkein'),
					numPages: 300,
				}),
			])
			store.put([
				Book.create({
					title: 'The Lord of the Rings',
					id: Book.createId('lotr'),
					author: Author.createId('tolkein'),
					numPages: 1000,
				}),
			])
		})

		await new Promise((resolve) => requestAnimationFrame(resolve))
		expect(listener).toHaveBeenCalledTimes(1)
		expect(listener.mock.lastCall?.[0]).toMatchInlineSnapshot(`
		{
		  "changes": {
		    "added": {
		      "author:cassidy": {
		        "id": "author:cassidy",
		        "isPseudonym": false,
		        "name": "Butch Cassidy",
		        "typeName": "author",
		      },
		      "author:mcavoy": {
		        "id": "author:mcavoy",
		        "isPseudonym": false,
		        "name": "James McAvoy",
		        "typeName": "author",
		      },
		      "author:tolkein": {
		        "id": "author:tolkein",
		        "isPseudonym": false,
		        "name": "J.R.R Tolkein",
		        "typeName": "author",
		      },
		      "book:hobbit": {
		        "author": "author:tolkein",
		        "id": "book:hobbit",
		        "numPages": 300,
		        "title": "The Hobbit",
		        "typeName": "book",
		      },
		      "book:lotr": {
		        "author": "author:tolkein",
		        "id": "book:lotr",
		        "numPages": 1000,
		        "title": "The Lord of the Rings",
		        "typeName": "book",
		      },
		    },
		    "removed": {},
		    "updated": {},
		  },
		  "source": "user",
		}
	`)

		transact(() => {
			store.update(Author.createId('tolkein'), (author) => ({
				...author,
				name: 'Jimmy Tolks',
			}))
			store.update(Book.createId('lotr'), (book) => ({ ...book, numPages: 42 }))
		})

		await new Promise((resolve) => requestAnimationFrame(resolve))
		expect(listener).toHaveBeenCalledTimes(2)

		expect(listener.mock.lastCall?.[0]).toMatchInlineSnapshot(`
		{
		  "changes": {
		    "added": {},
		    "removed": {},
		    "updated": {
		      "author:tolkein": [
		        {
		          "id": "author:tolkein",
		          "isPseudonym": false,
		          "name": "J.R.R Tolkein",
		          "typeName": "author",
		        },
		        {
		          "id": "author:tolkein",
		          "isPseudonym": false,
		          "name": "Jimmy Tolks",
		          "typeName": "author",
		        },
		      ],
		      "book:lotr": [
		        {
		          "author": "author:tolkein",
		          "id": "book:lotr",
		          "numPages": 1000,
		          "title": "The Lord of the Rings",
		          "typeName": "book",
		        },
		        {
		          "author": "author:tolkein",
		          "id": "book:lotr",
		          "numPages": 42,
		          "title": "The Lord of the Rings",
		          "typeName": "book",
		        },
		      ],
		    },
		  },
		  "source": "user",
		}
	`)

		transact(() => {
			store.update(Author.createId('mcavoy'), (author) => ({
				...author,
				name: 'Sookie Houseboat',
			}))
			store.remove([Book.createId('lotr')])
		})

		await new Promise((resolve) => requestAnimationFrame(resolve))
		expect(listener).toHaveBeenCalledTimes(3)

		expect(listener.mock.lastCall?.[0]).toMatchInlineSnapshot(`
		{
		  "changes": {
		    "added": {},
		    "removed": {
		      "book:lotr": {
		        "author": "author:tolkein",
		        "id": "book:lotr",
		        "numPages": 42,
		        "title": "The Lord of the Rings",
		        "typeName": "book",
		      },
		    },
		    "updated": {
		      "author:mcavoy": [
		        {
		          "id": "author:mcavoy",
		          "isPseudonym": false,
		          "name": "James McAvoy",
		          "typeName": "author",
		        },
		        {
		          "id": "author:mcavoy",
		          "isPseudonym": false,
		          "name": "Sookie Houseboat",
		          "typeName": "author",
		        },
		      ],
		    },
		  },
		  "source": "user",
		}
	`)
	})

	it('supports filtering history by scope', () => {
		const listener = vi.fn()
		store.listen(listener, {
			scope: 'session',
		})

		store.put([
			Author.create({ name: 'J.R.R Tolkien', id: Author.createId('tolkien') }),
			Book.create({
				title: 'The Hobbit',
				id: Book.createId('hobbit'),
				author: Author.createId('tolkien'),
				numPages: 300,
			}),
		])

		expect(listener).toHaveBeenCalledTimes(0)

		store.put([
			Author.create({ name: 'J.D. Salinger', id: Author.createId('salinger') }),
			Visit.create({ id: Visit.createId('jimmy'), visitorName: 'Jimmy Beans' }),
		])

		expect(listener).toHaveBeenCalledTimes(1)

		expect(listener.mock.calls[0][0].changes).toMatchInlineSnapshot(`
		{
		  "added": {
		    "visit:jimmy": {
		      "booksInBasket": [],
		      "id": "visit:jimmy",
		      "typeName": "visit",
		      "visitorName": "Jimmy Beans",
		    },
		  },
		  "removed": {},
		  "updated": {},
		}
	`)
	})

	it('supports filtering history by scope (2)', () => {
		const listener = vi.fn()
		store.listen(listener, {
			scope: 'document',
		})

		store.put([
			Author.create({ name: 'J.D. Salinger', id: Author.createId('salinger') }),
			Visit.create({ id: Visit.createId('jimmy'), visitorName: 'Jimmy Beans' }),
		])

		expect(listener).toHaveBeenCalledTimes(1)

		expect(listener.mock.calls[0][0].changes).toMatchInlineSnapshot(`
		{
		  "added": {
		    "author:salinger": {
		      "id": "author:salinger",
		      "isPseudonym": false,
		      "name": "J.D. Salinger",
		      "typeName": "author",
		    },
		  },
		  "removed": {},
		  "updated": {},
		}
	`)
	})

	it('supports filtering history by source', () => {
		const listener = vi.fn()
		store.listen(listener, {
			source: 'remote',
		})

		store.put([
			Author.create({ name: 'J.D. Salinger', id: Author.createId('salinger') }),
			Visit.create({ id: Visit.createId('jimmy'), visitorName: 'Jimmy Beans' }),
		])

		expect(listener).toHaveBeenCalledTimes(0)

		store.mergeRemoteChanges(() => {
			store.put([
				Author.create({ name: 'J.R.R Tolkien', id: Author.createId('tolkien') }),
				Book.create({
					title: 'The Hobbit',
					id: Book.createId('hobbit'),
					author: Author.createId('tolkien'),
					numPages: 300,
				}),
			])
		})

		expect(listener).toHaveBeenCalledTimes(1)

		expect(listener.mock.calls[0][0].changes).toMatchInlineSnapshot(`
		{
		  "added": {
		    "author:tolkien": {
		      "id": "author:tolkien",
		      "isPseudonym": false,
		      "name": "J.R.R Tolkien",
		      "typeName": "author",
		    },
		    "book:hobbit": {
		      "author": "author:tolkien",
		      "id": "book:hobbit",
		      "numPages": 300,
		      "title": "The Hobbit",
		      "typeName": "book",
		    },
		  },
		  "removed": {},
		  "updated": {},
		}
	`)
	})

	it('supports filtering history by source (user)', () => {
		const listener = vi.fn()
		store.listen(listener, {
			source: 'user',
		})

		store.mergeRemoteChanges(() => {
			store.put([
				Author.create({ name: 'J.R.R Tolkien', id: Author.createId('tolkien') }),
				Book.create({
					title: 'The Hobbit',
					id: Book.createId('hobbit'),
					author: Author.createId('tolkien'),
					numPages: 300,
				}),
			])
		})

		expect(listener).toHaveBeenCalledTimes(0)

		store.put([
			Author.create({ name: 'J.D. Salinger', id: Author.createId('salinger') }),
			Visit.create({ id: Visit.createId('jimmy'), visitorName: 'Jimmy Beans' }),
		])

		expect(listener).toHaveBeenCalledTimes(1)

		expect(listener.mock.calls[0][0].changes).toMatchInlineSnapshot(`
		{
		  "added": {
		    "author:salinger": {
		      "id": "author:salinger",
		      "isPseudonym": false,
		      "name": "J.D. Salinger",
		      "typeName": "author",
		    },
		    "visit:jimmy": {
		      "booksInBasket": [],
		      "id": "visit:jimmy",
		      "typeName": "visit",
		      "visitorName": "Jimmy Beans",
		    },
		  },
		  "removed": {},
		  "updated": {},
		}
	`)
	})

	it('does not keep global history if no listeners are attached', () => {
		store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })])
		expect((store as any).historyAccumulator._history).toHaveLength(0)
	})

	it('flushes history before attaching listeners', async () => {
		try {
			// @ts-expect-error
			globalThis.__FORCE_RAF_IN_TESTS__ = true
			store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })])
			const firstListener = vi.fn()
			store.listen(firstListener)
			expect(firstListener).toHaveBeenCalledTimes(0)

			store.put([Author.create({ name: 'Chips McCoy', id: Author.createId('chips') })])

			expect(firstListener).toHaveBeenCalledTimes(0)

			const secondListener = vi.fn()

			store.listen(secondListener)

			expect(firstListener).toHaveBeenCalledTimes(1)
			expect(secondListener).toHaveBeenCalledTimes(0)

			await new Promise((resolve) => requestAnimationFrame(resolve))

			expect(firstListener).toHaveBeenCalledTimes(1)
			expect(secondListener).toHaveBeenCalledTimes(0)
		} finally {
			// @ts-expect-error
			globalThis.__FORCE_RAF_IN_TESTS__ = false
		}
	})

	it('does not overwrite default properties with undefined', () => {
		const tolkein = Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })
		expect(tolkein.isPseudonym).toBe(false)

		const harkaway = Author.create({
			name: 'Nick Harkaway',
			id: Author.createId('harkaway'),
			isPseudonym: true,
		})
		expect(harkaway.isPseudonym).toBe(true)

		const burns = Author.create({
			name: 'Anna Burns',
			id: Author.createId('burns'),
			isPseudonym: undefined,
		})

		expect(burns.isPseudonym).toBe(false)
	})

	it('allows changed to be merged without triggering listeners', () => {
		const id = Author.createId('tolkein')
		store.put([Author.create({ name: 'J.R.R Tolkein', id })])

		const listener = vi.fn()
		store.listen(listener)

		// Return the exact same value that came in
		store.update(id, (author) => author)

		expect(listener).not.toHaveBeenCalled()
	})

	it('tells listeners the source of the changes so they can decide if they want to run or not', async () => {
		const listener = vi.fn()
		store.listen(listener)

		store.put([Author.create({ name: 'Jimmy Beans', id: Author.createId('jimmy') })])

		await new Promise((resolve) => requestAnimationFrame(resolve))
		expect(listener).toHaveBeenCalledTimes(1)
		expect(listener.mock.calls[0][0].source).toBe('user')

		store.mergeRemoteChanges(() => {
			store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })])
			store.put([
				Book.create({
					title: 'The Hobbit',
					id: Book.createId('hobbit'),
					author: Author.createId('tolkein'),
					numPages: 300,
				}),
			])
		})

		await new Promise((resolve) => requestAnimationFrame(resolve))
		expect(listener).toHaveBeenCalledTimes(2)
		expect(listener.mock.calls[1][0].source).toBe('remote')

		store.put([Author.create({ name: 'Steve Ok', id: Author.createId('stever') })])

		await new Promise((resolve) => requestAnimationFrame(resolve))
		expect(listener).toHaveBeenCalledTimes(3)
		expect(listener.mock.calls[2][0].source).toBe('user')
	})
})

describe('snapshots', () => {
	let store: Store<Book | Author>

	beforeEach(() => {
		store = new Store({
			props: {},
			schema: StoreSchema.create<Book | Author>({
				book: Book,
				author: Author,
			}),
		})

		transact(() => {
			store.put([
				Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') }),
				Author.create({ name: 'James McAvoy', id: Author.createId('mcavoy') }),
				Author.create({ name: 'Butch Cassidy', id: Author.createId('cassidy') }),
				Book.create({
					title: 'The Hobbit',
					id: Book.createId('hobbit'),
					author: Author.createId('tolkein'),
					numPages: 300,
				}),
			])
			store.put([
				Book.create({
					title: 'The Lord of the Rings',
					id: Book.createId('lotr'),
					author: Author.createId('tolkein'),
					numPages: 1000,
				}),
			])
		})
	})

	it('creates and loads a snapshot', () => {
		const serializedStore1 = store.serialize('all')
		const serializedSchema1 = store.schema.serialize()

		const snapshot1 = store.getStoreSnapshot()

		const store2 = new Store({
			props: {},
			schema: StoreSchema.create<Book | Author>({
				book: Book,
				author: Author,
			}),
		})

		store2.loadStoreSnapshot(snapshot1)

		const serializedStore2 = store2.serialize('all')
		const serializedSchema2 = store2.schema.serialize()
		const snapshot2 = store2.getStoreSnapshot()

		expect(serializedStore1).toEqual(serializedStore2)
		expect(serializedSchema1).toEqual(serializedSchema2)
		expect(snapshot1).toEqual(snapshot2)
	})

	it('throws errors when loading a snapshot with a different schema', () => {
		const snapshot1 = store.getStoreSnapshot()

		const store2 = new Store({
			props: {},
			schema: StoreSchema.create<Book>({
				book: Book,
				// no author
			}),
		})

		expect(() => {
			// @ts-expect-error
			store2.loadStoreSnapshot(snapshot1)
		}).toThrowErrorMatchingInlineSnapshot(`[Error: Missing definition for record type author]`)
	})

	it('throws errors when loading a snapshot with a different schema', () => {
		const snapshot1 = store.getStoreSnapshot()

		const store2 = new Store({
			props: {},
			schema: StoreSchema.create<Book>({
				book: Book,
			}),
		})

		expect(() => {
			store2.loadStoreSnapshot(snapshot1 as any)
		}).toThrowErrorMatchingInlineSnapshot(`[Error: Missing definition for record type author]`)
	})

	it('migrates the snapshot', () => {
		const snapshot1 = store.getStoreSnapshot()
		const up = vi.fn((s: any) => {
			s['book:lotr'].numPages = 42
		})

		expect((snapshot1.store as any)['book:lotr'].numPages).toBe(1000)

		const store2 = new Store({
			props: {},
			schema: StoreSchema.create<Book | Author>(
				{
					book: Book,
					author: Author,
				},
				{
					migrations: [
						createMigrationSequence({
							sequenceId: 'com.tldraw',
							retroactive: true,
							sequence: [
								{
									id: `com.tldraw/1`,
									scope: 'store',
									up,
								},
							],
						}),
					],
				}
			),
		})

		expect(() => {
			store2.loadStoreSnapshot(snapshot1)
		}).not.toThrow()

		expect(up).toHaveBeenCalledTimes(1)
		expect(store2.get(Book.createId('lotr'))!.numPages).toBe(42)
	})

	it('migrates the snapshot with storage scope', () => {
		const snapshot1 = store.getStoreSnapshot()
		const up = vi.fn((storage: any) => {
			const book = storage.get('book:lotr')
			storage.set('book:lotr', { ...book, numPages: 42 })
		})

		expect((snapshot1.store as any)['book:lotr'].numPages).toBe(1000)

		const store2 = new Store({
			props: {},
			schema: StoreSchema.create<Book | Author>(
				{
					book: Book,
					author: Author,
				},
				{
					migrations: [
						createMigrationSequence({
							sequenceId: 'com.tldraw',
							retroactive: true,
							sequence: [
								{
									id: `com.tldraw/1`,
									scope: 'storage',
									up,
								},
							],
						}),
					],
				}
			),
		})

		expect(() => {
			store2.loadStoreSnapshot(snapshot1)
		}).not.toThrow()

		expect(up).toHaveBeenCalledTimes(1)
		expect(store2.get(Book.createId('lotr'))!.numPages).toBe(42)
	})

	it('storage scope migration can delete records', () => {
		const snapshot1 = store.getStoreSnapshot()
		const up = vi.fn((storage: any) => {
			storage.delete('author:mcavoy')
		})

		expect((snapshot1.store as any)['author:mcavoy']).toBeDefined()

		const store2 = new Store({
			props: {},
			schema: StoreSchema.create<Book | Author>(
				{
					book: Book,
					author: Author,
				},
				{
					migrations: [
						createMigrationSequence({
							sequenceId: 'com.tldraw',
							retroactive: true,
							sequence: [
								{
									id: `com.tldraw/1`,
									scope: 'storage',
									up,
								},
							],
						}),
					],
				}
			),
		})

		expect(() => {
			store2.loadStoreSnapshot(snapshot1)
		}).not.toThrow()

		expect(up).toHaveBeenCalledTimes(1)
		expect(store2.get(Author.createId('mcavoy'))).toBeUndefined()
	})

	it('storage scope migration can iterate records', () => {
		const snapshot1 = store.getStoreSnapshot()
		const up = vi.fn((storage: any) => {
			for (const [id, record] of storage.entries()) {
				if (record.typeName === 'book') {
					storage.set(id, { ...record, numPages: record.numPages + 100 })
				}
			}
		})

		expect((snapshot1.store as any)['book:lotr'].numPages).toBe(1000)
		expect((snapshot1.store as any)['book:hobbit'].numPages).toBe(300)

		const store2 = new Store({
			props: {},
			schema: StoreSchema.create<Book | Author>(
				{
					book: Book,
					author: Author,
				},
				{
					migrations: [
						createMigrationSequence({
							sequenceId: 'com.tldraw',
							retroactive: true,
							sequence: [
								{
									id: `com.tldraw/1`,
									scope: 'storage',
									up,
								},
							],
						}),
					],
				}
			),
		})

		expect(() => {
			store2.loadStoreSnapshot(snapshot1)
		}).not.toThrow()

		expect(up).toHaveBeenCalledTimes(1)
		expect(store2.get(Book.createId('lotr'))!.numPages).toBe(1100)
		expect(store2.get(Book.createId('hobbit'))!.numPages).toBe(400)
	})

	it('storage scope migration can use values() and keys()', () => {
		const snapshot1 = store.getStoreSnapshot()
		const keysCollected: string[] = []
		const valuesCollected: any[] = []

		const up = vi.fn((storage: any) => {
			for (const key of storage.keys()) {
				keysCollected.push(key)
			}
			for (const value of storage.values()) {
				valuesCollected.push(value)
			}
		})

		const store2 = new Store({
			props: {},
			schema: StoreSchema.create<Book | Author>(
				{
					book: Book,
					author: Author,
				},
				{
					migrations: [
						createMigrationSequence({
							sequenceId: 'com.tldraw',
							retroactive: true,
							sequence: [
								{
									id: `com.tldraw/1`,
									scope: 'storage',
									up,
								},
							],
						}),
					],
				}
			),
		})

		expect(() => {
			store2.loadStoreSnapshot(snapshot1)
		}).not.toThrow()

		expect(up).toHaveBeenCalledTimes(1)
		expect(keysCollected).toContain('book:lotr')
		expect(keysCollected).toContain('book:hobbit')
		expect(keysCollected).toContain('author:tolkein')
		expect(keysCollected).toContain('author:mcavoy')
		expect(keysCollected).toContain('author:cassidy')
		expect(valuesCollected.length).toBe(5)
	})
})

describe('diffs', () => {
	let store: Store<LibraryType>
	const authorId = Author.createId('tolkein')
	const bookId = Book.createId('hobbit')

	beforeEach(() => {
		store = new Store({
			props: {},
			schema: StoreSchema.create<LibraryType>({
				book: Book,
				author: Author,
				visit: Visit,
			}),
		})
	})

	it('produces diffs from `extractingChanges`', () => {
		expect(
			store.extractingChanges(() => {
				store.put([Author.create({ name: 'J.R.R Tolkein', id: authorId })])
				store.put([
					Book.create({ title: 'The Hobbit', id: bookId, author: authorId, numPages: 300 }),
				])
			})
		).toMatchInlineSnapshot(`
		{
		  "added": {
		    "author:tolkein": {
		      "id": "author:tolkein",
		      "isPseudonym": false,
		      "name": "J.R.R Tolkein",
		      "typeName": "author",
		    },
		    "book:hobbit": {
		      "author": "author:tolkein",
		      "id": "book:hobbit",
		      "numPages": 300,
		      "title": "The Hobbit",
		      "typeName": "book",
		    },
		  },
		  "removed": {},
		  "updated": {},
		}
	`)

		expect(
			store.extractingChanges(() => {
				store.remove([authorId])
				store.update(bookId, (book) => ({ ...book, title: 'The Hobbit: There and Back Again' }))
			})
		).toMatchInlineSnapshot(`
		{
		  "added": {},
		  "removed": {
		    "author:tolkein": {
		      "id": "author:tolkein",
		      "isPseudonym": false,
		      "name": "J.R.R Tolkein",
		      "typeName": "author",
		    },
		  },
		  "updated": {
		    "book:hobbit": [
		      {
		        "author": "author:tolkein",
		        "id": "book:hobbit",
		        "numPages": 300,
		        "title": "The Hobbit",
		        "typeName": "book",
		      },
		      {
		        "author": "author:tolkein",
		        "id": "book:hobbit",
		        "numPages": 300,
		        "title": "The Hobbit: There and Back Again",
		        "typeName": "book",
		      },
		    ],
		  },
		}
	`)
	})
	it('produces diffs from `addHistoryInterceptor`', () => {
		const diffs: any[] = []
		const interceptor = vi.fn((diff) => diffs.push(diff))
		store.addHistoryInterceptor(interceptor)

		store.put([
			Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') }),
			Book.create({ title: 'The Hobbit', id: bookId, author: authorId, numPages: 300 }),
		])
		expect(interceptor).toHaveBeenCalledTimes(1)

		store.extractingChanges(() => {
			store.remove([authorId])

			store.update(bookId, (book) => ({ ...book, title: 'The Hobbit: There and Back Again' }))
		})
		expect(interceptor).toHaveBeenCalledTimes(3)

		expect(diffs).toMatchInlineSnapshot(`
		[
		  {
		    "changes": {
		      "added": {
		        "author:tolkein": {
		          "id": "author:tolkein",
		          "isPseudonym": false,
		          "name": "J.R.R Tolkein",
		          "typeName": "author",
		        },
		        "book:hobbit": {
		          "author": "author:tolkein",
		          "id": "book:hobbit",
		          "numPages": 300,
		          "title": "The Hobbit",
		          "typeName": "book",
		        },
		      },
		      "removed": {},
		      "updated": {},
		    },
		    "source": "user",
		  },
		  {
		    "changes": {
		      "added": {},
		      "removed": {
		        "author:tolkein": {
		          "id": "author:tolkein",
		          "isPseudonym": false,
		          "name": "J.R.R Tolkein",
		          "typeName": "author",
		        },
		      },
		      "updated": {},
		    },
		    "source": "user",
		  },
		  {
		    "changes": {
		      "added": {},
		      "removed": {},
		      "updated": {
		        "book:hobbit": [
		          {
		            "author": "author:tolkein",
		            "id": "book:hobbit",
		            "numPages": 300,
		            "title": "The Hobbit",
		            "typeName": "book",
		          },
		          {
		            "author": "author:tolkein",
		            "id": "book:hobbit",
		            "numPages": 300,
		            "title": "The Hobbit: There and Back Again",
		            "typeName": "book",
		          },
		        ],
		      },
		    },
		    "source": "user",
		  },
		]
	`)
	})

	it('can apply and invert diffs', () => {
		store.put([
			Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') }),
			Book.create({ title: 'The Hobbit', id: bookId, author: authorId, numPages: 300 }),
		])

		const checkpoint1 = store.getStoreSnapshot()

		const forwardsDiff = store.extractingChanges(() => {
			store.remove([authorId])
			store.update(bookId, (book) => ({ ...book, title: 'The Hobbit: There and Back Again' }))
		})

		const checkpoint2 = store.getStoreSnapshot()

		store.applyDiff(reverseRecordsDiff(forwardsDiff))
		expect(store.getStoreSnapshot()).toEqual(checkpoint1)

		store.applyDiff(forwardsDiff)
		expect(store.getStoreSnapshot()).toEqual(checkpoint2)
	})
})

describe('callbacks', () => {
	let store: Store<Book>
	let callbacks: any[] = []

	const book1Id = Book.createId('darkness')
	const book1 = Book.create({
		title: 'the left hand of darkness',
		id: book1Id,
		author: Author.createId('ursula'),
		numPages: 1,
	})
	const book2Id = Book.createId('dispossessed')
	const book2 = Book.create({
		title: 'the dispossessed',
		id: book2Id,
		author: Author.createId('ursula'),
		numPages: 1,
	})

	let onAfterCreate: ReturnType<typeof vi.fn>
	let onAfterChange: ReturnType<typeof vi.fn>
	let onAfterDelete: ReturnType<typeof vi.fn>

	let onBeforeCreate: ReturnType<typeof vi.fn>
	let onBeforeChange: ReturnType<typeof vi.fn>
	let onBeforeDelete: ReturnType<typeof vi.fn>

	let onOperationComplete: ReturnType<typeof vi.fn>

	beforeEach(() => {
		store = new Store({
			props: {},
			schema: StoreSchema.create<Book>({
				book: Book,
			}),
		})

		onAfterCreate = vi.fn((record) => callbacks.push({ type: 'create', record }))
		onAfterChange = vi.fn((from, to) => callbacks.push({ type: 'change', from, to }))
		onAfterDelete = vi.fn((record) => callbacks.push({ type: 'delete', record }))

		onBeforeCreate = vi.fn((record) => record)
		onBeforeChange = vi.fn((_from, to) => to)
		onBeforeDelete = vi.fn((_record) => {})

		onOperationComplete = vi.fn(() => callbacks.push({ type: 'complete' }))
		callbacks = []

		store.sideEffects.registerAfterCreateHandler('book', onAfterCreate)
		store.sideEffects.registerAfterChangeHandler('book', onAfterChange)
		store.sideEffects.registerAfterDeleteHandler('book', onAfterDelete)

		store.sideEffects.registerBeforeCreateHandler('book', onBeforeCreate)
		store.sideEffects.registerBeforeChangeHandler('book', onBeforeChange)
		store.sideEffects.registerBeforeDeleteHandler('book', onBeforeDelete)

		store.sideEffects.registerOperationCompleteHandler(onOperationComplete)
	})

	it('fires callbacks at the end of an `atomic` op', () => {
		store.atomic(() => {
			expect(callbacks).toHaveLength(0)

			store.put([book1, book2])

			expect(callbacks).toHaveLength(0)
		})

		expect(callbacks).toMatchObject([
			{ type: 'create', record: { id: book1Id } },
			{ type: 'create', record: { id: book2Id } },
			{ type: 'complete' },
		])
	})

	it('doesnt fire callback for a record created then deleted', () => {
		store.atomic(() => {
			store.put([book1])
			store.remove([book1Id])
		})
		expect(callbacks).toMatchObject([{ type: 'complete' }])
	})

	it('bails out if too many callbacks are fired', () => {
		let limit = 10
		onAfterCreate.mockImplementation((record: any) => {
			if (record.numPages < limit) {
				store.put([{ ...record, numPages: record.numPages + 1 }])
			}
		})
		onAfterChange.mockImplementation((from: any, to: any) => {
			if (to.numPages < limit) {
				store.put([{ ...to, numPages: to.numPages + 1 }])
			}
		})

		// this should be fine:
		store.put([book1])
		expect(store.get(book1Id)!.numPages).toBe(limit)

		// if we increase the limit thought, it should crash:
		limit = 10000
		store.clear()
		expect(() => {
			store.put([book2])
		}).toThrowErrorMatchingInlineSnapshot(
			`[Error: Maximum store update depth exceeded, bailing out]`
		)
	})

	it('keeps firing operation complete callbacks until all are cleared', () => {
		// steps:
		// 0, 1, 2: after change increment pages
		// 3: after change, do nothing
		// 4: operation complete, increment pages by 1000
		// 5, 6: after change increment pages
		// 7: after change, do nothing
		// 8: operation complete, do nothing
		// 9: done!
		let step = 0

		store.put([book1])

		onAfterChange.mockImplementation((prev: any, next: any) => {
			if ([0, 1, 2, 5, 6].includes(step)) {
				step++
				store.put([{ ...next, numPages: next.numPages + 1 }])
			} else if ([3, 7].includes(step)) {
				step++
			} else {
				throw new Error(`Wrong step: ${step}`)
			}
		})

		onOperationComplete.mockImplementation(() => {
			if (step === 4) {
				step++
				const book = store.get(book1Id)!
				store.put([{ ...book, numPages: book.numPages + 1000 }])
			} else if (step === 8) {
				step++
			} else {
				throw new Error(`Wrong step: ${step}`)
			}
		})

		store.put([{ ...book1, numPages: 2 }])

		expect(store.get(book1Id)!.numPages).toBe(1007)
		expect(step).toBe(9)
	})

	test('fired during mergeRemoteChanges are flushed at the end so that they end up receiving remote source but outputting user source changes', () => {
		const diffs: HistoryEntry<Book>[] = []
		store.listen((entry) => {
			diffs.push(entry)
		})

		const firstOrderEffectSources: string[] = []
		store.sideEffects.registerAfterCreateHandler('book', (record, source) => {
			firstOrderEffectSources.push(source)
			if (record.title.startsWith('Harry Potter')) {
				store.put([
					{
						...record,
						title: record.title + ' is a really great book fr fr',
					},
				])
			}
		})

		const secondOrderEffectSources: string[] = []
		store.sideEffects.registerAfterChangeHandler('book', (from, to, source) => {
			secondOrderEffectSources.push(source)
		})

		store.mergeRemoteChanges(() => {
			store.put([
				{
					...book1,
					title: "Harry Potter and the Philosopher's Stone",
				},
			])
		})

		expect(firstOrderEffectSources).toMatchInlineSnapshot(`
		[
		  "remote",
		]
	`)

		// recursive changes are always user
		expect(secondOrderEffectSources).toMatchInlineSnapshot(`
		[
		  "user",
		]
	`)

		expect(diffs).toMatchInlineSnapshot(`
		[
		  {
		    "changes": {
		      "added": {
		        "book:darkness": {
		          "author": "author:ursula",
		          "id": "book:darkness",
		          "numPages": 1,
		          "title": "Harry Potter and the Philosopher's Stone",
		          "typeName": "book",
		        },
		      },
		      "removed": {},
		      "updated": {},
		    },
		    "source": "remote",
		  },
		  {
		    "changes": {
		      "added": {},
		      "removed": {},
		      "updated": {
		        "book:darkness": [
		          {
		            "author": "author:ursula",
		            "id": "book:darkness",
		            "numPages": 1,
		            "title": "Harry Potter and the Philosopher's Stone",
		            "typeName": "book",
		          },
		          {
		            "author": "author:ursula",
		            "id": "book:darkness",
		            "numPages": 1,
		            "title": "Harry Potter and the Philosopher's Stone is a really great book fr fr",
		            "typeName": "book",
		          },
		        ],
		      },
		    },
		    "source": "user",
		  },
		]
	`)
	})

	test('noop changes do not fire with store.atomic', () => {
		const book1A = book1
		const book1B = {
			...book1,
			title: book1.title + ' is a really great book fr fr',
		}
		const book1C = structuredClone(book1A)
		store.put([book1A])

		store.atomic(() => {
			store.put([book1B])
			store.put([book1C])
		})
		expect(onAfterChange).toHaveBeenCalledTimes(0)

		store.atomic(() => {
			store.put([book1B])
			store.put([book1C])
			store.put([book1B])
		})

		expect(onAfterChange).toHaveBeenCalledTimes(1)
	})

	test('an atomic block with callbacks enabled can be overridden with an atomic block with callbacks disabled which causes the beforeCallbacks only to not run', () => {
		store.atomic(() => {
			store.put([book1])
			store.atomic(() => {
				store.put([book2])
			}, false)
		})

		expect(onBeforeCreate).toHaveBeenCalledTimes(1)
		expect(onAfterCreate).toHaveBeenCalledTimes(2)

		store.atomic(() => {
			store.update(book1Id, (book) => ({
				...book,
				numPages: book.numPages + 1,
			}))
			store.atomic(() => {
				store.update(book2Id, (book) => ({
					...book,
					numPages: book.numPages + 1,
				}))
			}, false)
		})

		expect(onBeforeChange).toHaveBeenCalledTimes(1)
		expect(onAfterChange).toHaveBeenCalledTimes(2)

		store.atomic(() => {
			store.remove([book1Id])
			store.atomic(() => {
				store.remove([book2Id])
			}, false)
		})
		expect(onBeforeDelete).toHaveBeenCalledTimes(1)
		expect(onAfterDelete).toHaveBeenCalledTimes(2)
	})
})
